- Published on
Spring Boot 3 JPA N+1 해결 - EntityGraph·BatchSize
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 느린데 CPU도 놀고, DB 커넥션도 여유가 있어 보이는데 응답 시간이 꾸준히 늘어지는 경우가 있습니다. 로그를 켜 보면 같은 형태의 select ... where id = ? 가 수십~수백 번 반복되고, 원인은 대부분 JPA의 대표적인 성능 함정인 N+1 입니다.
Spring Boot 3(대개 Hibernate 6 계열)에서도 본질은 같습니다. LAZY 연관을 순회하는 순간, 컬렉션/프록시 초기화를 위해 추가 쿼리가 발생합니다. 이 글에서는 운영에서 가장 많이 쓰이는 두 가지 해법인 @EntityGraph 와 @BatchSize(또는 hibernate.default_batch_fetch_size)를 중심으로, 언제 무엇을 선택해야 하는지 기준과 예제를 정리합니다.
참고: 성능 이슈는 DB 인덱스/플랜 문제와 함께 나타나는 경우가 많습니다. N+1 을 잡았는데도 느리다면 인덱스/통계 문제도 같이 점검하세요. 예: PostgreSQL 인덱스 안 타는 이유 9가지와 해결
N+1이 발생하는 전형적인 흐름
예를 들어 Order 와 OrderItem 이 1:N 관계이고, OrderItem 은 Product 를 N:1 로 참조한다고 가정합니다.
- 1번 쿼리: 주문 목록
N건을 조회 - 추가 쿼리: 각 주문마다 아이템을 조회하면서
N번 - 추가 쿼리: 각 아이템마다 상품을 조회하면서
N * M번
코드 레벨에서는 단순한 반복문인데, DB 레벨에서는 쿼리 폭발이 일어납니다.
재현용 엔티티 예제
아래 예제는 일부 필드만 단순화했습니다.
@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> items = new ArrayList<>();
// getters
}
@Entity
@Table(name = "order_item")
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 = "product_id")
private Product product;
private int quantity;
// getters
}
@Entity
@Table(name = "product")
public class Product {
@Id @GeneratedValue
private Long id;
private String name;
// getters
}
서비스에서 다음처럼 DTO를 만들기 위해 연관을 순회하면 N+1 이 터지기 쉽습니다.
@Transactional(readOnly = true)
public List<OrderSummaryDto> findOrderSummaries() {
List<Order> orders = orderRepository.findTop100ByOrderByIdDesc();
return orders.stream()
.map(o -> new OrderSummaryDto(
o.getId(),
o.getMember().getId(),
o.getItems().stream()
.map(oi -> oi.getProduct().getName())
.toList()
))
.toList();
}
해결 전략 개요: EntityGraph vs BatchSize
두 방법의 핵심 차이는 “한 번의 쿼리로 미리 당겨올 것인가” vs “추가 쿼리를 하되, 묶어서 적게 날릴 것인가” 입니다.
@EntityGraph- 장점: 특정 화면/유스케이스에서 필요한 연관을 명시적으로 fetch 하여 N+1 을 근본적으로 제거
- 단점: 조인으로 인해 row 수가 폭증하거나(특히 컬렉션), 페이징과 충돌할 수 있음
@BatchSize/hibernate.default_batch_fetch_size- 장점:
LAZY를 유지하면서도 프록시 초기화 쿼리를IN절로 묶어 쿼리 수를 크게 감소 - 단점: “쿼리 폭발”을 “쿼리 감소”로 완화하는 것이지, 조인처럼 한 번에 끝나지는 않음. 또한 배치 크기 튜닝이 필요
- 장점:
운영에서는 보통 다음 순서가 안전합니다.
- 우선
BatchSize로 전역 완화(리스크 낮음) - 특정 API/화면에서 확실히 필요한 연관만
EntityGraph로 최적화 - 그래도 복잡하면 전용 조회 쿼리(예: DTO projection)로 분리
해법 1: EntityGraph로 필요한 연관을 정확히 fetch
Repository에 EntityGraph 적용
주문 목록을 가져올 때 member, items, 그리고 items.product 까지 필요하다고 가정하면 다음처럼 그래프를 지정합니다.
public interface OrderRepository extends JpaRepository<Order, Long> {
@EntityGraph(attributePaths = {
"member",
"items",
"items.product"
})
List<Order> findTop100ByOrderByIdDesc();
}
이렇게 하면 orders 를 가져오는 시점에 연관을 함께 로딩하므로, 서비스 레이어의 순회에서 추가 쿼리가 거의 사라집니다.
주의 1: 컬렉션 fetch는 row 폭증을 유발할 수 있음
Order 100건에 각 items 가 평균 10개면, 조인 결과 row 는 1000개가 됩니다. DB/네트워크/ORM 중복 제거 비용이 증가할 수 있습니다.
- 화면에서 정말로 아이템까지 필요하지 않다면
items를 그래프에서 빼세요. - 아이템은 필요하지만
items.product까지는 필요 없으면 단계적으로 최소화하세요.
주의 2: 페이징과의 충돌
컬렉션 fetch 조인은 페이징(Pageable)에서 문제가 되기 쉽습니다. DB 레벨에서 페이징을 걸면 조인된 row 기준으로 잘려서 부모 엔티티가 누락될 수 있고, Hibernate가 메모리에서 페이징을 하도록 바뀌면 성능이 급격히 나빠질 수 있습니다.
이 경우의 대표 해법은 다음 중 하나입니다.
- 1단계: 부모만 페이징 조회
- 2단계: 부모 id 목록으로 자식들을
IN으로 조회(이때BatchSize가 큰 도움)
해법 2: BatchSize로 LAZY 초기화 쿼리를 묶어서 줄이기
BatchSize 는 “프록시 초기화 시점”에 Hibernate가 여러 엔티티의 연관을 한 번에 조회하도록 돕습니다.
예를 들어 Order 100건을 조회한 뒤 order.getMember() 를 순회하면서 접근하면 원래는 100번의 member 조회가 발생할 수 있습니다. 배치를 켜면 다음처럼 줄어듭니다.
- 배치 크기 50이면, 대략 2번의
member where id in (...)로 수렴
방법 A: 전역 설정으로 적용
Spring Boot 3에서 application.yml 에 다음처럼 설정합니다.
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 50
- 일반적으로 30~100 사이에서 시작하는 경우가 많습니다.
- 너무 크게 잡으면
IN절이 비대해져 플랜이 흔들리거나 파싱 비용이 커질 수 있습니다.
방법 B: 엔티티에 국소 적용
특정 연관에만 적용하고 싶다면 @BatchSize 를 사용합니다.
@Entity
public class Order {
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
@BatchSize(size = 50)
private List<OrderItem> items = new ArrayList<>();
}
@Entity
public class OrderItem {
@ManyToOne(fetch = FetchType.LAZY)
@BatchSize(size = 100)
private Product product;
}
전역 설정과 엔티티 설정이 섞일 때는 “어느 쪽이 더 우선인지”를 팀 규칙으로 정해 혼란을 줄이는 것이 좋습니다(보통은 전역 기본값 + 예외만 애노테이션).
BatchSize가 특히 유리한 케이스
- 컬렉션 fetch 조인이 페이징을 망가뜨리는 목록 API
- “항상 필요하지는 않지만, 접근할 때는 많이 접근하는” 연관
- EntityGraph를 남발하면 조인 폭증이 우려되는 도메인
EntityGraph와 BatchSize를 같이 쓰는 실전 조합
현실적인 타협안은 다음 패턴입니다.
- 목록에서는
Order와member정도만EntityGraph로 당겨옴(1:1 또는 N:1 위주) - 컬렉션인
items는LAZY유지 - 대신
items에BatchSize를 걸어, 필요해지는 순간 쿼리를 묶어서 가져옴
예시:
public interface OrderRepository extends JpaRepository<Order, Long> {
@EntityGraph(attributePaths = {"member"})
Page<Order> findByStatusOrderByIdDesc(OrderStatus status, Pageable pageable);
}
이렇게 하면 페이징 안정성을 유지하면서도, member 로 인한 N+1 은 제거하고, items 는 배치로 완화합니다.
쿼리 확인 방법: SQL 로그와 통계로 “정량” 검증
N+1 은 “느낌”이 아니라 “쿼리 개수”로 확인해야 합니다.
SQL 로그
spring:
jpa:
properties:
hibernate:
format_sql: true
highlight_sql: true
logging:
level:
org.hibernate.SQL: debug
org.hibernate.orm.jdbc.bind: trace
- Hibernate 6에서는 바인딩 로그 카테고리가 환경에 따라 다를 수 있습니다. 위 설정이 과도하면 운영에서는 끄고, 스테이징에서만 켜는 것을 권장합니다.
Hibernate 통계(스테이징에서만)
spring:
jpa:
properties:
hibernate:
generate_statistics: true
logging:
level:
org.hibernate.stat: debug
통계 로그로 “쿼리 수가 실제로 줄었는지”를 확인하면 튜닝 방향이 흔들리지 않습니다.
흔한 함정과 체크리스트
1) EAGER 로 바꿔서 해결하려는 시도
EAGER 는 단기적으로는 N+1 을 숨길 수 있지만, 다른 화면/API에서 원치 않는 조인을 유발해 더 큰 장애를 만들기 쉽습니다. 기본은 LAZY 를 유지하고, 조회 유스케이스에서만 fetch 전략을 제어하세요.
2) OSIV 켠 상태에서 “컨트롤러에서 연관 접근”
OSIV(Open Session In View)가 켜져 있으면 컨트롤러/뷰 렌더링 단계에서 프록시가 초기화되며 N+1 이 더 늦게 터집니다. 성능 이슈가 “랜덤하게” 보이기 쉬운 패턴입니다.
- 가능하면 트랜잭션(서비스) 내부에서 DTO 변환을 끝내고, 필요한 연관은 그 안에서 로딩 전략을 확정하세요.
3) distinct 로 모든 것이 해결된다고 믿기
JPQL에서 distinct 는 SQL distinct 와 ORM 레벨 중복 제거가 섞여 동작합니다. 중복 row 자체를 줄여주는 게 아니라, 결과 엔티티를 중복 제거해 “겉보기 결과”만 맞추는 경우가 많습니다. row 폭증은 여전히 비용입니다.
결론: 선택 기준 요약
- 화면/유스케이스에서 필요한 연관이 명확하고, 조인 폭증 위험이 낮다
@EntityGraph로 명시적으로 fetch
- 페이징이 중요하거나, 컬렉션 조인이 부담스럽다
BatchSize로 LAZY 로딩을 묶어서 완화
- 운영 안정성을 최우선으로, 점진적으로 개선하고 싶다
- 전역
hibernate.default_batch_fetch_size로 1차 완화 후, 병목 API에만EntityGraph를 추가
- 전역
추가로, 애플리케이션 지연의 원인이 DB뿐만 아니라 스레딩/락/대기에서 오는 경우도 있습니다. 가상 스레드나 동시성 변경 이후 지연이 커졌다면 다음 글도 함께 점검해 보세요: Spring Boot 3 가상스레드 적용 후 지연·데드락 진단
필요하시면 Pageable 기반 목록 API에서 “부모 페이징 + 자식 일괄 조회”를 EntityGraph 와 BatchSize 조합으로 구현하는 템플릿(리포지토리 2단계 조회, DTO 매핑, 쿼리 수 기대치)까지 이어서 정리해드릴게요.