- Published on
Spring Boot 3 JPA N+1 폭발 대응 - fetch join·EntityGraph·Batch
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 갑자기 느려지고 DB 커넥션이 바닥나며, 로그에는 select ... where ...가 끝없이 반복된다면 N+1을 의심해야 합니다. Spring Boot 3(Jakarta) + Hibernate 6 조합에서도 N+1은 여전히 가장 흔한 성능 함정이고, 특히 @OneToMany/@ManyToOne 연관관계와 DTO 변환, JSON 직렬화가 맞물릴 때 “폭발”합니다.
이 글에서는 (1) N+1을 정확히 재현/진단하는 방법, (2) fetch join, @EntityGraph, Hibernate @BatchSize/default_batch_fetch_size를 언제 어떻게 적용할지, (3) 페이징/컬렉션 로딩/중복 row 같은 실무 함정을 정리합니다. N+1이 커넥션 풀 고갈로 이어지는 패턴도 자주 보이므로, 필요하면 Spring Boot 3에서 HikariCP 커넥션 고갈 원인 9가지도 함께 점검해보세요.
N+1이 터지는 전형적인 구조
도메인 예시
아래처럼 Order -> Member (ManyToOne), Order -> OrderItem (OneToMany)가 있을 때, 주문 목록을 조회하면서 멤버/아이템을 접근하면 N+1이 쉽게 발생합니다.
@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
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
}
@Entity
public class OrderItem {
@Id @GeneratedValue
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id")
private Order order;
private String sku;
}
N+1을 만드는 서비스 코드
@Transactional(readOnly = true)
public List<OrderDto> listOrders() {
List<Order> orders = orderRepository.findTop100ByOrderByIdDesc();
// DTO 변환 과정에서 LAZY 프록시 접근이 발생
return orders.stream()
.map(o -> new OrderDto(
o.getId(),
o.getMember().getName(), // 여기서 order마다 member 로딩
o.getItems().size() // 여기서 order마다 items 로딩
))
.toList();
}
orders1번 조회member는 주문 개수만큼 추가 조회 (N)items도 주문 개수만큼 추가 조회 (N)
즉, 1 + N + N 형태로 “폭발”합니다.
먼저 진단: 로그/통계로 N+1을 눈으로 확인
SQL 로그 최소 설정
운영에서는 과도한 SQL 로그가 부담이지만, 재현 환경에서는 아래 정도는 필수입니다.
spring:
jpa:
properties:
hibernate:
format_sql: true
highlight_sql: true
use_sql_comments: true
logging:
level:
org.hibernate.SQL: debug
org.hibernate.orm.jdbc.bind: trace
org.hibernate.SQL로 쿼리 개수를 확인bind트레이스로 파라미터 바인딩 확인
“N+1인지”를 빠르게 판별하는 체크
- 목록 조회 1번 이후, 동일한 패턴의
select ... where id=?가 연속으로 찍히는가? - DTO 매핑/JSON 직렬화 지점에서 트리거되는가?
- 트랜잭션 경계 밖에서 프록시 초기화 예외를 피하려고
OpenEntityManagerInView를 켜두진 않았는가?
해결 전략 1: fetch join (가장 강력하지만 제약이 많다)
fetch join은 연관 엔티티를 한 번의 쿼리로 당겨오므로 N+1을 가장 확실하게 제거합니다.
ManyToOne/OneToOne은 fetch join이 정석
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("select o from Order o join fetch o.member where o.id in :ids")
List<Order> findAllWithMember(@Param("ids") List<Long> ids);
}
Order목록 +Member를 한 번에 로딩- 결과 row가 늘지 않으므로(대부분) 페이징과도 궁합이 좋습니다.
OneToMany 컬렉션 fetch join의 함정: 중복 row와 페이징
@Query("select distinct o from Order o " +
"join fetch o.member " +
"join fetch o.items")
List<Order> findAllWithMemberAndItems();
Order1건에items가 5개면 SQL 결과 row는 5배- JPA는 영속성 컨텍스트에서 중복 엔티티를 합치지만, DB 레벨에서 페이징이 깨질 수 있음
- Hibernate는 컬렉션 fetch join + pagination에 대해 경고를 내거나(또는 메모리 페이징) 성능 문제를 유발할 수 있습니다.
권장 패턴
- 페이지가 필요하면 컬렉션은 fetch join으로 한 번에 당기지 말고, 아래의
Batch/EntityGraph조합 또는 “2단계 조회(IDs -> IN 조회)”를 고려합니다.
해결 전략 2: @EntityGraph (리포지토리 메서드에 선언적으로 적용)
@EntityGraph는 “이 쿼리에서 어떤 연관을 함께 로딩할지”를 선언적으로 지정합니다. fetch join과 유사한 효과를 내면서도, 메서드 시그니처 중심으로 관리하기 좋아 실무에서 활용도가 높습니다.
ManyToOne을 EntityGraph로 함께 로딩
public interface OrderRepository extends JpaRepository<Order, Long> {
@EntityGraph(attributePaths = {"member"})
List<Order> findTop100ByOrderByIdDesc();
}
- 기존 메서드 이름 쿼리를 유지하면서
member를 즉시 로딩 - N+1 제거에 효과적
중첩 로딩도 가능
@EntityGraph(attributePaths = {"member", "items"})
@Query("select o from Order o where o.id in :ids")
List<Order> findAllGraph(@Param("ids") List<Long> ids);
다만 컬렉션(items)을 포함하면 fetch join과 마찬가지로 row 증가/페이징 이슈가 생길 수 있으니, “목록 + 페이징”에서는 신중히 적용해야 합니다.
해결 전략 3: Batch Fetch (페이징/목록에서 가장 실전적)
fetch join이 “한 번에 다 가져오기”라면, Batch Fetch는 “N을 N/배치크기로 줄이기”입니다. 즉, Order 100건을 조회한 뒤 member를 100번 조회하는 대신, 예를 들어 25개씩 묶어 4번만 조회합니다.
글로벌 설정: default_batch_fetch_size
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
LAZY연관을 초기화할 때IN (?, ?, ...)형태로 묶어서 조회- 컬렉션/엔티티 프록시 모두에 적용
- 보통 50~200 사이에서 워크로드에 맞춰 튜닝
개별 엔티티에 @BatchSize 적용
@Entity
@BatchSize(size = 100)
public class Member {
@Id @GeneratedValue
private Long id;
}
@Entity
public class Order {
// ...
@BatchSize(size = 100)
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> items = new ArrayList<>();
}
Batch Fetch가 특히 좋은 케이스
- 페이징 목록에서
Order만 페이지로 가져오고, 화면 렌더링/DTO 변환 중 연관이 필요할 때 - 컬렉션 fetch join으로 row 폭증이 우려될 때
- 쿼리를 복잡하게 만들기 싫고, “쿼리 수만 줄여도 충분”할 때
실전 조합 가이드: 어떤 걸 언제 쓰나
1) ManyToOne만 필요하다 → fetch join 또는 EntityGraph
- 주문 목록에서 작성자/회원명 정도만 필요
- row 증가가 거의 없고 쿼리도 단순
추천: @EntityGraph(attributePaths = "member") 또는 join fetch o.member
2) OneToMany 컬렉션까지 필요하지만 페이징이 있다 → Batch + 2단계 조회
- 1단계:
Order페이지 조회 (member는 fetch join 가능) - 2단계:
items는 배치 로딩으로 N+1 완화 또는 별도 IN 쿼리로 한 번에 로딩
예시(2단계: ID 목록 기반 재조회):
@Transactional(readOnly = true)
public List<OrderDto> listOrdersPaged(int page, int size) {
Page<Order> orders = orderRepository.findPageWithMember(
PageRequest.of(page, size)
);
List<Long> ids = orders.getContent().stream().map(Order::getId).toList();
// 컬렉션을 포함한 그래프/페치조인으로 재조회 (페이징은 이미 끝남)
List<Order> hydrated = orderRepository.findAllWithItemsByIdIn(ids);
return hydrated.stream().map(OrderDto::from).toList();
}
리포지토리:
public interface OrderRepository extends JpaRepository<Order, Long> {
@EntityGraph(attributePaths = {"member"})
@Query("select o from Order o")
Page<Order> findPageWithMember(Pageable pageable);
@Query("select distinct o from Order o " +
"join fetch o.items " +
"where o.id in :ids")
List<Order> findAllWithItemsByIdIn(@Param("ids") List<Long> ids);
}
핵심은 페이징 쿼리와 컬렉션 로딩 쿼리를 분리하는 것입니다.
3) 화면/응답이 DTO 중심이다 → 애초에 DTO Projection 고려
엔티티 그래프를 복잡하게 만들기보다, 필요한 컬럼만 뽑는 DTO 쿼리가 더 효율적인 경우가 많습니다.
public record OrderRow(Long orderId, String memberName) {}
@Query("select new com.example.OrderRow(o.id, m.name) " +
"from Order o join o.member m " +
"order by o.id desc")
List<OrderRow> findOrderRows();
- 연관 로딩 자체가 필요 없어 N+1 구조가 사라짐
- 다만 복잡한 계층 구조 DTO(주문+아이템 목록)는 별도 매핑 전략이 필요
Spring Boot 3/Hibernate 6에서 자주 겪는 함정
1) distinct는 만능이 아니다
JPQL의 distinct는 중복 엔티티를 줄이는 데 도움은 되지만,
- DB 결과 row 자체가 줄어드는 것은 아닐 수 있고
- 페이징 문제를 근본적으로 해결하지는 못합니다.
2) OpenEntityManagerInView로 “겉보기 해결”을 하지 말 것
컨트롤러/뷰 렌더링 단계에서 LAZY 로딩이 터지지 않게 하려고 OSIV를 켜면,
- 트랜잭션 밖에서 지연 로딩이 발생해 쿼리 흐름이 예측 불가
- 요청 처리 후반에 쿼리가 몰려 커넥션이 오래 점유
- 결국 트래픽 증가 시 커넥션 풀 고갈로 이어질 수 있습니다.
이 패턴은 N+1과 결합하면 특히 위험합니다. 커넥션 풀이 자주 고갈된다면 위에서 언급한 글(Spring Boot 3에서 HikariCP 커넥션 고갈 원인 9가지)의 “느린 쿼리 + 과도한 동시성” 항목도 같이 보세요.
3) JSON 직렬화가 N+1 트리거가 되는 경우
엔티티를 그대로 API 응답으로 내보내면 Jackson이 getter를 호출하면서 LAZY 초기화를 유발합니다.
- 엔티티 직접 노출 대신 DTO 변환
- 필요한 연관만 미리 로딩(fetch join/graph/batch)
- 순환참조/프록시 이슈도 함께 예방
체크리스트: N+1을 “재발하지 않게” 만드는 운영 습관
- 목록 API/화면마다 “필요한 연관”을 명시적으로 결정했는가? (그래프/페치/DTO)
- 컬렉션 fetch join을 페이징 쿼리에 섞지 않았는가?
default_batch_fetch_size를 적절히 설정했는가?- SQL 로그/슬로우 쿼리/쿼리 카운트를 관찰할 장치가 있는가?
- 성능 이슈가 커넥션 풀 고갈로 번지지 않는가?
결론: 정답은 하나가 아니라 “상황별 최적 조합”
- fetch join: 가장 강력한 N+1 제거 도구. 특히
ManyToOne에 최적. 다만 컬렉션 + 페이징은 조심. - EntityGraph: 리포지토리 레벨에서 선언적으로 관리하기 좋고 유지보수성이 높음.
- Batch Fetch: 페이징/목록에서 현실적인 타협점. N+1을 “N/배치”로 줄여 체감 성능을 크게 개선.
실무에서는 보통 ManyToOne은 fetch join/EntityGraph, 컬렉션은 batch + 필요 시 2단계 조회, DTO projection은 성능이 민감한 API에 선택적으로 적용 조합이 가장 안정적으로 작동합니다.