JPA N+1 문제와 해결법 총정리

taehee kim·2023년 4월 18일
16

0. 배경

JPA로 개발 과정에서 N+1문제를 만나면서 해맸던 부분들과 실제 프로덕션에서 이를 제대로 처리하지 못하면 성능적인 저하뿐만 아니라 장애로 이어질 수 있다는 부분을 깨닫고 정리 해보고자 합니다.

1. N+1 Select문제

정의

N+1 문제는 ORM 기술에서 특정 객체를 대상으로 수행한 쿼리가 해당 객체가 가지고 있는 연관관계 또한 조회하게 되면서 N번의 추가적인 쿼리가 발생하는 문제를 말합니다.

원인

N+1문제가 발생하는 근본적인 원인은 관계형 데이터베이스와 객체지향 언어간의 패러다임 차이로 인해 발생합니다. 객체는 연관관계를 통해 레퍼런스를 가지고 있으면 언제든지 메모리 내에서 Random Access를 통해 연관 객체에 접근할 수 있지만 RDB의 경우 Select 쿼리를 통해서만 조회할 수 있기 때문입니다.

예시

  • 해당 코드에서 Article은 여러 Opinion을 가지고 있습니다.
  • ArticleRepository에서 여러 Article을 조회하는 method를 호출할 시 1개의 Select쿼리로 인해 Article이 조회 되고 FetchType.Lazy설정으로 인해 List Opinion자리에는 프록시 객체가 생성되게 됩니다. 해당 컬렉션을 코드내에서 조회하려고 하는 순간 N개의 Select쿼리가 발생하게 됩니다.
@Entity
public class Article extends BaseEntity{
    @OneToMany(mappedBy = "article", fetch = FetchType.LAZY, cascade = CascadeType.REMOVE)
    private List<Opinion> opinions = new ArrayList<>();
}
Hibernate: 
    select
        article0_.article_id as article_1_2_,
        article0_.created_at as created_2_2_,
        article0_.updated_at as updated_3_2_,
        article0_.created_by as created_4_2_,
        article0_.updated_by as updated_5_2_,
        article0_.anonymity as anonymit6_2_,
        article0_.api_id as api_id7_2_,
        article0_.content as content8_2_,
        article0_.content_category as content_9_2_,
        article0_.date as date10_2_,
        article0_.is_complete as is_comp11_2_,
        article0_.is_deleted as is_dele12_2_,
        article0_.participant_num as partici13_2_,
        article0_.participant_num_max as partici14_2_,
        article0_.title as title15_2_,
        article0_.version as version16_2_ 
    from
        article article0_
Hibernate: 
    select
        opinions0_.article_id as article10_11_0_,
        opinions0_.opinion_id as opinion_1_11_0_,
        opinions0_.opinion_id as opinion_1_11_1_,
        opinions0_.created_at as created_2_11_1_,
        opinions0_.updated_at as updated_3_11_1_,
        opinions0_.created_by as created_4_11_1_,
        opinions0_.updated_by as updated_5_11_1_,
        opinions0_.api_id as api_id6_11_1_,
        opinions0_.article_id as article10_11_1_,
        opinions0_.content as content7_11_1_,
        opinions0_.is_deleted as is_delet8_11_1_,
        opinions0_.level as level9_11_1_,
        opinions0_.member_author_id as member_11_11_1_,
        opinions0_.parent_opinion_id as parent_12_11_1_ 
    from
        opinion opinions0_ 
    where
        opinions0_.article_id=?
Hibernate: 
    select
        opinions0_.article_id as article10_11_0_,
        opinions0_.opinion_id as opinion_1_11_0_,
        opinions0_.opinion_id as opinion_1_11_1_,
        opinions0_.created_at as created_2_11_1_,
        opinions0_.updated_at as updated_3_11_1_,
        opinions0_.created_by as created_4_11_1_,
        opinions0_.updated_by as updated_5_11_1_,
        opinions0_.api_id as api_id6_11_1_,
        opinions0_.article_id as article10_11_1_,
        opinions0_.content as content7_11_1_,
        opinions0_.is_deleted as is_delet8_11_1_,
        opinions0_.level as level9_11_1_,
        opinions0_.member_author_id as member_11_11_1_,
        opinions0_.parent_opinion_id as parent_12_11_1_ 
    from
        opinion opinions0_ 
    where
        opinions0_.article_id=?

N+1이 문제가 되는 이유

N+1문제가 발생하면 쿼리가 배수적으로 증가하면서 DB에 큰 부담이 발생하게 되고 장애 요인이 될 수 있습니다. 또한 사용자 관점에서 지연율 또한 크게 증가할 수 있습니다.

