Spring JPA N+1 해결 전략

N+1 문제란?

JPA에서 가장 흔하고 치명적인 성능 문제다. 부모 엔티티 1건을 조회한 뒤, 연관된 자식 엔티티를 건별로 추가 쿼리하는 현상이다. 부모 N건을 조회하면 자식 조회 쿼리가 N번 추가로 발생해 총 N+1번의 쿼리가 실행된다:

// 주문 10건 조회 → 1번 쿼리
List<Order> orders = orderRepository.findAll();

// 각 주문의 items에 접근 → 10번 추가 쿼리 (N+1!)
for (Order order : orders) {
    order.getItems().size(); // Lazy Loading → SELECT 발생
}
// 총 11번 쿼리 실행

데이터가 적을 때는 티가 안 나지만, 주문 1000건이면 1001번 쿼리가 실행된다. DB 커넥션 풀 고갈, 응답 시간 급증의 주범이다.

N+1 발생 조건

로딩 전략 N+1 발생? 시점
LAZY (기본, @OneToMany) O — 프록시 접근 시 연관 컬렉션 접근 시
EAGER O — findAll() 등 JPQL 사용 시 엔티티 로딩 직후

EAGER로 바꾸면 해결될까? 아니다. findById()처럼 EntityManager가 직접 로딩하는 경우엔 JOIN으로 가져오지만, JPQL 기반 쿼리(findAll(), findByStatus() 등)에서는 EAGER여도 N+1이 발생한다.

해결법 1: Fetch Join (JPQL)

가장 직관적인 해결법. JOIN FETCH로 연관 엔티티를 한 번에 가져온다:

public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query("SELECT o FROM Order o JOIN FETCH o.items WHERE o.status = :status")
    List<Order> findByStatusWithItems(@Param("status") OrderStatus status);

    // 다중 연관관계 Fetch Join
    @Query("SELECT o FROM Order o " +
           "JOIN FETCH o.items i " +
           "JOIN FETCH o.customer " +
           "WHERE o.id = :id")
    Optional<Order> findByIdWithItemsAndCustomer(@Param("id") Long id);
}

주의: 컬렉션 Fetch Join의 페이징 문제

// ❌ 컬렉션 Fetch Join + 페이징 → 메모리에서 페이징 (위험!)
@Query("SELECT o FROM Order o JOIN FETCH o.items")
Page<Order> findAllWithItems(Pageable pageable);
// HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory!

// ❌ 2개 이상 컬렉션 Fetch Join → MultipleBagFetchException
@Query("SELECT o FROM Order o JOIN FETCH o.items JOIN FETCH o.payments")
List<Order> findWithItemsAndPayments(); // 예외 발생!

컬렉션 Fetch Join은 페이징 불가, 2개 이상 컬렉션 동시 Fetch 불가라는 제약이 있다.

해결법 2: @EntityGraph

JPQL 없이 Spring Data JPA 메서드 이름 쿼리에서도 Fetch Join을 적용한다:

public interface OrderRepository extends JpaRepository<Order, Long> {

    // attributePaths로 즉시 로딩할 연관관계 지정
    @EntityGraph(attributePaths = {"items", "customer"})
    List<Order> findByStatus(OrderStatus status);

    // Named EntityGraph 사용
    @EntityGraph(value = "Order.withItems", type = EntityGraph.EntityGraphType.FETCH)
    Optional<Order> findById(Long id);
}

// 엔티티에 Named EntityGraph 정의
@Entity
@NamedEntityGraph(
    name = "Order.withItems",
    attributeNodes = {
        @NamedAttributeNode("items"),
        @NamedAttributeNode("customer")
    }
)
public class Order { ... }

@EntityGraph는 내부적으로 LEFT JOIN을 사용하므로 Fetch Join과 동일한 컬렉션 제약이 적용된다.

해결법 3: @BatchSize (Hibernate)

N+1을 완전히 제거하지 않고 줄이는 전략. IN 절로 묶어서 쿼리 횟수를 줄인다:

@Entity
public class Order {

    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    @BatchSize(size = 100)  // 최대 100개씩 IN절로 묶어서 조회
    private List<OrderItem> items;
}

// 주문 1000건 조회 시:
// BatchSize 없음: 1 + 1000 = 1001 쿼리
// BatchSize(100): 1 + 10 = 11 쿼리 (100개씩 IN절)

