mabubsoragodong 2025. 3. 12. 11:14

1+N은 왜 일어나는가?

 

기본 정의: N+1 문제는 ORM(Object-Relational Mapping)을 사용할 때 발생하는 퍼포먼스 문제

  • 하나의 쿼리로 N개의 객체를 로딩한 후, 각 객체에 연관된 데이터를 추가로 조회하는 개별 쿼리가 N번 실행되면서 총 N+1번의 쿼리가 발생하는 문제
  • 아니 그냥 하나만 추가되는건데 문제가 생기는거야??
    • ㅇㅇ N+1 문제는 단순히 "하나의 쿼리가 더 실행된다"는 문제가 아니라, 시스템의 확장성과 성능에 심각한 영향을 미칠 수 있는 구조적 문제
    • 요청이 동시에 여러 사용자로부터 발생한다면, 데이터베이스는 수백,수천,수만개의 쿼리를 추가 처리!

 

이런 코드는 절대 짜면 안되지만 이해를 쉽게하기 위한 예시!
이런거 짜면 하루종일 시니어 개발자한테 혼납니다.

public void printBooksWithReviews() {
        // 모든 책을 조회하는 쿼리 1회 실행
        List<Book> books = bookRepository.findAll();

        // 각 책에 대한 리뷰를 조회하는 N번의 추가 쿼리 실행
        books.forEach(book -> {
            // 해당 책의 ID를 사용하여 리뷰를 조회
            List<Review> reviews = reviewRepository.findByBookId(book.getId());
          
        });
    }

 

  1. 책 목록을 조회하는 단 하나의 쿼리 후, 각 책의 리뷰를 불러오기 위해 추가적인 쿼리를 실행
  2. 이 경우, 책의 수만큼 리뷰 조회 쿼리가 추가적으로 발생하여, 책이 많을수록 데이터베이스에 부담이 크게 증가, 이로 인해 애플리케이션의 응답 시간이 늘어나고 사용자 경험이 저하됨. 실행은 됨..
  3. 예를 들어, 100권의 책이 있다면, 책 목록을 가져오는 1회의 쿼리와 각 책에 대한 리뷰를 가져오는 100회의 추가 쿼리, 총 101회의 데이터베이스 쿼리가 필요. 이는 서버와 데이터베이스에 큰 부담을 주며 응답 시간을 상당히 증가시킴
  4. fetch 타입이 FetchType.LAZY로 설정된 경우에는 연관 관계로 데이터를 가져오면서도 N+1 문제가 생길 수 있음

 

List<Review> reviews = reviewRepository.findByBookId(book.getId()); 
(리포지토리를 통해 직접 쿼리를 실행)
와
List<Review> reviews = book.getReviews(); 
(ORM의 연관 관계와 지연 로딩 전략을 활용)

가 동일한 결과를 만들어냄, N+1 문제 발생 가능성

 

 

해결방법은 무엇인가?

 

1. JOIN FETCH 사용

  • 관계가 있는 엔티티를 한 번의 쿼리로 함께 로드해야 할 때 사용
  • JOIN FETCH를 사용하면 한 번의 쿼리로 연관된 엔티티들을 함께 로드할 수 있음
  • 이 방법은 FetchType.EAGER로 설정된 것과 유사한 효과를 내지만, 쿼리를 명시적으로 제어할 수 있다는 장점
  • 권장 여부 → 매우 추천. 필요한 모든 데이터를 포함한 효율적인 쿼리를 생성하여 성능을 크게 향상!
  • SQL 조인의 종류는 아님
  • JPQL에서 성능 최적화를 위해 제공하는 기능이다.
  • 연관된 엔티티나 컬렉션을 SQL 한 번에 함께 조회하는 기능이다.
public interface BookRepository extends JpaRepository<Book, Long> {
    @Query("SELECT b FROM Book b JOIN FETCH b.reviews WHERE b.id = :id")
    Book findBookWithReviewsById(Long id);
}

 

⭐막간정보 JPQL?

JPQL은 엔티티 객체를 조회하는 객체지향 쿼리다.

 

테이블을 대상으로 쿼리하는 것이 아니라 엔티티 객체를 대상으로 쿼리한다.

 

