Home N+1 문제
Post
Cancel

N+1 문제

JPA의 N+1 문제

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


N+1 문제란?

N+1이란 엔티티 하나를 조회하기 위해서 연관된 엔티티까지 조회 쿼리문이 N+1번 날라간다.

이로 인해 시스템에 심각한 성능 저하가 일어날 수 있다.

이러한 부분을 N+1 문제라고 한다.


N + 1 문제는 연관관계가 설정된 엔티티 사이에서 한 엔티티를 조회하였을 때

조회된 엔티티의 개수(N 개)만큼 연관된 엔티티를 조회하기 위해 추가적인 쿼리가 발생하는 문제를 의미한다.


N + 1에서 1은 한 엔티티를 조회하기 위한 쿼리의 개수이며,

N은 조회된 엔티티의 개수만큼 연관된 데이터를 조회하기 위한 추가적인 쿼리의 개수를 의미한다.


☑️ 엔티티 조회 쿼리(1 번) + 조회된 엔티티의 개수(N 개)만큼 연관된 엔티티를 조회하기 위한 추가 쿼리 (N 번)



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

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


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

영속성 컨텍스트에 의해 관리되는 엔티티들은 데이터베이스에 쿼리를 실행하거나 변경사항을 반영할 때 이를 관리하고 최적화한다.


LAZY 로딩을 사용하는 경우,

연관된 엔티티들은 실제로 필요한 시점까지 데이터베이스에서 조회되지 않기 때문에 영속성 컨텍스트에 관리되고 있어야 한다.

그렇지 않으면 해당 연관된 엔티티들을 조회하는 과정에서 영속성 컨텍스트가 없어서 LazyInitializationException이 발생할 수 있다.


EAGER 로딩을 사용하는 경우에는 엔티티를 조회할 때 연관된 다른 엔티티들도 한꺼번에 데이터베이스에서 함께 조회되기 때문에

영속성 컨텍스트가 없더라도 상관없이 엔티티와 함께 연관된 Entity들까지 모두 가져오게 된다.

EAGER 로딩을 사용하는 경우에는

영속성 컨텍스트에 해당 Entity와 함께 연관된 엔티티들이 모두 영속상태가 아니어도 문제가 발생하지 않는다.

@Transactional 어노테이션을 달아 트랜잭션 범위를 정해준다.




발생

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

fetch = FetchType.LAZY로 설정하면 N+1 문제는 발생하지 않지만 해당 객체에 대해 전부 조회하는 경우에 N+1 문제가 발생한다.


FetchType을 변경하는 것은 단지 N+1 발생 시점을 연관관계 데이터를 사용하는 시점으로 미룰지,

아니면 초기 데이터 로드 시점에 가져오느냐에 차이만 있는 것이다.

결국 fetch = FetchType.EAGER or fetch = FetchType.LAZY로 설정하는 것과 관계 없이 N+1 문제가 발생된다.




예시

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

하나의 게시글에 여러 개의 댓글이 있는 상황에서 게시글을 조회할 때 Eager 로딩으로 설정된 경우,

게시글 1개를 가져오기 위해 추가적으로 댓글들도 함께 가져오게 된다.

→ 게시글 1개를 가져오는 쿼리 1개와 댓글을 가져오는 쿼리 N 개가 실행되어 총 N + 1 개의 쿼리가 발생하게 된다.



code

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@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 문제가 발생할 수 있다.

이 경우 N+1번의 쿼리가 발생하는 것이다.

  • Registry 조회 - 1
  • Comment의 갯수(각 Registry가 가지고 있는 Comment 조회) - N

→ 쿼리가 발생하는 시점만 달라질 뿐, 전체적인 쿼리의 수는 동일하다.



findAll()메서드를 호출하게 되면 아래와 같이 실행된다.


EAGER
JPQL에서 동작한 쿼리를 통해서 객체에 데이터가 바인딩 된다.

