Published on

Spring Boot 3 JPA N+1 폭발, fetch join 튜닝 실전

Authors

서버가 느려졌는데 CPU도, 네트워크도, DB 커넥션도 애매하게 바쁘다면 JPA의 N+1을 먼저 의심해야 합니다. 특히 Spring Boot 3 환경에서 엔티티 연관관계를 LAZY로 두고 조회 화면을 만들면, 코드 한 줄은 단순해 보이지만 실제로는 1 + N 쿼리가 순식간에 1 + N + (N*M) 형태로 폭발합니다.

이 글은 N+1을 재현한 뒤, fetch join으로 어디까지 해결할 수 있는지, 그리고 컬렉션 페치 조인과 페이지네이션의 함정, 중복 row 처리, 대안(배치 페치, DTO 쿼리)까지 실전 기준으로 정리합니다.

성능 문제를 다룰 때는 DB 자체 컨디션도 함께 확인하는 게 좋습니다. 예를 들어 PostgreSQL이라면 테이블 bloat나 wraparound로 인해 쿼리가 느려지는 경우도 있어, N+1 튜닝과 병행해 점검하면 원인 분리가 빨라집니다: PostgreSQL VACUUM 안됨? bloat·wraparound 10분 진단

N+1이 폭발하는 전형적인 코드

예시 도메인은 Order(주문) Member(주문자) OrderItem(주문상품) Item(상품)으로 가정합니다.

  • Order : ManyToOne member
  • Order : OneToMany orderItems
  • OrderItem : ManyToOne item

엔티티 예시

@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> orderItems = new ArrayList<>();

    // getters
}

@Entity
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 = "item_id")
    private Item item;

    private int quantity;

    // getters
}

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().getName(),             // 여기서 Member N+1
            o.getOrderItems().size()             // 여기서 OrderItem N+1
        ))
        .toList();
}

findTop100... 쿼리 1번 이후, 각 주문마다 member 접근 시 추가 쿼리, orderItems 접근 시 추가 쿼리가 나가며, 더 깊게 들어가 orderItemsitem까지 접근하면 곱연산으로 폭발합니다.

먼저 확인: 정말 N+1인가?

N+1은 “코드가 연관 엔티티에 접근하는 순간” 발생합니다. 따라서 재현과 측정이 중요합니다.

SQL 로그 설정(필수)

Spring Boot 3에서 최소한 아래 정도는 켜두는 편이 좋습니다.

spring:
  jpa:
    properties:
      hibernate:
        format_sql: true
        highlight_sql: true
logging:
  level:
    org.hibernate.SQL: debug
    org.hibernate.orm.jdbc.bind: trace
  • org.hibernate.SQL로 실제 SQL 개수와 형태를 확인
  • bind 파라미터까지 보면 “같은 패턴이 N번 반복”되는지 바로 드러납니다

fetch join으로 N+1을 끊는 방법

핵심은 “조회 시점에 필요한 연관을 한 번에 당겨오게” 만드는 것입니다.

1) ManyToOne은 fetch join이 가장 안전한 승리

Order에서 MemberManyToOne입니다. 이 케이스는 페치 조인이 대부분의 상황에서 정답에 가깝습니다.

public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query("""
        select o
        from Order o
        join fetch o.member m
        order by o.id desc
    """)
    List<Order> findRecentOrdersWithMember();
}

이렇게 하면 주문 1번 조회로 주문자까지 같이 로딩되어 member 접근에서 추가 쿼리가 사라집니다.

주의점

  • ManyToOne/OneToOne은 row 수가 폭발하지 않으므로 페치 조인이 비교적 안전
  • 반면 컬렉션(OneToMany)은 조인 시 row가 늘어나서 중복과 페이지네이션 문제가 생깁니다

2) 컬렉션(OneToMany) fetch join의 함정: 중복 row

OrderOrderItem을 페치 조인하면 SQL 결과 row는 OrderItem 개수만큼 늘어납니다.

@Query("""
    select distinct o
    from Order o
    join fetch o.member
    join fetch o.orderItems oi
    order by o.id desc
""")
List<Order> findOrdersWithMemberAndItems();
  • distinct는 JPA 레벨에서 엔티티 중복 제거에 도움
  • 하지만 DB 레벨 distinct와는 의미가 다를 수 있고, 정렬/페이징과 만나면 복잡해집니다

3) 컬렉션 fetch join + 페이지네이션은 거의 금지

가장 흔한 사고는 아래입니다.

@Query("""
    select distinct o
    from Order o
    join fetch o.orderItems
    order by o.id desc
""")
Page<Order> findPage(Pageable pageable);

이 조합은 다음 문제가 생깁니다.

  • DB는 row 기준으로 limit/offset을 적용
  • 하지만 우리는 Order 기준으로 페이지를 원함
  • 결과적으로 페이지 크기가 흔들리거나 누락/중복이 발생
  • Hibernate가 경고를 내거나, 메모리에서 페이징을 시도해 성능이 더 나빠질 수 있음

실전 튜닝 패턴 3가지

현업에서 가장 안정적인 선택지는 보통 아래 3가지 중 하나입니다.

패턴 A: 페이지는 루트만, 컬렉션은 배치 페치로

1단계: 페이지는 Order만 조회(필요하면 member는 페치 조인)