// 글로벌 설정도 가능:
// spring.jpa.properties.hibernate.default_batch_fetch_size=100

@BatchSize는 Fetch Join과 달리 페이징이 가능하고 다중 컬렉션에도 적용할 수 있다. 페이징이 필요한 목록 조회에서 가장 실용적인 해결책이다.

해결법 4: @Fetch(FetchMode.SUBSELECT)

서브쿼리로 연관 엔티티를 한 번에 로딩한다:

@Entity
public class Order {

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

// 실행되는 쿼리:
// 1) SELECT * FROM orders WHERE status = 'PAID'
// 2) SELECT * FROM order_items WHERE order_id IN
//      (SELECT id FROM orders WHERE status = 'PAID')
// → 정확히 2번의 쿼리

SUBSELECT는 원본 쿼리를 서브쿼리로 재사용하므로, 데이터 건수와 관계없이 항상 2번의 쿼리만 실행된다. 단, 원본 쿼리가 복잡하면 서브쿼리 성능이 떨어질 수 있다.

해결법 비교

방법 쿼리 수 페이징 다중 컬렉션 적용 범위
Fetch Join 1 ❌ (1개만) 쿼리별
@EntityGraph 1 ❌ (1개만) 쿼리별
@BatchSize 1 + ⌈N/size⌉ 글로벌/엔티티
SUBSELECT 2 엔티티
DTO Projection 1 쿼리별

실전 패턴: 상황별 선택 기준

// 1. 단건 조회 + 연관 엔티티 필요 → Fetch Join
@Query("SELECT o FROM Order o JOIN FETCH o.items WHERE o.id = :id")
Optional<Order> findByIdWithItems(Long id);

// 2. 목록 조회 + 페이징 + 연관 엔티티 → BatchSize
// application.yml에 글로벌 설정
// spring.jpa.properties.hibernate.default_batch_fetch_size: 100
Page<Order> findByStatus(OrderStatus status, Pageable pageable);

// 3. API 응답에 필요한 필드만 → DTO Projection (N+1 원천 차단)
@Query("SELECT new com.example.dto.OrderSummary(o.id, o.status, c.name) " +
       "FROM Order o JOIN o.customer c WHERE o.status = :status")
Page<OrderSummary> findOrderSummaries(OrderStatus status, Pageable pageable);

Spring JPA Projection 최적화에서 다룬 DTO Projection은 N+1 문제를 원천적으로 차단한다. 엔티티가 아닌 DTO로 바로 조회하므로 영속성 컨텍스트에 올라가지 않고, Lazy Loading 자체가 발생하지 않는다.

N+1 탐지 자동화

// 테스트에서 쿼리 수 검증 (spring-boot-starter-test + datasource-proxy)
@SpringBootTest
class OrderServiceTest {

    @Autowired
    private OrderService orderService;

    @Test
    void findOrders_shouldNotCauseNPlus1() {
        // given
        QueryCountHolder.clear();

        // when
        orderService.findByStatus(OrderStatus.PAID, PageRequest.of(0, 20));

        // then
        QueryCount queryCount = QueryCountHolder.getGrandTotal();
        assertThat(queryCount.getSelect())
            .as("N+1 쿼리 발생!")
            .isLessThanOrEqualTo(3); // 허용 최대 쿼리 수
    }
}

// application.yml - 개발 환경에서 쿼리 로그 활성화
spring:
  jpa:
    show-sql: true
    properties:
      hibernate:
        format_sql: true
        generate_statistics: true  # 쿼리 통계

hibernate.generate_statistics=true를 켜면 세션 종료 시 실행된 쿼리 수, 캐시 히트율 등을 로그로 출력한다. Spring @Conditional 자동 설정으로 개발 환경에서만 활성화하면 프로덕션 성능에 영향을 주지 않는다.

정리

상황 권장 해결법
단건 상세 조회 Fetch Join / @EntityGraph
목록 + 페이징 @BatchSize (글로벌 100)
API 목록 응답 DTO Projection
글로벌 기본 방어 default_batch_fetch_size: 100
탐지 datasource-proxy + 테스트

N+1은 “알면 잡히고, 모르면 당하는” 문제다. 글로벌 BatchSize로 기본 방어를 깔고, 핵심 조회에는 Fetch Join이나 DTO Projection을 적용하며, 테스트로 쿼리 수를 검증하는 3중 방어가 실무의 정석이다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux