[JPA] N+1

DaeHoon·2023년 11월 26일
0

N+1 현상

  • 요청이 1개의 쿼리로 처리되길 기대했는데, N개의 추가 쿼리가 발생하는 현상
  • 실무에서는 지연 로딩을 주로 사용하므로 지연로딩에서의 @OneToMany 컬럼에서 발생하는 N+1을 중점으로 설명

지연 로딩에서의 N+1

  • 위와 같은 관계로 설정이 되어있다고 해보자.

  • 지연 로딩으로 Many에 해당하는 데이터를 proxy 객체로 가져오고, 실제 해당 데이터가 필요할 경우 DB에 쿼리를 날려 데이터를 요청한다.
  • 이 상황에서 select * from crew 라는 쿼리를 질의했다고 가정해보자.

  • 3개의 할일 정보를 가져오기 위해 프록시 객체로 getSize() 메서드를 통해 1차 캐시에 데이터가 있는지 확인하고, 없으면 select * from 할일 where 크루_id = 1이라는 메서드를 디비에 질의한다.

  • 콘솔을 확인해보면 이런 식으로 N개의 쿼리가 추가로 발생하는데, N이 100만이 넘어가면 디비에 엄청난 과부하를 줄 수 있는 상황.

Fetch Join으로 해결

  • Fetch Join: 연관된 엔티티나 컬렉션을 한 번에 같이 조회하는 기능
  • 연관된 엔티티까지 영속성 컨텍스트에 전부 올림.

  • select 크루.*, 할일.* from 크루 join fetch 할일 이런 식으로 쿼리를 날렸을 경우에, 데이터를 한 번에 가져와서 영속성 컨텍스트에 전부 올렸기 때문에, getSize()로 1차 캐시에서 데이터를 가져올 수 있게 됨. 즉 1개의 쿼리로 문제를 해결함.

그러면 즉시 로딩으로 다 가져오면 되잖아요

  • JPQL이 처음 쿼리를 만들 때, 조회 대상이 되는 Entity만 조회를 한 다음 연관된 엔티티를 확인하는데, 이 때 Global Fetch 전략을 확인한다.
  • 즉시 로딩으로 확인이 되는 순간 해당 조회 대상 엔티티와 관련된 모든 Entity를 가져오게 된다.조회 대상과 연관된 엔티티에 대한 정보가 필요 없는 상황에서도 해당 엔티티를 가져오게 되니 쓸데없이 N+1 상황이 발생하게 된다. (즉시 로딩을 실무에서 사용하지 않는 이유)
  • 즉시 로딩 최대한 사용하지 말고, 지연 로딩 + fetch join을 쓰자.

그러면 N+1은 Fetch Join으로 해결이 다 되나요?

  • OneToMany 상황에서 Paging 처리를 할 때에 이슈가 발생한다.
예시 시나리오
엔티티: AuthorBook
관계: 하나의 Author는 여러 Book을 가질 수 있는 @OneToMany 관계
목표: Author와 그들의 BookFetch Join으로 조회하면서, Author 기준으로 페이지 당 3명씩 페이징 하려고 함

String jpql = "SELECT a FROM Author a JOIN FETCH a.books";
Query query = entityManager.createQuery(jpql)
                           .setFirstResult(0) // 페이지 시작
                           .setMaxResults(3); // 페이지 크기
List<Author> authors = query.getResultList();
  • 페이징 이슈: 위의 쿼리에서 페이징의 기준은 Author 엔티티가 아니라 book과 조인한 결과에 기반하여 페이징이 된다.
    • 예를 들어, 첫 번째 Author가 5개의 Book을 가지고 있다면, 이 쿼리는 첫 번째 Author와 그의 모든 Book을 가져와 메모리에 올린다. 페이지당 3명의 Author를 기대했지만, 실제로는 하나의 Author와 Author와 연관된 3개의 Book만 첫 페이지에 표시된다.
    • 또한Author에 대한 중복된 정보가 여러 번 나타날 수 있다.
  • 메모리 이슈: 만약 100만건의 데이터에서 10건의 데이터만 paging을 하는 경우에, 100만 건을 전부 메모리에 올린 다음, 메모리 내에서 페이징을 진행한 뒤 사용자에게 반환한다. -> OOM이 발생할 확률이 매우 높다.

해결 방법

  • DTO에 필요한 컬럼만 정의해서 사용: 엔티티 대신 DTO(Data Transfer Object)를 사용하여 필요한 데이터만 선택적으로 조회하고, 페이징을 적용할 수 있다. 다만 엔티티를 DTO에 선언하면 그 엔티티를 찾기 위해 추가로 쿼리가 발생하니까 웬만하면 DTO에 엔티티 필드는 선언하지 말자.
  • Batch Size 설정: @OneToMany 매핑에 batch size를 설정하여 관련 엔티티를 일정 단위로 묶어서 로드할 수 있다. 이는 N+1 문제를 완화하는데 도움이 된다.
  • ManyToOne 관계에서 페이징

요약

  • @EntityGraph를 이용한 페이징
    • page 값과 상관 없이 전체결과를 조회한 후, 나중에 페이징 처리를 한다
  • @EntityGraph 없이 페이징
    • limit을 이용해 페이지만큼 조회하지만, n+1이 발생한다
  • @EntityGraph 없이 페이징 + default_batch_fetch_size 혹은 @BatchSize 설정
    • limit을 이용해 조회하고, n+1 없이 in 절을 활용해 조회한다.
profile
평범한 백엔드 개발자

0개의 댓글