Joshbla 2023. 5. 22. 21:55

[ JPA ] N+1 문제

면접에서 JPA N+1문제에 대한 질문이 나왔는데 잘 대답하지 못했다.

이전에 봤었는데 가볍게 읽어보고 넘어간게 후회가 됐다. 

그래서 이번 기회에 다시 정리해보려고 한다.


N+1문제란?

우선 JPA에서 N+1문제란 

요청이 1개의 쿼리로 행해지길 기대했지만 N개의 추가쿼리가 발생하는 문제이다.

 

테이블은 아래와 같이 구성되어있고 

회원 한명이 여러개의 리뷰를 남길 수 있으므로 1:N(일대다) 관계이다.

이러한 예시로 한 번 공부해보겠다.


Fetch Type

우선 jpa의 Fetch Type에 대해서 알아봐야한다.

Fetch Type이란 jpa가 하나의 Entity를 조회할 때 연관관계가 설정되어있는 객체를 어떻게 조회할지 설정하는 값이다.

 

연관관계에 따라 기본으로 설정되어있는 Fetch Type이 다르다.

  연관관계 FetchType기본값 
~ToOne 일대일 (OneToOne) Eager
  다대일 (ManyToOne) Eager
~ToMany 일대다 (OneToMany) Lazy
  다대다 (ManyToMany) Lazy

 

FetchType.Eager

즉시로딩 전략으로 회원을 조회할 때 회원에 연관되어있는 객체까지 모두 조회하는 방법이다.

 

자 이제 jpa의 findAll() 메서드로 모든 회원정보를 조회해보자.

그러면 이와 같이 맨 첫줄에 모든 회원을 조회하는 쿼리가 작성됐다.

그런데 그 아래로 연관관계로 인해 회원 수 만큼의 review를 조회하는 쿼리가 추가로 작성됐다.

 

지금은 회원이 적어서 괜찮지만 수백만의 회원이 사용하는 서비스라면 부담이 클 것이다.

 

FetchType.Lazy

지연로딩 전략으로 회원을 조회할 때 회원만을 조회하고

연관된 객체는 객체 정보를 실제 사용하는 시점에 쿼리를 따로 날리는 방법이다.

 

jpa findAll()메서드로 회원정보를 조회해보면

이렇게 한 건의 쿼리만 발생하였다.

 

그러면 해결이 된것일까?

그러나 지연로딩 전략은 위에 말했듯이 객체를 실제 사용하는 시점에 추가 쿼리가 작성된다.

전체 회원을 조회한 목적에 따라 추가적인 쿼리가 나갈 수 있다는 것이다.

예를들어 목적이 각 회원이 작성한 리뷰의 수를 알고싶어서라고 한다면

public void getReviewCnt(){
    List<Member> list = memberRepository.findAll();
    int sum = 0;
    for (Member m : list) {
        System.out.println(m.getReviews().size());
    }
}

위와같이 작성할 것이고

결국 같은양의 추가적인 쿼리가 발생하게 된다.

 

그렇다면 어떻게 해결할 수 있을까?


Fetch Join 사용

Fetch join은 엔티티를 조회할 때 연관된 엔티티까지 한 번에 같이 조회하는 기능이다.

 

사용방법

1) 쿼리문에 직접 작성

@Query("select distinct m from Member m left join fetch m.reviews")
List<Member> findAllJPQLFetch();

이처럼 join문에 fetch를 걸어서 연관관계에있는 reviews를 같이 즉시로딩하면

이처럼 하나의 쿼리로 전체회원 각자의 리뷰수를 구할 수 있게됐다.

 

*쿼리문에 distinct를 걸어주는 이유는 1:N 관계에서 컬렉션 조회 시 데이터의 중복문제가 발생할 수 있기 때문이다.

따라서 중복을 막아주는 distinct를 걸어서 문제를 해결했다.

 

2) @EntityGraph 어노테이션 사용

    @EntityGraph(attributePaths = {"reviews"}, type = EntityGraph.EntityGraphType.FETCH)
    @Query("select distinct m from Member m left join m.reviews")
    List<Member> findAllJPQLEntityGraph();

이처럼 query에 fetch를 추가하지 않고 @EntityGraph 어노테이션을 사용하면 같은 기능으로 작동하는 것을 알 수 있다.

같은 결과

Fetch Join의 문제점

Pagination을 할 때 문제가 발생한다.

페이징된 데이터를 가져왔을 때 위에서 잠깐 말했던 데이터 중복문제가 발생할 수 있다.

만약 중복데이터가 존재한다면 페이징 된 데이터의 개수가 그만큼 줄어들 것이고 원하는 결과와 달라질 수있다.

(~ToOne(일대일, 다대일) 연관관계에선 데이터 중복이 일어나지 않기 때문에 그냥 fetch join과 페이지네이션을 함께 사용해도 된다. 문제는 일대다, 다대다의 경우에 발생한다.)

 

따라서 jpa는 페이징 처리를 하고 데이터를 가져오는 것이 아니라

모든 데이터를 불러와서 인메모리에서 페이징 처리를 하게 된다.

원래는 필요한 만큼만 데이터를 불러오기 위해 페이징을 사용하는 것이지만 모든 데이터를 불러오게 되므로 성능이슈가 발생하게된다.

 

실제로 FetchJoin과 페이지네이션을 함께 사용하면 아래와 같은 경고가 발생한다.

FetchJoin과 Pagination을 함께 사용

HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!

그리고 실제 작성된 쿼리를 보면

페이지네이션에 사용되는 limit절이 포함되어있지 않은 결과를 볼 수 있다.

위에서 말한것처럼 모든 데이터를 가져와 인메모리에서 페이징을 하기때문이다.

 

그렇다면 페이지네이션을 사용하려면 어떻게 해야할까?

@BatchSize를 이용하여 해결할 수 있다.

이 어노테이션을 사용하면 연관된 객체를 조회할 때 지정된 size 만큼 SQL의 IN절을 사용하여 조회하게된다.


정리

- N+1문제는 데이터를 조회할 때 연관관계로 인해 불필요한 쿼리까지 같이 발생하는 문제이다.

- 임시적인 해결방법으로는 Fetch Type을 지연로딩 전략으로 바꾸는 것이있다. (실제 연관된 객체를 사용한다면 무의미)

- 본질적인 해결을 위해서는 FetchJoin을 사용해야하며 직접 fetch를 추가해주거나 @EntityGraph 어노테이션을 이용하여 해결할 수 있다.

- 다만 이 방법을 사용하면 페이지네이션을 사용할 때 문제가 발생한다. 이는 BatchSize를 설정해 해결할 수 있다.

 

 

 

참고 블로그: JPA 모든 N+1 발생 케이스과 해결책 (velog.io)

 

JPA 모든 N+1 발생 케이스과 해결책

N+1이 발생하는 모든 케이스 (즉시로딩, 지연로딩)에서의 해결책과 그 해결책에서의 문제를 해결하는 방법에 대해 이야기 하려합니다 😀

velog.io

N+1 문제 - Incheol's TECH BLOG (gitbook.io)

 

N+1 문제 - Incheol's TECH BLOG

Query를 실행하도록 지원해주는 다양한 플러그인이 있다. 대표적으로 Mybatis, QueryDSL, JOOQ, JDBC Template 등이 있을 것이다. 이를 사용하면 로직에 최적화된 쿼리를 구현할 수 있다.

incheol-jung.gitbook.io