N+1 해결 도구 완전 정복 — `fetch join` / `@EntityGraph` / `@BatchSize`는 언제 쓰나요?

스터디·11분 읽기
시리즈#N+1· 6/7
전체 보기
  1. 01Discovery 모듈 성능 개선기 — Protobuf 제거, 캐시 도입, 검색 정확도 향상
  2. 02N+1 쿼리와 캐시 미스 — 커뮤니티 모듈 성능 개선기
  3. 03채팅방 목록 요약 API 성능 개선기 - 응답 경량화와 N+1 제거
  4. 04N+1 쿼리 문제 완전 정복 — 왜 느려지고 어떻게 해결할까
  5. 05JPA Fetch 전략 완전 정복 — `LAZY` vs `EAGER`와 N+1이 생기는 진짜 이유
  6. 06N+1 해결 도구 완전 정복 — `fetch join` / `@EntityGraph` / `@BatchSize`는 언제 쓰나요?읽는 중
  7. 07관리자 예약 목록 API `Broken pipe` 해결기 — 루프 안 N+1과 공유 DTO 반복 생성

세 도구, 왜 비교해서 알아야 하나요?

N+1을 줄일 수 있는 도구는 JPA에 여러 개 있는데, 많은 문서가 "fetch join을 쓰자"로 끝납니다. 실제로는 상황에 따라 fetch join으로 해결이 안 되거나 오히려 더 나쁜 선택이 됩니다.

  • fetch join으로 묶고 싶은 연관이 두 개 이상의 컬렉션입니다
  • 한 레포지토리 메서드를 두 가지 컨텍스트에서 쓰는데 한쪽만 연관이 필요합니다
  • 페이징이 필요한 목록 조회에서 컬렉션까지 로딩해야 합니다
  • 이미 여러 경로에서 같은 엔티티를 조회하는데, 조회마다 fetch join JPQL을 새로 짜기 번거롭습니다

이 상황마다 적합한 도구가 다릅니다. fetch join, @EntityGraph, @BatchSizeN+1을 줄인다는 목표는 같지만, 해결하는 축이 서로 다릅니다. 이 글은 세 도구의 동작을 각각 풀고, 마지막에 상황별 선택 기준을 표로 정리합니다.

기준: 이 글은 Jakarta Persistence 3.1 (JPA 3.1) 명세와 Hibernate 6.x 구현을 기준으로 작성합니다. fetch joinJakarta Persistence 3.1 — §4.4.5.3 Fetch Joins, @EntityGraph는 같은 명세의 §3.7 Entity Graphs, @BatchSizeHibernate 6 User Guide — §5.1.6 Batch fetching을 참조합니다. 이 글은 JPA Fetch 전략 글영속성 컨텍스트 글을 전제로 하고, 기본 N+1 개념은 N+1 글에서 이미 다뤘습니다. 코드 예시는 Kotlin + Spring Data JPA입니다.

먼저 가장 짧은 답부터 보면

세 도구는 "쿼리 수를 어떻게 줄이는가" 가 다릅니다.

  • fetch join한 번의 JOIN 쿼리로 연관을 함께 조회합니다
  • @EntityGraph같은 아이디어지만 JPQL을 바꾸지 않고 메서드에 선언합니다
  • @BatchSize — 쿼리를 0으로 줄이는 게 아니라 N개를 IN 쿼리 하나로 묶습니다
관점 fetch join @EntityGraph @BatchSize
표준 JPQL 문법 JPA 표준 Hibernate 확장
접근 방식 쿼리 재작성 메서드 애너테이션 로딩 시점 개선
쿼리 모양 LEFT JOIN LEFT JOIN 원래 쿼리 + IN 쿼리 추가
컬렉션 2개 불가 불가 가능
페이징 + 컬렉션 위험 (메모리 페이징) 위험 (같은 이유) 안전
재사용성 쿼리마다 새로 작성 메서드 단위 재사용 엔티티 전역 설정

즉, "무조건 fetch join"이 아니라 상황에 맞게 조합하는 것이 정답입니다.

Phase 1. fetch join — 한 번의 JOIN으로 끌어오기

핵심: JPQL에서 연관을 JOIN FETCH로 선언합니다

@Query("""
  SELECT o FROM Order o
  JOIN FETCH o.user
  WHERE o.status = :status
""")
fun findPaidOrdersWithUser(status: String): List<Order>

이 쿼리는 다음 하나의 SQL을 만듭니다.

SELECT o.*, u.*
FROM `order` o
LEFT JOIN user u ON o.user_id = u.id
WHERE o.status = ?;

부모와 연관이 한 번에 로딩되므로 루프 안에서 order.user.name 을 접근해도 추가 쿼리가 나가지 않습니다.

장점

  • 표준 JPQL 문법이라 어떤 JPA 구현체에서도 동작합니다
  • 해당 쿼리에만 적용되기 때문에, 같은 엔티티를 다른 쿼리에서는 LAZY 그대로 둘 수 있습니다

한계

  • JPQL을 직접 써야 합니다. Spring Data 파생 쿼리(findByStatus)에는 적용할 수 없습니다
  • 컬렉션 JOIN FETCH는 페이징과 궁합이 나쁩니다 (앞 글에서 다룬 카르테시안 곱과 메모리 페이징 경고)
  • 두 개 이상의 컬렉션을 JOIN FETCH 하면 MultipleBagFetchException이 납니다

JOIN FETCH + DISTINCT

컬렉션을 JOIN FETCH하면 부모가 자식 수만큼 중복됩니다. JPA에서는 SELECT DISTINCT를 붙이는 관습이 있습니다.

@Query("""
  SELECT DISTINCT o FROM Order o
  JOIN FETCH o.items
  WHERE o.status = :status
""")
fun findPaidOrdersWithItems(status: String): List<Order>

Hibernate 6부터는 엔티티 참조 중복 제거가 Java 측에서 자동으로 처리됩니다. 그래서 JPQL의 DISTINCT는 이제 SQL에 그대로 전달되어 DB 레벨 DISTINCT를 강제하는 역할만 남았습니다 (이전 버전의 hibernate.query.passDistinctThrough=false 같은 힌트는 제거됐습니다). 불필요하게 DISTINCT를 붙이면 DB의 DISTINCT 비용만 더해지므로, Hibernate 6에서는 fetch joinDISTINCT를 붙이지 않는 편이 오히려 깔끔합니다.

Phase 2. @EntityGraph — JPQL 없이 쿼리 단위로 연관 지정

핵심: 어떤 연관을 함께 로딩할지 메서드에 선언합니다

@EntityGraph는 JPA 2.1부터 표준입니다. Spring Data JPA에서는 리포지토리 메서드에 애너테이션으로 붙일 수 있습니다.

interface OrderRepository : JpaRepository<Order, Long> {

  @EntityGraph(attributePaths = ["user", "shippingAddress"])
  fun findByStatus(status: String): List<Order>
}

실행 시 Hibernate는 usershippingAddressLEFT JOIN으로 묶은 쿼리를 만듭니다. 동작 결과는 fetch join과 거의 같습니다.

fetch join과 무엇이 다른가요?

기능상 겹치지만, 쿼리를 직접 쓰지 않고 연관만 선언한다는 점이 중요합니다.

  • Spring Data의 파생 쿼리(findByStatus, findByUserId...)에 그대로 붙일 수 있습니다
  • 같은 메서드를 재사용하면서 연관만 바꾸고 싶을 때 편리합니다
  • 페치 그래프를 재사용 가능한 이름(@NamedEntityGraph)으로 정의해 둘 수도 있습니다

한계

  • 내부적으로 LEFT JOIN을 쓰기 때문에 컬렉션을 두 개 이상 넣으면 fetch join과 같은 카르테시안 곱 문제가 생깁니다
  • 페이징 + 컬렉션 조합도 fetch join과 똑같이 메모리 페이징 경고가 뜹니다
  • 연관마다 로딩 전략을 세밀하게 바꾸려면 EntityGraphType.FETCH / LOAD 의 차이를 이해해야 합니다 (대부분 기본값으로 충분합니다)

참고: @EntityGraph(type = EntityGraphType.FETCH) 는 그래프에 포함된 연관만 EAGER로 당기고, 나머지는 LAZY로 둡니다. LOAD 타입은 그래프에 없는 연관을 엔티티에 선언된 기본 Fetch 전략 대로 둡니다. 기본값은 FETCH 입니다.

Phase 3. @BatchSizeIN 쿼리로 묶어서 줄이기

핵심: 쿼리 수를 0으로 만들지는 않지만, N을 상수로 줄입니다

@BatchSize는 Hibernate 확장입니다. 프록시 또는 컬렉션이 초기화될 때 한 번에 여러 부모의 연관을 묶어서 읽습니다.

@Entity
class Order(
  @Id val id: Long,

  @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
  @BatchSize(size = 100)
  val items: List<OrderItem>,
)

이 엔티티에서 주문 50건을 읽고 각각 items를 접근하면, SQL은 다음처럼 나갑니다.

SELECT * FROM `order` WHERE status = ?;              -- 1번
SELECT * FROM order_item WHERE order_id IN (?, ?, ..., ?);  -- 1번 (50개 IN)

쿼리는 1 + 1 = 2번 으로 끝납니다. 페이지 크기가 100을 넘으면 IN 묶음이 2번, 3번으로 늘어나지만 여전히 상수입니다.

장점

  • 카르테시안 곱이 없습니다 — 각 테이블을 개별 쿼리로 읽기 때문에 행이 곱해지지 않습니다
  • 두 개 이상의 컬렉션을 같이 로딩해도 문제가 없습니다. 각각 별개의 IN 쿼리로 나갑니다
  • 페이징과 궁합이 좋습니다 — 부모 페이징은 DB에서 그대로 일어나고, 자식은 IN으로 끌어옵니다

한계

  • 표준이 아닙니다 (Hibernate 확장). JPA 명세를 100% 지키는 코드베이스면 쓸 수 없습니다
  • 쿼리가 완전히 한 번은 아닙니다. 부모 쿼리 + 연관 쿼리가 합쳐 최소 2번은 나갑니다
  • 엔티티 레벨 애너테이션이기 때문에, 이 엔티티를 읽는 모든 쿼리에 영향을 줍니다. 특정 쿼리에서만 끄기 어렵습니다

전역 설정 — default_batch_fetch_size

@BatchSize를 연관마다 붙이지 않고 한 번에 적용하려면 application.yml에서 전역 설정을 켭니다.

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100

프로젝트 대부분에서는 이 설정만 켜두고, 특별히 더 큰 값이 필요한 연관에만 @BatchSize를 덧붙이는 패턴을 많이 씁니다.

IN 절 크기에 대한 주의

DB마다 IN 절에 들어갈 수 있는 최대 파라미터 수에 제한이 있습니다. MySQL은 명시적 상한이 없지만 파서 메모리 한계에 의존합니다. PostgreSQL은 과거 $32767 파라미터 제한이 있었지만 최신은 완화됐습니다. Oracle은 1000 제한이 있습니다. 대부분의 실무 @BatchSize 값은 100~500 사이에서 잡습니다. 너무 크게 잡으면 쿼리 캐시 적중률이 떨어지고, 너무 작게 잡으면 쿼리 수가 늘어납니다.

Phase 4. 실전 선택 기준

세 도구의 선택은 세 가지 축만 보면 빠르게 결정됩니다.

축 1 — 연관이 단일이냐 컬렉션이냐

  • 단일 연관 (@ManyToOne/@OneToOne): fetch join 또는 @EntityGraph. 행이 안 늘어나므로 한 번의 JOIN으로 끝납니다
  • 컬렉션: 건수가 적으면 fetch join 가능. 많거나 두 개 이상이면 @BatchSize

축 2 — 페이징이 필요한가

  • 컬렉션이 포함되고 페이징도 필요하면 fetch join/@EntityGraph메모리 페이징 경고가 뜹니다
  • 이 조합에서는 거의 항상 @BatchSize 가 정답입니다

축 3 — 한 번만 쓰는 쿼리냐, 여러 곳에서 재사용되는 연관이냐

  • 한 번만 쓰는 목록 쿼리: JPQL + fetch join이 읽기 쉬움
  • 여러 API에서 같은 연관을 자주 로딩: @EntityGraph로 메서드에 선언하거나 @NamedEntityGraph로 이름 붙여 재사용
  • 엔티티를 읽는 대부분의 경로에서 필요: @BatchSize 또는 default_batch_fetch_size를 설정해 전역으로 커버

상황별 추천

상황 추천 이유
단일 연관만 함께 조회 JOIN FETCH 또는 @EntityGraph 단일 JOIN으로 끝남
컬렉션 하나, 작은 건수, 페이징 없음 JOIN FETCH Hibernate 6은 Java 측에서 중복 제거 자동 처리
컬렉션 하나, 페이징 있음 @BatchSize 메모리 페이징 회피
컬렉션 둘 이상 @BatchSize fetch join은 불가능
파생 쿼리(findByXxx)에 연관 추가 @EntityGraph JPQL을 쓰지 않고 선언만
여러 API에서 반복되는 페치 @NamedEntityGraph 이름 붙여 재사용
전역 기본값으로 덜어내기 default_batch_fetch_size 프로젝트 전반의 N+1을 저렴하게 완화

조합해서 쓰기

현실에서는 하나의 조회가 세 도구를 조합하기도 합니다. 예를 들어 주문 목록에 userfetch join으로 묶고, items 컬렉션과 coupons 컬렉션은 @BatchSize로 가져오는 식입니다.

@Entity
class Order(
  @Id val id: Long,

  @ManyToOne(fetch = FetchType.LAZY)
  @JoinColumn(name = "user_id")
  val user: User,

  @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
  @BatchSize(size = 200)
  val items: List<OrderItem>,

  @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
  @BatchSize(size = 200)
  val coupons: List<Coupon>,
)

@Query("""
  SELECT o FROM Order o
  JOIN FETCH o.user
  WHERE o.status = :status
""")
fun findPaidOrdersWithUser(status: String, pageable: Pageable): Page<Order>

위 조합은 다음처럼 동작합니다.

  • userJOIN FETCH로 한 쿼리 안에 포함
  • 페이징은 JOIN FETCH가 단일 연관만 포함하기 때문에 DB에서 안전하게 수행
  • itemscoupons는 접근 시 IN 쿼리 두 번으로 묶여서 끌려옴

즉, fetch join안전한 단일 연관에만 사용하고, 컬렉션은 @BatchSize에 위임하는 패턴이 가장 자주 쓰이는 실전 형태입니다.

Phase 5. 함께 주의해야 할 지점

1. fetch joinGROUP BY/집계

JOIN FETCH는 결과를 엔티티로 돌려주기 때문에 집계 쿼리와 잘 맞지 않습니다. 집계가 필요하면 JOIN FETCH 대신 일반 JOIN + GROUP BY를 쓰고, DTO 프로젝션으로 결과를 받습니다.

2. @EntityGraph + native query

@EntityGraph는 JPQL/Criteria에만 적용됩니다. 네이티브 쿼리에는 동작하지 않으므로, 네이티브 쿼리가 필요하면 JOIN으로 직접 써서 DTO에 매핑합니다.

3. @BatchSize의 로그 확인

@BatchSize가 실제로 IN 쿼리로 묶이는지 확인하려면 hibernate.show_sql 또는 p6spy 같은 SQL 로거로 쿼리를 봐야 합니다. "N+1이 해결된 줄 알았는데 여전히 수십 번 쿼리가 나가는" 원인의 상당수가 @BatchSize가 예상한 연관에 안 붙어 있거나, 해당 트랜잭션이 닫혀 다른 트랜잭션에서 로딩되는 경우입니다.

4. DTO 프로젝션이 더 깔끔한 순간

세 도구를 조합해도 엔티티 전체를 끌고 올 필요가 없는 목록 API는 많습니다. 이럴 때는 DTO 프로젝션이 더 단순한 답입니다.

@Query("""
  SELECT new com.example.OrderView(o.id, u.name, o.totalPrice)
  FROM Order o JOIN o.user u
  WHERE o.status = :status
""")
fun listOrderViews(status: String): List<OrderView>

이 쿼리는 영속성 컨텍스트도, 프록시도, N+1도 신경 쓸 필요가 없습니다. 목록 API에서 엔티티 자체가 필요 없다면 DTO가 거의 항상 더 단순합니다.

정리

N+1을 줄이는 도구는 하나가 아닙니다. 세 도구의 역할은 명확히 다릅니다.

  • fetch join — JPQL로 쿼리 모양을 다시 씁니다. 단일 연관에 안전합니다
  • @EntityGraph — 같은 효과를 파생 쿼리에도 붙일 수 있게 합니다
  • @BatchSize — 쿼리 수를 0으로 만들지 않지만, 컬렉션과 페이징이 있을 때 가장 안전한 선택입니다

상황을 가르는 기준은 "연관이 단일인가 컬렉션인가", "페이징이 필요한가", "이 쿼리만 쓸 것인가 아니면 여러 곳에서 쓸 것인가" 세 가지입니다. 현실에서는 조합해 쓰는 경우가 많으며, 실전 패턴은 단일 연관은 fetch join, 컬렉션은 @BatchSize 입니다.

다음 글에서는 JPA 영역을 잠시 벗어나 Spring @Transactional의 전파 속성과 롤백 규칙을 다룹니다. Fetch 전략까지 다져놓았다면, 그 다음은 트랜잭션 경계를 설계하는 방식이 제일 큰 설계 결정입니다.

다음으로 읽어볼 글