목록을 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이 없다" → 루프에서 접근하면 발생한다.
- ❌ "페치 조인이 만능" → 컬렉션 + 페이징에서는 쓰면 안 된다.