Post

N+1 문제

N+1 문제

JPA의 N+1 문제

1 : N 매핑을 하면서 나타날 수 있는 N+1 문제에 대해 알아본다.


N+1 문제란?

N+1 문제란 엔티티 하나를 조회했을 때 연관된 엔티티 조회 쿼리가 추가로 N번 발생하는 문제다.


ex) 게시글을 조회한다고 가정

1
Post (1) ---- (N) Comment


게시글 목록을 조회하는 쿼리가 실행된다.

1
select * from post

여러 개의 게시글(Post)이 조회된다.


이후 각 게시글의 댓글을 조회할 때 다음과 같은 쿼리가 실행된다.

1
select * from comment where post_id = ?

이 쿼리는 조회된 게시글 개수만큼 반복 실행된다.

결과적으로 실행되는 쿼리는 다음과 같다.


게시글 조회 쿼리 : 1번

댓글 조회 쿼리 : N번 (조회된 게시글 개수만큼)

→ 1 + N 쿼리 발생


이것을 N+1 문제라고 한다.


N + 1에서 1은 엔티티를 조회하기 위한 최초 쿼리의 개수이다.

N은 조회된 엔티티의 개수만큼 연관 데이터를 조회하기 위해 추가로 발생하는 쿼리의 개수이다.


☑️ 엔티티 조회 쿼리 (1번) + 조회된 엔티티 개수만큼 연관 엔티티 조회 쿼리 (N번)



참고로 LAZY 로딩을 사용할 때는 해당 Entity가 영속 상태여야 한다.

→ 쿼리 실행 시점에 엔티티가 영속성 컨텍스트에 의해 관리되고 있어야 한다.


영속성 컨텍스트란 JPA에서 엔티티들을 관리하기 위한 논리적인 저장소이다.

영속성 컨텍스트는 엔티티의 생명주기를 관리하고 DB 접근을 최적화한다.


LAZY 로딩은 연관된 엔티티를 실제로 사용하는 시점까지 조회를 미룬다.

이때 영속성 컨텍스트가 존재하지 않으면 연관 엔티티를 조회할 수 없어 LazyInitializationException이 발생할 수 있다.


EAGER 로딩은 엔티티를 조회할 때 연관된 엔티티를 즉시 함께 조회한다.

이 경우 영속성 컨텍스트가 없어도 연관 엔티티가 이미 로딩되어 있기 때문에 문제가 발생하지 않는다.

@Transactional 로 트랜잭션 범위를 명확히 지정해준다.




발생

fetch = FetchType.EAGER로 설정하면 N+1 문제가 발생한다.

fetch = FetchType.LAZY로 설정하면 초기 조회 시에는 발생하지 않지만 연관 엔티티를 사용하는 시점에 N+1 문제가 발생한다.


FetchType 변경은 N+1 문제를 해결하는 것이 아니라 발생 시점을 미루는 것에 불과하다.

결국 FetchType.EAGERFetchType.LAZY 모두 N+1 문제가 발생할 수 있다.




예시

Registry와 Comment는 (1:N) 관계이다.

하나의 게시글에 여러 개의 댓글이 존재하는 구조이다.

게시글을 EAGER 로딩으로 조회하면 게시글과 함께 댓글도 즉시 조회된다.

→ 게시글 조회 쿼리 1개 + 댓글 조회 쿼리 N개가 실행된다.



code

해당 블로그를 통해 코드를 작성했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Test
void test(){
    System.out.println("------------ Registry 전체 조회 요청 ------------");
    List<Registry> registry = registryRepository.findAll();
    System.out.println("------------ Registry 전체 조회 완료. [1번의 쿼리 발생]------------\n\n");

    System.out.println("------------ Registry title & main 조회 요청 ------------");
    registry.forEach(it -> System.out.printf("Registry 제목: [%s], Registry 내용: [%s]%n", it.getTitle(), it.getMain()));
    System.out.println("------------ Registry 제목 & 내용 조회 완료. [추가적인 쿼리 발생하지 않음]------------\n\n");

    System.out.println("------------ Registry에 달린 comment 내용 조회 요청 [조회된 Registry의 개수 만큼 추가적인 쿼리 발생]------------");
    registry.forEach(post -> {
        post.getComments().forEach(comment -> {
            System.out.printf("Registry 제목: [%s], COMMENT 내용: [%s]\n", comment.getRegistry().getTitle(), comment.getComment());
        });
    });
    System.out.println("\n------------ Registry에 달린 comment 내용 조회 완료 ------------\n\n");
}

게시글 하나당 댓글을 조회하는 쿼리가 반복적으로 발생하는 것을 확인할 수 있다.

