Published on

Spring Boot JPA N+1 10분 진단·해결 가이드

Authors

서버가 느려졌는데 CPU도, GC도, DB 커넥션도 애매하게 정상이라면 가장 먼저 의심할 것 중 하나가 JPA의 N+1입니다. 특히 @OneToMany / @ManyToOne 연관관계를 LAZY로 두고 목록 화면이나 배치성 조회를 만들면, 코드 한 줄이 SQL 수백 번으로 터지기 쉽습니다.

이 글은 “10분 안에” 다음을 끝내는 흐름으로 구성했습니다.

  • 1분: 증상 패턴으로 N+1 의심하기
  • 3분: 로그로 재현하고 정확히 카운트하기
  • 5분: 상황별 처방(가장 안전한 순서로)
  • 1분: 재발 방지 체크리스트

더 많은 해결 옵션을 한 번에 정리한 글은 Spring Boot 3+ JPA N+1 즉시 잡는 7가지도 함께 참고하면 좋습니다.

1) 1분 진단: 이 패턴이면 N+1 가능성 높음

다음 중 하나라도 해당하면 N+1을 거의 확정적으로 의심해도 됩니다.

  • 목록 API 응답 시간이 데이터 건수에 비례해 선형으로 늘어남(예: 20건은 200ms, 200건은 2s)
  • APM에서 DB 쿼리 수가 요청당 수십~수백 개로 폭증
  • 코드에 stream().map(...) / for 루프 안에서 연관 엔티티 접근이 있음
  • 페이징 조회에서 “페이지는 20개인데 쿼리가 21개, 41개…” 같은 패턴

전형적인 코드 냄새는 아래처럼 “부모 리스트 조회 후 자식 접근”입니다.

List<Order> orders = orderRepository.findByStatus(OrderStatus.PAID);

return orders.stream()
    .map(o -> new OrderDto(
        o.getId(),
        o.getMember().getName(), // 여기서 추가 쿼리
        o.getOrderItems().size()  // 여기서 추가 쿼리
    ))
    .toList();

memberorderItems가 LAZY라면, 첫 쿼리 1번 + 각 주문마다 연관 로딩 쿼리가 붙어 N+1이 됩니다.

2) 3분 재현: SQL 로그로 “쿼리 개수”를 눈으로 확인

2-1. Hibernate SQL 로그 켜기

운영에서 무작정 SQL 로그를 켜는 건 부담이 크니, 우선 로컬/스테이징에서 재현하세요.

application.yml 예시입니다.

spring:
  jpa:
    properties:
      hibernate:
        format_sql: true
logging:
  level:
    org.hibernate.SQL: debug
    org.hibernate.orm.jdbc.bind: trace
  • org.hibernate.SQL은 실행 SQL
  • org.hibernate.orm.jdbc.bind는 바인딩 파라미터

이 상태에서 문제 API를 한 번 호출하고 로그를 봅니다.

2-2. “1 + N” 패턴을 찾는 요령

로그에서 이런 형태를 찾으면 됩니다.

  • 먼저 select ... from orders ... 1번
  • 이어서 select ... from members where id = ? 가 주문 수만큼 반복
  • 또는 select ... from order_items where order_id = ? 가 주문 수만큼 반복

핵심은 “동일한 형태의 쿼리가 파라미터만 바뀌며 반복”되는지입니다.

2-3. 테스트로 쿼리 수를 고정해 검증(선택)

재발 방지를 위해 “쿼리 수 제한” 테스트를 추가하면 효과가 큽니다. 대표적으로 Hibernate Statistics를 켜고 검증할 수 있습니다.

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

요청 1회에 쿼리가 5개를 넘지 않아야 한다 같은 기준을 팀에서 합의하면, 리팩터링 중에 N+1이 다시 생겨도 빨리 잡힙니다.

3) 5분 해결: 가장 안전한 순서로 적용하기

N+1 해결은 “무조건 Fetch Join”이 아니라, 조회 목적과 데이터 형태에 맞춰 선택해야 합니다. 아래 순서대로 시도하면 실패 확률이 낮습니다.

3-1. 1순위: DTO 직접 조회(목록 API에 특히 강력)

엔티티 그래프를 그대로 반환하려는 습관이 N+1을 자주 만듭니다. 목록 화면은 보통 필요한 컬럼만 있으면 되므로 DTO로 바로 조회하는 것이 가장 안전합니다.

public record OrderRow(
    Long orderId,
    String memberName,
    long itemCount
) {}

@Query("""
select new com.example.api.OrderRow(
  o.id,
  m.name,
  count(oi.id)
)
from Order o
join o.member m
left join o.orderItems oi
where o.status = :status
group by o.id, m.name
""
)
List<OrderRow> findOrderRows(@Param("status") OrderStatus status);

장점

  • N+1 구조 자체가 사라짐(단일 쿼리로 끝)
  • 직렬화 이슈(순환참조), 지연로딩 예외 같은 부수 문제도 함께 감소

주의

  • countgroup by가 들어가면 쿼리 플랜을 확인해야 함
  • 화면 요구가 복잡하면 DTO가 많아질 수 있음(하지만 운영 안정성은 보통 더 좋아짐)

3-2. 2순위: Fetch Join(단건 상세 조회에 적합)

상세 화면에서 Order 1건을 가져오면서 연관을 같이 가져오고 싶다면 Fetch Join이 직관적입니다.

@Query("""
select o from Order o
join fetch o.member
left join fetch o.orderItems
where o.id = :id
""
)
Optional<Order> findDetail(@Param("id") Long id);

