프로그래밍 공부/Spring

N+1 문제 해결하기

N+1 문제란?

연관 관계가 설정된 엔티티를 조회할 경우에 조회된 데이터 개수(n) 만큼 연관관계의 조회 쿼리가 추가로 발생하여 데이터를 읽어오는 현상

 

발생 이유

N+1 문제가 발생하는 이유는 JPA가 JPQL을 분석해서 SQL을 생성할 때는 글로벌 Fetch 전략을 참고하지 않고 오직 JPQL 자체만을 사용하기 때문이다. 즉, 아래와 같은 순서로 동작하기 때문에 발생한다고 보면 된다.

  1. findAll()을 한 순간 select t from Team t 이라는 JPQL 구문이 생성되고 해당 구문을 분석한 select * from team이라는 SQL이 생성되어 실행된다.
  2. DB의 결과를 받아 team 엔티티의 인스턴스들을 생성한다.
  3. team과 연관되어 있는 user 도 로딩을 해야 한다.
  4. 영속성 컨텍스트에서 연관된 user가 있는지 확인한다.
  5. 영속성 컨텍스트에 없다면 2에서 만들어진 team 인스턴스들 개수에 맞게 select * from user where team_id =?이라는 SQL 구문이 생성된다. ( N+1 발생 )

 

 

해결법

Fetch Join

JPQL을 사용하여 DB에서 데이터를 가져올 때 처음부터 연관된 데이터까지 같이 가져오게 하는 방법이다. (SQL Join 문을 생각하면 된다. )

별도의 메소드를 만들어줘야 하며 @Query 어노테이션을 사용해서 join fetch 엔티티.연관관계_엔티티 구문을 만들어 주면 된다.

 

Fetch Join과 페이지네이션

그러나 JPA에서 paging을 하게 되면, OneToMany, ManyToMany와 같은 컬렉션 관계는 fetch join이 불가능해진다. 만약 적용하게 되더라도 경고 로그가 남으면서 모든 데이터를 메모리에 불러와 페이징을 적용하는 것을 볼 수 있다. 즉, OOM(Out of Memory)이 발생할 확률이 굉장히 높아지는 것이다.

왜 이런 일이 발생하는 것일까?

Join의 결과를 생각해보면 된다. ~toMany의 관계에서는 ~대다 관계를 조인하면서 데이터의 개수가 바뀌기 때문이다. 그렇기 때문에 JPA에서는 기본적으로 이를 막아두게 된다. (참고용 영한님의 인프런Q&A)

해결책

  • ~ToOne  관계의 엔티티는 Fetch Join해도 괜찮다.
  • ~ToMany 관계의 엔티티인 경우
    • @BatchSize 혹은 spring.jpa.properties.hibernate.default_batch_fetch_size 옵션을 적용하여 쿼리의 개수를 줄인다.
      1. X 타입 엔티티가 지연 로딩된 ~ToMany 관계의 Y 타입 컬렉션을 최초 조회할 때
      2. 이미 조회한 X 타입 엔티티(즉, 영속성 컨텍스트에서 관리되고 있는 엔티티)들의 ID들을 모아서
      3. WHERE Y.X_ID IN (?, ?, ?...) 와 같은 SQL IN 구문에 담아 Y 타입 데이터 조회 쿼리를 날린다.
      4. X 타입 엔티티들이 필요로 하는 모든 Y 타입 데이터를 한 번에 조회한다.
      여기서 Batch Size 옵션에 할당되는 숫자는 IN 구문에 넣을 부모 엔티티 Key(ID)의 최대 개수를 의미한다.

 

Batch Size

위에서 설명했듯 이 옵션은 정확히는 N+1 문제를 안 일어나게 하는 방법은 아니고 N+1 문제가 발생하더라도 select * from user where team_id = ? 이 아닌 select * from user where team_id in (?, ?, ? ) 방식으로 N+1 문제가 발생하게 하는 방법이다. 이렇게 하면 100번 일어날 N+1 문제를 1번만 더 조회하는 방식으로 성능을 최적화할 수 있다.

 

 

참고자료

https://jojoldu.tistory.com/165

https://programmer93.tistory.com/83

https://tecoble.techcourse.co.kr/post/2020-10-21-jpa-fetch-join-paging/