N+1 문제 해결 2편 - @BatchSize와 default_batch_fetch_size로 컬렉션 조회 최적화하기
지난 글에서는 다대일(N:1) 관계에서 발생하는 N+1 문제를 Fetch Join으로 해결하는 방법을 다뤘습니다. 이번 글에서는 Fetch Join만으로는 해결이 어려운 일대다(1:N), 컬렉션 조회에서의 N+1 문제를 @BatchSize로 해결하는 방법을 정리합니다.
1. 컬렉션 Fetch Join의 한계
주문(Order)이 여러 개의 주문 항목(OrderItem)을 가지는 일대다 관계라고 가정해보겠습니다. 이 경우 컬렉션을 Fetch Join하면 두 가지 문제가 생깁니다.
- 데이터베이스 조인 특성상 결과가 뻥튀기되어, 페이징(Paging) 처리가 불가능합니다.
- 컬렉션이 두 개 이상이면 카테시안 곱이 발생해 데이터 정합성이 깨지고, JPA가 예외를 던집니다.
// 컬렉션 Fetch Join + 페이징 -> 메모리에서 페이징 처리되며 경고 로그 발생
@Query("select o from Order o join fetch o.orderItems")
List<Order> findAllWithItems(Pageable pageable);
이런 한계 때문에 컬렉션 조회에서는 Fetch Join 대신 다른 접근이 필요합니다.
2. @BatchSize로 해결하기
@BatchSize는 연관된 컬렉션을 조회할 때, 건별로 쿼리를 날리는 대신 IN 절로 묶어서 한 번에 가져오는 방식입니다.
@Entity
public class Order {
@Id @GeneratedValue
private Long id;
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
@BatchSize(size = 100)
private List<OrderItem> orderItems = new ArrayList<>();
}
이렇게 설정하면 주문 목록을 조회한 뒤 각 주문의 orderItems에 접근할 때, 건마다 쿼리가 나가지 않고 최대 100건 단위로 IN 절을 사용한 쿼리가 나갑니다.
select oi.*
from order_item oi
where oi.order_id in (1, 2, 3, 4, ... , 100);
주문이 300건이면 쿼리가 300번이 아니라, 100건씩 끊어서 3번만 실행됩니다.
3. 전역 설정으로 한 번에 적용하기
엔티티마다 @BatchSize를 붙이는 대신, application.yml에 전역 옵션을 설정하면 모든 지연 로딩 컬렉션에 동일하게 적용됩니다. 실무에서는 이 방식을 더 많이 사용합니다.
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
이 옵션 하나로 컬렉션뿐 아니라 지연 로딩되는 단건 연관관계(@ManyToOne, @OneToOne)에도 동일하게 배치 조회가 적용됩니다.
4. 페이징까지 함께 처리하기
컬렉션은 Fetch Join하지 않고 지연 로딩으로 두면, 페이징도 정상적으로 동작합니다.
@Query("select o from Order o")
Page<Order> findAllOrders(Pageable pageable);
이렇게 목록은 페이징 쿼리로 가져오고, 컬렉션은 default_batch_fetch_size 설정에 의해 IN 절로 일괄 조회되면서 N+1 문제와 페이징 문제를 동시에 해결할 수 있습니다.
5. size 값은 어떻게 정할까
@BatchSize나 default_batch_fetch_size의 값은 무조건 크게 잡는다고 좋은 게 아닙니다.
- 너무 작으면(예: 10) IN 절 쿼리가 여러 번 나뉘어 나갈 수 있습니다.
- 너무 크면(예: 1000) 한 번에 조회되는 데이터량이 많아져 오히려 부담이 될 수 있고, 데이터베이스의 IN 절 파라미터 제한에 걸릴 수도 있습니다.
보통 100~1000 사이에서, 실제 트래픽과 평균 컬렉션 크기를 보며 조정하는 것이 일반적입니다.
6. 정리
- 다대일 관계의 N+1 문제는 Fetch Join으로 해결하고
- 일대다 컬렉션의 N+1 문제는 @BatchSize / default_batch_fetch_size로 해결한다
이렇게 관계의 방향에 따라 다른 전략을 적용하는 것이 핵심입니다. 두 가지를 함께 이해하고 있으면, 목록 조회 API를 설계할 때 처음부터 쿼리 발생 횟수를 예측하고 대응할 수 있습니다.
다음 글에서는 조회 성능을 한 단계 더 끌어올리는 방법으로, Redis를 활용한 캐싱 전략을 다뤄보겠습니다.
'성능 최적화' 카테고리의 다른 글
| 성능 최적화 STEP 6 - 분산 락(Distributed Lock)으로 동시성 문제 해결하기 (0) | 2026.07.06 |
|---|---|
| 성능 최적화 STEP 5 - 인덱스 설계와 튜닝으로 쿼리 속도 개선하기 (0) | 2026.07.06 |
| 성능 최적화 STEP 4 - Spring Batch 청크 지향 처리로 대량 데이터 안전하게 다루기 (0) | 2026.07.03 |
| 성능 최적화 STEP 3 - Redis를 활용한 캐싱 전략 (0) | 2026.07.03 |
| 성능 최적화 STEP 1 - JPA Fetch Join으로 쿼리 최적화하기 (0) | 2026.07.02 |