그 이후 JPA에서는 글로벌 패치 전략(즉시 로딩)을 받아들여 해당 객체 대해서 추가적인 LAZY 로딩으로 N+1을 발생시킨다.


LAZY
동일하게 객체에 데이터가 바인딩되지만(JPA가 글로벌 패치 전략을 받아들이지만)

LAZY 로딩이기 때문에 추가적인 SQL을 발생시키지 않는다.

하지만 LAZY로 추가적인 작업을 진행하게되면 결국 N+1 문제가 발생하게 된다.




해결방법

fetch join, @EntityGraph로 해결이 가능하다.

(그러나 페이징은 진행할 수 없으며,

둘 이상의 컬렉션을 fetch join하는 데이터가 부정합하게 조회되기 때문에 이를 사용하지 않는 것이 좋다.)

OneToMany 관계에서는 @BatchSize 혹은 @Fetch(FetchMode.SUBSELECT)로 해결한다.



1. Fetch Join

조인할 때 연관된 엔티티나 컬렉션를 함께 조회하려고 할 때 사용한다 결과는 EAGER와 똑같지만 과정은 다르다.

EAGER의 경우에는 N+1 쿼리가 발생하지만 Fetch Join의 경우에는 한번이 쿼리문으로 해결이 가능하다.

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

Spring Data JPA 에서는 @Query 어노테이션을 이용하여 JPQL를 생성할 수 있다.

사용하는 방법은 위와 동일하게 join fetch 뒤에 연관된 entity나 컬렉션을 적어주면 된다.


fetch 키워드를 사용하게 되면 연관된 entity나 collection을 한 번에 같이 조회할 수 있다.

→ fetch join을 사용하게 되면 연관된 entity는 프록시가 아닌 실제 entity를 조회하게 되므로

연관관계 객체까지 한 번의 쿼리로 가져올 수 있다.

하지만 collection을 fetch join하면 페이징 API를 사용할 수 없으며, 둘 이상 collection을 fetch 할 수 없다.




2. EntityGraph

@EntityGraph도 마찬가지로

EntityGraph 상에 있는 Entity들의 연관관계 속에서 필요한 엔티티와 컬렉션을 함께 조회하려고 할 때 사용한다.

fetch join을 편하게 사용하도록 도와주는 기능

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

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

*중괄호는 넣어도 되고 안넣어도 되지만 여러 속성을 지정할 경우 넣어줘야한다. ex)attributePaths = {"registry", "comment"}


Registry에서 comments로 설정했기 때문에 @EntityGraph(attributePaths = {"comments"})로 설정했다.

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


Spring Data JPA에서 적용하려는 메소드 위에 @EntityGraph 어노테이션을 달고 옵션을 준다

attributePaths는 같이 조회할 연관 엔티티명을 적으면 된다. ,(콤마)를 통하여 여러 개를 줄 수도 있다.

type은 EntityGraphType.LOAD, EntityGraphType.FETCH 2가지가 있다.

  • LOAD : attributePaths에 정의한 엔티티들은 EAGER, 나머지는 글로벌 패치 전략에 따라 패치한다.
  • FETCH : attributePaths에 정의한 엔티티들은 EAGER, 나머지는 LAZY로 패치한다.


image


@EntityGraph를 사용하여 특정 연관 관계를 Eager 로딩으로 설정하면,

해당 연관 관계를 사용하는 쿼리 실행 시점에 모든 연관된 엔티티를 함께 조회하게 된다.

이는 기본적으로 Eager 로딩과 동일한 효과를 가지지만, Eager 로딩과 다른 점도 있다.

Eager 로딩은 엔티티 클래스 자체에 설정되는 것이고, @EntityGraph는 특정 쿼리 메서드에만 적용할 수 있는 기능이다.

즉, 특정 쿼리 메서드에서만 Eager 로딩을 수행하고 나머지 경우에는 LAZY 로딩을 유지할 수 있다.



comment로 적용해보기

@EntityGraph 적용 전

