Published on

Spring Boot JPA N+1 폭탄 - 배치·페치조인 튜닝

Authors

서버 성능 이슈를 파고들다 보면, 애플리케이션 로그는 조용한데 DB 커넥션 풀이 고갈되고 응답 시간이 급격히 늘어나는 순간을 만나게 됩니다. 그 중심에 JPA의 N+1 문제가 있는 경우가 많습니다. 특히 목록 조회 API에서 엔티티 연관관계를 무심코 접근하면, “부모 1번 조회 + 자식 N번 조회”가 발생하며 트래픽이 조금만 늘어도 DB가 먼저 무너집니다.

이 글에서는 Spring Boot + JPA(Hibernate) 환경에서 N+1을 재현 → 진단 → 해결(페치 조인 / 배치 페치) → 적용 체크리스트 순서로 정리합니다. 운영 장애로 번지기 전에, 쿼리 개수를 통제 가능한 수준으로 낮추는 것이 목표입니다.

> 성능 이슈는 종종 인프라 증상으로도 나타납니다. 예를 들어 오토스케일이 흔들리거나, 타임아웃이 늘어나는 형태로 보일 수 있습니다. 비슷한 관점의 운영 트러블슈팅은 EKS HPA 폭주를 KEDA 큐기반 오토스케일링으로 안정화, 네트워크/타임아웃 진단은 EKS Pod→S3 504 타임아웃 - VPC 엔드포인트·NAT·DNS 진단도 참고할 만합니다.

N+1이 터지는 전형적인 구조

가장 흔한 예는 Order(주문)OrderItem(주문상품) 같은 1:N 관계입니다.

예시 엔티티

@Entity
@Table(name = "orders")
public class Order {
    @Id @GeneratedValue
    private Long id;

    private String customerName;

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

    // getter
}

@Entity
@Table(name = "order_item")
public class OrderItem {
    @Id @GeneratedValue
    private Long id;

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

    private String productName;
    private int quantity;

    // getter
}

핵심은 대부분의 연관관계가 기본적으로 LAZY라는 점입니다(권장). 문제는 LAZY 그 자체가 아니라, 조회 후 반복문에서 연관 컬렉션에 접근하는 방식입니다.

N+1을 유발하는 서비스 코드

@Service
@RequiredArgsConstructor
public class OrderService {
    private final OrderRepository orderRepository;

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

        return orders.stream()
            .map(o -> new OrderDto(
                o.getId(),
                o.getCustomerName(),
                o.getItems().size() // 여기서 LAZY 초기화 발생
            ))
            .toList();
    }
}

public record OrderDto(Long id, String customerName, int itemCount) {}

이때 발생하는 쿼리 패턴은 보통 다음과 같습니다.

  • select * from orders ... limit 100; (1번)
  • 각 주문마다 select * from order_item where order_id = ?; (최대 100번)

1 + N.

진단: “진짜로 N+1인가?”를 확인하는 방법

1) SQL 로그로 쿼리 개수 확인

운영에서는 과도한 SQL 로깅이 부담이지만, 개발/스테이징에서 N+1을 잡는 데는 매우 유용합니다.

# application.yml
spring:
  jpa:
    properties:
      hibernate:
        format_sql: true
        show_sql: true
        use_sql_comments: true
logging:
  level:
    org.hibernate.SQL: debug
    org.hibernate.orm.jdbc.bind: trace
  • org.hibernate.SQL로 실제 SQL 출력
  • bind 로깅으로 바인딩 파라미터 확인

2) Hibernate Statistics(선택)

쿼리 수를 더 체계적으로 보고 싶다면 통계를 켜고, 특정 요청에서 실행된 쿼리 수를 집계할 수 있습니다.

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

3) “지연 로딩이 언제 터지는지” 코드에서 확인

  • 컨트롤러/서비스 DTO 매핑 과정
  • Jackson 직렬화 과정(엔티티를 그대로 반환할 때)
  • toString(), equals(), hashCode()에서 연관관계 접근