SQL과 비슷한 문법을 가지며, JPQL은 결국 SQL로 변환된다.

 

JPA에서 제공하는 메소드 호출만으로 섬세한 쿼리 작성이 어렵다는 문제에서 JPQL이 탄생된 것이다.

 

JPQL 특징

  • 테이블이 아닌 객체를 검색하는 객체지향 쿼리
  • SQL을 추상화 했기 때문에 특정 벤더에 종속적이지 않음
  • JPA는 JPQL을 분석하여 SQL을 생성한 후 DB에서 조회

기본 문법

String jpql = "select m from Member as m where m.name = 'coco'";

JPQL은 SQL과 문법이 유사하지만 몇 가지 다른 점이 있다.

 

1. 대소문자 구분

엔티티와 속성은 대소문자를 구분한다.

엔티티 이름인 Member, 그리고 Member의 속성 name은 대소문자를 구분해줘야 한다.

반면에 SELECT, FROM, AS 같은 JPQL 키워드는 대소문자를 구분하지 않아도 된다.

 

2. 엔티티 이름

JPQL에서 사용한 Member는 클래스 이름이 아닌 엔티티 이름이다. 엔티티 이름은 @Entity(name="abcd")로 설정 가능하다.

name 속성을 생략하면 기본 값으로 클래스 이름을 사용한다.

 

3. 별칭

JPQL에서 엔티티의 별칭은 필수적으로 명시해야 한다.

별칭을 명시하는 AS 키워드는 생략할 수 있다.

 

@Query("SELECT b FROM Book b JOIN FETCH b.reviews WHERE b.id = :id")

이 JPQL은 다음과 같은 의미이다.

 

1. SELECT b FROM Book b

  • Book 엔터티를 조회하겠다는 의미입니다.
  • b는 Book 엔터티의 별칭(alias)입니다.

2. JOIN FETCH b.reviews

  • Book 엔터티와 연관된 reviews 컬렉션즉시 로딩(Eager Fetching) 하도록 강제합니다.
  • 즉, Book을 조회할 때, reviews도 한 번의 쿼리로 함께 가져옵니다.
  • Book과 reviews는 1:N 관계(예: 한 권의 책이 여러 개의 리뷰를 가질 수 있음)일 가능성이 큽니다.

3. WHERE b.id = :id

  • 특정 id 값을 가진 Book 엔터티만 조회합니다.
  • :id는 바인딩 변수(파라미터) 로, 실행 시점에 값이 주어집니다.

 

2. 배치 사이즈 설정

  • 대량의 연관 데이터를 로드할 때 N+1 쿼리 수를 줄이기 위해 사용
  • 완전한 해결책은 아님 → 배치 사이즈는 쿼리의 수를 줄이지만, 여전히 여러 쿼리가 필요. 완벽한 해결을 위해서는 JOIN FETCH나 다른 데이터 로딩 전략을 고려
  • ORM 설정에서 @BatchSize 어노테이션을 사용하여 한 번에 로드할 연관 엔티티의 수를 조정할 수 있음
  • 이 방법은 많은 수의 엔티티를 처리할 때 유용하며, 너무 많은 쿼리가 발생하는 것을 줄일 수 있음
  • 권장 여부 → 사용 환경에 따라 선택. 데이터 양과 성능 요구 사항에 따라 적절한 배치 크기 설정이 필요
@Entity
@Table(name = "books")
public class Book {
    @OneToMany(mappedBy = "book", fetch = FetchType.LAZY)
    @BatchSize(size = 10)
    private List<Review> reviews;
}

이 설정은 Review를 로드할 때 최대 10개의 Book에 대한 리뷰를 한 번에 가져옴

 

만약 이렇게 호출 한다면,

List<Parent> parents = parentRepository.findAll();

// 실제로 사용해야 쿼리가 나가기 때문에 size() 까지 호출해줌
parents.get(0).getChildren().size();
parents.get(1).getChildren().size();

 

before

SELECT * FROM parent

SELECT * FROM child WHERE child.parent_id = 1
SELECT * FROM child WHERE child.parent_id = 2

