Published on

Spring Boot 3에서 JPA N+1 실전 제거법

Authors

서버 성능 이슈를 파고들다 보면, CPU나 네트워크보다 먼저 터지는 병목이 의외로 DB 쿼리 폭증인 경우가 많습니다. 그중 대표가 JPA의 N+1 문제입니다. 로컬에서는 “그럭저럭”인데, 운영에서 트래픽이 붙는 순간 쿼리가 N배로 늘어나며 레이턴시가 튀고 커넥션 풀이 고갈되는 패턴이 자주 나옵니다.

이 글은 Spring Boot 3(=Hibernate 6 계열) 기준으로 N+1을 재현 → 탐지 → 제거 → 회귀 방지까지 실전 관점에서 정리합니다. 단순히 “fetch join 쓰세요”가 아니라, 컬렉션/페이징/다대다/DTO 화면 등 실제 서비스에서 부딪히는 제약과 대안을 함께 다룹니다.

운영 장애로까지 번지면 Tomcat 스레드가 대기하며 503이 보이기도 합니다. 이런 경우에는 애플리케이션 레벨 병목(쿼리 폭증)과 인프라 증상을 함께 봐야 하니, 필요하면 Spring Boot 3+ Tomcat 503 원인별 진단·해결도 같이 참고하세요.

N+1이 생기는 정확한 구조

N+1은 보통 아래 흐름에서 발생합니다.

  1. 부모 엔티티 N개를 조회하는 쿼리 1번 실행
  2. 각 부모가 가진 연관 엔티티(또는 컬렉션)를 접근하는 순간, 지연 로딩(LAZY)이 트리거됨
  3. 부모 N개 각각에 대해 연관을 가져오는 쿼리가 N번 추가 실행

즉, 1 + N이 됩니다. N이 100이면 101쿼리, N이 1,000이면 1,001쿼리로 폭발합니다.

재현용 예제 모델 (Spring Boot 3 / Hibernate 6)

가장 흔한 형태인 Member(1) - Order(N) - OrderItem(N) 구조로 보겠습니다.

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

    private String name;

    @OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
    private List<Order> orders = new ArrayList<>();
}

@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<>();
}

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

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

    private String sku;
    private int price;
}

그리고 서비스에서 흔히 하는 실수(리스트 조회 후 연관 접근):

@Transactional(readOnly = true)
public List<String> memberOrderSkus() {
    List<Member> members = memberRepository.findTop100ByOrderByIdAsc(); // 쿼리 1번

    List<String> result = new ArrayList<>();
    for (Member m : members) {
        for (Order o : m.getOrders()) {          // 여기서 members 개수만큼 추가 쿼리(N)
            for (OrderItem i : o.getItems()) {   // 여기서 orders 개수만큼 추가 쿼리(또 다른 N)
                result.add(i.getSku());
            }
        }
    }
    return result;
}

이 코드는 2단계, 3단계로 N+1이 중첩될 수 있습니다(실제로는 N+1+M+… 형태).

먼저 “보이게” 만들어라: 쿼리 로깅/통계

N+1은 발견이 늦으면 대부분 운영에서 터집니다. 개발/스테이징에서 쿼리 수쿼리 패턴을 쉽게 보게 만드는 것이 중요합니다.

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
  • org.hibernate.orm.jdbc.bind: 바인딩 파라미터

2) Hibernate 통계로 쿼리 카운트 체크(테스트에서 유용)

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

테스트에서 “이 API는 쿼리 3개 이하” 같은 식으로 회귀 방지도 가능합니다.

3) 운영에서는 DB 락/대기까지 같이 보자

N+1은 단순히 쿼리 수만 늘리는 게 아니라, 같은 테이블을 반복 조회하며 버퍼 캐시/인덱스/락 경합을 악화시킬 수 있습니다. 실제로 트랜잭션 경합이 커지면 데드락이나 타임아웃으로 이어지기도 합니다. DB 레벨에서 병목을 해석해야 할 때는 MySQL·PostgreSQL 데드락 분석과 트랜잭션·인덱스 튜닝도 같이 보면 좋습니다.

제거 전략 1: Fetch Join (가장 강력하지만 제약이 많다)

1) 단건/소수 조회: Fetch Join이 정답인 경우

부모와 연관을 “항상 같이” 쓰는 화면/API라면 fetch join이 가장 직관적입니다.

