Published on

Spring Boot 3 JPA N+1 폭발 해결 - fetch join·EntityGraph

Authors

서버가 느려졌는데 CPU도 여유롭고, DB도 죽을 만큼 바쁘진 않아 보이는데 응답 시간이 꾸준히 늘어난다면 JPA의 N+1을 의심해볼 만합니다. 특히 Spring Boot 3(= Hibernate 6 계열)로 올라오면서 SQL 로그/통계가 더 잘 보이기도 하고, 페이징/컬렉션 로딩 전략을 잘못 잡았을 때 체감 성능 저하가 더 크게 나타나는 경우가 많습니다.

이 글에서는 Spring Boot 3 환경에서 N+1이 왜 생기는지, 어디서 “폭발”하는지, 그리고 fetch join, @EntityGraph, 배치 로딩(@BatchSize, hibernate.default_batch_fetch_size)로 어떻게 정리하는지까지 실무 중심으로 정리합니다.

참고로 성능 이슈는 원인 추적이 절반입니다. 운영 환경에서 문제가 재현되지 않거나 로그가 부족하면 진단이 길어집니다. 이런 점에서 관측/진단을 체계화하는 습관이 중요한데, 인프라/운영 진단 관점은 systemd 서비스가 자꾸 재시작될 때 7단계 진단 같은 글의 접근 방식도 꽤 도움이 됩니다.

N+1이란 무엇이고, 왜 Spring Boot 3에서 더 자주 체감될까

N+1은 “목록 1번 조회 + 각 행마다 연관 엔티티를 추가로 N번 조회” 패턴입니다. 예를 들어 Order 목록을 100개 가져온 뒤, 각 주문의 member를 접근하는 순간 지연 로딩이 발동하면 member 조회가 100번 더 나가 총 101번 쿼리가 됩니다.

Spring Boot 3/Hibernate 6에서 N+1이 새로 생긴다기보다, 다음 조건에서 더 자주 체감됩니다.

  • API 응답 DTO를 만들며 엔티티 그래프를 순회하는 코드가 많음
  • Jackson 직렬화 중 프록시 접근으로 지연 로딩이 연쇄적으로 발생
  • 페이징 + 컬렉션 로딩을 섞어 잘못된 해결책을 적용(예: 컬렉션 fetch join으로 페이징)
  • toString, 로깅, 디버깅 중 연관 필드 접근으로 의도치 않은 로딩

핵심은 “연관을 언제 로딩할지”를 명시하지 않으면, 대부분의 경우 런타임에 접근 시점에 로딩되며 그게 곧 N+1로 이어진다는 점입니다.

재현용 예제: 전형적인 N+1 폭발 코드

엔티티 모델

아래는 주문-회원-주문상품-상품의 흔한 구조입니다.

@Entity
@Table(name = "orders")
public class Order {

    @Id @GeneratedValue
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;

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

    // getter
}

@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)
    @JoinColumn(name = "order_id")
    private Order order;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "item_id")
    private Item item;

    private int orderPrice;
    private int count;
}

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

    private String name;
}

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

@Transactional(readOnly = true)
public List<OrderDto> findOrders() {
    List<Order> orders = orderRepository.findAll();

    return orders.stream()
        .map(o -> new OrderDto(
            o.getId(),
            o.getMember().getName(),          // 여기서 member 지연 로딩
            o.getOrderItems().stream()        // 여기서 orderItems 지연 로딩
                .map(oi -> oi.getItem().getName()) // 여기서 item 지연 로딩
                .toList()
        ))
        .toList();
}
  • findAll()은 주문만 1번
  • getMember()로 주문 개수만큼 추가 쿼리
  • getOrderItems()로 주문 개수만큼 추가 쿼리
  • getItem()로 주문상품 개수만큼 추가 쿼리

데이터가 조금만 늘어도 쿼리가 기하급수적으로 늘어납니다.

1차 처방: fetch join으로 필요한 연관을 “한 번에” 가져오기

fetch join은 연관 엔티티를 즉시 로딩으로 강제하며, SQL 조인으로 한 번에 가져옵니다. 가장 직관적이고, 조회 요구사항이 명확할 때 강력합니다.

ManyToOne/OneToOne은 fetch join이 특히 잘 맞는다

주문 목록에서 회원 이름이 필요하다면 다음처럼 해결합니다.

public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query("select o from Order o join fetch o.member")
    List<Order> findAllWithMember();
}