배치 사이즈를 적용하지 않으면 child 테이블을 조회하기 위해 두 개의 쿼리가 날아갑니다.

만약 parents 의 갯수가 더 많다면 갯수만큼 쿼리가 날아갈겁니다.

 

 

after

SELECT * FROM parent

SELECT * FROM child WHERE child.parent IN (1, 2)

배치 사이즈를 추가하면 여러 쿼리를 하나의 IN 쿼리로 만들어줍니다.

IN 절에 들어가는 요소의 갯수는 설정 가능합니다.

만약 조건 갯수보다 설정한 배치사이즈 크기가 더 작다면 IN 쿼리가 추가로 날아갑니다.

예를 들어 size 를 100 으로 설정했기 때문에 데이터가 250 개라면 1 ~ 100, 101 ~ 200, 201 ~ 250 이렇게 세 번에 나누어서 IN 쿼리를 날립니다.

 

정리하자면 배치사이즈를 이 만큼 설정하는건, 연관관계의 데이터를 여러번 가져올 때 배치사이즈를 설정한 만큼 IN 쿼리를 만들어 하나의 SQL문으로 가져오겠다는 의미이다. 

 

3. DTO(Data Transfer Object) 사용

  • 뷰나 API 응답으로 필요한 데이터만 선택적으로 전달
  • 필요한 데이터만 선택적으로 로드하기 위해 DTO를 사용
  • 불필요한 데이터를 로드하지 않아 성능을 향상
  • 권장 여부 → 매우 추천. 불필요한 데이터 로드를 방지하여 성능을 개선
public class BookDetailDto {
    private String title;
    private String author;
    private List<String> reviewContents;

    public BookDetailDto(String title, String author, List<String> reviewContents) {
        this.title = title;
        this.author = author;
        this.reviewContents = reviewContents;
    }
}

public interface BookRepository extends JpaRepository<Book, Long> {
    @Query("SELECT new com.example.dto.BookDetailDto(b.title, b.author, r.content) FROM Book b JOIN b.reviews r")
    List<BookDetailDto> findAllBookDetails();
}

 

 

4. Entity Graphs 사용

  • Entity Graph를 사용하면 특정 쿼리에 대한 엔티티의 로딩 전략을 세밀하게 제어
  • JPA 2.1부터 지원!
  • 권장 여부 → 유연성을 제공하므로 추천, 특히 복잡한 도메인 모델에서 유용
public interface BookRepository extends JpaRepository<Book, Long> {
    @EntityGraph(attributePaths = {"reviews"})
    List<Book> findAll();
}

findAll 메소드를 호출할 때 Book의 reviews를 즉시 로드
attributePaths 속성에 "reviews"를 지정함으로써 Book 엔티티를 조회할 때 연관된 
Review 엔티티들을 즉시 로드.
이렇게 설정함으로써, 각 Book 엔티티에 대해 별도로 Review를 로드하는 쿼리를 실행하지 않아도, 
한 번의 쿼리로 필요한 모든 데이터를 가져옴

 

 

해결 방법 사용 사례 권장 여부 주요 특징 주의점
JOIN FETCH 관계가 있는 엔티티를 한 번의 쿼리로 함께 로드할 때 매우 추천 한 번의 쿼리로 필요한 모든 데이터 로드 반환되는 데이터의 양이 많아질 수 있음
배치 사이즈 설정 대량의 연관 데이터를 로드할 때 상황에 따라 선택 N+1 쿼리 수를 줄임, 근본적 해결법은 아니고 그냥 성능 향상법 적절한 배치 크기를 설정해야 함
DTO 사용 뷰나 API 응답으로 필요한 데이터만 전달할 때 매우 추천 불필요한 데이터 로드 방지 데이터 변환 과정이 필요함
Entity Graphs 특정 쿼리에서 필드 로드 방식을 제어할 때 추천 쿼리 세밀 제어 가능 복잡한 설정이 필요할 수 있음
FetchType.EAGER 연관된 엔티티가 항상 필요한 경우 권장하지 않음 연관 엔티티를 미리 로드하여 지연 없음 불필요한 데이터 로드로 성능 저하 가능성