특히 엔티티를 API 응답으로 직접 내보내는 것은 N+1뿐 아니라 순환참조/프록시 문제까지 유발하므로 지양하는 편이 좋습니다.

해결 전략 1: Fetch Join으로 한 방에 가져오기

N+1의 가장 직관적인 해결책은 페치 조인(fetch join) 입니다. 연관 엔티티를 조인으로 한 번에 당겨와서, 반복 접근 시 추가 쿼리가 나가지 않게 합니다.

1:N 컬렉션 페치 조인 기본

public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query("select distinct o from Order o left join fetch o.items where o.id in :ids")
    List<Order> findAllWithItems(@Param("ids") List<Long> ids);
}
  • join fetch o.items로 items를 함께 로딩
  • distinct중복 row로 인해 Order가 중복 생성되는 현상을 완화하기 위해 자주 사용

페치 조인의 함정: 페이징과 1:N

Order 목록을 페이징하면서 items까지 페치 조인을 걸면, DB row가 Order x Item으로 뻥튀기되면서 정확한 페이징이 깨지거나, Hibernate가 경고/예외를 내기도 합니다.

  • 1:1, N:1(단일값 연관)은 페이징과 함께 fetch join이 비교적 안전
  • 1:N 컬렉션 fetch join + paging은 매우 주의

안전한 패턴: 2단계 조회

  1. 먼저 페이징으로 Order id만 가져오기
  2. id 목록으로 fetch join 재조회
public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query("select o.id from Order o order by o.id desc")
    Page<Long> findOrderIds(Pageable pageable);

    @Query("select distinct o from Order o left join fetch o.items where o.id in :ids")
    List<Order> findAllWithItemsByIdIn(@Param("ids") List<Long> ids);
}

@Service
@RequiredArgsConstructor
public class OrderQueryService {
    private final OrderRepository orderRepository;

    @Transactional(readOnly = true)
    public List<OrderDto> list(Pageable pageable) {
        Page<Long> page = orderRepository.findOrderIds(pageable);
        List<Order> orders = orderRepository.findAllWithItemsByIdIn(page.getContent());

        // id in (...) 는 정렬이 보장되지 않으므로 필요 시 정렬 맞추기
        Map<Long, Order> map = orders.stream().collect(Collectors.toMap(Order::getId, o -> o));

        return page.getContent().stream()
            .map(map::get)
            .map(o -> new OrderDto(o.getId(), o.getCustomerName(), o.getItems().size()))
            .toList();
    }
}

이 방식은 쿼리가 2번으로 고정되고, 페이징도 유지됩니다.

해결 전략 2: 배치 페치(Batch Fetch)로 N을 “N/batch”로 줄이기

페치 조인이 만능은 아닙니다.

  • 화면/요구사항에 따라 연관 컬렉션이 필요 없을 수도 있고
  • 1:N 페치 조인은 페이징/중복 row/메모리 사용량 문제가 있고
  • 여러 컬렉션을 동시에 fetch join 하면 Cartesian product로 더 위험

이럴 때 Hibernate의 배치 페치가 실용적입니다. 핵심 아이디어는:

  • LAZY는 유지
  • 다만 여러 프록시 초기화가 발생할 때, Hibernate가 where id in (?, ?, ... ) 형태로 묶어서 가져옴

전역 설정: hibernate.default_batch_fetch_size

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100
  • 보통 50~200 사이에서 시작(트래픽/DB/쿼리 플랜에 따라 조정)
  • 너무 크게 잡으면 IN 절이 과도해져 플랜/파싱 비용이 늘 수 있음

엔티티/연관관계 단위 설정: @BatchSize

@Entity
public class Order {

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

@Entity
public class OrderItem {

    @BatchSize(size = 100)
    @ManyToOne(fetch = FetchType.LAZY)
    private Order order;
}

전역 설정이 부담스럽거나, 특정 연관관계만 최적화하고 싶을 때 유용합니다.

배치 페치가 실제로 바꾸는 쿼리 형태

기존 N+1:

