Published on

Spring Boot 3 JPA N+1 해결 - EntityGraph·BatchSize

Authors

서버가 느린데 CPU도 놀고, DB 커넥션도 여유가 있어 보이는데 응답 시간이 꾸준히 늘어지는 경우가 있습니다. 로그를 켜 보면 같은 형태의 select ... where id = ? 가 수십~수백 번 반복되고, 원인은 대부분 JPA의 대표적인 성능 함정인 N+1 입니다.

Spring Boot 3(대개 Hibernate 6 계열)에서도 본질은 같습니다. LAZY 연관을 순회하는 순간, 컬렉션/프록시 초기화를 위해 추가 쿼리가 발생합니다. 이 글에서는 운영에서 가장 많이 쓰이는 두 가지 해법인 @EntityGraph@BatchSize(또는 hibernate.default_batch_fetch_size)를 중심으로, 언제 무엇을 선택해야 하는지 기준과 예제를 정리합니다.

참고: 성능 이슈는 DB 인덱스/플랜 문제와 함께 나타나는 경우가 많습니다. N+1 을 잡았는데도 느리다면 인덱스/통계 문제도 같이 점검하세요. 예: PostgreSQL 인덱스 안 타는 이유 9가지와 해결

N+1이 발생하는 전형적인 흐름

예를 들어 OrderOrderItem 이 1:N 관계이고, OrderItemProduct 를 N:1 로 참조한다고 가정합니다.

  • 1번 쿼리: 주문 목록 N 건을 조회
  • 추가 쿼리: 각 주문마다 아이템을 조회하면서 N
  • 추가 쿼리: 각 아이템마다 상품을 조회하면서 N * M

코드 레벨에서는 단순한 반복문인데, DB 레벨에서는 쿼리 폭발이 일어납니다.

재현용 엔티티 예제

아래 예제는 일부 필드만 단순화했습니다.

@Entity
@Table(name = "orders")
public class Order {

    @Id @GeneratedValue
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;

    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    private List<OrderItem> items = new ArrayList<>();

    // getters
}

@Entity
@Table(name = "order_item")
public class OrderItem {

    @Id @GeneratedValue
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "order_id")
    private Order order;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "product_id")
    private Product product;

    private int quantity;

    // getters
}

@Entity
@Table(name = "product")
public class Product {

    @Id @GeneratedValue
    private Long id;

    private String name;

    // getters
}

서비스에서 다음처럼 DTO를 만들기 위해 연관을 순회하면 N+1 이 터지기 쉽습니다.

@Transactional(readOnly = true)
public List<OrderSummaryDto> findOrderSummaries() {
    List<Order> orders = orderRepository.findTop100ByOrderByIdDesc();

    return orders.stream()
        .map(o -> new OrderSummaryDto(
            o.getId(),
            o.getMember().getId(),
            o.getItems().stream()
                .map(oi -> oi.getProduct().getName())
                .toList()
        ))
        .toList();
}

해결 전략 개요: EntityGraph vs BatchSize

두 방법의 핵심 차이는 “한 번의 쿼리로 미리 당겨올 것인가” vs “추가 쿼리를 하되, 묶어서 적게 날릴 것인가” 입니다.

  • @EntityGraph
    • 장점: 특정 화면/유스케이스에서 필요한 연관을 명시적으로 fetch 하여 N+1 을 근본적으로 제거
    • 단점: 조인으로 인해 row 수가 폭증하거나(특히 컬렉션), 페이징과 충돌할 수 있음
  • @BatchSize / hibernate.default_batch_fetch_size
    • 장점: LAZY 를 유지하면서도 프록시 초기화 쿼리를 IN 절로 묶어 쿼리 수를 크게 감소
    • 단점: “쿼리 폭발”을 “쿼리 감소”로 완화하는 것이지, 조인처럼 한 번에 끝나지는 않음. 또한 배치 크기 튜닝이 필요

