Published on

Spring Boot 3에서 JPA N+1 즉시 잡는 법

Authors

서버 성능을 갉아먹는 JPA N+1 문제는 “원인 파악이 늦어지는 순간”부터 장애로 번집니다. 특히 Spring Boot 3 + Hibernate 6 조합에서는 기본 지연 로딩(LAZY)이 잘 작동하는 만큼, 개발 중엔 조용하다가 트래픽이 붙는 시점에 쿼리 폭발로 드러나는 경우가 많습니다.

이 글은 N+1을 즉시 발견하고, 가장 빠른 해결책을 선택하는 데 초점을 둡니다. 단순히 fetch = EAGER로 바꿔서 “일단 해결”하는 방식은 부작용이 커서 권하지 않습니다.

관련해서 트랜잭션 경계가 꼬이면 N+1이 더 늦게 발견되거나(혹은 OSIV 의존) 반대로 LazyInitializationException으로 터지기도 합니다. 트랜잭션이 기대대로 동작하지 않을 때는 Spring Boot 3에서 @Transactional이 안 먹는 6가지도 함께 점검해 보세요.

N+1이란 무엇이고, 왜 “즉시” 잡아야 하나

전형적인 상황은 이렇습니다.

  • 부모 엔티티 목록을 1번 조회(예: 주문 목록)
  • 각 부모의 자식 컬렉션을 접근하는 순간, 부모 개수만큼 추가 쿼리가 실행

즉, 1 + N번 쿼리가 나가면서 응답 시간이 선형으로 증가합니다. 로컬 데이터 20건에서는 티가 안 나지만, 운영에서 2,000건만 되어도 DB가 즉시 병목이 됩니다.

N+1은 보통 다음 조건에서 잘 발생합니다.

  • @OneToMany, @ManyToOneLAZY인데 리스트 화면에서 연관 데이터를 함께 출력
  • Jackson 직렬화가 엔티티 그래프를 따라가며 연관 객체 접근
  • 서비스 계층에서 루프를 돌며 연관 객체 접근
  • 페이징 + 컬렉션 페치 조합을 잘못 사용

1단계: “지금 N+1이 터졌다”를 가장 빨리 확인하는 방법

SQL 로그를 사람이 읽을 수 있게 만든다

Spring Boot 3에서 가장 빠른 방법은 Hibernate SQL 로그 + 바인딩 값을 켜는 것입니다.

# application.yml
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? 바인딩 값을 보여줍니다.

이 상태에서 API 한 번 호출했을 때, 동일한 패턴의 쿼리가 반복되면 N+1을 거의 확정할 수 있습니다.

“쿼리 개수”를 테스트에서 자동으로 검증한다

운영에서야 APM이 잡아주지만, 개발 단계에서 즉시 잡으려면 테스트에서 쿼리 개수 상한을 걸어두는 방식이 효과적입니다.

Hibernate 통계를 켜고, 특정 유스케이스에서 쿼리가 몇 번 실행되는지 확인합니다.

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

그리고 통계 로그를 확인하거나, 필요하면 SessionFactory 통계를 직접 조회해 검증할 수 있습니다(프로젝트 구성에 따라 접근 방식은 달라질 수 있습니다).

OSIV에 가려진 N+1을 의심한다

OSIV(Open Session In View)가 켜져 있으면 컨트롤러/뷰 렌더링 단계에서도 지연 로딩이 진행되어 N+1이 “조용히” 발생합니다. 반대로 OSIV를 끄면 LazyInitializationException이 빨리 터져서 문제를 조기에 발견할 수 있습니다.

spring:
  jpa:
    open-in-view: false

OSIV를 끄는 것은 “정답”이라기보다는 N+1을 숨기지 않게 만드는 강력한 안전장치입니다.

2단계: 가장 흔한 N+1 재현 예제

예를 들어 OrderOrderItem이 있고, 주문 목록에서 아이템을 같이 보여준다고 가정합니다.

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

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

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

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

  private String productName;
}

서비스에서 이런 코드를 작성하면 N+1이 터집니다.

@Transactional(readOnly = true)
public List<OrderDto> listOrders() {
  List<Order> orders = orderRepository.findAll(); // 1번

  return orders.stream()
      .map(o -> new OrderDto(
          o.getId(),
          o.getItems().stream() // 여기서 주문 개수만큼 쿼리
              .map(OrderItem::getProductName)
              .toList()
      ))
      .toList();
}

3단계: “즉시” 고치는 4가지 실전 해법

N+1 해결은 한 가지 정답이 아니라 화면/요구사항에 맞는 도구를 고르는 문제입니다.

해법 A: Fetch Join으로 한 번에 가져오기(가장 직관적)

부모와 연관 데이터를 함께 써야 한다면, 우선 join fetch가 가장 빠르게 효과를 냅니다.

public interface OrderRepository extends JpaRepository<Order, Long> {

  @Query("select distinct o from Order o left join fetch o.items")
  List<Order> findAllWithItems();
}
  • distinct는 조인으로 인해 Order가 중복 row로 나오는 것을 엔티티 레벨에서 제거하기 위한 장치입니다.
  • 컬렉션 페치 조인은 결과 row가 불어나기 쉬워서, 데이터가 많으면 메모리/전송량이 커집니다.

주의할 점:

  • 페이징(Pageable)과 컬렉션 fetch join은 조합이 위험합니다. DB 페이징이 깨지거나 메모리 페이징으로 바뀌는 등 부작용이 생길 수 있습니다.

