Post

JPA N+1 문제와 해결 - fetch join, batch size, EntityGraph

JPA N+1 문제와 해결 - fetch join, batch size, EntityGraph

Series 4 1편에서 영속성 컨텍스트를, 2편에서 트랜잭션을 봤습니다. JPA를 쓰면서 가장 자주 만나는 성능 문제는 N+1 selects입니다. 분명 코드는 단순한데 로그를 보면 SQL이 수백 번 나가는 상황입니다. 이번 글에서는 N+1이 왜 생기고, fetch join / batch size / EntityGraph로 어떻게 해결하며, 어떤 함정이 있는지를 정리합니다. Series 4(데이터 계층)의 마무리입니다.

TL;DR

  • N+1: 부모 목록을 1번 조회한 뒤, 각 부모의 LAZY 연관을 루프에서 접근하면 요소마다 추가 쿼리가 나가 1 + N번이 된다.
  • fetch join(권장): JPQL join fetch로 연관까지 한 쿼리에 가져온다.
  • batch fetching: @BatchSize / default_batch_fetch_sizeIN 절에 묶어 1 + ceil(N/k)번으로 줄인다.
  • EntityGraph / DTO projection: 가져올 연관을 선언하거나, 필요한 컬럼만 조회한다.
  • 함정: 컬렉션 fetch join + 페이징은 메모리에서 페이징되고, 컬렉션 두 개를 동시에 fetch join하면 MultipleBagFetchException이 난다.

1. N+1이란

OrderitemsLAZY로 가지고 있을 때, 주문 목록을 조회한 뒤 각 주문의 items를 접근하는 흔한 코드입니다.

1
2
3
4
5
List<Order> orders = orderRepository.findAll();   // 쿼리 1: SELECT * FROM orders (N건)
for (Order o : orders) {
    o.getItems().size();   // LAZY items 접근 -> 주문마다 SELECT (쿼리 N건)
}
// 총 1 + N 번의 쿼리

N+1 selects 문제 부모 목록을 1번 조회하고, 각 요소의 LAZY 연관을 접근할 때마다 추가 쿼리가 나가 1 + N번이 된다.

주문이 100건이면 1 + 100 = 101번의 쿼리가 나갑니다. 데이터가 늘수록 선형으로 악화되므로, 목록이 커질 때 갑자기 느려지는 전형적인 원인입니다.

LAZY 자체가 문제는 아닙니다. 컬렉션 기본 전략은 LAZY가 옳습니다. 문제는 “목록을 돌면서 매번 연관을 건드리는” 접근 패턴이고, 해결은 필요한 연관을 미리 한 번에 가져오는 것입니다.


2. 해결 전략 한눈에

대표적인 해결책은 fetch join과 batch fetching입니다.

N+1 해결: fetch join과 batch fetching fetch join은 조인으로 한 쿼리에, batch fetching은 IN 절로 묶어 1 + ceil(N/k)번에 가져온다.


3. fetch join (권장)

JPQL join fetch는 연관을 SQL 조인으로 함께 가져와 N+1을 한 쿼리로 만듭니다. 공식 튜닝 가이드도 outer join fetching을 우선 전략으로 권합니다.

1
2
@Query("select o from Order o join fetch o.items")
List<Order> findAllWithItems();   // items까지 한 쿼리에

연관이 단일(@ManyToOne)이면 가장 깔끔합니다. 다만 컬렉션 fetch join에는 아래 4절의 함정이 있습니다.


4. batch fetching (@BatchSize)

연관을 여전히 LAZY로 두되, 초기화가 필요할 때 여러 건을 IN 절로 묶어 한 번에 가져오는 방식입니다. 쿼리 수가 1 + N에서 1 + ceil(N/k)로 줄어듭니다.

1
2
3
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
@BatchSize(size = 100)
private List<Item> items;
1
2
3
4
5
6
# 전역 적용 (application.yml)
spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100

페이징과 함께 쓰기 좋아, 컬렉션은 보통 fetch join 대신 batch fetching으로 푸는 경우가 많습니다.


5. EntityGraph와 DTO projection

  • @EntityGraph: Spring Data JPA 리포지토리 메서드에 가져올 연관을 선언합니다.
1
2
3
@EntityGraph(attributePaths = "items")
@Query("select o from Order o")
List<Order> findAllWithItemsGraph();
  • DTO projection: 엔티티 전체가 아니라 화면에 필요한 컬럼만 조회합니다. 영속성 컨텍스트/연관 로딩 자체를 피하므로, 조회 전용 화면에서 가장 가볍습니다.

6. 함정

  • 컬렉션 fetch join + 페이징. 컬렉션을 fetch join하면서 페이징하면, Hibernate가 DB 페이징을 못 하고 메모리에서 페이징합니다(경고 로그와 함께 전체 로딩). 컬렉션은 batch fetching이나 @EntityGraph로 풀고, 페이징이 필요하면 to-one만 fetch join하는 식으로 분리합니다.
  • 컬렉션 두 개 동시 fetch join. 서로 다른 컬렉션을 한 쿼리에서 fetch join하면 카테시안 곱이 되어 MultipleBagFetchException이 발생합니다. Set을 쓰거나 쿼리를 나눕니다.
  • fetch join 남발. 필요 없는 연관까지 매번 fetch join하면 중복 행과 큰 결과셋으로 오히려 느려집니다. 화면별로 필요한 만큼만 가져옵니다.

7. Series 4 마무리

영속성 컨텍스트 -> 트랜잭션과 전파 -> N+1과 성능으로 데이터 계층 세 편을 마쳤습니다. JPA가 추상화해 주는 편의 뒤에서 실제로 무슨 일이 일어나는지(언제 쿼리가 나가고, 어디에 캐시되고, 어떻게 묶이는지)를 알면 성능 문제를 예방할 수 있습니다.

capstone 연결: N+1은 DB 부하만이 아니라 대량 엔티티를 JVM 힙에 올리는 문제이기도 합니다. 목록이 커질수록 영속성 컨텍스트에 쌓이는 객체와 쿼리가 함께 늘어, GC 압박과 응답 지연으로 이어집니다.

DevSecOps 비유: N+1은 루프 안에서 매번 원격 호출을 하는 안티패턴과 같습니다(개별 호출을 bulk/batch로 묶기). fetch 전략 선택은 미리 가져오기(prefetch) vs 필요할 때 가져오기(on-demand)의 트레이드오프이고, 운영에서는 쿼리 수를 로깅/모니터링(datasource-proxy, p6spy 등)으로 관찰해 N+1을 조기에 잡습니다.


8. 참고 자료

This post is licensed under CC BY 4.0 by the author.