2.N+1 Select 문제 해결법

2-1. Eager Loading으로 N+1문제를 해결하려고 하면 안됩니다.

  • Eager Loading은 연관된 Entity객체를 한번에 조회하도록 하는 기능으로 특정 경우에 N+1문제를 부분적으로 해결해줄 수 있지만 사용하지 않는 것이 좋습니다.

EAGER Loading을 사용해서는 안되는 이유

  • 어떤 Entity 연관관계 범위까지 Join쿼리로 조회 해올지 예상하기가 힘들어지기 때문에 오히려 필요없는 데이터까지 로딩하여 비효율적일 수 있습니다.
  • 또한 Entity관계가 복잡해지면 N+1문제가 해결 되지 않는 경우가 많습니다.
  • 그 예시로 제가 작성한 코드에서도 다른 연관관계들로 인해EAGER Loading으로 설정해도 N + 1문제가 그대로 발생합니다.
@Entity
public class Article extends BaseEntity{
    @OneToMany(mappedBy = "article", fetch = FetchType.EAGER, cascade = CascadeType.REMOVE)
    private List<Opinion> opinions = new ArrayList<>();
}

OneToOne관계에서 연관관계의 주인이 아닌 Entity에서는 Lazy Loading으로 설정하는 것이 불가능합니다. 이 경우를 제외하고는 모두 LAZY Loading으로 설정해야합니다.(JPA는 연관관계 객체가 존재하지 않으면 프록시 객체를 생성하지 않고 해당 연관관계 필드를 null로 비워 두려고 하는데 연관관계의 주인이 아닌 테이블에는 FK가 없기 때문에 연관관계 주인 테이블과 Join하지 않으면 이를 알 수 없기 때문입니다. 따라서 항상 Eager Loading됩니다)

2-2. Fetch Join + Lazy Loading

Fetch Join은 Root Entity에 대해서 조회 할 때 Lazy Loading 으로 설정 되어있는 연관관계를 Join쿼리를 발생시켜 한번에 조회할 수 있는 기능입니다.

적용사례

@Query("select Distinct a from Article a join fetch a.opinions")
List<Article> findAllArticleFetchJoinOpinion();

결과

  • 다음과 같이 총 3번 발생하던 쿼리가 한번의 join쿼리조회 됨을 알 수 있습니다.
select
        distinct article0_.article_id as article_1_2_0_,
        opinions1_.opinion_id as opinion_1_11_1_,
        article0_.created_at as created_2_2_0_,
        article0_.updated_at as updated_3_2_0_,
        opinions1_.article_id as article10_11_1_,
        ...
        opinions1_.opinion_id as opinion_1_11_0__ 
    from
        article article0_ 
    inner join
        opinion opinions1_ 
            on article0_.article_id=opinions1_.article_id

자세한 개념

1. Fetch Join과 일반 Join의 차이

근본적인 차이는 Fetch Join은 ORM에서의 사용을 전제로 DB Schema를 Entity로 자동 변환 해주고 영속성 컨텍스트에 영속화 해준다는 부분에 있습니다.

  • 이 때문에 Fetch Join을 통해 조회 하면 연관 관계는 영속성 컨텍스트 1차캐시에 저장되어 다시 엔티티 그래프를 탐색하더라도 조회 쿼리가 수행 되지 않습니다.
  • 반면 일반 Join쿼리는 단순히 데이터를 조회 하는 개념으로 영속성 컨텍스트나 Entity와는 무관합니다.
  • 따라서 가능하다면 Fetch Join을 활용해야 ORM을 활용하여 관계형 데이터베이스와의 패러다임차이를 줄일 수 있습니다.

2. Collection 연관관계 Fetch Join시 주의사항(매우 중요)

설명에 앞서 Fetch Join을 Collection에 대해서 할 경우 SQL Native Join 쿼리가 발생하게 되고 이 경우 1:n관계이기 때문에 1쪽의 데이터는 중복된 상태로 조회하게 됩니다.
다음과 같은 레코드 형태로 데이터를 받아오고 이를 객체로 매핑하는 것이라는 점을 알고 있어야 이후의 설명을 이해할 수 있습니다.

