- Published on
Spring Boot 3에서 JPA N+1 잡는 fetch join·EntityGraph
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 성능 이슈를 트레이싱하다 보면, DB 자체는 느리지 않은데 애플리케이션이 유난히 느린 케이스가 있습니다. 그중 대표가 JPA의 N+1 문제입니다. 목록 API 하나가 수십~수백 번의 select를 터뜨리며 지연을 만들고, 커넥션 풀을 잠식해 다른 요청까지 느려지게 만듭니다.
Spring Boot 3(= Hibernate 6, Jakarta Persistence)에서도 본질은 동일합니다. 연관관계가 LAZY로 선언되어 있어도, 화면/응답 DTO를 만들기 위해 연관 데이터를 접근하는 순간 추가 쿼리가 발생합니다. 문제는 “한 번”이 아니라 “엔티티 개수 N번” 반복된다는 데 있습니다.
이 글에서는 N+1을 재현하는 최소 예제를 만들고, fetch join과 EntityGraph로 해결하는 방법을 비교합니다. 또한 페이징, 컬렉션 페치 조인, 중복 row 등 실무에서 자주 부딪히는 함정까지 같이 정리합니다. DB 쿼리 튜닝 관점의 관측 방법은 PostgreSQL 느린 쿼리 튜닝 - auto_explain+pg_stat_statements 글도 함께 보면 좋습니다.
N+1이 생기는 전형적인 흐름
N+1은 보통 다음 흐름에서 터집니다.
- 부모 엔티티 목록을 조회하는 쿼리 1번 실행
- 각 부모 엔티티에서 연관 엔티티를 접근하는 순간, 부모 개수만큼 추가 쿼리 N번 실행
예를 들어 Order와 Member가 ManyToOne 관계이고 Order 목록을 조회한 뒤 응답에서 order.getMember().getName() 같은 접근을 하면, member 로딩을 위해 주문 개수만큼 쿼리가 추가로 나갑니다.
예제 도메인
OrderManyToOneMemberOrderOneToManyOrderItemOrderItemManyToOneProduct
실무에서 흔한 “주문 목록 + 주문자 + 아이템 요약” 화면을 상정해 보겠습니다.
Spring Boot 3 + JPA 설정(관측 준비)
N+1은 “발생했다”가 아니라 “몇 번의 쿼리가 나갔는지”가 중요합니다. 로컬에서 재현할 때는 SQL 로그와 바인딩 파라미터를 켜두면 확인이 쉽습니다.
# application.yml
spring:
jpa:
properties:
hibernate:
format_sql: true
logging:
level:
org.hibernate.SQL: debug
org.hibernate.orm.jdbc.bind: trace
운영에서는 SQL 로그를 그대로 켜기 어렵기 때문에, DB 레벨에서 슬로우 쿼리/빈도 관측이 필요합니다. PostgreSQL을 쓴다면 pg_stat_statements로 “같은 쿼리가 N번 반복되는 패턴”을 잡아내는 게 특히 유용합니다. 자세한 방법은 위 내부 링크 글을 참고하세요.
N+1 재현: 단순 목록 API
엔티티 예시
// Member.java
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
}
// Order.java
@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<>();
}
// OrderItem.java
@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 = "product_id")
private Product product;
private int quantity;
}
// Product.java
@Entity
public class Product {
@Id @GeneratedValue
private Long id;
private String name;
}
문제를 만드는 서비스 코드
@Service
@Transactional(readOnly = true)
public class OrderQueryService {
private final OrderRepository orderRepository;
public OrderQueryService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
public List<OrderSummaryDto> list() {
List<Order> orders = orderRepository.findAll();
return orders.stream()
.map(o -> new OrderSummaryDto(
o.getId(),
o.getMember().getName(),
o.getOrderItems().stream()
.map(oi -> oi.getProduct().getName())
.toList()
))
.toList();
}
}
findAll()로 주문 목록 1번o.getMember()로 주문 개수만큼member조회o.getOrderItems()로 주문 개수만큼order_item조회- 각 아이템의
product까지 접근하면 아이템 개수만큼 추가 조회
즉, “주문 N개, 아이템 총 M개”일 때 대략 1 + N + N + M 같은 형태로 쿼리 수가 폭증합니다.
해결 1: fetch join으로 필요한 연관을 한 번에 당기기
fetch join은 JPQL에서 연관 엔티티를 즉시 로딩하도록 강제합니다. 핵심은 “DTO 변환 과정에서 연관 접근이 일어나기 전에” 필요한 연관을 미리 조인으로 가져오는 것입니다.
ManyToOne은 fetch join이 특히 효과적
Order 목록에서 Member를 같이 가져오는 건 비교적 안전합니다(카디널리티가 주문:회원 = N:1).
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("""
select o
from Order o
join fetch o.member
order by o.id desc
""")
List<Order> findAllWithMember();
}
이렇게 하면 주문 목록을 가져오는 쿼리 1번에 member까지 포함됩니다. DTO 변환에서 o.getMember().getName()이 더 이상 추가 쿼리를 만들지 않습니다.
컬렉션(OneToMany) fetch join의 함정
orderItems 같은 컬렉션을 fetch join하면 다음 문제가 따라옵니다.
- 조인 결과 row가 뻥튀기됨(주문 1개에 아이템 5개면 결과 row 5개)
- JPA가
Order를 중복으로 만들지 않도록 애써 합치지만, 결과 리스트는 중복이 섞일 수 있음 - 페이징이 사실상 깨짐(조인된 row 기준으로
limit이 적용되기 때문)
중복을 줄이려고 distinct를 붙이는 패턴이 유명합니다.
@Query("""
select distinct o
from Order o
join fetch o.member
join fetch o.orderItems oi
join fetch oi.product
order by o.id desc
""")
List<Order> findAllWithItemsAndProduct();
다만 distinct는 “SQL distinct”와 “JPA의 엔티티 중복 제거”가 섞여 동작합니다. 데이터가 커질수록 정렬/중복 제거 비용이 커질 수 있고, 무엇보다 페이징과 함께 쓰면 결과가 흔들립니다.
페이징이 필요하면 이렇게 분리하는 게 안전
실무에서는 “목록 페이징 + 상세 확장” 요구가 많습니다. 이때 컬렉션 fetch join으로 한 방에 해결하려다 성능/정확성 둘 다 잃기 쉽습니다.
안전한 패턴은 2단계입니다.
- 페이징은
ManyToOne까지만 fetch join
@Query("""
select o
from Order o
join fetch o.member
order by o.id desc
""")
Page<Order> findPageWithMember(Pageable pageable);
- 컬렉션은 별도 쿼리로 한 번에 당기기(
IN)
public interface OrderItemRepository extends JpaRepository<OrderItem, Long> {
@Query("""
select oi
from OrderItem oi
join fetch oi.product
where oi.order.id in :orderIds
""")
List<OrderItem> findAllByOrderIdsWithProduct(List<Long> orderIds);
}
서비스에서 조립:
@Transactional(readOnly = true)
public List<OrderSummaryDto> list(Pageable pageable) {
Page<Order> page = orderRepository.findPageWithMember(pageable);
List<Order> orders = page.getContent();
List<Long> orderIds = orders.stream().map(Order::getId).toList();
List<OrderItem> items = orderItemRepository.findAllByOrderIdsWithProduct(orderIds);
Map<Long, List<OrderItem>> itemsByOrderId = items.stream()
.collect(Collectors.groupingBy(oi -> oi.getOrder().getId()));
return orders.stream()
.map(o -> new OrderSummaryDto(
o.getId(),
o.getMember().getName(),
itemsByOrderId.getOrDefault(o.getId(), List.of()).stream()
.map(oi -> oi.getProduct().getName())
.toList()
))
.toList();
}
쿼리는 보통 주문 1번 + 아이템 1번으로 수렴합니다(페이지 사이즈와 무관). N+1을 안정적으로 제거하면서 페이징도 지킬 수 있습니다.
해결 2: EntityGraph로 “어떤 연관을 로딩할지” 선언하기
EntityGraph는 리포지토리 메서드 단위로 로딩 그래프를 지정하는 방식입니다. 장점은 다음과 같습니다.
- JPQL 문자열을 덜 쓰고, 메서드 선언으로 의도를 표현
- 기존 쿼리를 크게 바꾸지 않고도 fetch 전략을 덮어쓸 수 있음
- 상황별로 다른 그래프를 만들어 재사용 가능
간단 예시: 주문 목록에서 member만 즉시 로딩
public interface OrderRepository extends JpaRepository<Order, Long> {
@EntityGraph(attributePaths = {"member"})
List<Order> findAllByOrderByIdDesc();
}
이렇게 하면 member는 즉시 로딩되도록 쿼리가 나갑니다(구현체가 내부적으로 fetch join에 준하는 SQL을 만들거나 추가 select를 최적화합니다). 결과적으로 member에 대한 N+1을 줄일 수 있습니다.
중첩 그래프: orderItems와 product까지
public interface OrderRepository extends JpaRepository<Order, Long> {
@EntityGraph(attributePaths = {"member", "orderItems", "orderItems.product"})
List<Order> findTop50ByOrderByIdDesc();
}
주의할 점은 컬렉션을 포함하는 순간 fetch join과 동일한 함정(중복 row, 페이징 문제)이 그대로 나타날 수 있다는 것입니다. EntityGraph는 “표현 방식”이 다를 뿐, “컬렉션을 한 방에 당긴다”는 전략 자체의 트레이드오프는 동일합니다.
fetch join vs EntityGraph: 선택 기준
fetch join이 더 적합한 경우
- 복잡한 조건/조인 순서를 JPQL로 명확히 통제하고 싶을 때
- 특정 쿼리에서만 확실히 한 번에 당겨야 할 때
- 성능 이슈를 해결하면서 SQL 형태를 예측 가능하게 유지하고 싶을 때
EntityGraph가 더 적합한 경우
- 같은 조회 메서드를 여러 화면/유스케이스에서 재사용하고, 상황별 로딩만 바꾸고 싶을 때
- JPQL 문자열을 줄이고 리포지토리를 선언적으로 유지하고 싶을 때
ManyToOne중심의 N+1을 빠르게 제거하고 싶을 때
실무적으로는 “기본은 EntityGraph로 단순 N+1 제거, 복잡한 화면은 fetch join 또는 2단계 조회로 최적화” 조합이 많이 쓰입니다.
추가로 자주 묻는 포인트
OSIV와 N+1
OSIV(Open Session In View)가 켜져 있으면 컨트롤러/뷰 렌더링 단계에서도 지연 로딩이 가능해져 N+1이 더 늦게 터질 수 있습니다. 즉, 문제를 숨기기도 합니다.
- OSIV
true: 개발 초기 편하지만, 어디서 쿼리가 나가는지 통제가 어려워짐 - OSIV
false: 서비스 계층에서 필요한 연관을 확실히 로딩해야 해서 N+1을 더 빨리 발견 가능
Spring Boot 3에서도 운영에서는 OSIV를 끄고, 서비스 계층에서 조회 전략을 확정하는 접근이 일반적으로 권장됩니다.
spring:
jpa:
open-in-view: false
Batch size로 “N+1을 N/batch+1로” 줄이는 방법
hibernate.default_batch_fetch_size 또는 @BatchSize는 지연 로딩을 “한 건씩” 하지 않고 “IN 절로 묶어서” 가져오게 해줍니다. 완전한 의미의 1쿼리로 만들지는 못하지만, N+1 폭발을 완화하는 데는 효과가 있습니다.
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
이 옵션은 fetch join/EntityGraph와 경쟁하기보다는 보완재로 보는 게 좋습니다. 특히 여러 화면에서 다양한 연관 접근이 섞일 때 “최악을 완화”하는 안전장치가 됩니다.
성능 검증: 쿼리 수, 응답 시간, DB 부하를 함께 보기
N+1 해결은 “쿼리 수 줄이기”로 끝나지 않습니다. 조인을 과하게 하면 단일 쿼리가 무거워져서 오히려 느려질 수 있습니다. 다음을 함께 체크하세요.
- API 호출 1회당 SQL 실행 횟수
- 가장 무거운 SQL의 실행 시간과 반환 row 수
- DB CPU/IO 상승 여부
- 커넥션 풀 대기 시간
PostgreSQL을 쓰면 auto_explain과 pg_stat_statements로 “빈도 높은 반복 쿼리”와 “무거운 단일 쿼리”를 동시에 잡아낼 수 있습니다. 관련 실전 방법은 PostgreSQL 느린 쿼리 튜닝 - auto_explain+pg_stat_statements를 참고하세요.
정리
- N+1은
LAZY연관을 “목록에서 반복 접근”할 때 발생하는 구조적 문제입니다. fetch join은 SQL을 예측 가능하게 만들며,ManyToOneN+1 제거에 특히 강력합니다.- 컬렉션 fetch join은 중복 row와 페이징 문제를 동반하므로, 페이징이 필요하면
ManyToOne fetch join + 컬렉션 IN 조회같은 2단계 전략이 안전합니다. EntityGraph는 조회 메서드에 로딩 전략을 선언적으로 붙여 재사용성을 높입니다. 다만 컬렉션을 포함하면 fetch join과 동일한 트레이드오프가 있습니다.- 최종적으로는 “쿼리 수”와 “단일 쿼리 무게”를 함께 보고, 서비스 요구(페이징/정렬/응답 형태)에 맞는 전략을 선택해야 합니다.