image


@EntityGraph 적용 후

image


= = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = =


image




join fetch와 @EntityGraph 사용

join fetch와 @EntityGraph 사용 할 경우 출력되는 쿼리

Join Fetch

image


@Entity Graph

image

Join FetchInner Join, Entity GraphOuter Join이라는 차이점이 있다.

공통적으로 카테시안 곱(Cartesian Product)이 발생하여 Comment의 수만큼 Registry가 중복 발생하게된다.


해결방법

1. 1:N 필드의 타입을 Set으로 선언하기

1
2
3
4
@OneToMany(mappedBy = "registry", cascade = CascadeType.ALL, orphanRemoval = true)
@JsonIgnore
@ToString.Exclude
private List<Comment> comments = new LinkedHashSet<>();

Set이 순서가 보장되지 않기에 LinkedHashSet을 사용하여 순서를 보장한다.


2. distinct를 사용해서 중복 제거하기

join fetch

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


@EntityGraph

1
2
3
@EntityGraph(attributePaths = "comments")
@Query("select DISTINCT r from Registry r")
List<Registry> findAllEntityGraph();



차이 확인해보기

1
2
List<Registry> registry = registryRepository.findAll();
System.out.println("registry size : " + registry.size());

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

image


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

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

image

image


db join 때문에 각 regisry와 연결된 comment가 join 되면서 중복된 값들까지 포함하여 17개의 결과가 나왔다.

DISTINCT를 적용하면 중복을 제거하기 때문에 registry만을 조회하므로

registry의 개수에 해당하는 6개의 결과가 나오게 된 것이다.

→ comment는 조회 결과에 영향을 미치지 않게 된다.


registry와 comment에 각각 1개씩 db를 추가하여 실행해봤을 때

DISTINCT를 적용하기 전후 각각 registry의 size는 7, 18로 나왔다.


또한 join fetch를 적용했을 때는 여전히 registry의 size가 6으로 나왔고

entityGraph로 적용했을 때는 실제 db 개수에 맞는 7이 나왔다.

이유는 정확히 모르겠으나 join fetch를 적용했을 때 중복된 registry 데이터가 하나 제거되어 6개가 나온게 아닐까?

EntityGraph는 실제 db와 일치하는 7개의 결과가 나오니 EntityGraph를 적용하는게 맞을 것 같다.


  • join fetch: INNER JOIN 방식으로 데이터를 가져오면서 중복된 결과가 있을 경우,
    distinct를 사용하여 중복을 제거한다.

  • EntityGraph: 로딩 전략을 지정한 대로 연관 엔티티를 로딩한다.
    예를 들어 @EntityGraph에서는 comments를 제외하고 registry만 로딩하므로 중복 문제가 발생하지 않는다.




3. Batch Size

@BatchSize는 Hibernate에서 제공하는 기능으로,

쿼리를 실행할 때 지정한 크기만큼 일괄 처리(Batch)하여 데이터를 가져오는 방법이다.

spring.jpa.properties.hibernate.default_batch_fetch_size = 10 or

@BatchSize 을 통해서 설정한 size 만큼 데이터를 미리 로딩 한다.

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

연관된 엔티티를 10개씩 가져올 때마다 1번의 쿼리를 실행한다.

즉, 연관된 엔티티를 10개씩 묶어서 가져오는 방식이다.

image

즉 연관된 엔티티를 조회할때 size 만큼 where in 쿼리를 통해서 조회하게되고 size를 넘어가게 되면 추가로 where in 쿼리를 진행한다.




4. @Fetch(FetchMode.SUBSELECT)

연관된 엔티티들을 서브쿼리를 통해 한 번에 조회하는 방식이다.

기본 엔티티와 연관된 모든 엔티티들을 하나의 쿼리로 조회하여 한 번에 가져오는 것이다.

서브쿼리를 사용한 FETCH SUBSELECT는 기본적으로 엔티티만 가져온 후,