@Query("""
    select o
    from Order o
    join fetch o.member
    order by o.id desc
""")
Page<Order> findPageWithMember(Pageable pageable);

2단계: 컬렉션은 hibernate.default_batch_fetch_size로 N+1을 완화

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100

이 설정은 LAZY 컬렉션/프록시를 여러 건 접근할 때 where id in (...) 형태로 묶어서 가져오게 해줍니다.

  • 장점: 페이지네이션 안전, 구현 난이도 낮음
  • 단점: 쿼리 수가 1 + (컬렉션 로딩을 위한 몇 번) 형태로 남을 수 있음

패턴 B: ToOne은 fetch join, 컬렉션은 필요한 화면에서만 별도 조회

예를 들어 목록 화면은 주문자까지만 필요하고, 주문 상세에서만 아이템을 보여준다면 목록에서는 컬렉션을 아예 로딩하지 않는 게 정답입니다.

@Transactional(readOnly = true)
public Page<OrderListDto> findOrderList(Pageable pageable) {
    Page<Order> page = orderRepository.findPageWithMember(pageable);
    return page.map(o -> new OrderListDto(o.getId(), o.getMember().getName()));
}

@Transactional(readOnly = true)
public OrderDetailDto findOrderDetail(Long orderId) {
    Order order = orderRepository.findByIdWithItems(orderId)
        .orElseThrow();

    return OrderDetailDto.from(order);
}

상세 조회는 단건이므로 컬렉션 페치 조인도 상대적으로 안전합니다.

@Query("""
    select distinct o
    from Order o
    join fetch o.member
    join fetch o.orderItems oi
    join fetch oi.item
    where o.id = :orderId
""")
Optional<Order> findByIdWithItems(Long orderId);

패턴 C: DTO 직접 조회로 끝내기(가장 예측 가능)

엔티티 그래프를 화면까지 끌고 가면, 어디선가 LAZY를 밟는 순간 쿼리가 튀어나옵니다. 화면이 고정된 조회 API라면 DTO로 필요한 컬럼만 가져오는 것이 가장 예측 가능합니다.

@Query("""
    select new com.example.api.dto.OrderRowDto(
        o.id,
        m.name,
        oi.quantity,
        i.name
    )
    from Order o
    join o.member m
    join o.orderItems oi
    join oi.item i
    where o.id in :orderIds
""")
List<OrderRowDto> findOrderRows(@Param("orderIds") List<Long> orderIds);
  • 장점: 쿼리 형태가 명확, N+1 구조적으로 차단
  • 단점: 재사용성 낮을 수 있고, 화면 요구사항 변경에 따라 DTO/쿼리 수정 필요

fetch join 튜닝 체크리스트

1) fetch join은 “필요한 것만”

무조건 다 페치 조인하면 row 폭발로 이어집니다.

  • 목록: ToOne만 페치 조인
  • 상세: 단건이면 컬렉션 페치 조인 가능
  • 통계/리포트: DTO 조회 고려

2) 컬렉션 fetch join을 쓴다면 distinct를 습관처럼

select distinct o from Order o join fetch o.orderItems

다만 distinct가 만능은 아닙니다. 특히 정렬/페이징과 결합하면 결과가 기대와 다를 수 있으니, 컬렉션 페치 조인으로 페이징을 해결하려고 하지 않는 편이 좋습니다.

3) 트랜잭션 밖에서 LAZY 접근 금지

Open Session In View를 꺼둔 환경에서는 트랜잭션 밖에서 LAZY를 밟으면 예외가 납니다. 켜둔 환경에서는 예외 대신 “조용한 N+1”이 생깁니다.

  • 서비스 계층에서 DTO로 변환을 끝내기
  • 컨트롤러/뷰에서 엔티티를 직접 순회하지 않기

4) 측정은 “쿼리 수 + 실행 시간 + row 수”

N+1을 잡았는데도 느리다면 다음을 의심해야 합니다.

  • 조인으로 row 수가 과도하게 늘어남
  • 인덱스 부재로 조인 비용이 증가
  • DB 컨디션 문제(bloat 등)

DB 쪽 컨디션 점검이 필요하면 위에서 언급한 VACUUM 진단 글도 함께 보세요: PostgreSQL VACUUM 안됨? bloat·wraparound 10분 진단

결론: fetch join은 N+1의 특효약이지만, 범위가 있다

  • ManyToOne/OneToOne N+1은 fetch join으로 깔끔하게 해결되는 경우가 많습니다.
  • OneToMany 컬렉션은 페치 조인 시 row 증가, 중복, 페이지네이션 붕괴가 핵심 리스크입니다.
  • 목록 페이지는 ToOne fetch join + 배치 페치 조합이 가장 현실적이고,
  • 상세 단건은 컬렉션 페치 조인도 안전한 편이며,
  • 화면이 고정된 조회 API는 DTO 직접 조회가 가장 예측 가능하고 운영 친화적입니다.

다음에 N+1이 의심될 때는 “어디서 LAZY를 밟는지”를 먼저 찾고, fetch join은 필요한 연관에만 최소로 적용한 뒤, 컬렉션은 배치 페치나 DTO 쿼리로 분리하는 순서로 접근하면 폭발을 안정적으로 진압할 수 있습니다.