Published on

Spring Boot 3 JPA N+1 폭발 - 5가지 해결법

Authors

서버가 느려졌는데 CPU도, GC도, 네트워크도 애매하게 정상처럼 보일 때가 있습니다. 로그를 자세히 보면 DB 쿼리가 수백~수천 번 쏟아지고, 그 원인이 N+1 인 경우가 많습니다. 특히 Spring Boot 3 + Hibernate 6 조합에서는 엔티티 연관관계를 그대로 노출하거나 기본 LAZY 로딩에 기대는 순간, 화면 한 번 그리는 데 쿼리가 폭발하기 쉽습니다.

이 글에서는 (1) N+1이 언제 발생하는지 재현하고, (2) 원인을 빠르게 확인하는 방법, (3) 실무에서 가장 많이 쓰는 5가지 해결법을 각각 장단점과 함께 정리합니다.

N+1이란 무엇이고, 왜 Spring Boot 3에서 더 자주 보일까

N+1은 “부모를 조회하는 쿼리 1번 + 자식(또는 연관 엔티티)을 N번 추가 조회”가 발생하는 패턴입니다.

  • 예: 주문 목록 20건을 가져온 뒤, 각 주문의 회원 정보를 화면에 찍는 순간 회원 조회가 20번 더 발생
  • 원인: 연관관계가 LAZY 인데, 반복문/직렬화/템플릿 렌더링 중 프록시가 초기화되면서 추가 쿼리가 실행됨

Spring Boot 3에서 더 자주 체감되는 이유는 보통 다음과 같습니다.

  • Hibernate 6로 올라오며 SQL/로딩 전략에 대한 “묵시적 기대”가 깨지는 지점이 있음
  • REST 응답에서 엔티티를 그대로 반환하는 관행이 남아 있으면, Jackson 직렬화 과정에서 연관 로딩이 연쇄적으로 발생
  • 페이징 + 컬렉션 fetch가 섞이면 예상치 못한 쿼리/메모리 문제가 함께 발생

N+1 재현: 가장 흔한 코드

아래는 전형적인 예시입니다. OrderMemberManyToOne 으로 참조하고 LAZY 로딩이라고 가정합니다.

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

  @Id @GeneratedValue
  private Long id;

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

  // getter
}

// Member.java
@Entity
public class Member {
  @Id @GeneratedValue
  private Long id;

  private String name;
}

// OrderRepository.java
public interface OrderRepository extends JpaRepository<Order, Long> {
}

// OrderService.java
@Service
@Transactional(readOnly = true)
public class OrderService {

  private final OrderRepository orderRepository;

  public OrderService(OrderRepository orderRepository) {
    this.orderRepository = orderRepository;
  }

  public List<String> findOrderMemberNames() {
    List<Order> orders = orderRepository.findAll(); // 1번
    return orders.stream()
        .map(o -> o.getMember().getName()) // N번
        .toList();
  }
}

데이터가 100건이면 orders 1번 + member 100번이 됩니다. 로컬에서는 “그럭저럭”이지만, 운영에서 트래픽이 겹치면 DB 커넥션 풀이 먼저 무너집니다.

먼저 진단: 지금 정말 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) “페이지 하나 렌더링”의 쿼리 수를 수치화

N+1은 “느리다”보다 “쿼리 수가 비정상적으로 많다”로 정의하는 게 좋습니다. 운영 진단 관점에서는 DB 레벨에서도 확인이 가능합니다. 예를 들어 PostgreSQL을 쓴다면, 쿼리 폭증과 함께 테이블 bloat, vacuum 지연 같은 2차 문제가 겹치기도 합니다. 관련해서는 PostgreSQL VACUUM 안됨? bloat·wraparound 10분 진단 도 같이 참고하면 좋습니다.

해결 1) Fetch Join으로 한 번에 가져오기 (가장 강력)

ManyToOne, OneToOne 같은 단건 연관은 fetch join이 가장 직관적이고 효과가 큽니다.

public interface OrderRepository extends JpaRepository<Order, Long> {

  @Query("select o from Order o join fetch o.member")
  List<Order> findAllWithMember();
}

@Service
@Transactional(readOnly = true)
public class OrderService {
  private final OrderRepository orderRepository;

  public OrderService(OrderRepository orderRepository) {
    this.orderRepository = orderRepository;
  }

  public List<String> findOrderMemberNames() {
    return orderRepository.findAllWithMember().stream()
        .map(o -> o.getMember().getName())
        .toList();
  }
}

장점

  • 쿼리 1번으로 끝낼 수 있음
  • 코드가 명확하고, “이 조회에서는 연관을 반드시 쓴다”는 의도를 표현 가능

주의점

  • 컬렉션(OneToMany) fetch join은 결과 row가 뻥튀기되어 중복이 생길 수 있음
  • 페이징과 함께 쓰면 DB 레벨 페이징이 깨지거나(또는 메모리 페이징) 성능 문제가 생길 수 있음

해결 2) @EntityGraph 로딩 그래프 지정 (Repository 메서드에 붙이기 좋음)

JPQL을 직접 쓰지 않고도 fetch 전략을 바꿀 수 있습니다.

public interface OrderRepository extends JpaRepository<Order, Long> {

  @EntityGraph(attributePaths = {"member"})
  List<Order> findAll();
}

또는 메서드를 분리해 의도를 더 명확히 합니다.

public interface OrderRepository extends JpaRepository<Order, Long> {

  @EntityGraph(attributePaths = {"member"})
  List<Order> findAllWithMemberBy();
}

장점

  • JPQL 문자열을 줄이고, 스프링 데이터 스타일로 유지 가능
  • 특정 조회에만 선택적으로 eager 로딩 적용 가능

주의점

  • 복잡한 조건/조인/집계가 들어가면 결국 JPQL 또는 QueryDSL이 더 명확할 때가 많음
  • 컬렉션까지 그래프로 당기면 결과 크기가 커질 수 있음

해결 3) Batch Fetching으로 N을 줄이기 (N+1을 1+K로)

fetch join이 항상 정답은 아닙니다. 화면에서 연관 엔티티를 “가끔만” 쓰거나, 컬렉션을 여러 개 건드릴 가능성이 있으면 batch fetching이 안전한 타협점이 됩니다.

설정 1) 전역 default_batch_fetch_size

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100

설정 2) 엔티티별 @BatchSize

@Entity
public class Order {

  @ManyToOne(fetch = FetchType.LAZY)
  @BatchSize(size = 100)
  private Member member;
}

이 방식은 LAZY 로딩이 발생하더라도, Hibernate가 프록시들을 모아서 in 쿼리로 묶어 가져옵니다.

  • 기존: 1 + N
  • 배치 적용: 1 + (N / batchSize) 수준

장점

  • 코드 변경이 적고 범용적으로 효과가 남
  • 컬렉션 fetch join + 페이징 충돌을 피하면서도 쿼리 수를 크게 줄일 수 있음

주의점

  • 쿼리 수가 “0”이 되는 건 아님. 트래픽이 크면 여전히 부담
  • in 절이 커져서 DB 플랜/캐시 효율이 떨어질 수 있으니 적절한 사이즈(예: 50~200)를 실측으로 결정

해결 4) DTO 직접 조회로 “필요한 것만” 가져오기 (가장 예측 가능)

엔티티 그래프를 잘 설계해도, API 응답은 결국 “화면/클라이언트가 필요한 필드”만 있으면 됩니다. 그럴 때는 DTO 프로젝션이 가장 예측 가능합니다.

public record OrderSummaryDto(Long orderId, Long memberId, String memberName) {}

public interface OrderRepository extends JpaRepository<Order, Long> {

  @Query("""
      select new com.example.dto.OrderSummaryDto(o.id, m.id, m.name)
      from Order o
      join o.member m
      """)
  List<OrderSummaryDto> findOrderSummaries();
}

장점

  • N+1 자체가 구조적으로 발생하지 않음
  • 전송/직렬화 비용이 줄고, API 스펙이 안정적

주의점

  • 엔티티 변경이 DTO에 즉시 반영되지 않으므로 유지보수 비용이 생길 수 있음
  • 복잡한 화면은 DTO가 많아질 수 있음(대신 성능/안정성이 좋아짐)

해결 5) 페이징 + 컬렉션 로딩 전략 분리 (가장 실무적인 함정 회피)

실무에서 N+1이 “폭발”하는 지점은 대개 페이징 목록 API입니다.

  • 주문 20건 페이징 조회
  • 각 주문의 orderItems(컬렉션) 접근
  • 컬렉션이 LAZY 라서 주문마다 아이템 조회가 추가로 나감

여기서 컬렉션 fetch join으로 “한 번에” 당기고 싶지만, JPA에서 컬렉션 fetch join + 페이징은 위험합니다.

권장 패턴: 2단계 조회

  1. 페이징은 부모만 정확히 자르기
  2. 필요한 컬렉션은 in으로 한 번에 로딩
public interface OrderRepository extends JpaRepository<Order, Long> {

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

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

@Service
@Transactional(readOnly = true)
public class OrderQueryService {

  private final OrderRepository orderRepository;

  public OrderQueryService(OrderRepository orderRepository) {
    this.orderRepository = orderRepository;
  }

  public List<Order> findOrdersWithItems(Pageable pageable) {
    Page<Order> page = orderRepository.findPage(pageable);
    List<Long> ids = page.getContent().stream().map(Order::getId).toList();
    return orderRepository.findAllWithItemsByIdIn(ids);
  }
}

핵심은 “페이징 쿼리”와 “컬렉션 로딩 쿼리”를 분리해, 결과의 정확성과 성능을 동시에 잡는 것입니다.

장점

  • 페이징 정확도 보장
  • 컬렉션 로딩을 1번으로 제한 가능

주의점

  • 쿼리가 2번 이상은 발생(대신 예측 가능)
  • 정렬 조건이 복잡하면 ids 추출과 재조회 시 정렬 유지에 신경 써야 함

자주 하는 실수: OSIV에 기대서 “일단 된다”로 끝내기

Open Session In View(OSIV)를 켜두면 컨트롤러/뷰까지 영속성 컨텍스트가 열려 있어서, 화면 렌더링 중에 프록시가 초기화되어도 예외가 안 납니다. 하지만 이는 N+1을 더 늦게, 더 크게 터뜨리는 원인이 되기도 합니다.

  • OSIV가 켜져 있으면 “서비스에서 쿼리를 통제”하기 어렵습니다.
  • 트래픽이 늘면 DB 커넥션 점유 시간이 길어져 풀 고갈이 빨라집니다.

가능하면 조회 계층에서 필요한 연관을 명시적으로 로딩(fetch join, entity graph, DTO, batch)하고, OSIV는 정책적으로 결정하는 편이 안전합니다.

어떤 해결책을 언제 쓰면 좋은가: 선택 가이드

  • 단건 연관(ManyToOne, OneToOne)이고 항상 필요하다: fetch join 또는 @EntityGraph
  • 컬렉션 연관이고 페이징이 있다: 2단계 조회 + in 로딩, 또는 batch fetching
  • API 응답이 명확한 읽기 전용이다: DTO 직접 조회
  • 전체적으로 쿼리 수를 낮추고 싶다: default_batch_fetch_size 를 기본으로 깔고, 병목 구간만 fetch join/DTO로 최적화

운영에서 확인해야 할 체크리스트

  • 동일한 요청 1회에 SQL이 몇 번 나가는지(로깅/모니터링)
  • 쿼리 수가 줄었는데도 느리면, 인덱스/통계/테이블 bloat 같은 DB 요인도 같이 점검
  • 배치 사이즈를 올렸더니 DB CPU가 오르는지(큰 in 절의 역효과)
  • 페이징 엔드포인트에서 컬렉션 fetch join을 무리하게 쓰지 않았는지

성능 문제는 애플리케이션만의 문제가 아니라 DB 운영 상태와 함께 증폭됩니다. 특히 PostgreSQL이라면 vacuum 지연이나 bloat가 쿼리 폭증과 맞물려 장애로 이어질 수 있으니, 필요할 때 PostgreSQL VACUUM 안됨? bloat·wraparound 10분 진단 같은 관점으로도 함께 보세요.

마무리

Spring Boot 3 JPA에서 N+1은 “JPA가 느리다”가 아니라 “로딩 전략을 명시하지 않았다”에 가깝습니다.

  • 빠르게 잡으려면 fetch join 또는 DTO 조회로 쿼리 수를 즉시 줄이고
  • 구조적으로 안정화하려면 batch fetching과 페이징 전략 분리로 예측 가능한 쿼리 패턴을 만들고
  • 마지막으로 OSIV/직렬화 경로를 정리해, 의도치 않은 지연 로딩이 발생하지 않게 통제하는 것이 핵심입니다.

위 5가지 중 하나만 고집하기보다, 엔드포인트 성격(목록/상세/검색/페이징)과 데이터 크기에 맞춰 섞어 쓰는 것이 가장 현실적인 해법입니다.