- Published on
Spring Boot 3에서 JPA N+1 즉시 잡는 법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 성능을 갉아먹는 JPA N+1 문제는 “원인 파악이 늦어지는 순간”부터 장애로 번집니다. 특히 Spring Boot 3 + Hibernate 6 조합에서는 기본 지연 로딩(LAZY)이 잘 작동하는 만큼, 개발 중엔 조용하다가 트래픽이 붙는 시점에 쿼리 폭발로 드러나는 경우가 많습니다.
이 글은 N+1을 즉시 발견하고, 가장 빠른 해결책을 선택하는 데 초점을 둡니다. 단순히 fetch = EAGER로 바꿔서 “일단 해결”하는 방식은 부작용이 커서 권하지 않습니다.
관련해서 트랜잭션 경계가 꼬이면 N+1이 더 늦게 발견되거나(혹은 OSIV 의존) 반대로 LazyInitializationException으로 터지기도 합니다. 트랜잭션이 기대대로 동작하지 않을 때는 Spring Boot 3에서 @Transactional이 안 먹는 6가지도 함께 점검해 보세요.
N+1이란 무엇이고, 왜 “즉시” 잡아야 하나
전형적인 상황은 이렇습니다.
- 부모 엔티티 목록을 1번 조회(예: 주문 목록)
- 각 부모의 자식 컬렉션을 접근하는 순간, 부모 개수만큼 추가 쿼리가 실행
즉, 1 + N번 쿼리가 나가면서 응답 시간이 선형으로 증가합니다. 로컬 데이터 20건에서는 티가 안 나지만, 운영에서 2,000건만 되어도 DB가 즉시 병목이 됩니다.
N+1은 보통 다음 조건에서 잘 발생합니다.
@OneToMany,@ManyToOne이LAZY인데 리스트 화면에서 연관 데이터를 함께 출력- Jackson 직렬화가 엔티티 그래프를 따라가며 연관 객체 접근
- 서비스 계층에서 루프를 돌며 연관 객체 접근
- 페이징 + 컬렉션 페치 조합을 잘못 사용
1단계: “지금 N+1이 터졌다”를 가장 빨리 확인하는 방법
SQL 로그를 사람이 읽을 수 있게 만든다
Spring Boot 3에서 가장 빠른 방법은 Hibernate SQL 로그 + 바인딩 값을 켜는 것입니다.
# application.yml
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을 보여줍니다.org.hibernate.orm.jdbc.bind는?바인딩 값을 보여줍니다.
이 상태에서 API 한 번 호출했을 때, 동일한 패턴의 쿼리가 반복되면 N+1을 거의 확정할 수 있습니다.
“쿼리 개수”를 테스트에서 자동으로 검증한다
운영에서야 APM이 잡아주지만, 개발 단계에서 즉시 잡으려면 테스트에서 쿼리 개수 상한을 걸어두는 방식이 효과적입니다.
Hibernate 통계를 켜고, 특정 유스케이스에서 쿼리가 몇 번 실행되는지 확인합니다.
spring:
jpa:
properties:
hibernate:
generate_statistics: true
logging:
level:
org.hibernate.stat: debug
그리고 통계 로그를 확인하거나, 필요하면 SessionFactory 통계를 직접 조회해 검증할 수 있습니다(프로젝트 구성에 따라 접근 방식은 달라질 수 있습니다).
OSIV에 가려진 N+1을 의심한다
OSIV(Open Session In View)가 켜져 있으면 컨트롤러/뷰 렌더링 단계에서도 지연 로딩이 진행되어 N+1이 “조용히” 발생합니다. 반대로 OSIV를 끄면 LazyInitializationException이 빨리 터져서 문제를 조기에 발견할 수 있습니다.
spring:
jpa:
open-in-view: false
OSIV를 끄는 것은 “정답”이라기보다는 N+1을 숨기지 않게 만드는 강력한 안전장치입니다.
2단계: 가장 흔한 N+1 재현 예제
예를 들어 Order와 OrderItem이 있고, 주문 목록에서 아이템을 같이 보여준다고 가정합니다.
@Entity
public class Order {
@Id @GeneratedValue
private Long id;
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> items = new ArrayList<>();
}
@Entity
public class OrderItem {
@Id @GeneratedValue
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
private Order order;
private String productName;
}
서비스에서 이런 코드를 작성하면 N+1이 터집니다.
@Transactional(readOnly = true)
public List<OrderDto> listOrders() {
List<Order> orders = orderRepository.findAll(); // 1번
return orders.stream()
.map(o -> new OrderDto(
o.getId(),
o.getItems().stream() // 여기서 주문 개수만큼 쿼리
.map(OrderItem::getProductName)
.toList()
))
.toList();
}
3단계: “즉시” 고치는 4가지 실전 해법
N+1 해결은 한 가지 정답이 아니라 화면/요구사항에 맞는 도구를 고르는 문제입니다.
해법 A: Fetch Join으로 한 번에 가져오기(가장 직관적)
부모와 연관 데이터를 함께 써야 한다면, 우선 join fetch가 가장 빠르게 효과를 냅니다.
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("select distinct o from Order o left join fetch o.items")
List<Order> findAllWithItems();
}
distinct는 조인으로 인해Order가 중복 row로 나오는 것을 엔티티 레벨에서 제거하기 위한 장치입니다.- 컬렉션 페치 조인은 결과 row가 불어나기 쉬워서, 데이터가 많으면 메모리/전송량이 커집니다.
주의할 점:
- 페이징(
Pageable)과 컬렉션fetch join은 조합이 위험합니다. DB 페이징이 깨지거나 메모리 페이징으로 바뀌는 등 부작용이 생길 수 있습니다.
해법 B: @EntityGraph로 필요한 연관만 로딩(레포지토리 메서드에 붙이기)
JPQL을 직접 쓰기 싫거나, 메서드 시그니처 기반 쿼리를 유지하고 싶다면 @EntityGraph가 깔끔합니다.
public interface OrderRepository extends JpaRepository<Order, Long> {
@EntityGraph(attributePaths = {"items"})
List<Order> findByIdIn(List<Long> ids);
}
- 특정 API에서만 연관을 로딩하고 싶을 때 유리합니다.
- 복잡한 조건 검색은 여전히 JPQL/Querydsl이 필요할 수 있습니다.
해법 C: Batch Size로 “N+1을 1+N/batch로 완화”(즉효성 좋음)
화면 특성상 모든 연관을 한 번에 페치 조인하기가 부담스럽다면, Hibernate 배치 로딩으로 쿼리를 묶어 보낼 수 있습니다.
설정은 전역으로도 가능하고:
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
필드 단위로도 가능합니다.
@OneToMany(mappedBy = "order")
@BatchSize(size = 100)
private List<OrderItem> items;
이 방식은 N+1을 “완전 제거”하진 않지만, 예를 들어 주문이 1,000건이면 아이템 로딩이 1,000번이 아니라 대략 10번으로 줄어드는 식으로 즉시 체감 성능 개선이 나옵니다.
해법 D: DTO로 필요한 컬럼만 조회(대규모 트래픽에서 가장 안전)
목록 화면은 엔티티 그래프를 그대로 내보내기보다, 필요한 데이터만 DTO로 조회하는 편이 안정적입니다.
예: 주문과 아이템을 평탄화해서 조회한 뒤, 애플리케이션에서 그룹핑합니다.
public record OrderItemRow(Long orderId, String productName) {}
public interface OrderQueryRepository {
List<OrderItemRow> findOrderItemRows();
}
@Repository
public class OrderQueryRepositoryImpl implements OrderQueryRepository {
@PersistenceContext
private EntityManager em;
@Override
public List<OrderItemRow> findOrderItemRows() {
return em.createQuery(
"select new com.example.OrderItemRow(o.id, i.productName) " +
"from Order o join o.items i",
OrderItemRow.class
).getResultList();
}
}
- N+1 자체가 구조적으로 발생하지 않습니다.
- 네트워크/메모리 사용량을 예측하기 쉽습니다.
- 다만 도메인 로직이 엔티티에 강하게 묶여 있다면 적용 범위를 나눠야 합니다(조회 전용 쿼리 모델 권장).
4단계: “즉시 잡기”를 방해하는 흔한 함정
EAGER로 바꾸는 것은 대부분 정답이 아니다
EAGER는 “항상” 가져옵니다. 지금 화면에서는 필요하지만 다른 API에서는 불필요한 조인을 만들고, 또 다른 N+1/조인 폭발의 원인이 됩니다.
즉시 해결이 필요하면 우선 fetch join이나 @EntityGraph로 특정 유스케이스에서만 가져오게 만드세요.
Jackson 직렬화로 연관을 타고 들어가는 문제
컨트롤러가 엔티티를 그대로 반환하면, 직렬화 과정에서 연관 객체 접근이 발생해 N+1이 터질 수 있습니다. 가장 좋은 해결은 API 응답을 DTO로 고정하는 것입니다.
컬렉션 페치 조인 + 페이징
- 페이징이 필요하면 보통 2단계로 갑니다.
- 페이징은 부모만 조회
- 그 부모 ID 목록으로 자식들을
IN조회(@EntityGraph또는 별도 쿼리)
- 그 부모 ID 목록으로 자식들을
이 패턴은 구현이 약간 늘지만, 운영에서 예측 가능한 성능을 제공합니다.
5단계: 추천하는 “10분 진단 체크리스트”
open-in-view를false로 두고도 API가 정상 동작하는가- SQL 로그에서 같은 형태의 쿼리가 N번 반복되는가
- 반복 쿼리의
where조건이 부모 ID로만 바뀌는가 - 해결책 선택
- 화면에서 연관을 반드시 함께 써야 함:
fetch join또는@EntityGraph - 데이터가 많고 목록 응답이 중요: DTO 조회
- 구조 변경이 어렵고 빠른 완화가 필요: 배치 로딩
- 화면에서 연관을 반드시 함께 써야 함:
문제 원인을 빠르게 좁히는 디버깅 관점은 인프라 이슈를 다룰 때도 동일합니다. 배포/운영에서 “빨리 원인 찾기” 사고방식은 K8s CrashLoopBackOff 즉시 원인 찾는 법 글의 접근과도 통합니다.
결론: N+1은 “발견 시스템”과 “해결 패턴”을 세트로 가져가야 한다
Spring Boot 3에서 JPA N+1을 즉시 잡으려면 두 가지가 필요합니다.
- 발견: SQL 로그/바인딩 값, OSIV off, 테스트에서 쿼리 상한 검증
- 해결:
fetch join,@EntityGraph, 배치 로딩, DTO 조회 중 상황에 맞는 선택
개인적으로는 운영 안정성 기준으로 다음 우선순위를 추천합니다.
- 목록/검색 API: DTO 조회 또는 2단계 조회
- 단건 상세:
@EntityGraph또는fetch join - 레거시/급한 완화: 배치 로딩
이 조합으로 가면 “N+1을 나중에 발견해서 장애가 나는” 시나리오를 대부분 제거할 수 있습니다.