운영에서는 보통 다음 순서가 안전합니다.

  1. 우선 BatchSize 로 전역 완화(리스크 낮음)
  2. 특정 API/화면에서 확실히 필요한 연관만 EntityGraph 로 최적화
  3. 그래도 복잡하면 전용 조회 쿼리(예: DTO projection)로 분리

해법 1: EntityGraph로 필요한 연관을 정확히 fetch

Repository에 EntityGraph 적용

주문 목록을 가져올 때 member, items, 그리고 items.product 까지 필요하다고 가정하면 다음처럼 그래프를 지정합니다.

public interface OrderRepository extends JpaRepository<Order, Long> {

    @EntityGraph(attributePaths = {
        "member",
        "items",
        "items.product"
    })
    List<Order> findTop100ByOrderByIdDesc();
}

이렇게 하면 orders 를 가져오는 시점에 연관을 함께 로딩하므로, 서비스 레이어의 순회에서 추가 쿼리가 거의 사라집니다.

주의 1: 컬렉션 fetch는 row 폭증을 유발할 수 있음

Order 100건에 각 items 가 평균 10개면, 조인 결과 row 는 1000개가 됩니다. DB/네트워크/ORM 중복 제거 비용이 증가할 수 있습니다.

  • 화면에서 정말로 아이템까지 필요하지 않다면 items 를 그래프에서 빼세요.
  • 아이템은 필요하지만 items.product 까지는 필요 없으면 단계적으로 최소화하세요.

주의 2: 페이징과의 충돌

컬렉션 fetch 조인은 페이징(Pageable)에서 문제가 되기 쉽습니다. DB 레벨에서 페이징을 걸면 조인된 row 기준으로 잘려서 부모 엔티티가 누락될 수 있고, Hibernate가 메모리에서 페이징을 하도록 바뀌면 성능이 급격히 나빠질 수 있습니다.

이 경우의 대표 해법은 다음 중 하나입니다.

  • 1단계: 부모만 페이징 조회
  • 2단계: 부모 id 목록으로 자식들을 IN 으로 조회(이때 BatchSize 가 큰 도움)

해법 2: BatchSize로 LAZY 초기화 쿼리를 묶어서 줄이기

BatchSize 는 “프록시 초기화 시점”에 Hibernate가 여러 엔티티의 연관을 한 번에 조회하도록 돕습니다.

예를 들어 Order 100건을 조회한 뒤 order.getMember() 를 순회하면서 접근하면 원래는 100번의 member 조회가 발생할 수 있습니다. 배치를 켜면 다음처럼 줄어듭니다.

  • 배치 크기 50이면, 대략 2번의 member where id in (...) 로 수렴

방법 A: 전역 설정으로 적용

Spring Boot 3에서 application.yml 에 다음처럼 설정합니다.

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 50
  • 일반적으로 30~100 사이에서 시작하는 경우가 많습니다.
  • 너무 크게 잡으면 IN 절이 비대해져 플랜이 흔들리거나 파싱 비용이 커질 수 있습니다.

방법 B: 엔티티에 국소 적용

특정 연관에만 적용하고 싶다면 @BatchSize 를 사용합니다.

@Entity
public class Order {

    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    @BatchSize(size = 50)
    private List<OrderItem> items = new ArrayList<>();
}

@Entity
public class OrderItem {

    @ManyToOne(fetch = FetchType.LAZY)
    @BatchSize(size = 100)
    private Product product;
}

전역 설정과 엔티티 설정이 섞일 때는 “어느 쪽이 더 우선인지”를 팀 규칙으로 정해 혼란을 줄이는 것이 좋습니다(보통은 전역 기본값 + 예외만 애노테이션).

BatchSize가 특히 유리한 케이스

  • 컬렉션 fetch 조인이 페이징을 망가뜨리는 목록 API
  • “항상 필요하지는 않지만, 접근할 때는 많이 접근하는” 연관
  • EntityGraph를 남발하면 조인 폭증이 우려되는 도메인

EntityGraph와 BatchSize를 같이 쓰는 실전 조합

