- Published on
Spring Boot JPA N+1 폭발 - 원인·해결 8가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 느려졌는데 CPU도 여유 있고 DB도 버티는 것처럼 보일 때, 로그를 켜보면 같은 형태의 select가 수십~수천 번 반복되는 경우가 많습니다. 이때 대표적인 범인이 JPA의 N+1 문제입니다. 특히 Spring Boot에서 기본 설정 그대로 엔티티 연관관계를 다루면, 개발 환경에서는 티가 안 나다가 데이터가 늘어나는 순간 쿼리 폭발로 이어집니다.
이 글에서는 N+1이 왜 생기는지 원인을 구조적으로 설명하고, 실무에서 자주 쓰는 해결책 8가지를 각각 언제 쓰고 무엇을 조심해야 하는지까지 코드로 정리합니다.
N+1이란 무엇이고 왜 “폭발”하나
N+1은 보통 다음 패턴으로 발생합니다.
- 부모 목록을 가져오는 쿼리 1번
- 부모 N개 각각에 대해 자식(또는 연관 엔티티)을 가져오는 쿼리 N번
즉 총 쿼리 수가 1 + N이 됩니다. 문제는 N이 페이지 크기(예: 20) 수준이면 그럭저럭 넘어가지만, 목록이 커지거나 연관관계가 중첩되면 1 + N + N*M처럼 기하급수적으로 늘 수 있다는 점입니다.
재현 예제: @OneToMany 컬렉션 접근
아래처럼 Order가 OrderItem을 가진 전형적인 모델을 가정해 보겠습니다.
@Entity
public class Order {
@Id @GeneratedValue
private Long id;
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> items = new ArrayList<>();
// getter
}
@Entity
public class OrderItem {
@Id @GeneratedValue
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
private Order order;
@ManyToOne(fetch = FetchType.LAZY)
private Product product;
}
서비스에서 목록을 가져오고, 뷰/DTO 변환 과정에서 컬렉션에 접근하면 다음이 터집니다.
@Transactional(readOnly = true)
public List<OrderDto> list() {
List<Order> orders = orderRepository.findAll();
return orders.stream()
.map(o -> new OrderDto(o.getId(), o.getItems().size()))
.toList();
}
findAll()로 주문 목록 1번- 각 주문마다
items초기화 쿼리 N번
이게 N+1입니다.
원인 4가지: “LAZY가 나쁘다”가 아니다
N+1은 단순히 LAZY 때문이 아니라, 언제 로딩되는지 제어하지 못한 상태에서 연관 필드를 접근하기 때문에 생깁니다. 실무에서 흔한 원인은 다음 4가지로 요약됩니다.
1) DTO 변환 시점이 쿼리 계획 없이 진행됨
엔티티를 그대로 반환하지 않더라도, DTO로 바꾸는 순간 연관 필드 접근이 발생합니다. 특히 toString()이나 로깅에서 터지는 경우도 많습니다.
2) OSIV 환경에서 “컨트롤러까지 LAZY 접근”이 허용됨
OSIV가 켜져 있으면 트랜잭션 밖에서도 영속성 컨텍스트가 열려 있어, 컨트롤러/뷰에서 LAZY 로딩이 실행됩니다. 이때 개발자는 쿼리가 나가는지 인지하기 어렵습니다.
3) 컬렉션 페치 조인과 페이징을 같이 쓰면서 우회 로딩이 발생
fetch join을 잘못 쓰면 페이징이 깨져 Hibernate가 메모리 페이징으로 바꾸거나, 중복 제거 과정에서 추가 쿼리가 나갑니다.
4) 다단계 연관 접근으로 중첩 N+1이 발생
예: order.items.product처럼 한 단계 더 들어가면 N+1이 2단, 3단으로 늘어납니다.
해결 8가지: 상황별로 선택하는 실전 처방
아래 8가지는 “무조건 이게 정답”이 아니라, 화면/API 요구사항과 데이터 크기에 따라 조합하는 도구 상자입니다.
1) 페치 조인(fetch join)으로 한 번에 가져오기
가장 직접적인 해결책입니다. 필요한 연관을 미리 조인해서 가져오면 LAZY 초기화 쿼리가 추가로 나가지 않습니다.
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("select o from Order o join fetch o.items where o.id = :id")
Optional<Order> findByIdWithItems(Long id);
}
주의점:
@OneToMany컬렉션을 페치 조인하면 결과가 뻥튀기됩니다(조인으로 행이 늘어남).- 목록 조회에서 컬렉션 페치 조인은 페이징과 충돌합니다.
2) @EntityGraph로 로딩 그래프를 선언적으로 제어
쿼리 문자열을 더럽히지 않고, 레포지토리 메서드 단위로 로딩 전략을 지정할 수 있습니다.
public interface OrderRepository extends JpaRepository<Order, Long> {
@EntityGraph(attributePaths = {"items", "items.product"})
@Query("select o from Order o where o.id in :ids")
List<Order> findAllByIdInWithGraph(List<Long> ids);
}
장점:
- JPQL을 단순하게 유지하면서 필요한 연관만 가져올 수 있습니다.
주의점:
- 그래프가 커지면 결국 조인 폭이 넓어져 결과 크기가 커질 수 있습니다.
3) 배치 페치(default_batch_fetch_size)로 N을 줄이기
N+1을 “0으로” 만들기 어렵거나, 여러 화면에서 폭넓게 터지는 경우 배치 페치가 비용 대비 효과가 좋습니다. Hibernate가 LAZY 로딩을 할 때 IN 쿼리로 묶어서 가져옵니다.
application.yml 예시:
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
효과:
N번 쿼리가N/100수준으로 줄어듭니다.
주의점:
- 여전히 추가 쿼리는 발생합니다. 다만 폭발을 “완화”하는 전략입니다.
- 배치 크기는 데이터 분포와 DB 파라미터 제한(예:
IN최대 길이)에 맞춰 조정해야 합니다.
4) 컬렉션은 페치 조인 대신 “2쿼리 전략”으로 페이징 유지
목록 API에서 페이징이 중요하면, 컬렉션까지 한 번에 조인하지 말고 다음처럼 2단계로 가져오는 패턴이 안전합니다.
- 부모 ID만 페이징으로 조회
- 그 ID 목록으로 자식들을 한 번에 로딩(페치 조인 또는 그래프)
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("select o.id from Order o order by o.id desc")
Page<Long> findPageIds(Pageable pageable);
@EntityGraph(attributePaths = {"items"})
@Query("select o from Order o where o.id in :ids")
List<Order> findAllByIdIn(List<Long> ids);
}
@Transactional(readOnly = true)
public List<OrderDto> list(Pageable pageable) {
Page<Long> pageIds = orderRepository.findPageIds(pageable);
List<Order> orders = orderRepository.findAllByIdIn(pageIds.getContent());
// 필요하면 id 순서 재정렬
return orders.stream().map(OrderDto::from).toList();
}
장점:
- 페이징 정확도 유지
- 컬렉션 로딩도 N+1 없이 처리 가능
주의점:
IN조회 후 정렬이 깨질 수 있어, 원래 순서대로 재정렬 로직이 필요할 수 있습니다.
5) DTO 프로젝션으로 “필요한 컬럼만” 직접 조회
엔티티 그래프를 잘 짜도, 화면이 원하는 데이터가 일부라면 엔티티를 통째로 가져오는 것 자체가 낭비일 수 있습니다. 이때는 DTO로 바로 조회하는 게 깔끔합니다.
public record OrderRow(Long orderId, Long itemId, String productName) {}
public interface OrderQueryRepository {
@Query("""
select new com.example.OrderRow(o.id, i.id, p.name)
from Order o
join o.items i
join i.product p
where o.id in :ids
""")
List<OrderRow> findRows(List<Long> ids);
}
장점:
- 불필요한 엔티티 로딩/변경 감지 비용 감소
- API 응답 형태에 최적화
주의점:
- 조인 결과를 애플리케이션에서 그룹핑해야 할 수 있습니다.
6) @ManyToOne은 기본을 LAZY로 두되, 화면 단위로 명시 로딩
JPA 스펙상 @ManyToOne 기본 fetch는 EAGER입니다. 이를 그대로 두면 N+1과는 별개로 “원치 않는 조인/추가 쿼리”가 여기저기서 발생합니다.
@ManyToOne(fetch = FetchType.LAZY)
private Member member;
그 대신, 필요한 화면에서만 fetch join 또는 @EntityGraph로 가져오세요. 이렇게 해야 쿼리 계획을 예측할 수 있습니다.
7) OSIV를 끄고, 트랜잭션 경계 안에서만 DTO 변환
OSIV를 끄면 컨트롤러에서 LAZY 접근 시 예외가 나므로, “어디서 쿼리가 나가는지”가 강제적으로 드러납니다. 결과적으로 N+1을 조기에 잡는 데 도움이 됩니다.
application.yml:
spring:
jpa:
open-in-view: false
패턴:
- 서비스 계층에서 필요한 연관을 로딩하고 DTO로 변환까지 끝냄
- 컨트롤러는 DTO만 반환
주의점:
- 기존 코드가 컨트롤러/뷰에서 엔티티를 만지고 있었다면 수정 범위가 큽니다.
8) 쿼리 관측(로그·지표)으로 “폭발 지점”을 자동 탐지
N+1은 코드 리뷰만으로 놓치기 쉽습니다. 운영에서 터지기 전에 테스트/스테이징에서 쿼리 수를 계측해 폭발을 감지하는 체계를 권장합니다.
기본 SQL 로깅(개발용):
logging:
level:
org.hibernate.SQL: debug
org.hibernate.orm.jdbc.bind: trace
실무 팁:
- API별 쿼리 수를 메트릭으로 수집하거나, 특정 임계치를 넘으면 경고하도록 만듭니다.
- “렌더링 폭발”을 막기 위해 리렌더링을 줄이듯, 백엔드도 “쿼리 폭발”을 수치로 관리하는 게 효과적입니다. 프론트 관점의 폭발 제어는 React 렌더링 폭발 - useMemo·key로 리렌더 차단 글의 접근과 유사합니다.
자주 하는 실수 체크리스트
컬렉션 페치 조인 + 페이징을 한 번에 하려는 시도
- 증상: 결과 개수가 이상하거나, 성능이 오히려 악화
- 대안: 위의 “2쿼리 전략” 또는 배치 페치
distinct로 모든 것이 해결된다고 믿기
JPQL의 distinct는 SQL distinct + Hibernate의 중복 엔티티 제거가 섞여 동작합니다. 중복 제거는 해주지만, 페이징/정렬/성능 문제를 완전히 해결해주지는 않습니다.
엔티티를 API 응답으로 그대로 반환
순환 참조, 예기치 않은 LAZY 로딩, 직렬화 과정 쿼리 발생 등 문제가 겹칩니다. DTO를 기본으로 두는 편이 안전합니다.
추천 조합: 실무에서 가장 무난한 패턴
- 기본 매핑: 연관은 가능하면
LAZY - 목록 API(페이징 필요): 부모 페이징 ID 조회 +
IN으로 상세 로딩(그래프/페치 조인) - 상세 API(단건): 필요한 연관은
fetch join또는@EntityGraph - 광범위한 완화:
default_batch_fetch_size로 N+1 리스크를 낮춤 - 운영 안정성: OSIV 끄기 + 쿼리 계측으로 폭발 조기 탐지
장애를 “폭발”로 키우는 공통 원인은 관측 부재입니다. 예를 들어 인프라에서도 종료가 지연되면 원인을 쪼개서 추적하듯, 쿼리도 어디서 늘어나는지 분해해서 봐야 합니다. 이런 디버깅 관점은 Kubernetes Pod가 Terminating에 멈출 때 - finalizer·grace·SIGTERM 실전 디버깅의 문제 접근 방식과도 닮아 있습니다.
마무리
N+1은 JPA를 쓰는 한 피하기 어려운 함정이지만, “발생 원리”를 이해하고 화면 단위로 로딩 전략을 설계하면 충분히 통제 가능합니다. 중요한 건 한 가지 은탄환이 아니라,
- 페치 조인/그래프/DTO 프로젝션으로 “필요한 만큼만 미리 로딩”하고
- 배치 페치와 2쿼리 전략으로 “목록과 페이징을 안전하게” 만들며
- OSIV와 계측으로 “폭발을 조기에 발견”하는
이 3축을 함께 운영하는 것입니다.