이렇게 하면 Order를 순회하며 getMember()를 호출해도 추가 쿼리가 발생하지 않습니다.

컬렉션 fetch join은 주의: 중복 row와 페이징 문제

OrderorderItems까지 같이 당겨오면 더 좋아 보이지만, 컬렉션(OneToMany) fetch join은 다음 문제가 생깁니다.

  • 조인 결과가 Order 기준으로 row가 뻥튀기 됨(주문 1개에 주문상품 5개면 row 5개)
  • JPA는 객체 그래프를 맞추려고 중복 제거를 수행하지만, DB에서 내려오는 데이터량은 증가
  • 컬렉션 fetch join + 페이징(Pageable)은 정확한 페이징이 깨질 수 있음

그래도 “페이징이 필요 없고”, “상대적으로 작은 집합”이라면 아래처럼 단번에 해결할 수 있습니다.

public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query("select distinct o from Order o " +
           "join fetch o.member " +
           "join fetch o.orderItems oi " +
           "join fetch oi.item")
    List<Order> findAllWithItems();
}

여기서 distinct는 JPQL 레벨에서 중복 엔티티를 줄이는 데 도움을 줍니다(단, DB DISTINCT와 동일 의미로만 보면 오해가 생길 수 있습니다).

실무 팁: “목록 화면”은 보통 두 단계가 안전하다

  • 1단계: Order + member 정도만 fetch join으로 가져오기
  • 2단계: 상세 진입 시에만 컬렉션을 최적화해서 로딩

이렇게 화면/API 요구사항 단위로 조회를 분리하면, 페이징/정렬 요구사항과도 충돌이 줄어듭니다.

2차 처방: @EntityGraph로 선언적으로 로딩 그래프 지정

@EntityGraph는 “이 쿼리에서는 어떤 연관을 함께 로딩할지”를 애너테이션으로 선언합니다. JPQL을 길게 쓰지 않아도 되고, 스프링 데이터 JPA 메서드 네이밍과도 잘 섞입니다.

예시: 주문 목록에서 member만 같이 로딩

public interface OrderRepository extends JpaRepository<Order, Long> {

    @EntityGraph(attributePaths = {"member"})
    List<Order> findByIdGreaterThan(Long id);
}

이 경우 내부적으로 fetch join에 준하는 전략으로 연관을 미리 로딩합니다.

중첩 연관도 가능하지만, 과용은 금물

public interface OrderRepository extends JpaRepository<Order, Long> {

    @EntityGraph(attributePaths = {"member", "orderItems", "orderItems.item"})
    @Query("select o from Order o")
    List<Order> findAllGraph();
}

다만 컬렉션을 포함하는 순간 앞서 말한 “row 뻥튀기/페이징 이슈”가 그대로 따라옵니다. @EntityGraph는 편의 기능이지, 컬렉션 fetch join의 물리적 한계를 없애주진 않습니다.

3차 처방: 배치 로딩으로 N+1을 “1+1 또는 1+K”로 완화

fetch join이 항상 정답은 아닙니다.

  • 페이징이 필요하다
  • 컬렉션까지 한 번에 가져오면 데이터량이 너무 커진다
  • 화면에서 연관을 “가끔”만 쓰는데 매번 조인하면 낭비다

이럴 때 배치 로딩이 현실적인 타협안입니다. 지연 로딩은 유지하되, 프록시 초기화 시점에 IN 쿼리로 모아서 가져옵니다.

설정: hibernate.default_batch_fetch_size

application.yml 예시입니다.

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100

이렇게 하면 예를 들어 orders 100개를 순회하며 order.getMember()를 호출할 때, member를 100번 단건 조회하는 대신 IN 절로 묶어서 몇 번에 나눠 가져옵니다(배치 크기와 DB 파라미터 제한에 따라 분할).

엔티티 단위로 @BatchSize 지정

전역 설정이 부담스럽다면 필요한 연관에만 적용할 수도 있습니다.

@Entity
public class Order {

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

@Entity
public class OrderItem {

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

배치 로딩은 “쿼리 수를 줄이되, 조인으로 row가 폭발하는 문제를 피하고 싶을 때” 특히 유효합니다.

Spring Boot 3에서 자주 하는 실수 5가지

1) OSIV에 기대서 컨트롤러에서 엔티티를 막 순회

Open Session In View가 켜져 있으면(기본값은 프로젝트 설정에 따라 다름) 컨트롤러/뷰 렌더링 단계에서도 지연 로딩이 가능해져서, 문제를 더 늦게 발견합니다. API 서버라면 OSIV를 끄고 서비스 계층에서 필요한 연관을 로딩한 뒤 DTO로 반환하는 편이 안전합니다.

spring:
  jpa:
    open-in-view: false

2) 컬렉션 fetch join으로 페이징을 해결하려고 함