레코드ArticleOpinion
1Article1Opinion1
2Article1Opinion2
3Article2Opinion3
4Article2Opinion4
2-1. Distinct 절을 사용해야합니다.
  • 위에서 Join을 통해 Application Server로 조회된 데이터를 보면 Article들은 1:n관계이기 때문에 중복 되어 존재함을 알 수 있습니다. 이 때문에 Fetch Join을 통해 조회 시 동일한 Article 객체가 n만큼의 관계만큼 생성됩니다.
  • 이를 방지하기 위해서는 Distinct절을 활용해야합니다.이 Distinct절은 JPQL상의 Distinct로 SQL에서의 Distinct와는 조금 다릅니다.
  • SQL에서의 Distinct절은 DB에서 수행되며 join되어 발생한 데이터 형태에서 각 row를 비교하여 다른 경우만 남깁니다. 이때 Article 이 겹치는 경우는 있지만 Opinion은 절대 겹치지 않기때문에 어떠한 row도 같지 않습니다.즉, Article1+opinion1 은 Article1+opinion2와 같을 수 없습니다.
  • 반면 Application에서는 JPQL의 Distinct를 수행할 때 조회 대상 Entity 즉, Select쿼리 바로 다음에 오는 Entity객체에 대해서 Distinct를 수행합니다. 따라서 중복되는 Article을 제거해줄 수 있습니다.
2-2. Collection Fetch Join은 하나까지만 가능합니다.
  • 여러 Collection에 대해서 Fetch Join을 하게 되면 잘못된 결과과 발생하기 때문에 꼭 하나까지만 Fetch Join해야합니다.
2-3. Paging을 해서는 안됩니다(Out Of Memory 발생 가능).
  • Collection Fetch Join에서 Paging을 할 경우 다음과 같은 발생하게 됩니다. 이를 해석해보면 'Paging을 Memory에서 하고 있다.'라는 의미입니다.
2022-01-16 12:37:18.309  WARN 39536 --- [           main] o.h.h.internal.ast.QueryTranslatorImpl   : HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!
  • 이런 문제가 발생하는 이유는 첫번째로 JPA는 DB table에서 레코드 관계를 영속화된 Entity형태로 완벽하게 표현하는 것을 전제로 하기 때문입니다. 예를 들어 Article1에 2개의 댓글이 써있고 Article2에 2개의 댓글이 써 있다면 영속화 되어있는 Entity는 무조건 이 관계를 그대로 표현하여 Article1 Entity의 Collection에는 2개의 Opinion이 있고 Article2 Entity의 Collection에는 2개의 Opinion이 있어야 합니다.
  • 하지만, 만약 paging을 위에서 Native SQL Join쿼리에 의해서 생긴 스키마에 대해서 하게 된다면 Application, JPA입장에서는 실제 DB 레코드의 관계와 다른 데이터를 받게 될 수 있고 누락된 레코드 관계가 있다는 것을 알 수가 없게 됩니다. page size를 3으로 적용하면 다음과 같이 데이터를 가져오게 되고 Article2는 Opinion을 1개만 가지고 있는것으로 알게 됩니다.
레코드ArticleOpinion
1Article1Opinion1
2Article1Opinion2
3Article2Opinion3
  • 이러한 문제를 방지하고 객체 관점에서 paging을 적용하기 위해 JPA에서 Paging을 하게 되면 join쿼리 레코드 관점이 아니라 조회 주 대상 Entity에 대해서(Select 단어 바로 다음에 나오는 객체) paging을 적용합니다. 이 경우에는 Article이 대상이 될 것입니다.
  • 만약, 해당 JPQL에서 page size 3을 하면 Article을 3개까지만 가져오는 의미이고 Article이 두개 이므로 모두 가져올 수 있게 됩니다.
  • 여기 까지는 별 문제가 없는 것 같은데 이러한 동작 방식이 Out Of Memory를 일으킬 수 있고 이는 처음에 보았던 경고 문구와 완계가 있습니다.
  • JPA에서 Join으로 받아온 데이터를 JPQL관점에서 주 Entity를 기준으로 Pagination을 하려면 테이블의 모든 데이터를 Application Server의 Memory로 로딩해야하기 때문입니다. 이 때문에 앞전에 보았던 경고문구가 발생한 것이고 이는 메모리 과부하로 장애 요인이 될 수 있습니다.
  • 따라서 컬렉션 Fetch Join에서는 Paging을 절대로 해서는 안됩니다.
  • 꼭 paging이 필요하다면 일반 Join쿼리를 활용하거나 아니면 뒤에 나올 BatchSize 옵션을 설정하여 활용하는 것이 바람직합니다.

2-3. default_batch_fetch_size, @BatchSize

  • Lazy Loading시 프록시 객체를 조회할 때 where in절로 묶어서 한번에 조회 할 수 있게 해주는 옵션입니다.
  • yml에 전역 옵션으로 적용할 수 있고 @BatchSize를 통해 연관관계 BatchSize를 다르게 적용할 수 있습니다.
