Published on

Spring Boot 3 JPA N+1 폭발을 끝내는 법

Authors

서버 성능 이슈를 추적하다 보면, CPU나 네트워크보다 먼저 DB 커넥션과 쿼리 수가 폭발하는 순간이 있습니다. Spring Boot 3 환경에서 JPA(Hibernate)를 쓰는 서비스라면 그 중심에 N+1 문제가 자주 있습니다. 특히 트래픽이 늘거나(혹은 가상 스레드 같은 동시성 모델을 도입했을 때) 응답 지연이 커지면, “쿼리는 별로 안 날리겠지”라는 가정이 깨지면서 커넥션 풀이 먼저 고갈됩니다.

이 글에서는 Spring Boot 3에서 N+1이 왜 더 자주 ‘폭발’처럼 보이는지, 그리고 Fetch Join / EntityGraph / Batch Size / DTO 직접 조회를 어떤 기준으로 선택해야 하는지 실전 관점에서 정리합니다. (커넥션 풀이 먼저 터지는 증상까지 같이 보려면 Spring Boot 3 가상스레드에서 HikariCP 고갈 해결도 함께 읽어보면 맥락이 잘 이어집니다.)

N+1 문제란 무엇인가 (그리고 왜 ‘폭발’처럼 보이나)

  • 1: 루트 엔티티 목록을 조회하는 쿼리 1번
  • N: 각 엔티티의 연관 엔티티를 LAZY 로딩하면서 N번 추가 쿼리

예를 들어 Order 목록을 100건 조회하고, 각 주문의 member를 접근하면 member를 가져오기 위해 최대 100번의 추가 쿼리가 나갈 수 있습니다.

Spring Boot 3에서 “폭발”처럼 체감되는 이유는 보통 다음이 겹치기 때문입니다.

  1. 페이지/리스트 API가 기본적으로 N이 크다: 20~100건은 흔합니다.
  2. JSON 직렬화가 연관 필드를 건드린다: 컨트롤러에서 엔티티를 그대로 반환하면 Jackson이 getter를 타면서 LAZY 로딩을 유발합니다.
  3. 동시 요청 증가 시 커넥션 풀이 먼저 바닥난다: 쿼리 수가 늘면 커넥션 점유 시간이 늘고, 결국 타임아웃/대기열이 발생합니다.
  4. **OSIV(Open Session In View)**가 켜져 있으면 “서비스 레이어 밖”에서도 LAZY 로딩이 가능해져 문제를 늦게 발견한다.

재현: 가장 흔한 N+1 패턴

도메인 예시

@Entity
public class Order {
    @Id @GeneratedValue
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    private Member member;

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

    // getters
}

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;
    private String name;
}

@Entity
public class OrderItem {
    @Id @GeneratedValue
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    private Order order;

    @ManyToOne(fetch = FetchType.LAZY)
    private Item item;
}

@Entity
public class Item {
    @Id @GeneratedValue
    private Long id;
    private String name;
}

문제 코드

@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
    @Query("select o from Order o order by o.id desc")
    List<Order> findRecent();
}

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

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

    public List<OrderDto> recentOrders() {
        List<Order> orders = orderRepository.findRecent();
        // 여기서 member, orderItems 접근 시 N+1
        return orders.stream()
                .map(o -> new OrderDto(
                        o.getId(),
                        o.getMember().getName(),
                        o.getOrderItems().size()
                ))
                .toList();
    }
}

public record OrderDto(Long orderId, String memberName, int itemCount) {}

위 코드는 findRecent() 1번 + o.getMember() N번 + o.getOrderItems() N번 등으로 쉽게 폭발합니다.

N+1 탐지: 로그/통계로 “정량화”하기

1) Hibernate SQL 로그

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 파라미터까지 보려면 jdbc.bind를 trace로

2) 통계로 쿼리 수 보기

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

통계는 환경에 따라 로그가 많이 나올 수 있으니 개발/스테이징에서만 권장합니다.

