Published on

Spring Boot 3+ JPA N+1 즉시 잡는 7가지

Authors

서버 성능 이슈를 파고들다 보면, 결국 DB 쿼리 폭증이 원인인 경우가 많습니다. 그중 대표가 JPA의 N+1 문제입니다. 겉으로는 정상 동작하지만, 목록 한 번 조회했을 뿐인데 연관 엔티티를 로딩하느라 쿼리가 N개 더 나가면서 지연이 눈덩이처럼 커집니다.

Spring Boot 3(= Spring Framework 6, Jakarta Persistence)에서도 본질은 동일합니다. 다만 Hibernate 6 계열(부트 3 기본)이 되면서 일부 동작/로그 포맷이 달라졌고, 성능 튜닝 옵션을 더 체계적으로 구성할 필요가 있습니다.

이 글은 “지금 당장 N+1을 줄여야 하는” 상황에서 바로 적용 가능한 7가지를 우선순위/선택 기준과 함께 정리합니다.

> 참고: 운영에서 지연이 누적되면 결국 타임아웃/재시도/리소스 고갈로 이어집니다. DB 부하가 커진 뒤에는 애플리케이션 레벨에서 OOM까지 번질 수 있으니, 장애 분석 관점은 Linux OOM Killer 원인추적 - dmesg·cgroup·로그 같은 글과 함께 보는 것도 도움이 됩니다.

N+1이 생기는 전형적인 패턴

엔티티가 @ManyToOne(fetch = LAZY) 또는 @OneToMany(fetch = LAZY)로 설정되어 있고, 서비스/컨트롤러에서 다음과 같이 루프를 돌며 연관 객체를 접근하면 N+1이 발생합니다.

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

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

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

  // getters
}

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

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

  @ManyToOne(fetch = FetchType.LAZY)
  private Product product;
}
List<Order> orders = orderRepository.findAll();
for (Order o : orders) {
  // LAZY 초기화 시점마다 추가 쿼리 발생 가능
  String memberName = o.getMember().getName();
  int itemCount = o.getItems().size();
}

핵심은 조회는 한 번인데, 연관 로딩이 객체 접근 시점에 추가로 발생한다는 점입니다.

1) 가장 먼저: SQL/바인딩 로그로 “진짜 N+1” 확인

N+1인지 아닌지부터 확정해야 합니다. “느리다”는 체감만으로는 캐시/인덱스/락/네트워크 등 변수가 많습니다.

Spring Boot 3에서 빠르게 확인하려면 아래처럼 로그를 켭니다.

# application.yml
logging:
  level:
    org.hibernate.SQL: debug
    org.hibernate.orm.jdbc.bind: trace
  • org.hibernate.SQL: 실행 SQL
  • org.hibernate.orm.jdbc.bind: 바인딩 파라미터(하이버네이트 6에서 이 로거가 자주 쓰입니다)

추가로, 테스트/로컬에서는 p6spy 같은 JDBC 프록시로 쿼리 개수를 세도 좋습니다.

체크 포인트

  • 목록 1번 조회 후, 동일한 형태의 select ... from member where id=?가 반복되면 전형적 ManyToOne N+1
  • select ... from order_item where order_id=?가 반복되면 OneToMany N+1

2) Fetch Join으로 “해당 화면에서 필요한 연관만” 한 번에 가져오기

가장 즉효약은 JPQL fetch join입니다. 화면/유스케이스 단위로 필요한 연관을 명시하고 한 번에 로딩합니다.

public interface OrderRepository extends JpaRepository<Order, Long> {

  @Query("""
    select o from Order o
    join fetch o.member
    where o.id in :ids
  """)
  List<Order> findAllWithMember(@Param("ids") List<Long> ids);
}

컬렉션(fetch join + OneToMany) 주의

컬렉션까지 fetch join 하면 중복 row가 생기고 페이징이 깨질 수 있습니다.

@Query("""
  select distinct o from Order o
  join fetch o.member
  left join fetch o.items
  where o.status = :status
""")
List<Order> findAllWithMemberAndItems(@Param("status") OrderStatus status);
  • distinct로 엔티티 중복을 제거할 수 있지만
  • DB 레벨 페이징(limit/offset)과 결합하면 결과가 틀어질 수 있습니다.

선택 기준

  • ManyToOne/OneToOne 위주라면 fetch join이 가장 단순하고 강력
  • OneToMany 컬렉션까지 “한 번에”가 필요하면 페이징/중복/카테시안 곱을 꼭 고려

3) @EntityGraph로 “리포지토리 메서드에 로딩 그래프를 선언”

fetch join은 쿼리를 직접 작성해야 합니다. 반면 @EntityGraph메서드에 로딩할 연관을 선언할 수 있어 유지보수가 편합니다.

public interface OrderRepository extends JpaRepository<Order, Long> {

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

중첩도 가능합니다.

@EntityGraph(attributePaths = {"member", "items", "items.product"})
List<Order> findByIdIn(List<Long> ids);

장점

  • 쿼리 문자열 관리 부담 감소
  • 같은 도메인이라도 화면별로 다른 그래프를 선택하기 쉬움

주의

  • 컬렉션 로딩은 여전히 중복 row/페이징 이슈가 발생할 수 있음(근본적으로 fetch join과 동일한 성질)

4) Hibernate 배치 페치(@BatchSize / default_batch_fetch_size)로 “N을 N/b로 줄이기”

fetch join이 항상 정답은 아닙니다. 특히 컬렉션이 많거나, 화면에서 연관을 일부만 쓰는 경우엔 오히려 과로딩이 됩니다.

이때 배치 페치는 “LAZY는 유지하되, 프록시 초기화 시 IN 쿼리로 묶어서 가져오는” 전략입니다.

전역 설정

