JPA N+1 문제 해결: Fetch Join·EntityGraph·Batch Size 비교

· 약 11분 읽기 · 조회 2 #JPA#Hibernate#Spring Boot#성능#백엔드

JPA의 N+1은 목록 한 번을 읽었는데 연관 데이터를 읽느라 쿼리가 행 수만큼 더 나가는 문제다. 개발 DB의 10건에서는 보이지 않다가 운영의 수천 건에서 응답 시간과 커넥션 점유를 함께 끌어올린다. 이 글은 Spring Boot 3·Hibernate 6을 기준으로 N+1이 생기는 원리와 찾는 법, Fetch Join·@EntityGraph·Batch Size·DTO 프로젝션을 언제 무엇으로 고를지를 정리한 것이다.

사서가 서가와 책상 사이를 여러 번 오가며 책을 한 권씩 나르고, 다른 쪽에서는 책을 가득 실은 수레를 한 번에 밀고 오는 도서관

한눈에 보기

예시는 주문 100건 목록을 읽고 각 주문의 주문상품(orderItems) 컬렉션을 순회하는 화면이다.

방법 실행되는 SELECT 페이징과 함께 잘 맞는 곳
지연 로딩 그대로 1 + 100 가능 연관을 거의 안 읽는 화면
Fetch Join 1 컬렉션이면 위험 한 건 상세, ToOne 연관
@EntityGraph 1 Fetch Join과 같음 Repository 메서드 단위 지정
Batch Size 100 1 + 1 가능 컬렉션 + 페이징, 기본 방어선
DTO 프로젝션 1 가능 읽기 전용 목록, 일부 컬럼

주문 100건의 주문상품을 읽을 때 지연 로딩은 101번, 배치 크기 10은 11번, 배치 크기 100은 2번, Fetch Join은 1번 SELECT가 실행되는 예시 막대 그래프

N+1이 생기는 원리

@OneToMany와 @ManyToOne(fetch = LAZY)는 연관 객체 대신 프록시나 지연 컬렉션을 넣어 둔다. 실제 값은 처음 접근하는 순간 쿼리로 읽는다.

List<Order> orders = orderRepository.findAll();          // SELECT 1번
for (Order o : orders) {
    o.getOrderItems().size();                             // 주문마다 SELECT 1번
}

주문이 100건이면 쿼리는 101번이다. 같은 일이 세 곳에서 숨어서 생긴다.

  • JSON 직렬화: 컨트롤러가 엔티티를 그대로 반환하면 Jackson이 getter를 부르며 연관을 하나씩 읽는다.
  • 화면 렌더링: spring.jpa.open-in-view가 기본값 true이면 뷰에서도 지연 로딩이 된다. 쿼리가 서비스 밖에서 나가 추적이 어렵다.
  • EAGER: @ManyToOne의 기본값은 EAGER다. JPQL은 먼저 루트만 읽고 연관을 따로 채우므로 EAGER도 N+1을 막지 못한다. 연관은 모두 LAZY로 두고 필요할 때 한 번에 읽는 쪽이 통제하기 쉽다.

SQL 로그와 통계로 N+1 찾기

N+1은 눈으로 코드를 봐서는 놓친다. 쿼리 수를 세야 보인다.

# application-local.yml
spring:
  jpa:
    properties:
      hibernate:
        generate_statistics: true    # 세션마다 실행한 JDBC 문 수를 로그로 남긴다
logging:
  level:
    org.hibernate.SQL: debug
    org.hibernate.orm.jdbc.bind: trace   # Hibernate 6의 바인드 값 로그

로그에서 같은 모양의 select ... where order_id=?가 줄지어 나오면 N+1이다. 통계는 운영 부하를 늘리므로 로컬·테스트 프로필에서만 켠다.

회귀를 막으려면 테스트에서 쿼리 수를 단언한다.

@Autowired EntityManagerFactory emf;

@Test
void 주문_목록은_쿼리_2번_이내로_읽는다() {
    Statistics stats = emf.unwrap(SessionFactory.class).getStatistics();
    stats.setStatisticsEnabled(true);
    stats.clear();

    orderQueryService.findOrders(PageRequest.of(0, 20));

    assertThat(stats.getPrepareStatementCount()).isLessThanOrEqualTo(2);
}

해법 1: Fetch Join과 @EntityGraph

Fetch Join은 연관을 조인으로 한 번에 읽어 영속성 컨텍스트에 채운다.

@Query("select o from Order o join fetch o.member join fetch o.orderItems where o.id = :id")
Optional<Order> findDetail(@Param("id") Long id);

@EntityGraph는 같은 일을 Repository 메서드에 선언으로 붙인다. JPQL을 쓰지 않아도 되고, 조인은 left outer join으로 나간다.

@EntityGraph(attributePaths = {"member", "orderItems"})
Optional<Order> findWithItemsById(Long id);

둘에는 같은 한계가 있다.

  • 컬렉션 + 페이징: 컬렉션을 조인하면 주문 한 건이 주문상품 수만큼 행으로 늘어난다. DB에서 LIMIT을 걸 수 없으니 Hibernate는 전체를 읽은 뒤 메모리에서 자르고 경고를 남긴다(Hibernate 6에서는 HHH90003004, "firstResult/maxResults specified with collection fetch; applying in memory"). 주문이 10만 건이면 10만 건 전체가 힙에 올라온다. hibernate.query.fail_on_pagination_over_collection_fetch=true로 두면 경고 대신 예외가 나서 배포 전에 잡힌다.
  • 컬렉션 둘 이상: List 컬렉션 두 개를 함께 fetch하면 MultipleBagFetchException(cannot simultaneously fetch multiple bags)이 난다. Set으로 바꾸면 예외는 사라지지만 두 컬렉션의 곱만큼 행이 늘어난다.
  • 중복 루트: Hibernate 5까지는 컬렉션 조인 결과에 같은 주문이 여러 번 담겨 distinct를 붙였다. Hibernate 6은 엔티티 결과의 중복을 스스로 제거한다.

ToOne 연관(member, delivery)은 행을 늘리지 않으므로 페이징과 함께 Fetch Join해도 안전하다.

해법 2: Batch Size

Batch Size는 지연 로딩을 그대로 두고, 처음 접근할 때 아직 안 읽은 같은 종류의 프록시·컬렉션을 모아 한 번에 읽는다.

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100

주문 100건의 주문상품은 where order_id in (?, ?, ... 100개) 한 번으로 읽힌다. 데이터베이스에 따라 IN 목록 대신 배열 파라미터 한 개로 나갈 수도 있다. 엔티티나 필드 하나에만 적용하려면 @BatchSize(size = 100)을 붙인다.

  • 페이징이 깨지지 않는다. 루트 쿼리는 LIMIT을 그대로 쓰고, 연관은 별도 쿼리로 읽는다.
  • 컬렉션이 여러 개여도 된다. 컬렉션마다 쿼리가 1번씩 늘어날 뿐 곱으로 커지지 않는다.
  • 크기는 흔히 100~1,000 사이에서 시작해 실측으로 고른다. 너무 작으면 쿼리 수가 다시 늘고, 너무 크면 IN 목록이 길어져 DB가 실행 계획을 매번 새로 세우거나 파라미터 수 제한에 걸린다.

그래서 전역 default_batch_fetch_size를 기본 방어선으로 깔고, 꼭 한 쿼리로 읽어야 하는 곳만 Fetch Join을 쓰는 조합이 흔하다.

해법 3: DTO 프로젝션

화면이 엔티티를 수정하지 않고 일부 컬럼만 보여 준다면 엔티티를 읽을 이유가 없다.

public record OrderSummary(Long orderId, String memberName, long totalAmount) {}

@Query("""
    select new com.example.order.OrderSummary(o.id, m.name, o.totalAmount)
    from Order o join o.member m
    where o.status = :status
    """)
Page<OrderSummary> findSummaries(@Param("status") OrderStatus status, Pageable pageable);

쿼리 한 번에 필요한 컬럼만 읽고, 영속성 컨텍스트에 엔티티를 올리지 않으니 변경 감지 비용도 없다. 대신 컬렉션은 한 행에 담을 수 없으므로 주문상품 목록이 필요하면 주문 ID로 한 번 더 읽어 묶는다. 조회가 복잡해지면 이 부분을 MyBatis나 QueryDSL로 빼는 판단은 MyBatis와 JPA 성능 및 유지보수 관점 비교에 정리했다.

무엇을 고를까

조회 전용 화면이면 DTO 프로젝션, 아니면 컬렉션을 페이징하는지에 따라 Batch Size 또는 Fetch Join과 EntityGraph를 고르는 흐름도

상황 고를 것 고르지 않으면 생기는 일
상세 화면 한 건, 연관 여러 개 Fetch Join 또는 @EntityGraph 연관 수만큼 쿼리가 늘어난다
목록 + 페이징 + 컬렉션 Batch Size 메모리 페이징으로 힙이 찬다
목록 + 페이징 + ToOne만 Fetch Join 행 수만큼 회원 조회가 나간다
컬렉션 둘 이상 하나만 Fetch Join, 나머지 Batch Size MultipleBagFetchException 또는 곱 폭증
읽기 전용 목록, 일부 컬럼 DTO 프로젝션 쓰지 않는 컬럼과 엔티티를 메모리에 올린다

N+1은 쿼리 수 문제이면서 커넥션 점유 시간 문제다. 쿼리 101번이 하나씩 왕복하는 동안 커넥션 하나를 계속 붙잡으므로, 동시 요청이 몰리면 HikariCP Connection Pool 고갈로 번진다. 연관을 언제 읽는지는 트랜잭션 범위와도 엮인다. 서비스 밖에서 지연 로딩이 일어나지 않게 하는 경계 설계는 Spring Boot에서 트랜잭션 경계를 잘못 설계하면 발생하는 문제를 참고한다.

자주 하는 실수

EAGER로 바꾸면 해결된다고 생각한다. JPQL 조회에서는 루트를 읽은 뒤 연관을 따로 읽으므로 N+1이 그대로 남고, 필요 없는 화면에서도 항상 읽게 된다.

컬렉션 Fetch Join에 Pageable을 붙인다. 결과는 맞아 보이지만 전체 행을 메모리에 올려 자른다. 데이터가 늘면 OutOfMemoryError로 이어진다.

엔티티를 API 응답으로 그대로 반환한다. 직렬화가 연관을 따라가며 N+1을 만들고, 양방향 연관이면 무한 순환까지 생긴다.

open-in-view를 켠 채 두고 쿼리 위치를 모른다. 뷰나 직렬화 단계에서 나가는 쿼리는 서비스 코드를 아무리 봐도 보이지 않는다.

Batch Size를 넣고 쿼리 수를 확인하지 않는다. 엔티티 매핑이나 접근 순서에 따라 기대보다 쿼리가 많을 수 있다. 숫자는 로그와 테스트로 확인한다.

점검표

  • 모든 @ManyToOne·@OneToOne에 fetch = FetchType.LAZY를 적었다
  • default_batch_fetch_size를 정하고, 그 값에서 쿼리 수와 응답 시간을 실측했다
  • 컬렉션 Fetch Join 쿼리에 페이징을 붙인 곳이 없다
  • hibernate.query.fail_on_pagination_over_collection_fetch=true를 설정했다
  • List 컬렉션 두 개를 한 쿼리에서 함께 fetch하는 곳이 없다
  • 컨트롤러가 엔티티 대신 DTO를 반환한다
  • spring.jpa.open-in-view를 끌지 결정하고 그 이유를 적었다
  • 로컬 프로필에서 SQL 로그와 generate_statistics를 켜 두었다
  • 주요 목록 API에 쿼리 수를 단언하는 테스트가 있다
  • 읽기 전용 목록은 DTO 프로젝션으로 읽는다

N+1은 연관을 언제 읽는지 정하지 않아서 생기는 문제이므로, LAZY와 Batch Size를 기본으로 깔고 한 번에 읽어야 하는 곳만 Fetch Join·@EntityGraph·DTO 프로젝션으로 골라 쓰면서 쿼리 수를 테스트로 지키면 된다.

댓글 0개

아직 댓글이 없습니다. 첫 댓글을 남겨보세요.

댓글 쓰기

← 목록으로 돌아가기