해결 전략 1: Fetch Join (가장 직관적인 해법)

Fetch Join은 “연관 엔티티를 한 번에 가져오겠다”를 JPQL로 명시하는 방식입니다.

ManyToOne/OneToOne에 Fetch Join 적용

@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query("""
        select o from Order o
        join fetch o.member
        order by o.id desc
    """)
    List<Order> findRecentWithMember();
}
  • Order 목록 + Member를 한 번에 로딩
  • ManyToOne은 결과 row가 크게 늘지 않아 비교적 안전

OneToMany까지 Fetch Join하면 주의할 점

@Query("""
    select distinct o from Order o
    join fetch o.member
    join fetch o.orderItems
    order by o.id desc
""")
List<Order> findRecentWithMemberAndItems();
  • Order 1건이 orderItems 개수만큼 row로 뻥튀기됩니다.
  • distinct는 JPA 레벨에서 중복 엔티티를 제거하지만, DB row 수가 줄어드는 건 아닐 수 있습니다.
  • 페이징과 양립이 어렵습니다. (Hibernate가 메모리 페이징으로 바꾸거나, 경고/예외 상황이 생길 수 있음)

정리

  • ManyToOne/OneToOne은 Fetch Join을 적극 사용
  • OneToMany/ManyToMany는 “목록 + 컬렉션”을 한 번에 가져오는 순간 페이징/중복/메모리 이슈가 생기므로 다른 전략도 고려

해결 전략 2: EntityGraph (레포지토리 메서드에 선언적으로)

EntityGraph는 “이 쿼리에서 어떤 연관을 로딩할지”를 어노테이션으로 선언합니다. Fetch Join과 비슷한 효과를 내되, 메서드 시그니처를 크게 바꾸지 않고 적용하기 좋습니다.

@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {

    @EntityGraph(attributePaths = {"member"})
    List<Order> findTop20ByOrderByIdDesc();
}
  • 단순 조회 메서드에 붙이기 쉬움
  • 복잡한 JPQL을 줄일 수 있음

컬렉션까지 포함할 수도 있지만, Fetch Join과 동일하게 row 증가/페이징 이슈는 그대로 존재합니다.

해결 전략 3: Batch Size로 “N을 줄이기” (현실적인 타협)

Fetch Join으로 컬렉션까지 한 번에 가져오면 페이징이 깨지는 경우가 많습니다. 이때 자주 쓰는 타협이 Batch Fetching입니다.

핵심은 “LAZY 로딩이 발생하더라도, 한 건씩이 아니라 IN 쿼리로 묶어서 가져오자”입니다.

설정

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100

또는 엔티티/연관관계에 지정:

@BatchSize(size = 100)
@OneToMany(mappedBy = "order")
private List<OrderItem> orderItems = new ArrayList<>();

효과

  • orders 100건을 순회하며 orderItems에 접근해도
  • 쿼리가 100번이 아니라 1~몇 번where order_id in (...) 형태로 줄어듭니다.

주의

  • 배치 크기는 무조건 클수록 좋은 게 아닙니다. IN 절이 너무 커지면 플랜/네트워크/파싱 비용이 늘 수 있습니다.
  • 보통 50~200 사이에서 워크로드에 맞춰 튜닝합니다.

해결 전략 4: DTO 직접 조회(Projection)로 “필요한 것만” 가져오기

N+1의 근본 원인 중 하나는 “엔티티 그래프를 그대로 들고 와서, 나중에 여기저기 접근”하는 방식입니다. 화면/API 요구사항이 명확하다면 DTO로 필요한 필드만 select하는 게 가장 예측 가능하고 빠릅니다.

예: 주문 목록에 필요한 필드만

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

@Repository
public interface OrderQueryRepository {

    @Query("""
        select new com.example.OrderSummaryDto(o.id, m.name)
        from Order o
        join o.member m
        order by o.id desc
    """)
    List<OrderSummaryDto> findOrderSummaries();
}
  • 엔티티 로딩/영속성 컨텍스트 관리 부담이 줄어듭니다.
  • API 스펙이 명확한 조회성 화면에 특히 적합합니다.

단, 화면이 복잡해져서 여러 컬렉션/집계가 필요하면 쿼리가 복잡해질 수 있으니, 이 경우는 Querydsl/전용 조회 레포지토리로 분리하는 패턴을 추천합니다.

OSIV와 N+1: “늦게 터지는 폭탄” 제거하기

OSIV가 켜져 있으면 컨트롤러/뷰 렌더링 단계에서도 LAZY 로딩이 가능해져 N+1이 숨습니다. 운영에서 갑자기 느려지는 이유가 되기도 합니다.

spring:
  jpa:
    open-in-view: false
  • 서비스 계층에서 필요한 연관을 명시적으로 로딩(Fetch Join/EntityGraph/DTO)하도록 강제
  • 트랜잭션 경계가 명확해져 디버깅이 쉬워짐

단, OSIV를 끄면 기존에 “컨트롤러에서 엔티티를 그대로 반환”하던 코드가 LazyInitializationException을 내기 쉬우니, DTO 변환 위치/조회 전략을 함께 정리해야 합니다.

실전 선택 가이드: 어떤 해법을 언제 쓰나

1) 단건/목록 + ManyToOne

  • Fetch Join 또는 EntityGraph가 1순위
  • 페이징도 비교적 안전

2) 목록 + 컬렉션(OneToMany) + 페이징 필요

  • 컬렉션 Fetch Join은 피하는 편이 안전
  • Batch Size로 N을 줄이거나
  • 화면 요구에 맞춰 DTO 조회로 전환

3) 복잡한 화면(여러 컬렉션/집계/조건)

  • 엔티티 그래프를 억지로 맞추기보다
  • **전용 조회 쿼리(DTO/Querydsl)**로 분리

4) “쿼리 수 증가 → 커넥션 풀 고갈”까지 이어질 때

  • N+1 해결이 우선이지만, 동시에 커넥션 풀/동시성 모델도 점검해야 합니다.
  • 특히 가상 스레드/높은 동시성에서 문제가 두드러지면 Spring Boot 3 가상스레드에서 HikariCP 고갈 해결의 점검 항목(풀 사이즈, 타임아웃, DB 처리량)을 같이 보는 게 좋습니다.

체크리스트: N+1을 “다시는” 만들지 않는 습관

  1. 엔티티를 컨트롤러에서 그대로 반환하지 않는다 (DTO 반환)
  2. 목록 API는 항상 “연관 접근이 있는가?”를 먼저 의심한다
  3. ManyToOne은 Fetch Join/EntityGraph로 선로딩
  4. 컬렉션은 페이징이 있으면 Fetch Join을 경계하고 Batch Size/DTO를 고려
  5. OSIV는 가능하면 끄고, 트랜잭션 안에서 필요한 로딩을 끝낸다
  6. 성능 테스트에서 “쿼리 수/커넥션 점유 시간”을 지표로 잡는다

마무리

Spring Boot 3에서 JPA N+1은 단순한 ‘성능 안 좋은 코드’가 아니라, 트래픽이 올라가는 순간 쿼리 수 증가 → 커넥션 점유 시간 증가 → 풀 고갈 → 전체 지연으로 번지는 전형적인 장애 트리거입니다.

해결은 한 가지 은탄환이 아니라, 화면/쿼리 특성에 맞춰 **Fetch Join(단순 연관), EntityGraph(선언적 로딩), Batch Size(페이징+컬렉션 타협), DTO 직접 조회(예측 가능한 성능)**를 조합하는 것입니다.

다음 단계로는 “우리 서비스의 각 API가 어떤 전략을 쓰는지”를 표로 정리하고, OSIV 설정과 함께 일관된 규칙으로 가져가면 N+1은 더 이상 폭탄이 아니라 관리 가능한 비용이 됩니다.