Published on

Spring Boot 3에서 JPA N+1 30분 해결 가이드

Authors

서버가 느려졌는데 CPU도 널널하고, DB 커넥션도 남아 있고, 쿼리 하나하나는 빠른데 전체 요청만 유독 느릴 때가 있습니다. 이때 가장 흔한 원인 중 하나가 JPA N+1입니다. Spring Boot 3(Hibernate 6)로 넘어오면서 설정 키나 동작이 미묘하게 달라져 “예전 방식대로 했는데 왜 그대로지?” 같은 함정도 자주 밟습니다.

이 글은 30분 안에 N+1을 재현 → 로그로 확정 → 원인(연관관계/지연로딩/직렬화) 분리 → 상황별 해결책 적용까지 가는 실전 루트를 제공합니다. 트랜잭션 경계가 꼬여서 지연 로딩이 예상치 않게 터지는 케이스도 함께 다룹니다. (참고: 트랜잭션이 무시되는 경우는 N+1 진단을 더 어렵게 만듭니다: Spring Boot 3에서 @Transactional 무시되는 7가지 원인)


0) N+1을 3분 만에 재현하는 최소 예제

가장 흔한 구조는 Order(1) : OrderItem(N) 또는 Post(1) : Comment(N)입니다.

엔티티 예시

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

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

    // getter
}

@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;
    // getter
}

문제를 만드는 코드 (서비스/컨트롤러)

@Service
@RequiredArgsConstructor
public class OrderService {
    private final OrderRepository orderRepository;

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

        // 여기서 LAZY 컬렉션 접근 -> 주문 개수만큼 추가 쿼리
        orders.forEach(o -> o.getItems().size());
        return orders;
    }
}

findAll()로 주문 1번 조회 후, 각 주문의 items 접근 시 주문 개수만큼 쿼리가 추가로 나가면 전형적인 N+1입니다.


1) 5분 진단: “정말 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: 바인딩 파라미터

이제 한 요청에서 비슷한 select ... from order_items where order_id=?가 반복되면 N+1 확정입니다.

(2) “어디서 컬렉션을 건드렸는지” 찾기

N+1은 대개 아래 지점에서 발생합니다.

  • 서비스 로직에서 getItems()를 반복 호출
  • DTO 변환 과정에서 연관 컬렉션 접근
  • Jackson 직렬화가 엔티티 그래프를 따라가며 LAZY 로딩 유발

특히 컨트롤러가 엔티티를 그대로 반환하면(권장되지 않음) 직렬화 시점에 N+1이 터지기 쉽습니다.


2) 10분 해결: 가장 먼저 시도할 3가지 처방

N+1 해결은 “무조건 fetch join”이 아니라, **목적(화면/목록/상세/페이징/통계)**에 맞는 전략을 고르는 게 핵심입니다.

처방 A) fetch join (가장 즉효, 하지만 페이징/중복 주의)

한 번에 가져올 수 있는 화면(예: 주문 목록 + 아이템 간단 표시)이라면 fetch join이 가장 직관적입니다.

public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query("select distinct o from Order o left join fetch o.items")
    List<Order> findAllFetchItems();
}
  • distinctJPA 레벨 중복 제거를 유도합니다(조인으로 인해 Order가 중복 row로 나올 수 있음).
  • 컬렉션 fetch join은 페이징과 결합 시 위험합니다. (DB row 기준으로 페이징되어 결과가 깨질 수 있음)

페이징이 필요하면 아래 EntityGraph나 배치 페치를 우선 고려하세요.

처방 B) @EntityGraph (레포지토리 메서드에 얹기 쉬움)

public interface OrderRepository extends JpaRepository<Order, Long> {

    @EntityGraph(attributePaths = "items")
    @Query("select o from Order o")
    List<Order> findAllWithItemsGraph();
}
  • JPQL을 크게 바꾸지 않고도 fetch 전략을 바꿀 수 있어 유지보수에 유리합니다.
  • 내부적으로 fetch join과 유사한 쿼리가 생성될 수 있습니다.

처방 C) Hibernate batch fetch (N+1을 1+N/batch로 줄이기)

목록에서 연관 컬렉션을 모두 조인으로 당기기 부담스럽거나(데이터 폭발), 페이징을 유지해야 하면 배치 페치가 현실적인 타협입니다.

전역 설정

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100

또는 엔티티 단위 설정

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

이 방식은 주문 100개라면 items를 100번 조회하는 대신, in (...) 형태로 몇 번에 나눠 가져오게 만들어 쿼리 수를 급감시킵니다.


3) 5분 추가 점검: “N+1처럼 보이지만 다른 문제”들

(1) 트랜잭션 경계 밖 LAZY 접근