  • select * from orders limit 100;
  • select * from order_item where order_id=?; x100

배치 페치 적용 후(예: batch 50):

  • select * from orders limit 100;
  • select * from order_item where order_id in (..50개..); x2

1 + (N / batch) 로 감소합니다.

Fetch Join vs Batch Fetch: 언제 무엇을 쓸까

Fetch Join이 유리한 경우

  • 특정 API에서 연관 데이터를 반드시 사용
  • 1:1, N:1 같은 단일 연관을 함께 가져오고 싶음
  • “이 쿼리는 항상 같이 로딩”이라는 의도가 명확

Batch Fetch가 유리한 경우

  • LAZY를 유지하면서 N+1을 완화하고 싶음
  • 1:N 컬렉션 + 페이징이 필요
  • 화면/요구사항에 따라 연관 접근 여부가 달라짐(조건부)

실무에서는 보통

  • 조회 전용 API(목록/상세) 는 fetch join 또는 DTO projection으로 쿼리를 설계하고
  • 그 외 일반 로직에서는 batch fetch로 “안전망”을 깔아두는 조합이 많이 쓰입니다.

DTO Projection으로 더 강하게 제어하기(선택)

N+1을 없애는 또 다른 방식은 아예 엔티티 그래프를 로딩하지 않고, 필요한 컬럼만 DTO로 조회하는 것입니다.

예: 주문 목록에 itemCount만 필요하다면, items를 로딩할 필요가 없습니다.

public record OrderSummaryDto(Long id, String customerName, long itemCount) {}

public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query("""
        select new com.example.OrderSummaryDto(o.id, o.customerName, count(i))
        from Order o
        left join o.items i
        group by o.id, o.customerName
        order by o.id desc
    """)
    List<OrderSummaryDto> findOrderSummaries();
}
  • 엔티티/프록시 초기화 자체가 없으므로 N+1 여지가 줄어듦
  • 다만 복잡한 화면에서는 쿼리가 커지거나, 재사용성이 떨어질 수 있음

실전 튜닝 체크리스트

1) “어디서 연관관계가 터지는지”부터 잡기

  • DTO 매핑 단계에서 터지는지
  • JSON 직렬화에서 터지는지
  • 로그/디버깅용 toString()에서 터지는지

2) 목록 API는 페이징 + 1:N을 특히 조심

  • 1:N fetch join + paging은 위험
  • 2단계 조회(IDs → fetch join) 또는 batch fetch 고려

3) batch size는 근거를 갖고 조정

  • 100이 만능이 아님
  • DB의 IN 절 성능, 실행계획, 네트워크 왕복, 커넥션 풀 상태를 함께 봐야 함

4) 컬렉션을 여러 개 fetch join 하지 말기

  • Orderitems, coupons, payments 같은 컬렉션을 한 번에 fetch join하면 row가 기하급수적으로 증가
  • 필요하다면 쿼리를 분리하거나 DTO projection으로 전환

5) 운영 증상과 함께 관찰

N+1은 DB 부하를 올리고, 결국 애플리케이션 레벨에서는 다음처럼 보일 수 있습니다.

  • p95/p99 지연 급증
  • 커넥션 풀 대기 증가
  • 타임아웃/재시도 증가
  • 오토스케일 빈번(부하를 CPU로만 오해)

이런 현상이 보이면 애플리케이션 쿼리 수부터 의심하는 것이 빠릅니다.

마무리

Spring Boot JPA에서 N+1은 “모르면 당하는” 문제가 아니라, 언제든 재발할 수 있는 구조적 리스크입니다. 해결의 핵심은 두 가지입니다.

  • 특정 조회 요구에 맞게 Fetch Join(또는 DTO Projection) 으로 쿼리를 설계해 “필요한 데이터를 한 번에” 가져오고
  • 일반적인 연관 접근에는 Batch Fetch 로 “N을 줄이는 안전장치”를 마련하는 것

이 조합으로 쿼리 수를 예측 가능하게 만들면, 트래픽 증가에도 DB가 먼저 무너지는 상황을 크게 줄일 수 있습니다.