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중 방어가 실무의 정석이다.