해법 B: @EntityGraph로 필요한 연관만 로딩(레포지토리 메서드에 붙이기)

JPQL을 직접 쓰기 싫거나, 메서드 시그니처 기반 쿼리를 유지하고 싶다면 @EntityGraph가 깔끔합니다.

public interface OrderRepository extends JpaRepository<Order, Long> {

  @EntityGraph(attributePaths = {"items"})
  List<Order> findByIdIn(List<Long> ids);
}
  • 특정 API에서만 연관을 로딩하고 싶을 때 유리합니다.
  • 복잡한 조건 검색은 여전히 JPQL/Querydsl이 필요할 수 있습니다.

해법 C: Batch Size로 “N+1을 1+N/batch로 완화”(즉효성 좋음)

화면 특성상 모든 연관을 한 번에 페치 조인하기가 부담스럽다면, Hibernate 배치 로딩으로 쿼리를 묶어 보낼 수 있습니다.

설정은 전역으로도 가능하고:

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100

필드 단위로도 가능합니다.

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

이 방식은 N+1을 “완전 제거”하진 않지만, 예를 들어 주문이 1,000건이면 아이템 로딩이 1,000번이 아니라 대략 10번으로 줄어드는 식으로 즉시 체감 성능 개선이 나옵니다.

해법 D: DTO로 필요한 컬럼만 조회(대규모 트래픽에서 가장 안전)

목록 화면은 엔티티 그래프를 그대로 내보내기보다, 필요한 데이터만 DTO로 조회하는 편이 안정적입니다.

예: 주문과 아이템을 평탄화해서 조회한 뒤, 애플리케이션에서 그룹핑합니다.

public record OrderItemRow(Long orderId, String productName) {}

public interface OrderQueryRepository {
  List<OrderItemRow> findOrderItemRows();
}

@Repository
public class OrderQueryRepositoryImpl implements OrderQueryRepository {

  @PersistenceContext
  private EntityManager em;

  @Override
  public List<OrderItemRow> findOrderItemRows() {
    return em.createQuery(
        "select new com.example.OrderItemRow(o.id, i.productName) " +
        "from Order o join o.items i",
        OrderItemRow.class
    ).getResultList();
  }
}
  • N+1 자체가 구조적으로 발생하지 않습니다.
  • 네트워크/메모리 사용량을 예측하기 쉽습니다.
  • 다만 도메인 로직이 엔티티에 강하게 묶여 있다면 적용 범위를 나눠야 합니다(조회 전용 쿼리 모델 권장).

4단계: “즉시 잡기”를 방해하는 흔한 함정

EAGER로 바꾸는 것은 대부분 정답이 아니다

EAGER는 “항상” 가져옵니다. 지금 화면에서는 필요하지만 다른 API에서는 불필요한 조인을 만들고, 또 다른 N+1/조인 폭발의 원인이 됩니다.

즉시 해결이 필요하면 우선 fetch join이나 @EntityGraph특정 유스케이스에서만 가져오게 만드세요.

Jackson 직렬화로 연관을 타고 들어가는 문제

컨트롤러가 엔티티를 그대로 반환하면, 직렬화 과정에서 연관 객체 접근이 발생해 N+1이 터질 수 있습니다. 가장 좋은 해결은 API 응답을 DTO로 고정하는 것입니다.

컬렉션 페치 조인 + 페이징

  • 페이징이 필요하면 보통 2단계로 갑니다.
      1. 페이징은 부모만 조회
      1. 그 부모 ID 목록으로 자식들을 IN 조회(@EntityGraph 또는 별도 쿼리)

이 패턴은 구현이 약간 늘지만, 운영에서 예측 가능한 성능을 제공합니다.

5단계: 추천하는 “10분 진단 체크리스트”

  1. open-in-viewfalse로 두고도 API가 정상 동작하는가
  2. SQL 로그에서 같은 형태의 쿼리가 N번 반복되는가
  3. 반복 쿼리의 where 조건이 부모 ID로만 바뀌는가
  4. 해결책 선택
    • 화면에서 연관을 반드시 함께 써야 함: fetch join 또는 @EntityGraph
    • 데이터가 많고 목록 응답이 중요: DTO 조회
    • 구조 변경이 어렵고 빠른 완화가 필요: 배치 로딩

문제 원인을 빠르게 좁히는 디버깅 관점은 인프라 이슈를 다룰 때도 동일합니다. 배포/운영에서 “빨리 원인 찾기” 사고방식은 K8s CrashLoopBackOff 즉시 원인 찾는 법 글의 접근과도 통합니다.

결론: N+1은 “발견 시스템”과 “해결 패턴”을 세트로 가져가야 한다

Spring Boot 3에서 JPA N+1을 즉시 잡으려면 두 가지가 필요합니다.

  • 발견: SQL 로그/바인딩 값, OSIV off, 테스트에서 쿼리 상한 검증
  • 해결: fetch join, @EntityGraph, 배치 로딩, DTO 조회 중 상황에 맞는 선택

개인적으로는 운영 안정성 기준으로 다음 우선순위를 추천합니다.

  • 목록/검색 API: DTO 조회 또는 2단계 조회
  • 단건 상세: @EntityGraph 또는 fetch join
  • 레거시/급한 완화: 배치 로딩

이 조합으로 가면 “N+1을 나중에 발견해서 장애가 나는” 시나리오를 대부분 제거할 수 있습니다.