JPA 사용시 N+1 쿼리 문제를 해결하는 여러가지 방법

Jinkyuhan·2021년 12월 29일
0

N+1 문제란?

N+1문제는 통상적으로 여러개의 결과를 가져오는 1개의 쿼리를 실행했을 때, N개 쿼리가 추가로 발생하는 문제를 말한다. 프로세스 입장에서 I/O바운드인 DB쿼리는 굉장히 비싼 시간적 비용임으로, 서버에 성능적인 하락과 함께 DB에 불필요한 부하를 일으킨다.


왜 이런 문제가 발생하는가?

필연적인 선후 관계

EntityManager가 N개의 결과를 가져오는 쿼리를 1개 실행하고 나서야, 나머지 쿼리의 조건을 지정할 수 있다. 즉, 첫번째 쿼리의 결과가 두번째 쿼리의 조건이 된다. 하지만 진짜 문제는 두번째 쿼리에서 발생한다.

JPA는 첫번째 쿼리의 결과로 부터 연관된 Entity의 ID를 조건으로 N개의 쿼리를 날린다. 최악의 상황에는 아무생각 없이 한 FindAll() 요청 한번이 해당 테이블의 데이터 갯수만큼의 쿼리를 날리는 것이다.

조건에 따라 다르게 동작하는 JPQL

그렇다면 JPA는 두번째 쿼리를 왜 이렇게 비효율적으로 보낼까?

N+1의 발생 원인을 상세하게 알기 위해서, FindAll 과 같은 메소드가 어떻게 동작하는지 알아야 한다.

JPA는 EntityManger의 createQuery()메소드를 통해 JPQL을 사용하여 DB에 질의 한다. 이때 EntityManager는 쿼리의 조건에 따라서 쿼리를 다르게 처리한다/

  • ID조건을 통한 쿼리의 경우, Persistence Context에 캐시된 데이터가 있다면 이를 가져오고, 아닐 경우 D로 SQL 쿼리를 보낸다. (Persistence가 트랜잭션 레벨의 캐싱 역할)

  • ID가 아닌 다른 조건을 갖는 쿼리의 경우, Persistence Context를 체크하지 않고, 항상 DB에 바로 쿼리를 날린다.

  • 둘 중 어떤 경우든, 쿼리결과를 받아온 후 영속성 컨텍스트에 덮어쓴다.

JPA는 두번째 쿼리를 기본적으로 ID조건을 이용하여 쿼리한다. 즉, 조회할 두번째 Entity들이 Persistence Context에 이미 존재하여 Cache hit 하기를 기대하는 것이다.

사실, 이 방법이 아니면 JPA는 전체 ID에 대해서 굉장히 큰 한덩이의 WHERE IN 쿼리를 날려야 한다. 그리고 이것은 DB에 N x M번의 비교 연산을 수행하게 한다. 어쩌면, JPA의 관점에서는 DB에 큰 연산 하나를 수행시키기 보다 Cache hit을 바라면서 최대한 작은 트랜잭션 단위로 쿼리를 보내는 것이 최선의 선택지가 아니었을까 싶다.

언제 이런 문제가 발생하는가

N+1 쿼리 문제는 1:N 관계의 엔티티에 대한 쿼리를 날릴 때 발생하며, 두가지 경우로 나뉜다.

  • EAGER_LOADING 으로 설정된 칼럼을 가진 엔티티를 기준으로 쿼리할 때.
  • LAZY_LOADING 으로 설정된 칼럼을 가진 엔티티의 Collection에 대해서 참조 엔티티를 사용 할 때

즉, EAGER_LOADING이나LAZY_LOADING 이나, N:1 관계의 엔티티의 Collection에 대해서 연속적인 접근을 하는 이상 무조건 발생한다.


TODO: 수정 중

해결책

앞서 말했듯이, N+1쿼리 문제는 ORM의 한계 중 하나이므로 완전히 극복할 방법은 없다. N+1 쿼리문제를 이해하지 않고 사용하게되면 어떤 쿼리를 실행할지 모르기 애플리케이션의 성능에 치명적인 경우가 많다. 따라서 적재적소에 맞게 해결책을 사용해야한다.

1. fetch join 사용

  • 명시적 EagerLoading, FetchType.LAZY 무의미.

  • 카티잔 곱으로 조인됨

  • ManyToOne, OneToOne 관계의 A, B Entity에서 A를 기준으로 조회 시 A의 갯수만큼 가져오게됨 (중복된 값이 프로퍼티로 숨음, 주 엔티티객체에는 중복이 없음)

    • 페이징 하는데 문제가 없음
  • 하지만 OneToMany, ManyToMany 관계의 A, B Entity에서 A를 기준으로 조회 시 (A의 갯수) x (B의 갯수) 만큼의 엔티티 객체가 조회됨.(중복된 값이 드러남)

    • 페이징 불가
      -> 따라서 JPQL fetch join 구문에는 limit, offset구문이없음. fetch join + Pageable 사용시 full scan해서 메모리에서 페이징함.( 메모리 과적, full scan 오버헤드)
  • @OneToMany에서 둘 이상의 컬렉션을 페치 할 수 없다. simultaneously fetch multiple bag 오류 뜸

  • 즉, ManyToOne or OneToOne 관계에서의 사용에 최적화 되어있음 -> 다른 경우는 중복 제거 필요.

    • 중복 제거 방법 1. DISTINCT 사용
    • 중복 제거 방법 2. Set 사용

fetch join vs join
그냥 join 은 JPQL통해서 모든 데이터를 가져오지만, 연관관계를 고려하지 않고 select 의 대상으로 지정한 엔티티만 리턴됨.
리턴되지 않은 컬렉션을 사용하는 시점에서 N+1 쿼리 문제 그대로 발생

2. EntityGraph

  • @Query 작성시 미리 연결될 엔티티를 지정. -> 명시적인 EagerLoading이 됨.
  • fetch join과 크게 다를바 없음.
  • 단 outer join이 사용된다.

3. @BatchSize

  • 주 엔티티와 연관된 데이터들을 where in 구문을 통해서 지정된 갯수(size)만큼 반복해서 가져옴.
  • 최적화 하려면 대략적인 데이터 갯수를 알아야함.
  • Lazy 로딩 시에 첫번째 쿼리에서 먼저 size만큼의 참조엔티티를 가져오고 size이상의 엘리먼트에 접근할 때 추가적인 where in JPQL 이 일어남

4. FetchMode.subselect

  • 두번의 쿼리 (주 엔티티 쿼리, 참조엔티티 where in (주엔티티 id 쿼리))로 해결하는 방법임.
  • lazy 시에는 프록시 객체 사용시점에 두번째 쿼리가 실행된다.

ManyToOne에는 fetch join을, OneToMany에는 FetchType.SUBSELECT 혹은 @BatchSize를 사용한다

profile
신뢰를 주는 실력과 철학을 갖고 싶은 개발자입니다.

0개의 댓글