- Published on
Spring Boot JPA N+1 10분 진단·해결 가이드
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 느려졌는데 CPU도, GC도, DB 커넥션도 애매하게 정상이라면 가장 먼저 의심할 것 중 하나가 JPA의 N+1입니다. 특히 @OneToMany / @ManyToOne 연관관계를 LAZY로 두고 목록 화면이나 배치성 조회를 만들면, 코드 한 줄이 SQL 수백 번으로 터지기 쉽습니다.
이 글은 “10분 안에” 다음을 끝내는 흐름으로 구성했습니다.
- 1분: 증상 패턴으로 N+1 의심하기
- 3분: 로그로 재현하고 정확히 카운트하기
- 5분: 상황별 처방(가장 안전한 순서로)
- 1분: 재발 방지 체크리스트
더 많은 해결 옵션을 한 번에 정리한 글은 Spring Boot 3+ JPA N+1 즉시 잡는 7가지도 함께 참고하면 좋습니다.
1) 1분 진단: 이 패턴이면 N+1 가능성 높음
다음 중 하나라도 해당하면 N+1을 거의 확정적으로 의심해도 됩니다.
- 목록 API 응답 시간이 데이터 건수에 비례해 선형으로 늘어남(예: 20건은 200ms, 200건은 2s)
- APM에서 DB 쿼리 수가 요청당 수십~수백 개로 폭증
- 코드에
stream().map(...)/for루프 안에서 연관 엔티티 접근이 있음 - 페이징 조회에서 “페이지는 20개인데 쿼리가 21개, 41개…” 같은 패턴
전형적인 코드 냄새는 아래처럼 “부모 리스트 조회 후 자식 접근”입니다.
List<Order> orders = orderRepository.findByStatus(OrderStatus.PAID);
return orders.stream()
.map(o -> new OrderDto(
o.getId(),
o.getMember().getName(), // 여기서 추가 쿼리
o.getOrderItems().size() // 여기서 추가 쿼리
))
.toList();
member나 orderItems가 LAZY라면, 첫 쿼리 1번 + 각 주문마다 연관 로딩 쿼리가 붙어 N+1이 됩니다.
2) 3분 재현: SQL 로그로 “쿼리 개수”를 눈으로 확인
2-1. Hibernate SQL 로그 켜기
운영에서 무작정 SQL 로그를 켜는 건 부담이 크니, 우선 로컬/스테이징에서 재현하세요.
application.yml 예시입니다.
spring:
jpa:
properties:
hibernate:
format_sql: true
logging:
level:
org.hibernate.SQL: debug
org.hibernate.orm.jdbc.bind: trace
org.hibernate.SQL은 실행 SQLorg.hibernate.orm.jdbc.bind는 바인딩 파라미터
이 상태에서 문제 API를 한 번 호출하고 로그를 봅니다.
2-2. “1 + N” 패턴을 찾는 요령
로그에서 이런 형태를 찾으면 됩니다.
- 먼저
select ... from orders ...1번 - 이어서
select ... from members where id = ?가 주문 수만큼 반복 - 또는
select ... from order_items where order_id = ?가 주문 수만큼 반복
핵심은 “동일한 형태의 쿼리가 파라미터만 바뀌며 반복”되는지입니다.
2-3. 테스트로 쿼리 수를 고정해 검증(선택)
재발 방지를 위해 “쿼리 수 제한” 테스트를 추가하면 효과가 큽니다. 대표적으로 Hibernate Statistics를 켜고 검증할 수 있습니다.
spring:
jpa:
properties:
hibernate:
generate_statistics: true
logging:
level:
org.hibernate.stat: debug
요청 1회에 쿼리가 5개를 넘지 않아야 한다 같은 기준을 팀에서 합의하면, 리팩터링 중에 N+1이 다시 생겨도 빨리 잡힙니다.
3) 5분 해결: 가장 안전한 순서로 적용하기
N+1 해결은 “무조건 Fetch Join”이 아니라, 조회 목적과 데이터 형태에 맞춰 선택해야 합니다. 아래 순서대로 시도하면 실패 확률이 낮습니다.
3-1. 1순위: DTO 직접 조회(목록 API에 특히 강력)
엔티티 그래프를 그대로 반환하려는 습관이 N+1을 자주 만듭니다. 목록 화면은 보통 필요한 컬럼만 있으면 되므로 DTO로 바로 조회하는 것이 가장 안전합니다.
public record OrderRow(
Long orderId,
String memberName,
long itemCount
) {}
@Query("""
select new com.example.api.OrderRow(
o.id,
m.name,
count(oi.id)
)
from Order o
join o.member m
left join o.orderItems oi
where o.status = :status
group by o.id, m.name
""
)
List<OrderRow> findOrderRows(@Param("status") OrderStatus status);
장점
- N+1 구조 자체가 사라짐(단일 쿼리로 끝)
- 직렬화 이슈(순환참조), 지연로딩 예외 같은 부수 문제도 함께 감소
주의
count와group by가 들어가면 쿼리 플랜을 확인해야 함- 화면 요구가 복잡하면 DTO가 많아질 수 있음(하지만 운영 안정성은 보통 더 좋아짐)
3-2. 2순위: Fetch Join(단건 상세 조회에 적합)
상세 화면에서 Order 1건을 가져오면서 연관을 같이 가져오고 싶다면 Fetch Join이 직관적입니다.
@Query("""
select o from Order o
join fetch o.member
left join fetch o.orderItems
where o.id = :id
""
)
Optional<Order> findDetail(@Param("id") Long id);
주의 포인트가 있습니다.
- 컬렉션(
OneToMany) Fetch Join은 결과 row가 뻥튀기됩니다. 그래서 페이징과 궁합이 나쁩니다. - 컬렉션을 Fetch Join 한 상태에서
Pageable을 섞으면 경고가 뜨거나, 메모리에서 페이징하는 형태로 변질될 수 있습니다.
정리하면
- 단건 상세: Fetch Join OK
- 목록 + 페이징: DTO 조회 또는 다른 방법 권장
3-3. 3순위: @EntityGraph로 필요한 연관만 선언적으로 로딩
레포지토리 메서드에 “이 조회에서는 이 연관을 같이 가져온다”를 선언할 수 있습니다.
@EntityGraph(attributePaths = {"member"})
List<Order> findByStatus(OrderStatus status);
장점
- JPQL을 직접 쓰지 않고도 해결 가능
- 특정 조회에만 eager 로딩을 강제할 수 있어 부작용이 상대적으로 적음
주의
- 컬렉션까지 한 번에 가져오려 하면 Fetch Join과 동일한 페이징 문제가 다시 등장할 수 있음
3-4. 4순위: Batch Size로 “N+1을 1+N에서 1+ceil(N/batch)”로 줄이기
구조적으로 한 번에 조인해서 가져오기 어려운 경우(여러 컬렉션, 복잡한 그래프)에는 배치 로딩이 실용적입니다.
설정 예시입니다.
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
또는 연관 필드에만 지정할 수도 있습니다.
@BatchSize(size = 100)
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> orderItems = new ArrayList<>();
효과
- 주문 100건의 아이템을 로딩할 때,
order_id in (?, ?, ...)형태로 묶어서 가져옵니다. - 쿼리가 101번에서 2~3번으로 줄어드는 식의 개선이 흔합니다.
주의
- “완전한 제거”가 아니라 “완화”입니다.
- 배치 크기는 DB의
in리스트 제한, 쿼리 플랜, 네트워크 왕복 등을 고려해 조정해야 합니다.
3-5. 5순위: OSIV에 기대지 말고 트랜잭션 경계를 명확히
N+1은 종종 OSIV가 켜져 있을 때 더 늦게 발견됩니다. 컨트롤러에서 직렬화하는 순간 LAZY 로딩이 발생해 쿼리가 튀기 때문입니다.
가능하면 다음 방향을 권장합니다.
- API 계층은 DTO 반환
- 서비스 계층에서 필요한 연관을 “조회 시점에” 확정
- OSIV는 팀 정책에 따라 끄는 것을 검토
OSIV를 끄면 N+1이 “예외로 빨리 터져서” 오히려 조기 발견이 됩니다.
4) 흔한 함정 4가지(10분 내 추가 점검)
4-1. toString() / 로깅이 연관 로딩을 유발
엔티티에 Lombok @ToString을 붙이고 연관 필드를 포함하면, 로그 한 줄이 N+1을 만들 수 있습니다. 연관 필드는 @ToString.Exclude로 제외하세요.
4-2. JSON 직렬화가 프록시를 건드림
엔티티를 그대로 반환하면 Jackson이 getter를 호출하며 LAZY 로딩이 발생합니다. “엔티티 반환 금지, DTO 반환”이 가장 확실한 예방책입니다.
4-3. 컬렉션 Fetch Join + distinct의 착시
JPQL에서 select distinct o를 쓰면 “엔티티 중복 제거”는 되지만, DB row 뻥튀기는 그대로입니다. 네트워크/메모리는 이미 비용을 지불했을 수 있습니다.
4-4. EAGER로 바꿔서 해결하려는 시도
FetchType.EAGER는 N+1을 “다른 시점의 대량 조인/대량 로딩 문제”로 바꾸는 경우가 많습니다. 조회별로 제어 가능한 EntityGraph, Fetch Join, DTO 조회가 더 안전합니다.
5) 재발 방지 체크리스트
- 목록 API는 엔티티 반환 대신 DTO 조회를 기본값으로
- 페이징이 필요한 목록에서 컬렉션 Fetch Join을 피하기
- 반드시 필요한 연관만
EntityGraph또는 Fetch Join으로 명시 default_batch_fetch_size를 합리적으로 설정(예: 50~200부터 시작)- 스테이징에서 “요청당 쿼리 수”를 대시보드/로그로 관찰
N+1은 한 번 잡고 끝나는 문제가 아니라, 기능 추가 때마다 다시 생기기 쉬운 성격입니다. 위의 진단 루틴(로그로 반복 쿼리 찾기)과 해결 우선순위(DTO 조회 우선, 다음 Fetch Join, 그 다음 배치)를 팀 규칙처럼 굳혀두면 운영 장애로 커지기 전에 빠르게 차단할 수 있습니다.