주의 포인트가 있습니다.

  • 컬렉션(OneToMany) Fetch Join은 결과 row가 뻥튀기됩니다. 그래서 페이징과 궁합이 나쁩니다.
  • 컬렉션을 Fetch Join 한 상태에서 Pageable을 섞으면 경고가 뜨거나, 메모리에서 페이징하는 형태로 변질될 수 있습니다.

정리하면

  • 단건 상세: Fetch Join OK
  • 목록 + 페이징: DTO 조회 또는 다른 방법 권장

3-3. 3순위: @EntityGraph로 필요한 연관만 선언적으로 로딩

레포지토리 메서드에 “이 조회에서는 이 연관을 같이 가져온다”를 선언할 수 있습니다.

@EntityGraph(attributePaths = {"member"})
List<Order> findByStatus(OrderStatus status);

장점

  • JPQL을 직접 쓰지 않고도 해결 가능
  • 특정 조회에만 eager 로딩을 강제할 수 있어 부작용이 상대적으로 적음

주의

  • 컬렉션까지 한 번에 가져오려 하면 Fetch Join과 동일한 페이징 문제가 다시 등장할 수 있음

3-4. 4순위: Batch Size로 “N+1을 1+N에서 1+ceil(N/batch)”로 줄이기

구조적으로 한 번에 조인해서 가져오기 어려운 경우(여러 컬렉션, 복잡한 그래프)에는 배치 로딩이 실용적입니다.

설정 예시입니다.

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100

또는 연관 필드에만 지정할 수도 있습니다.

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

효과

  • 주문 100건의 아이템을 로딩할 때, order_id in (?, ?, ...) 형태로 묶어서 가져옵니다.
  • 쿼리가 101번에서 2~3번으로 줄어드는 식의 개선이 흔합니다.

주의

  • “완전한 제거”가 아니라 “완화”입니다.
  • 배치 크기는 DB의 in 리스트 제한, 쿼리 플랜, 네트워크 왕복 등을 고려해 조정해야 합니다.

3-5. 5순위: OSIV에 기대지 말고 트랜잭션 경계를 명확히

N+1은 종종 OSIV가 켜져 있을 때 더 늦게 발견됩니다. 컨트롤러에서 직렬화하는 순간 LAZY 로딩이 발생해 쿼리가 튀기 때문입니다.

가능하면 다음 방향을 권장합니다.

  • API 계층은 DTO 반환
  • 서비스 계층에서 필요한 연관을 “조회 시점에” 확정
  • OSIV는 팀 정책에 따라 끄는 것을 검토

OSIV를 끄면 N+1이 “예외로 빨리 터져서” 오히려 조기 발견이 됩니다.

4) 흔한 함정 4가지(10분 내 추가 점검)

4-1. toString() / 로깅이 연관 로딩을 유발

엔티티에 Lombok @ToString을 붙이고 연관 필드를 포함하면, 로그 한 줄이 N+1을 만들 수 있습니다. 연관 필드는 @ToString.Exclude로 제외하세요.

4-2. JSON 직렬화가 프록시를 건드림

엔티티를 그대로 반환하면 Jackson이 getter를 호출하며 LAZY 로딩이 발생합니다. “엔티티 반환 금지, DTO 반환”이 가장 확실한 예방책입니다.

4-3. 컬렉션 Fetch Join + distinct의 착시

JPQL에서 select distinct o를 쓰면 “엔티티 중복 제거”는 되지만, DB row 뻥튀기는 그대로입니다. 네트워크/메모리는 이미 비용을 지불했을 수 있습니다.

4-4. EAGER로 바꿔서 해결하려는 시도

FetchType.EAGER는 N+1을 “다른 시점의 대량 조인/대량 로딩 문제”로 바꾸는 경우가 많습니다. 조회별로 제어 가능한 EntityGraph, Fetch Join, DTO 조회가 더 안전합니다.

5) 재발 방지 체크리스트

  • 목록 API는 엔티티 반환 대신 DTO 조회를 기본값으로
  • 페이징이 필요한 목록에서 컬렉션 Fetch Join을 피하기
  • 반드시 필요한 연관만 EntityGraph 또는 Fetch Join으로 명시
  • default_batch_fetch_size를 합리적으로 설정(예: 50~200부터 시작)
  • 스테이징에서 “요청당 쿼리 수”를 대시보드/로그로 관찰

N+1은 한 번 잡고 끝나는 문제가 아니라, 기능 추가 때마다 다시 생기기 쉬운 성격입니다. 위의 진단 루틴(로그로 반복 쿼리 찾기)과 해결 우선순위(DTO 조회 우선, 다음 Fetch Join, 그 다음 배치)를 팀 규칙처럼 굳혀두면 운영 장애로 커지기 전에 빠르게 차단할 수 있습니다.