spring:
  jpa:
    properties:
      hibernate.default_batch_fetch_size: 100

엔티티별 설정

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

@Entity
class OrderItem {
  @BatchSize(size = 100)
  @ManyToOne(fetch = FetchType.LAZY)
  private Product product;
}

효과

  • order 100개를 순회하며 items를 접근하면, order_id in (...) 형태로 묶여 쿼리 수가 크게 감소

선택 기준

  • 페이징이 필요한 목록 화면
  • 연관을 항상 쓰는 건 아니지만, 쓰는 경우 N+1이 터지는 케이스

5) DTO/프로젝션으로 “필요한 컬럼만” 조회해서 엔티티 그래프 자체를 피하기

N+1을 잡겠다고 엔티티를 무리하게 fetch join하면, 결국 “필요 없는 컬럼/로우까지” 가져오기도 합니다. 읽기 전용 화면에서는 DTO 조회가 더 안전하고 빠릅니다.

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

public interface OrderRepository extends JpaRepository<Order, Long> {

  @Query("""
    select new com.example.OrderSummaryDto(
      o.id,
      m.name,
      sum(oi.price * oi.quantity)
    )
    from Order o
    join o.member m
    join o.items oi
    where o.status = :status
    group by o.id, m.name
  """)
  List<OrderSummaryDto> findSummaries(@Param("status") OrderStatus status);
}

장점

  • 엔티티 영속성 컨텍스트/프록시 초기화와 무관
  • 네트워크/메모리 사용량 절감
  • 화면 요구사항에 맞춰 SQL을 더 최적화하기 쉬움

주의

  • 복잡한 화면에서 DTO가 많아질 수 있으니 패키지/네이밍 규칙을 정해 관리

6) 컬렉션은 2단계 로딩(IDs 먼저 + IN 조회)로 페이징과 성능을 동시에 잡기

OneToMany fetch join + 페이징이 위험한 이유는 row가 뻥튀기되기 때문입니다. 이때 실무에서 많이 쓰는 패턴이 2단계 조회입니다.

  1. 페이징이 가능한 “루트 엔티티 ID 목록”을 먼저 가져오고
  2. 그 ID들을 기준으로 연관을 IN으로 한 번에 가져옵니다.
public interface OrderRepository extends JpaRepository<Order, Long> {

  @Query("select o.id from Order o where o.status = :status order by o.id desc")
  Page<Long> findIdsByStatus(@Param("status") OrderStatus status, Pageable pageable);

  @Query("""
    select distinct o from Order o
    join fetch o.member
    left join fetch o.items
    where o.id in :ids
  """)
  List<Order> findAllGraphByIds(@Param("ids") List<Long> ids);
}
Page<Long> page = orderRepository.findIdsByStatus(READY, PageRequest.of(0, 20));
List<Order> orders = orderRepository.findAllGraphByIds(page.getContent());

장점

  • 페이징 정확성 확보
  • 연관 로딩은 1~2번 쿼리로 수렴

주의

  • IN 목록이 너무 커지면 DB 파라미터 제한/플랜 이슈가 있을 수 있어 페이지 크기와 함께 튜닝

7) OSIV(Open Session In View) 끄고 “트랜잭션 경계에서 로딩 전략을 고정”

OSIV가 켜져 있으면 컨트롤러/뷰 렌더링 단계에서도 LAZY 로딩이 가능해집니다. 이건 개발 초기에 편하지만, 운영에서는 다음 문제가 생깁니다.

  • 어디서 쿼리가 나가는지 추적이 어려움(응답 직전 갑자기 N+1 폭발)
  • 트랜잭션 밖에서 지연 로딩이 일어나 DB 커넥션 점유가 길어짐

Spring Boot에서는 기본이 켜져 있는 경우가 많으니, 의도적으로 끄고 서비스 계층에서 필요한 연관을 확정하는 편이 안전합니다.

spring:
  jpa:
    open-in-view: false

이렇게 하면 컨트롤러에서 실수로 연관 접근 시 LazyInitializationException이 나며, “여기서 로딩하면 안 된다”는 신호를 강하게 줍니다. 그 지점에서 fetch join/EntityGraph/DTO 조회 등으로 해결하면 됩니다.

실무에서의 선택 가이드(요약)

  • ManyToOne/OneToOne 위주 + 화면에서 항상 필요: fetch join 또는 @EntityGraph
  • 페이징 목록 + 컬렉션 연관: default_batch_fetch_size + 필요 시 2단계 로딩
  • 읽기 전용/집계/리포트: DTO 프로젝션(필요 컬럼만)
  • 원인 추적이 어렵고 쿼리가 여기저기서 나감: OSIV 끄고 서비스에서 그래프 확정

마무리: “쿼리 수”를 KPI로 두고 회귀를 막기

N+1은 한 번 잡아도, 기능 추가/리팩터링으로 쉽게 재발합니다. 다음을 습관화하면 회귀를 크게 줄일 수 있습니다.

  • 주요 API(목록/상세)에 대해 쿼리 개수/응답시간을 테스트에서 관찰
  • 로딩 전략을 서비스 메서드 단위로 문서화(왜 fetch join인지, 왜 배치 사이즈인지)
  • 운영 장애로 번지기 전에, 지연이 누적될 때 시스템 자원(메모리/스레드/커넥션)도 함께 관찰

성능 문제는 대개 연쇄적으로 터집니다. DB 쿼리 폭증이 타임아웃과 재시도를 부르고, 그게 다시 부하를 키워 장애로 이어집니다. 장애 원인 추적 관점이 필요하다면 Linux OOM Killer 원인추적 - dmesg·cgroup·로그도 같이 참고해 두면, “JPA N+1 → 부하 증가 → 자원 고갈” 시나리오를 더 빠르게 진단할 수 있습니다.