컬렉션을 fetch join한 쿼리에 Pageable을 붙이면 메모리 페이징으로 바뀌거나, 결과가 왜곡될 수 있습니다. 목록은 ManyToOne까지만 fetch join하고, 컬렉션은 배치 로딩/별도 조회로 푸는 쪽이 일반적으로 안전합니다.

3) DTO 변환 중 “무심코” 연관 접근

DTO 생성자가 엔티티 그래프를 깊게 타고 들어가면 그 자체가 N+1 트리거가 됩니다. “이 API는 어디까지 로딩해야 하는가”를 먼저 정하고, 그에 맞는 조회 메서드를 별도로 두는 게 좋습니다.

4) toString()/로깅에서 연관 필드 접근

엔티티에 toString()을 자동 생성해두고 연관 필드를 포함시키면, 로그 한 줄 찍는 순간 쿼리가 우수수 나갈 수 있습니다. 연관은 제외하거나 식별자만 찍는 습관이 필요합니다.

5) “항상 EAGER로 바꾸면 해결”이라는 착각

FetchType.EAGER는 문제를 숨기거나 더 큰 문제(예상치 못한 조인/쿼리)를 만들기 쉽습니다. 기본은 LAZY로 두고, 조회 단위에서 fetch 전략을 제어하는 것이 일반적으로 더 낫습니다.

권장 조합: 상황별로 이렇게 고르면 실패 확률이 낮다

  • 목록 API + 페이징 필요
    • ManyToOne은 fetch join 또는 @EntityGraph
    • 컬렉션은 지연 로딩 + 배치 로딩
  • 상세 API(단건) + 연관을 깊게 보여줌
    • 컬렉션 포함 fetch join을 고려(데이터량이 통제 가능할 때)
    • 또는 “ID 목록 조회 후 2차 조회” 패턴으로 분리
  • 관리자/백오피스에서 엑셀 다운로드처럼 대량 출력
    • fetch join 남발보다 전용 쿼리/DTO 프로젝션도 검토
    • JDBC 템플릿/Querydsl로 필요한 컬럼만 뽑는 것이 더 나을 때가 많음

SQL을 눈으로 확인하는 체크리스트

  1. SQL 로그를 켜고, API 1회 호출에 쿼리가 몇 번 나가는지 센다
  2. 동일한 형태의 select ... where id = ?가 반복되면 N+1 가능성이 높다
  3. IN 쿼리로 바뀌는지 확인해 배치 로딩이 먹는지 본다
  4. 조인으로 row가 과도하게 늘어나지 않는지(전송 데이터량)도 함께 본다

운영에서 로그/진단은 성능 최적화의 시작점입니다. 배포 이후 이상 징후를 빨리 잡아내는 관점에서는 Argo CD Sync Failed - drift·Helm 값·RBAC 해결처럼 “상태 불일치”를 빠르게 좁혀가는 접근도 같이 익혀두면 좋습니다.

마무리: N+1은 ‘나쁜 코드’가 아니라 ‘불명확한 로딩 전략’의 결과

Spring Boot 3에서 JPA N+1은 여전히 가장 흔한 성능 함정입니다. 하지만 해결책도 명확합니다.

  • 단순하고 확실한 해결: fetch join
  • 선언적이고 재사용성 좋은 해결: @EntityGraph
  • 페이징/대량 데이터에 현실적인 완화: 배치 로딩(default_batch_fetch_size, @BatchSize)

중요한 건 “이 API가 어떤 연관을 얼마나 필요로 하는지”를 먼저 정의하고, 그에 맞춰 조회 메서드를 분리하는 것입니다. 그 원칙만 지키면 N+1은 폭발이 아니라, 통제 가능한 비용이 됩니다.

추가로, 데이터베이스 자체의 성능 저하(예: 테이블 bloat, vacuum 지연)가 겹치면 N+1의 체감이 훨씬 커질 수 있습니다. DB 쪽 이슈가 의심된다면 PostgreSQL autovacuum 지연으로 테이블 bloat 해결도 함께 점검해보는 것을 권합니다.