public interface MemberRepository extends JpaRepository<Member, Long> {

    @Query("select distinct m from Member m " +
           "left join fetch m.orders o " +
           "left join fetch o.items")
    List<Member> findMembersWithOrdersAndItems();
}
  • distinct는 JPA 레벨에서 중복 엔티티를 제거하는 용도로 자주 사용합니다(조인으로 row가 늘어나기 때문).

2) 컬렉션 fetch join + 페이징은 위험

@OneToMany 같은 컬렉션을 fetch join한 상태에서 Pageable을 붙이면,

  • DB 레벨 페이징이 깨지거나
  • Hibernate가 메모리에서 페이징을 하거나
  • 결과가 왜곡되는 문제가 생길 수 있습니다.

즉, “리스트 + 페이징 + 컬렉션 로딩”은 fetch join 단독으로 해결하기 어렵습니다.

대안은 아래 전략 3/4(배치 패칭, DTO 조회) 쪽이 더 안정적입니다.

제거 전략 2: EntityGraph (선언적으로 로딩 경로를 제어)

EntityGraph는 “이번 쿼리에서는 이 연관을 미리 로딩해”를 레포지토리 메서드에 선언할 수 있어 유지보수성이 좋습니다.

public interface MemberRepository extends JpaRepository<Member, Long> {

    @EntityGraph(attributePaths = {"orders"})
    List<Member> findTop100ByOrderByIdAsc();
}

다만,

  • 깊은 그래프(orders.items까지)로 가면 조인이 복잡해지고 row 폭증 위험이 있습니다.
  • 페이징 + 컬렉션 로딩 문제는 여전히 남습니다.

EntityGraph는 **“to-one(@ManyToOne/@OneToOne) 위주”**로 특히 효과가 좋습니다. to-one은 row를 폭증시키지 않으면서 N+1을 깔끔하게 제거할 수 있습니다.

제거 전략 3: Batch Fetching (페이징/리스트에서 실전적으로 가장 많이 씀)

페이징이 필요한 목록 API에서 흔한 접근은:

  • 1차 쿼리: 부모만 페이징으로 가져옴
  • 2차 쿼리: 연관 컬렉션을 where ... in (...)으로 한 번(또는 몇 번) 더 가져옴

Hibernate의 batch fetching은 이 패턴을 자동으로 만들어줍니다.

1) 설정: default_batch_fetch_size

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100

또는 엔티티별로:

@OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
@BatchSize(size = 100)
private List<Order> orders = new ArrayList<>();

2) 효과

members 100명을 조회 후 m.getOrders()를 접근하면,

  • 원래는 members 수만큼 100번 쿼리가 나가던 것이
  • select ... from orders where member_id in (..100개..) 같은 쿼리 1번(또는 2~3번)으로 줄어듭니다.

3) 튜닝 포인트

  • size는 너무 크면 IN 절이 커지고, 너무 작으면 쿼리 횟수가 늘어납니다.
  • 보통 50~200 사이에서 서비스/DB 특성에 맞춰 측정으로 결정합니다.

Batch fetching은 fetch join처럼 “한 방에 다 가져오기”는 아니지만, 페이징과 양립하면서 N+1을 크게 줄여주는 실전형 해법입니다.

제거 전략 4: DTO 직접 조회(화면/API 최적화의 끝판왕)

엔티티 그래프를 맞추는 데 시간이 많이 들거나, 화면이 요구하는 필드가 엔티티 구조와 다를 때는 DTO로 바로 조회하는 게 가장 깔끔합니다.

1) 단순 조인 DTO

public record OrderItemRow(Long memberId, Long orderId, String sku, int price) {}

public interface OrderQueryRepository {
    @Query("select new com.example.OrderItemRow(m.id, o.id, i.sku, i.price) " +
           "from Member m " +
           "join m.orders o " +
           "join o.items i " +
           "where m.id in :memberIds")
    List<OrderItemRow> findRows(@Param("memberIds") List<Long> memberIds);
}

이 방식은:

  • 영속성 컨텍스트에 엔티티를 잔뜩 올리지 않아도 되고
  • 필요한 컬럼만 가져오며
  • API 응답 형태에 맞춰 최적화하기 쉽습니다.

2) 2-step DTO 조립(페이징 + 상세 컬렉션)

