- Published on
Spring Boot 3 JPA N+1 폭발을 끝내는 법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 성능 이슈를 추적하다 보면, CPU나 네트워크보다 먼저 DB 커넥션과 쿼리 수가 폭발하는 순간이 있습니다. Spring Boot 3 환경에서 JPA(Hibernate)를 쓰는 서비스라면 그 중심에 N+1 문제가 자주 있습니다. 특히 트래픽이 늘거나(혹은 가상 스레드 같은 동시성 모델을 도입했을 때) 응답 지연이 커지면, “쿼리는 별로 안 날리겠지”라는 가정이 깨지면서 커넥션 풀이 먼저 고갈됩니다.
이 글에서는 Spring Boot 3에서 N+1이 왜 더 자주 ‘폭발’처럼 보이는지, 그리고 Fetch Join / EntityGraph / Batch Size / DTO 직접 조회를 어떤 기준으로 선택해야 하는지 실전 관점에서 정리합니다. (커넥션 풀이 먼저 터지는 증상까지 같이 보려면 Spring Boot 3 가상스레드에서 HikariCP 고갈 해결도 함께 읽어보면 맥락이 잘 이어집니다.)
N+1 문제란 무엇인가 (그리고 왜 ‘폭발’처럼 보이나)
- 1: 루트 엔티티 목록을 조회하는 쿼리 1번
- N: 각 엔티티의 연관 엔티티를 LAZY 로딩하면서 N번 추가 쿼리
예를 들어 Order 목록을 100건 조회하고, 각 주문의 member를 접근하면 member를 가져오기 위해 최대 100번의 추가 쿼리가 나갈 수 있습니다.
Spring Boot 3에서 “폭발”처럼 체감되는 이유는 보통 다음이 겹치기 때문입니다.
- 페이지/리스트 API가 기본적으로 N이 크다: 20~100건은 흔합니다.
- JSON 직렬화가 연관 필드를 건드린다: 컨트롤러에서 엔티티를 그대로 반환하면 Jackson이 getter를 타면서 LAZY 로딩을 유발합니다.
- 동시 요청 증가 시 커넥션 풀이 먼저 바닥난다: 쿼리 수가 늘면 커넥션 점유 시간이 늘고, 결국 타임아웃/대기열이 발생합니다.
- **OSIV(Open Session In View)**가 켜져 있으면 “서비스 레이어 밖”에서도 LAZY 로딩이 가능해져 문제를 늦게 발견한다.
재현: 가장 흔한 N+1 패턴
도메인 예시
@Entity
public class Order {
@Id @GeneratedValue
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
private Member member;
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> orderItems = 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)
private Order order;
@ManyToOne(fetch = FetchType.LAZY)
private Item item;
}
@Entity
public class Item {
@Id @GeneratedValue
private Long id;
private String name;
}
문제 코드
@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("select o from Order o order by o.id desc")
List<Order> findRecent();
}
@Service
@Transactional(readOnly = true)
public class OrderService {
private final OrderRepository orderRepository;
public OrderService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
public List<OrderDto> recentOrders() {
List<Order> orders = orderRepository.findRecent();
// 여기서 member, orderItems 접근 시 N+1
return orders.stream()
.map(o -> new OrderDto(
o.getId(),
o.getMember().getName(),
o.getOrderItems().size()
))
.toList();
}
}
public record OrderDto(Long orderId, String memberName, int itemCount) {}
위 코드는 findRecent() 1번 + o.getMember() N번 + o.getOrderItems() N번 등으로 쉽게 폭발합니다.
N+1 탐지: 로그/통계로 “정량화”하기
1) Hibernate SQL 로그
spring:
jpa:
properties:
hibernate:
format_sql: true
highlight_sql: true
logging:
level:
org.hibernate.SQL: debug
org.hibernate.orm.jdbc.bind: trace
org.hibernate.SQL로 실제 SQL 수를 확인- bind 파라미터까지 보려면
jdbc.bind를 trace로
2) 통계로 쿼리 수 보기
spring:
jpa:
properties:
hibernate:
generate_statistics: true
logging:
level:
org.hibernate.stat: debug
통계는 환경에 따라 로그가 많이 나올 수 있으니 개발/스테이징에서만 권장합니다.
해결 전략 1: Fetch Join (가장 직관적인 해법)
Fetch Join은 “연관 엔티티를 한 번에 가져오겠다”를 JPQL로 명시하는 방식입니다.
ManyToOne/OneToOne에 Fetch Join 적용
@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("""
select o from Order o
join fetch o.member
order by o.id desc
""")
List<Order> findRecentWithMember();
}
Order목록 +Member를 한 번에 로딩ManyToOne은 결과 row가 크게 늘지 않아 비교적 안전
OneToMany까지 Fetch Join하면 주의할 점
@Query("""
select distinct o from Order o
join fetch o.member
join fetch o.orderItems
order by o.id desc
""")
List<Order> findRecentWithMemberAndItems();
Order1건이orderItems개수만큼 row로 뻥튀기됩니다.distinct는 JPA 레벨에서 중복 엔티티를 제거하지만, DB row 수가 줄어드는 건 아닐 수 있습니다.- 페이징과 양립이 어렵습니다. (Hibernate가 메모리 페이징으로 바꾸거나, 경고/예외 상황이 생길 수 있음)
정리
ManyToOne/OneToOne은 Fetch Join을 적극 사용OneToMany/ManyToMany는 “목록 + 컬렉션”을 한 번에 가져오는 순간 페이징/중복/메모리 이슈가 생기므로 다른 전략도 고려
해결 전략 2: EntityGraph (레포지토리 메서드에 선언적으로)
EntityGraph는 “이 쿼리에서 어떤 연관을 로딩할지”를 어노테이션으로 선언합니다. Fetch Join과 비슷한 효과를 내되, 메서드 시그니처를 크게 바꾸지 않고 적용하기 좋습니다.
@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
@EntityGraph(attributePaths = {"member"})
List<Order> findTop20ByOrderByIdDesc();
}
- 단순 조회 메서드에 붙이기 쉬움
- 복잡한 JPQL을 줄일 수 있음
컬렉션까지 포함할 수도 있지만, Fetch Join과 동일하게 row 증가/페이징 이슈는 그대로 존재합니다.
해결 전략 3: Batch Size로 “N을 줄이기” (현실적인 타협)
Fetch Join으로 컬렉션까지 한 번에 가져오면 페이징이 깨지는 경우가 많습니다. 이때 자주 쓰는 타협이 Batch Fetching입니다.
핵심은 “LAZY 로딩이 발생하더라도, 한 건씩이 아니라 IN 쿼리로 묶어서 가져오자”입니다.
설정
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
또는 엔티티/연관관계에 지정:
@BatchSize(size = 100)
@OneToMany(mappedBy = "order")
private List<OrderItem> orderItems = new ArrayList<>();
효과
orders100건을 순회하며orderItems에 접근해도- 쿼리가
100번이 아니라1~몇 번의where order_id in (...)형태로 줄어듭니다.
주의
- 배치 크기는 무조건 클수록 좋은 게 아닙니다. IN 절이 너무 커지면 플랜/네트워크/파싱 비용이 늘 수 있습니다.
- 보통 50~200 사이에서 워크로드에 맞춰 튜닝합니다.
해결 전략 4: DTO 직접 조회(Projection)로 “필요한 것만” 가져오기
N+1의 근본 원인 중 하나는 “엔티티 그래프를 그대로 들고 와서, 나중에 여기저기 접근”하는 방식입니다. 화면/API 요구사항이 명확하다면 DTO로 필요한 필드만 select하는 게 가장 예측 가능하고 빠릅니다.
예: 주문 목록에 필요한 필드만
public record OrderSummaryDto(Long orderId, String memberName) {}
@Repository
public interface OrderQueryRepository {
@Query("""
select new com.example.OrderSummaryDto(o.id, m.name)
from Order o
join o.member m
order by o.id desc
""")
List<OrderSummaryDto> findOrderSummaries();
}
- 엔티티 로딩/영속성 컨텍스트 관리 부담이 줄어듭니다.
- API 스펙이 명확한 조회성 화면에 특히 적합합니다.
단, 화면이 복잡해져서 여러 컬렉션/집계가 필요하면 쿼리가 복잡해질 수 있으니, 이 경우는 Querydsl/전용 조회 레포지토리로 분리하는 패턴을 추천합니다.
OSIV와 N+1: “늦게 터지는 폭탄” 제거하기
OSIV가 켜져 있으면 컨트롤러/뷰 렌더링 단계에서도 LAZY 로딩이 가능해져 N+1이 숨습니다. 운영에서 갑자기 느려지는 이유가 되기도 합니다.
spring:
jpa:
open-in-view: false
- 서비스 계층에서 필요한 연관을 명시적으로 로딩(Fetch Join/EntityGraph/DTO)하도록 강제
- 트랜잭션 경계가 명확해져 디버깅이 쉬워짐
단, OSIV를 끄면 기존에 “컨트롤러에서 엔티티를 그대로 반환”하던 코드가 LazyInitializationException을 내기 쉬우니, DTO 변환 위치/조회 전략을 함께 정리해야 합니다.
실전 선택 가이드: 어떤 해법을 언제 쓰나
1) 단건/목록 + ManyToOne
- Fetch Join 또는 EntityGraph가 1순위
- 페이징도 비교적 안전
2) 목록 + 컬렉션(OneToMany) + 페이징 필요
- 컬렉션 Fetch Join은 피하는 편이 안전
- Batch Size로 N을 줄이거나
- 화면 요구에 맞춰 DTO 조회로 전환
3) 복잡한 화면(여러 컬렉션/집계/조건)
- 엔티티 그래프를 억지로 맞추기보다
- **전용 조회 쿼리(DTO/Querydsl)**로 분리
4) “쿼리 수 증가 → 커넥션 풀 고갈”까지 이어질 때
- N+1 해결이 우선이지만, 동시에 커넥션 풀/동시성 모델도 점검해야 합니다.
- 특히 가상 스레드/높은 동시성에서 문제가 두드러지면 Spring Boot 3 가상스레드에서 HikariCP 고갈 해결의 점검 항목(풀 사이즈, 타임아웃, DB 처리량)을 같이 보는 게 좋습니다.
체크리스트: N+1을 “다시는” 만들지 않는 습관
- 엔티티를 컨트롤러에서 그대로 반환하지 않는다 (DTO 반환)
- 목록 API는 항상 “연관 접근이 있는가?”를 먼저 의심한다
ManyToOne은 Fetch Join/EntityGraph로 선로딩- 컬렉션은 페이징이 있으면 Fetch Join을 경계하고 Batch Size/DTO를 고려
- OSIV는 가능하면 끄고, 트랜잭션 안에서 필요한 로딩을 끝낸다
- 성능 테스트에서 “쿼리 수/커넥션 점유 시간”을 지표로 잡는다
마무리
Spring Boot 3에서 JPA N+1은 단순한 ‘성능 안 좋은 코드’가 아니라, 트래픽이 올라가는 순간 쿼리 수 증가 → 커넥션 점유 시간 증가 → 풀 고갈 → 전체 지연으로 번지는 전형적인 장애 트리거입니다.
해결은 한 가지 은탄환이 아니라, 화면/쿼리 특성에 맞춰 **Fetch Join(단순 연관), EntityGraph(선언적 로딩), Batch Size(페이징+컬렉션 타협), DTO 직접 조회(예측 가능한 성능)**를 조합하는 것입니다.
다음 단계로는 “우리 서비스의 각 API가 어떤 전략을 쓰는지”를 표로 정리하고, OSIV 설정과 함께 일관된 규칙으로 가져가면 N+1은 더 이상 폭탄이 아니라 관리 가능한 비용이 됩니다.