image


image




FetchType.LAZY로 설정하더라도 댓글을 실제로 사용하는 시점에 추가 쿼리가 발생한다.

결과적으로 N + 1 문제는 동일하게 발생한다.

  • Registry 조회 : 1번
  • Comment 조회 : N번

→ 쿼리 발생 시점만 다를 뿐 전체 쿼리 수는 동일하다.



findAll() 호출 시 동작 방식은 다음과 같다.


EAGER

JPQL 실행 후 객체에 데이터가 바인딩된다.

글로벌 패치 전략에 의해 연관 엔티티를 추가로 조회하면서 N+1 문제가 발생한다.


LAZY

초기 바인딩 이후에는 추가 쿼리가 발생하지 않는다.

연관 엔티티를 사용하는 시점에 N+1 문제가 발생한다.




해결방법

fetch join과 @EntityGraph로 해결할 수 있다.

다만 페이징이 불가능하고 다중 컬렉션 fetch join 시 데이터 정합성 문제가 발생할 수 있다.

OneToMany 관계에서는 @BatchSize 또는 @Fetch(FetchMode.SUBSELECT)를 사용한다.



1. Fetch Join

연관된 엔티티를 조인하여 한 번의 쿼리로 조회한다.

결과는 EAGER와 동일하지만 쿼리 실행 방식이 다르다.

1
2
3
4
public interface RegistryRepository extends JpaRepository<Registry, Long> {
    @Query("select r from Registry r join fetch r.comments")
    List<Registry> findAll();
}

join fetch를 사용하면 연관 엔티티를 프록시가 아닌 실제 엔티티로 한 번에 조회한다.

컬렉션 fetch join은 페이징이 불가능하며 둘 이상의 컬렉션을 fetch 할 수 없다.




2. EntityGraph

@EntityGraph는 fetch join을 메서드 단위로 제어할 수 있도록 도와준다.

1
2
3
4
5
public interface RegistryRepository extends JpaRepository<Registry, Long> {

    @EntityGraph(attributePaths = "comments")
    List<Registry> findAll();
}

attributePaths에 함께 조회할 연관 엔티티를 지정한다.

type 옵션

  • LOAD : 지정한 엔티티만 EAGER, 나머지는 글로벌 전략
  • FETCH : 지정한 엔티티만 EAGER, 나머지는 LAZY

image


@EntityGraph는 엔티티가 아닌 쿼리 단위로 EAGER 로딩을 제어할 수 있다는 장점이 있다.



comment로 적용

@EntityGraph 적용 전

image


@EntityGraph 적용 후

image




join fetch와 @EntityGraph

Join Fetch는 INNER JOIN 방식이다.

image


EntityGraph는 OUTER JOIN 방식이다.

image


공통적으로 카테시안 곱이 발생하여 중복 데이터가 생길 수 있다.


해결방법

1. 컬렉션 타입을 Set으로 변경

1
2
@OneToMany(mappedBy = "registry", cascade = CascadeType.ALL, orphanRemoval = true)
private Set<Comment> comments = new LinkedHashSet<>();

2. DISTINCT 사용

1
2
@Query("select DISTINCT r from Registry r join fetch r.comments")
List<Registry> findAll();

실제 db에서 registry와 comment가 각각 6개, 17개의 데이터가 있다.

image

image

DISTINCT를 적용 붙이기 전에는 registry size가 17로 출력되었는데

DISTINCT를 적용하고 나서는 registry size가 6으로 출력되었다.

image

image




3. Batch Size

@BatchSize는 연관 엔티티를 지정한 크기만큼 묶어서 조회한다.

1
2
3
@BatchSize(size = 10)
@OneToMany(mappedBy = "registry", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Comment> comments = new ArrayList<>();

image

10개씩 where in 쿼리로 조회한다.




4. @Fetch(FetchMode.SUBSELECT)

연관 엔티티를 서브쿼리로 한 번에 조회한다.

1
2
3
@Fetch(FetchMode.SUBSELECT)
@OneToMany(mappedBy = "registry", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Comment> comments = new ArrayList<>();

image

서브쿼리를 통해 N+1 문제를 해결한다.




5. QueryBuilder

복잡한 쿼리는 QueryDSL, MyBatis, JOOQ 등을 사용한다.

1
2
3
return from(user)
       .leftJoin(user.comments, comment)
       .fetchJoin();






reference
JPA N+1 문제 원인 및 해결방법 알아보기
[JPA] N+1 문제가 발생하는 여러 상황과 해결방법
Spring JPA(ORM)의 N+1 쿼리 문제 해결

This post is licensed under CC BY 4.0 by the author.