Java 아키텍처·Spring 용어 사전
JPA페치 조인 · fetch join · @EntityGraph · BatchSize

N+1

목록 1번 + 각 항목의 연관 N번을 조회하는 문제. 페치 조인·EntityGraph·BatchSize로 푼다.

목록을 1번 조회하고, 각 항목의 연관을 N번 더 조회해 총 1+N번의 쿼리가 나가는 문제. JPA에서 가장 유명한 성능 함정이다.

어떻게 생기나

List<Order> orders = em.createQuery("select o from Order o", Order.class).getResultList();
// SELECT * FROM orders                       ← 1번

for (Order o : orders) {
    System.out.println(o.getMember().getName());
    // SELECT * FROM member WHERE id = ?       ← 주문 수만큼 N번
}

주문이 100건이면 쿼리가 101번 나간다. 각각은 1ms라도 왕복 100번이 곧 비용이다.

EAGER로 바꾸면 해결되지 않는다

  • em.find() 로 조회하면 — → 조인 한 번으로 가져온다 (해결된 것처럼 보인다)
  • JPQL 로 조회하면 — → JPA 가 일단 SQL 을 그대로 날리고,
    • EAGER 연관을 채우려고 추가 쿼리를 N번 보낸다
    • 오히려 항상 N+1 이다

"즉시 로딩이면 N+1이 없다" 는 대표적 오답이다.

해결 ① 페치 조인 — 가장 직접적

// 한 번의 조인으로 연관까지 채워 온다
@Query("select o from Order o join fetch o.member")
List<Order> findAllWithMember();

해결 ② @EntityGraph — 애노테이션으로

@EntityGraph(attributePaths = {"member"})
@Query("select o from Order o")
List<Order> findAllWithMember();

해결 ③ BatchSize — 컬렉션과 페이징에 필수

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100     # 전역 설정. 사실상 필수 옵션

1 + N → 1 + (N / batch_size) IN (?, ?, ?, ... ) 로 100개씩 묶어 조회한다

왜 컬렉션 페치 조인은 페이징이 안 되나

// ⚠️ 경고 로그가 뜨고 전체를 메모리로 가져와 페이징한다
@Query("select o from Order o join fetch o.items")
Page<Order> findAll(Pageable p);
// HHH000104: firstResult/maxResults specified with collection fetch; applying in memory

1:N 을 조인하면 주문 1건이 아이템 수만큼 행으로 늘어난다

  • DB 의 LIMIT 은 '행' 을 자르는데, 우리가 원하는 건 '주문' 단위다
  • JPA 는 어쩔 수 없이 전부 읽어 메모리에서 자른다 → OOM 위험

컬렉션은 페치 조인 대신 BatchSize 를 쓴다. 이게 정답이다.

MultipleBagFetchException

컬렉션 두 개를 동시에 페치 조인하면 카테시안 곱이 된다

  • 하나만 페치 조인하고 나머지는 BatchSize 로

면접 함정

  • "지연 로딩이면 N+1이 없다" → 루프에서 접근하면 발생한다.
  • "페치 조인이 만능" → 컬렉션 + 페이징에서는 쓰면 안 된다.

함께 보면 좋은 용어

노트에서 맥락과 함께 보기 — JPA·영속성 — 영속성 컨텍스트·N+1