연관된 엔티티들을 실제로 필요한 시점에 추가적인 쿼리를 통해 조회하는 방식이다.

엔티티를 가져올 때 불필요한 데이터를 최소화하면서, 필요한 시점에 추가 쿼리를 통해 연관된 엔티티를 조회하는 방식으로 N+1 문제를 해결한다.

반면, EAGER 로딩은 한 번의 쿼리로 모든 연관된 엔티티를 함께 가져오는 방식이다.

따라서 한 번에 필요한 모든 데이터를 가져오므로, FETCH SUBSELECT와 달리 추가 쿼리 없이 모든 연관된 엔티티를 조회할 수 있다.

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

image

서브쿼리를 통해 N + 1 문제를 해결하는 것을 확인할 수 있다.



@BatchSize와 @Fetch(FetchMode.SUBSELECT)는 모두 N+1 문제를 해결하기 위한 방법이지만,

@BatchSize는 여러 번의 쿼리를 실행할 수 있고,

FETCH SUBSELECT는 한 번의 쿼리로 모든 연관된 엔티티를 가져온다는 점에서 차이가 있다.




5. QueryBuilder

Query를 실행하도록 지원해주는 다양한 플러그인이 있다.

대표적으로 Mybatis, QueryDSL, JOOQ, JDBC Template 등이 있다.

이를 사용하면 로직에 최적화된 쿼리를 구현할 수 있다.

1
2
3
// QueryDSL로 구현한 예제
return from(user).leftJoin(user.comments, comment)
                   .fetchJoin()




결론

처음엔 JPA의 N + 1 문제라는 것이 무엇인지 정확히 알지 못했다.

이번 연관관계 매핑으로 인해 로그에서 N+1의 문제를 볼 수 있었고 자연스럽게 N+1 문제에 대해 공부할 수 있는 시간이 되었다.

  • N+1 문제는 하나의 엔티티를 조회할 때 연관된 엔티티들을 추가로 조회하면서 발생하는 문제다.

    → 상위 엔티티를 조회한 후 연관된 하위 엔티티를 사용할 때마다 별도의 쿼리가 추가로 실행되어 성능 저하를 발생시킨다.

  • N+1은 JPA를 사용하면서 연관관계를 맺는 엔티티를 사용한다면 한번 쯤은 부딪힐 수 있는 문제다.

  • Fetch Join이나 EntityGraph를 사용한다면 Join문을 이용하여 하나의 쿼리로 해결할 수 있지만
    중복 데이터 관리(카테시안곱)가 필요하고 FetchType을 어떻게 사용할지에 따라 달라질 수 있다.

  • BatchSize는 연관관계의 데이터 사이즈를 정확하게 알 수 있다면 최적화할 수 있는 size를 구할 수 있겠지만
    사실상 연관 관계 데이터의 최적화 데이터 사이즈를 알기는 쉽지 않다.

  • @Fetch(FetchMode.SUBSELECT)는 상위 엔티티와 연관된 모든 하위 엔티티들을 하나의 서브 쿼리로 조회한다.

  • JPA 만으로는 실제 비즈니스 로직을 모두 구현하기 부족할 수 있다.
    간단한 구현은 JPA를 사용하여 프로젝트의 퍼포먼스를 향상 시킬수 있겠지만
    다양한 비즈니스 로직을 복잡한 쿼리를 통해서 구현하다보면 다양한 난관에 부딪힐 수 있다.
    그리고 불필요한 쿼리도 항상 조심해야 한다. 그러므로 QueryBuilder를 함께 사용하는 것을 추천한다.



reference
JPA N+1 문제 및 해결방안
[JPA] N+1 문제가 발생하는 여러 상황과 해결방법
JPA N+1 발생원인과 해결 방법
Spring JPA(ORM)의 N+1 쿼리 문제 해결
[JPA] N+1가 발생하는 이유와 어떻게 해결하는지에 대해서 학습하기
N+1 문제

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