spring:
  jpa:
    properties:
        default_batch_fetch_size: 100
  • Batch Size 100~1000정도로 적용하고 DBMS에 따라서 where in 절은 1000까지 제한하는 경우가 있으므로 1000이상은 설정을 잘 하지 않는다. WAS는 BatchSize가 크면 어처피 데이터를 메모리에 로딩해야하는 것을 똑같기 때문에 좋지만, DB에서는 부담이 될 수 있기 때문에 적절하게 조절해야합니다.

Fetch Join vs Batch Size

Fetch Join의 한계를 Batch Size는 해결할 수 있습니다.

  • Collection Fetch Join시 paging문제나 1개까지만 Fetch Join을 할 수 있는 문제를 해결할 수 있습니다.

쿼리 개수 관점

  • 쿼리 개수는 Fetch Join이 유리합니다. Batch Size의 경우 몇번의 쿼리가 더 발생될 수 있습니다.

데이터 전송량 관점

  • 데이터 전송량 관점에서는 Batch Size가 유리합니다. Fetch Join은 Join을 하고 나서 가져오기 때문에 중복 데이터를 많이 가져와야하기 때문입니다.

  • Fetch Join의 경우

레코드ArticleOpinion
1Article1Opinion1
2Article1Opinion2
3Article2Opinion3
4Article2Opinion4
  • BatchSize의 경우
레코드Article
1Article1
2Article2
레코드Opinion
1Opinion1
2Opinion2
3Opinion3
4Opinion4

2-5. 일반 join후 Projection하여 특정 컬럼만 Dto로 조회

select new 패키지 경로.ArticleDto(원하는 필드) 
from Article ar
join ar.opinions op
where op.article_id = ar.id
  1. 장점: Entity Column이 많을 때 Projection하여 특정 컬럼만 조회할 수 있음, 커버링 인덱스 활용가능성 상승.
  2. 단점: 영속성 컨텍스트와 무관하게 동작하고 Repository가 Dto에 의존하게 되기 때문에 API변경에 DAO도 수정되어야 할 수 있음.
  3. 이 방식을 사용하는 쿼리는 DAO를 분리하는 것이 좋음.

2-4. @EntityGraph

EntityGraph와 Fetch Join차이점

처음에는 이 기능을 FetchJoin을 Annotation방식으로 편리하게 사용하는 기능으로 알고 있었는데 사실은 Lazy Loading을 Eager Loading으로 부분적으로 전환하는 기능입니다.

EntityGraph가 FetchJoin보다 나은점

  • 여러 1:N 연관관계를 한번에 Join해 올 수 있습니다. FetchJoin의 경우 1개의 Collection까지만 같이 Join하여 조회할 수 있습니다.

적용 사례

  • Spring Data Jpa를 사용할 경우 다음과 같이 사용하면 됩니다.
public interface ArticleRepository extends JpaRepository<Article, Long> , ArticleRepositoryCustom {

    void deleteByApiId(String apiId);

    @EntityGraph(attributePaths = {"articleMatchConditions"})
    Optional<Article> findEntityGraphArticleMatchConditionsByApiIdAndIsDeletedIsFalse(String articleId);

    @EntityGraph(attributePaths = {"articleMembers"})
    Optional<Article> findEntityGraphArticleMembersByApiIdAndIsDeletedIsFalse(String articleId);
    }

3. 변경 감지 쿼리에서 발생하는 문제(N+1문제는 아님)

  • N+1문제는 아니지만 변경 감지를 통해 여러 엔티티를 수정하는 경우 쿼리가 Entity개수만큼 발생하는 문제가 있기 때문에 유사한 성격을 띈다고 생각하여 정리하려고 합니다.
  • 이 경우 Update쿼리를 직접 작성해야합니다.
  • 주의할 경우 Update쿼리를 직접 작성한 경우 영속성 컨텍스트와 무관하므로 EntityManager.flush, clear를 호출해주는 것이 좋습니다.

4. 결론

  1. 1:1 연관관계는 최대한 fetch join을 활용하고 컬렉션 연관관계는 default_batch_fetch_size활용.
  2. 많은 컬럼 중 특정 컬럼만 조회해야 할 경우나 커버링 인덱스를 활용하고 싶은 경우 데이터 전송량을 줄이고 싶으면 일반 Join을 하고 Projection하여 Dto로 바로 변환. 다만 이 경우 DAO객체를 분리하여 작성하는 것이 좋음.
profile
Fail Fast

0개의 댓글