실전에서 많이 쓰는 패턴입니다.

  1. 페이지는 부모만 가져오기
  2. 해당 부모 ID 목록으로 자식들을 한 번에 가져오기
  3. 메모리에서 그룹핑하여 DTO 조립
@Transactional(readOnly = true)
public Page<MemberDto> members(Pageable pageable) {
    Page<Member> page = memberRepository.findAll(pageable);

    List<Long> memberIds = page.getContent().stream()
            .map(Member::getId)
            .toList();

    List<OrderItemRow> rows = orderQueryRepository.findRows(memberIds);

    Map<Long, List<OrderItemRow>> grouped = rows.stream()
            .collect(Collectors.groupingBy(OrderItemRow::memberId));

    List<MemberDto> content = page.getContent().stream()
            .map(m -> new MemberDto(m.getId(), m.getName(), grouped.getOrDefault(m.getId(), List.of())))
            .toList();

    return new PageImpl<>(content, pageable, page.getTotalElements());
}

이 패턴은 N+1을 구조적으로 차단하면서도 페이징 요구를 만족합니다. 다만 구현량이 늘어나는 대신, 성능 예측 가능성이 크게 올라갑니다.

흔한 함정과 체크리스트

1) OSIV(Open Session In View)로 N+1이 “숨겨지는” 문제

OSIV가 켜져 있으면 컨트롤러/뷰 렌더링 단계에서 LAZY 로딩이 계속 발생할 수 있어, 서비스 레이어에서는 멀쩡해 보이는데 API 응답 직전에 쿼리가 폭발합니다.

  • 운영에서는 OSIV를 끄는 팀도 많습니다.
  • 끄면 N+1이 더 빨리 드러나고, 트랜잭션 경계도 명확해집니다.
spring:
  jpa:
    open-in-view: false

OSIV를 끄면 LazyInitializationException이 뜰 수 있는데, 이는 “어디서 연관을 로딩해야 하는지”를 설계로 강제하는 신호로 받아들이는 게 좋습니다.

2) to-one은 fetch join/EntityGraph, to-many는 batch/DTO

경험적으로 아래 조합이 운영에서 안정적입니다.

  • @ManyToOne, @OneToOne(to-one): fetch join 또는 EntityGraph로 즉시 해결
  • @OneToMany, @ManyToMany(to-many):
    • 페이징 없으면 fetch join 고려
    • 페이징 있으면 batch fetching 또는 2-step DTO

3) “무조건 EAGER”는 금지

N+1이 싫어서 연관을 EAGER로 바꾸면:

  • 필요 없는 곳에서도 조인이 발생하고
  • 예측 불가능한 쿼리/성능 문제가 생기며
  • 순환 참조/폭발적 로딩으로 더 큰 장애를 만들 수 있습니다.

N+1은 로딩 전략을 쿼리 단위로 제어하는 방식(fetch join/graph/batch/DTO)으로 해결하는 게 정석입니다.

실전 권장 조합(요약)

  • 단건 상세 화면: fetch join 또는 EntityGraph로 필요한 연관만 즉시 로딩
  • 목록 + 페이징:
    • to-one은 join으로 당겨오기
    • to-many는 default_batch_fetch_size로 N+1을 N/B 수준으로 절감
    • 응답이 복잡하면 2-step DTO로 쿼리 수를 고정(보통 2~3개)
  • 회귀 방지: SQL 로그/통계 + 테스트에서 쿼리 수 상한을 걸기

마무리

Spring Boot 3에서 JPA N+1을 없애는 핵심은 “연관을 언제, 어떤 단위로 로딩할지”를 사용처(화면/API) 기준으로 설계하는 것입니다. fetch join은 강력하지만 페이징과 충돌하기 쉽고, EntityGraph는 선언적이지만 깊은 그래프에서 row 폭증 위험이 있습니다. 반면 batch fetching과 DTO 직접 조회는 목록/페이징에서 특히 예측 가능하고 운영 친화적입니다.

성능 문제는 보통 한 가지 원인으로 끝나지 않습니다. N+1로 인해 DB 부하가 커지면 커넥션 풀 대기, 스레드 고갈, 503까지 이어질 수 있으니 애플리케이션과 인프라를 함께 관찰하는 습관이 중요합니다. 필요하면 Spring Boot 3+ Tomcat 503 원인별 진단·해결MySQL·PostgreSQL 데드락 분석과 트랜잭션·인덱스 튜닝도 함께 점검해보세요.