- Published on
Spring Boot 3에서 JPA N+1 30분 해결 가이드
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 느려졌는데 CPU도 널널하고, DB 커넥션도 남아 있고, 쿼리 하나하나는 빠른데 전체 요청만 유독 느릴 때가 있습니다. 이때 가장 흔한 원인 중 하나가 JPA N+1입니다. Spring Boot 3(Hibernate 6)로 넘어오면서 설정 키나 동작이 미묘하게 달라져 “예전 방식대로 했는데 왜 그대로지?” 같은 함정도 자주 밟습니다.
이 글은 30분 안에 N+1을 재현 → 로그로 확정 → 원인(연관관계/지연로딩/직렬화) 분리 → 상황별 해결책 적용까지 가는 실전 루트를 제공합니다. 트랜잭션 경계가 꼬여서 지연 로딩이 예상치 않게 터지는 케이스도 함께 다룹니다. (참고: 트랜잭션이 무시되는 경우는 N+1 진단을 더 어렵게 만듭니다: Spring Boot 3에서 @Transactional 무시되는 7가지 원인)
0) N+1을 3분 만에 재현하는 최소 예제
가장 흔한 구조는 Order(1) : OrderItem(N) 또는 Post(1) : Comment(N)입니다.
엔티티 예시
@Entity
@Table(name = "orders")
public class Order {
@Id @GeneratedValue
private Long id;
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> items = new ArrayList<>();
// getter
}
@Entity
@Table(name = "order_items")
public class OrderItem {
@Id @GeneratedValue
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id")
private Order order;
private String sku;
// getter
}
문제를 만드는 코드 (서비스/컨트롤러)
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
@Transactional(readOnly = true)
public List<Order> findAllWithTouchingItems() {
List<Order> orders = orderRepository.findAll(); // 1번
// 여기서 LAZY 컬렉션 접근 -> 주문 개수만큼 추가 쿼리
orders.forEach(o -> o.getItems().size());
return orders;
}
}
findAll()로 주문 1번 조회 후, 각 주문의 items 접근 시 주문 개수만큼 쿼리가 추가로 나가면 전형적인 N+1입니다.
1) 5분 진단: “정말 N+1인지” 로그로 확정
(1) SQL 로그/바인딩 값 확인
Spring Boot 3에서 가장 많이 쓰는 조합은 아래입니다.
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: 실행 SQLorg.hibernate.orm.jdbc.bind: 바인딩 파라미터
이제 한 요청에서 비슷한 select ... from order_items where order_id=?가 반복되면 N+1 확정입니다.
(2) “어디서 컬렉션을 건드렸는지” 찾기
N+1은 대개 아래 지점에서 발생합니다.
- 서비스 로직에서
getItems()를 반복 호출 - DTO 변환 과정에서 연관 컬렉션 접근
- Jackson 직렬화가 엔티티 그래프를 따라가며 LAZY 로딩 유발
특히 컨트롤러가 엔티티를 그대로 반환하면(권장되지 않음) 직렬화 시점에 N+1이 터지기 쉽습니다.
2) 10분 해결: 가장 먼저 시도할 3가지 처방
N+1 해결은 “무조건 fetch join”이 아니라, **목적(화면/목록/상세/페이징/통계)**에 맞는 전략을 고르는 게 핵심입니다.
처방 A) fetch join (가장 즉효, 하지만 페이징/중복 주의)
한 번에 가져올 수 있는 화면(예: 주문 목록 + 아이템 간단 표시)이라면 fetch join이 가장 직관적입니다.
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("select distinct o from Order o left join fetch o.items")
List<Order> findAllFetchItems();
}
distinct는 JPA 레벨 중복 제거를 유도합니다(조인으로 인해Order가 중복 row로 나올 수 있음).- 컬렉션 fetch join은 페이징과 결합 시 위험합니다. (DB row 기준으로 페이징되어 결과가 깨질 수 있음)
페이징이 필요하면 아래 EntityGraph나 배치 페치를 우선 고려하세요.
처방 B) @EntityGraph (레포지토리 메서드에 얹기 쉬움)
public interface OrderRepository extends JpaRepository<Order, Long> {
@EntityGraph(attributePaths = "items")
@Query("select o from Order o")
List<Order> findAllWithItemsGraph();
}
- JPQL을 크게 바꾸지 않고도 fetch 전략을 바꿀 수 있어 유지보수에 유리합니다.
- 내부적으로 fetch join과 유사한 쿼리가 생성될 수 있습니다.
처방 C) Hibernate batch fetch (N+1을 1+N/batch로 줄이기)
목록에서 연관 컬렉션을 모두 조인으로 당기기 부담스럽거나(데이터 폭발), 페이징을 유지해야 하면 배치 페치가 현실적인 타협입니다.
전역 설정
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
또는 엔티티 단위 설정
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
@BatchSize(size = 100)
private List<OrderItem> items = new ArrayList<>();
이 방식은 주문 100개라면 items를 100번 조회하는 대신, in (...) 형태로 몇 번에 나눠 가져오게 만들어 쿼리 수를 급감시킵니다.
3) 5분 추가 점검: “N+1처럼 보이지만 다른 문제”들
(1) 트랜잭션 경계 밖 LAZY 접근
서비스에서 조회 후 컨트롤러/뷰 계층에서 LAZY 컬렉션을 건드리면 예측 불가능한 추가 쿼리(혹은 LazyInitializationException)가 납니다.
- 해결: 조회 + DTO 변환을 같은 트랜잭션 안에서 끝내기
- 또는 OSIV(Open Session In View) 의존을 줄이기
트랜잭션이 아예 적용되지 않는다면(프록시 미적용 등) 진단이 더 꼬입니다. 위 내부 링크 글을 함께 확인하는 게 좋습니다.
(2) Jackson 직렬화가 LAZY를 깨움
컨트롤러에서 엔티티를 그대로 반환하면:
order.items를 직렬화하려고 접근- LAZY 로딩 발생
- N+1 발생
해결은 간단합니다. 엔티티 반환 금지 + DTO 반환을 원칙으로 두세요.
4) 10분 완성: 실무에서 가장 안전한 패턴(DTO 조회)
목록 화면/검색 API는 엔티티 그래프를 그대로 노출하기보다, 필요한 컬럼만 DTO로 뽑는 게 성능과 안정성에 유리합니다.
(1) 주문 목록 + 아이템 개수만 필요할 때
public record OrderSummaryDto(Long orderId, Long itemCount) {}
public interface OrderQueryRepository {
List<OrderSummaryDto> findOrderSummaries();
}
@Repository
@RequiredArgsConstructor
public class OrderQueryRepositoryImpl implements OrderQueryRepository {
private final EntityManager em;
@Override
public List<OrderSummaryDto> findOrderSummaries() {
return em.createQuery(
"select new com.example.api.OrderSummaryDto(o.id, count(i.id)) " +
"from Order o left join o.items i " +
"group by o.id",
OrderSummaryDto.class
).getResultList();
}
}
- 컬렉션을 통째로 로딩하지 않고, 필요한 집계만 가져옵니다.
- N+1 자체가 구조적으로 발생할 여지가 줄어듭니다.
(2) 주문 목록 + 아이템 리스트까지 필요할 때(2-step 조회)
데이터가 큰 경우 한 방에 fetch join으로 당기면 row 폭발이 날 수 있습니다. 이때는 부모 먼저 페이징 → 자식 IN 조회로 나누는 패턴이 강력합니다.
public record OrderItemDto(Long orderId, Long itemId, String sku) {}
@Transactional(readOnly = true)
public Map<Long, List<OrderItemDto>> findItemsByOrderIds(List<Long> orderIds) {
List<OrderItemDto> rows = em.createQuery(
"select new com.example.api.OrderItemDto(o.id, i.id, i.sku) " +
"from OrderItem i join i.order o " +
"where o.id in :ids",
OrderItemDto.class
).setParameter("ids", orderIds)
.getResultList();
return rows.stream().collect(Collectors.groupingBy(OrderItemDto::orderId));
}
- 페이징은
Order조회에서 안정적으로 처리 - 자식은
IN (:ids)로 한 번(또는 몇 번)만 조회 - API 응답을 조립하기 쉽고 예측 가능한 성능이 나옵니다
5) 30분 체크리스트: 상황별 처방 선택 가이드
목록(페이징 없음), 연관 데이터도 함께 필요
- 1순위:
fetch join또는EntityGraph
목록(페이징 필요), 연관 컬렉션 접근이 필요
- 1순위:
default_batch_fetch_size/@BatchSize - 2순위: 부모 페이징 + 자식 IN 조회(2-step)
상세(단건), 연관 데이터가 확실히 필요
- 1순위:
fetch join(단건이라 안전)
API 응답이 DTO로 고정되고 성능이 중요
- 1순위: DTO 직접 조회(JPQL constructor, Querydsl 등)
6) 마무리: N+1은 “쿼리 수”가 아니라 “설계 신호”다
N+1은 단순히 쿼리를 합치면 끝나는 문제가 아니라,
- 엔티티를 어디까지 로딩할지
- 트랜잭션 경계를 어디로 둘지
- API 응답을 엔티티로 노출하지 않을지
- 페이징/정렬 요구사항을 어떻게 만족할지
같은 설계 선택이 누적되어 터지는 증상입니다.
오늘 바로 적용할 수 있는 최소 루트는 이겁니다.
- SQL 로그로 반복 쿼리 패턴 확인
- 발생 지점(서비스/DTO 변환/직렬화) 분리
- 단건/목록/페이징 여부에 맞춰
fetch join/EntityGraph/ batch fetch / DTO 조회 중 하나를 선택 - 트랜잭션이 기대대로 적용되는지 점검(특히 프록시/호출 구조)
이 4단계를 따르면 “원인 모를 느림”에서 “재현 가능한 성능 문제”로 바뀌고, 해결까지 걸리는 시간이 급격히 줄어듭니다.