서비스에서 조회 후 컨트롤러/뷰 계층에서 LAZY 컬렉션을 건드리면 예측 불가능한 추가 쿼리(혹은 LazyInitializationException)가 납니다.

  • 해결: 조회 + DTO 변환을 같은 트랜잭션 안에서 끝내기
  • 또는 OSIV(Open Session In View) 의존을 줄이기

트랜잭션이 아예 적용되지 않는다면(프록시 미적용 등) 진단이 더 꼬입니다. 위 내부 링크 글을 함께 확인하는 게 좋습니다.

(2) Jackson 직렬화가 LAZY를 깨움

컨트롤러에서 엔티티를 그대로 반환하면:

  • order.items를 직렬화하려고 접근
  • LAZY 로딩 발생
  • N+1 발생

해결은 간단합니다. 엔티티 반환 금지 + DTO 반환을 원칙으로 두세요.


4) 10분 완성: 실무에서 가장 안전한 패턴(DTO 조회)

목록 화면/검색 API는 엔티티 그래프를 그대로 노출하기보다, 필요한 컬럼만 DTO로 뽑는 게 성능과 안정성에 유리합니다.

(1) 주문 목록 + 아이템 개수만 필요할 때

public record OrderSummaryDto(Long orderId, Long itemCount) {}

public interface OrderQueryRepository {
    List<OrderSummaryDto> findOrderSummaries();
}

@Repository
@RequiredArgsConstructor
public class OrderQueryRepositoryImpl implements OrderQueryRepository {
    private final EntityManager em;

    @Override
    public List<OrderSummaryDto> findOrderSummaries() {
        return em.createQuery(
                "select new com.example.api.OrderSummaryDto(o.id, count(i.id)) " +
                "from Order o left join o.items i " +
                "group by o.id",
                OrderSummaryDto.class
        ).getResultList();
    }
}
  • 컬렉션을 통째로 로딩하지 않고, 필요한 집계만 가져옵니다.
  • N+1 자체가 구조적으로 발생할 여지가 줄어듭니다.

(2) 주문 목록 + 아이템 리스트까지 필요할 때(2-step 조회)

데이터가 큰 경우 한 방에 fetch join으로 당기면 row 폭발이 날 수 있습니다. 이때는 부모 먼저 페이징 → 자식 IN 조회로 나누는 패턴이 강력합니다.

public record OrderItemDto(Long orderId, Long itemId, String sku) {}

@Transactional(readOnly = true)
public Map<Long, List<OrderItemDto>> findItemsByOrderIds(List<Long> orderIds) {
    List<OrderItemDto> rows = em.createQuery(
            "select new com.example.api.OrderItemDto(o.id, i.id, i.sku) " +
            "from OrderItem i join i.order o " +
            "where o.id in :ids",
            OrderItemDto.class
    ).setParameter("ids", orderIds)
     .getResultList();

    return rows.stream().collect(Collectors.groupingBy(OrderItemDto::orderId));
}
  • 페이징은 Order 조회에서 안정적으로 처리
  • 자식은 IN (:ids)로 한 번(또는 몇 번)만 조회
  • API 응답을 조립하기 쉽고 예측 가능한 성능이 나옵니다

5) 30분 체크리스트: 상황별 처방 선택 가이드

목록(페이징 없음), 연관 데이터도 함께 필요

  • 1순위: fetch join 또는 EntityGraph

목록(페이징 필요), 연관 컬렉션 접근이 필요

  • 1순위: default_batch_fetch_size / @BatchSize
  • 2순위: 부모 페이징 + 자식 IN 조회(2-step)

상세(단건), 연관 데이터가 확실히 필요

  • 1순위: fetch join (단건이라 안전)

API 응답이 DTO로 고정되고 성능이 중요

  • 1순위: DTO 직접 조회(JPQL constructor, Querydsl 등)

6) 마무리: N+1은 “쿼리 수”가 아니라 “설계 신호”다

N+1은 단순히 쿼리를 합치면 끝나는 문제가 아니라,

  • 엔티티를 어디까지 로딩할지
  • 트랜잭션 경계를 어디로 둘지
  • API 응답을 엔티티로 노출하지 않을지
  • 페이징/정렬 요구사항을 어떻게 만족할지

같은 설계 선택이 누적되어 터지는 증상입니다.

오늘 바로 적용할 수 있는 최소 루트는 이겁니다.

  1. SQL 로그로 반복 쿼리 패턴 확인
  2. 발생 지점(서비스/DTO 변환/직렬화) 분리
  3. 단건/목록/페이징 여부에 맞춰 fetch join / EntityGraph / batch fetch / DTO 조회 중 하나를 선택
  4. 트랜잭션이 기대대로 적용되는지 점검(특히 프록시/호출 구조)

이 4단계를 따르면 “원인 모를 느림”에서 “재현 가능한 성능 문제”로 바뀌고, 해결까지 걸리는 시간이 급격히 줄어듭니다.