현실적인 타협안은 다음 패턴입니다.

  • 목록에서는 Ordermember 정도만 EntityGraph 로 당겨옴(1:1 또는 N:1 위주)
  • 컬렉션인 itemsLAZY 유지
  • 대신 itemsBatchSize 를 걸어, 필요해지는 순간 쿼리를 묶어서 가져옴

예시:

public interface OrderRepository extends JpaRepository<Order, Long> {

    @EntityGraph(attributePaths = {"member"})
    Page<Order> findByStatusOrderByIdDesc(OrderStatus status, Pageable pageable);
}

이렇게 하면 페이징 안정성을 유지하면서도, member 로 인한 N+1 은 제거하고, items 는 배치로 완화합니다.

쿼리 확인 방법: SQL 로그와 통계로 “정량” 검증

N+1 은 “느낌”이 아니라 “쿼리 개수”로 확인해야 합니다.

SQL 로그

spring:
  jpa:
    properties:
      hibernate:
        format_sql: true
        highlight_sql: true
logging:
  level:
    org.hibernate.SQL: debug
    org.hibernate.orm.jdbc.bind: trace
  • Hibernate 6에서는 바인딩 로그 카테고리가 환경에 따라 다를 수 있습니다. 위 설정이 과도하면 운영에서는 끄고, 스테이징에서만 켜는 것을 권장합니다.

Hibernate 통계(스테이징에서만)

spring:
  jpa:
    properties:
      hibernate:
        generate_statistics: true
logging:
  level:
    org.hibernate.stat: debug

통계 로그로 “쿼리 수가 실제로 줄었는지”를 확인하면 튜닝 방향이 흔들리지 않습니다.

흔한 함정과 체크리스트

1) EAGER 로 바꿔서 해결하려는 시도

EAGER 는 단기적으로는 N+1 을 숨길 수 있지만, 다른 화면/API에서 원치 않는 조인을 유발해 더 큰 장애를 만들기 쉽습니다. 기본은 LAZY 를 유지하고, 조회 유스케이스에서만 fetch 전략을 제어하세요.

2) OSIV 켠 상태에서 “컨트롤러에서 연관 접근”

OSIV(Open Session In View)가 켜져 있으면 컨트롤러/뷰 렌더링 단계에서 프록시가 초기화되며 N+1 이 더 늦게 터집니다. 성능 이슈가 “랜덤하게” 보이기 쉬운 패턴입니다.

  • 가능하면 트랜잭션(서비스) 내부에서 DTO 변환을 끝내고, 필요한 연관은 그 안에서 로딩 전략을 확정하세요.

3) distinct 로 모든 것이 해결된다고 믿기

JPQL에서 distinct 는 SQL distinct 와 ORM 레벨 중복 제거가 섞여 동작합니다. 중복 row 자체를 줄여주는 게 아니라, 결과 엔티티를 중복 제거해 “겉보기 결과”만 맞추는 경우가 많습니다. row 폭증은 여전히 비용입니다.

결론: 선택 기준 요약

  • 화면/유스케이스에서 필요한 연관이 명확하고, 조인 폭증 위험이 낮다
    • @EntityGraph 로 명시적으로 fetch
  • 페이징이 중요하거나, 컬렉션 조인이 부담스럽다
    • BatchSize 로 LAZY 로딩을 묶어서 완화
  • 운영 안정성을 최우선으로, 점진적으로 개선하고 싶다
    • 전역 hibernate.default_batch_fetch_size 로 1차 완화 후, 병목 API에만 EntityGraph 를 추가

추가로, 애플리케이션 지연의 원인이 DB뿐만 아니라 스레딩/락/대기에서 오는 경우도 있습니다. 가상 스레드나 동시성 변경 이후 지연이 커졌다면 다음 글도 함께 점검해 보세요: Spring Boot 3 가상스레드 적용 후 지연·데드락 진단


필요하시면 Pageable 기반 목록 API에서 “부모 페이징 + 자식 일괄 조회”를 EntityGraphBatchSize 조합으로 구현하는 템플릿(리포지토리 2단계 조회, DTO 매핑, 쿼리 수 기대치)까지 이어서 정리해드릴게요.