이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • N+1 문제는 왜 발생하는가?
  • Fetch join, EntityGraph, batch size는 각각 언제 쓰는가?
  • N+1을 해결하다가 pagination과 중복 row 문제가 생기는 이유는 무엇인가?

개요

N+1은 ORM을 사용하는 백엔드 개발자가 반드시 알아야 하는 문제다. 한 번의 목록 조회 후 각 행의 연관 객체를 접근하면서 추가 쿼리가 N번 실행되는 현상이다.

N+1은 단일 쿼리가 느린 문제가 아니라 많은 작은 쿼리가 합쳐져 API를 느리게 만드는 문제다.

원리

다음 코드를 보자.

List<Order> orders = orderRepository.findByMemberId(memberId);
for (Order order : orders) {
    order.getItems().size();
}

처음 주문 목록을 가져오는 SQL이 1번, 각 주문의 items를 가져오는 SQL이 주문 수만큼 실행될 수 있다.

select * from orders where member_id = ?
select * from order_items where order_id = ?
select * from order_items where order_id = ?
select * from order_items where order_id = ?

이것이 N+1이다.

Fetch Join

fetch join은 연관 데이터를 한 SQL로 함께 가져온다.

@Query("""
    select o
    from Order o
    join fetch o.member
    where o.id = :orderId
""")
Optional<Order> findWithMember(Long orderId);

단건 상세 조회에서 ManyToOne 연관을 함께 가져올 때 유용하다.

ManyToOne, OneToOne처럼 결과 row가 크게 늘지 않는 연관은 fetch join의 효과가 좋은 편이다. 반대로 OneToMany 컬렉션은 부모 row가 자식 수만큼 복제된다. N+1을 없앴는데 DB에서 읽는 row가 폭증하면 다른 병목이 생긴다.

컬렉션 fetch join은 row 폭증과 pagination 위치를 먼저 계산해야 한다.

@Query("""
    select distinct o
    from Order o
    join fetch o.items
    where o.member.id = :memberId
""")
List<Order> findOrdersWithItems(Long memberId);

주문 항목 수만큼 row가 늘어난다. pagination과 함께 쓰면 메모리에서 잘라지거나 경고가 발생할 수 있다.

목록에서 컬렉션까지 필요하다면 다음처럼 두 단계 조회가 더 안정적인 경우가 많다.

List<Long> orderIds = orderRepository.findPageIds(memberId, pageable);
List<Order> orders = orderRepository.findByIdInWithItems(orderIds);

첫 쿼리는 부모 id만 정렬과 limit으로 안정적으로 자르고, 두 번째 쿼리는 필요한 부모들의 컬렉션을 가져온다. 순서 보존과 중복 제거는 애플리케이션에서 명시적으로 처리한다.

EntityGraph

EntityGraph는 조회 시점에 fetch 계획을 선언적으로 지정할 수 있다.

@EntityGraph(attributePaths = {"member"})
List<Order> findByStatus(OrderStatus status);

Repository 메서드에 적용하기 쉽고, fetch join보다 메서드 조합이 자연스러운 경우가 있다. 그러나 생성 SQL은 반드시 확인해야 한다.

Batch Size

Batch fetching은 lazy loading을 완전히 없애지 않고 여러 연관 조회를 묶는다.

spring.jpa.properties.hibernate.default_batch_fetch_size=100

N개의 개별 조회를 줄여 where id in (...) 형태로 가져올 수 있다. 컬렉션 pagination과 함께 fetch join이 어렵다면 batch size가 현실적인 대안이 된다.

엔티티별로 @BatchSize를 둘 수도 있다.

@OneToMany(mappedBy = "order")
@BatchSize(size = 100)
private List<OrderItem> items = new ArrayList<>();

batch size가 너무 크면 한 번에 읽는 row와 SQL 파라미터가 늘어난다. 너무 작으면 round trip 감소 효과가 약하다. API 응답 크기, DB 파라미터 제한, 평균 컬렉션 크기를 함께 본다.

DTO Query

API 응답에 필요한 데이터만 조회하는 DTO projection도 좋은 선택이다.

@Query("""
    select new com.example.OrderSummary(o.id, o.status, m.email)
    from Order o
    join o.member m
    where o.member.id = :memberId
    order by o.createdAt desc
""")
List<OrderSummary> findOrderSummaries(Long memberId, Pageable pageable);

엔티티 그래프를 모두 로딩하지 않고 화면에 필요한 컬럼만 가져오므로 목록 API에 적합할 수 있다.

선택 기준

상황별 출발점은 다음처럼 잡을 수 있다.

상황우선 검토
단건 상세에서 작성자, 상태 같은 ManyToOne 필요fetch join 또는 EntityGraph
목록에서 화면 컬럼만 필요DTO projection
목록에서 컬렉션 개수만 필요집계 쿼리 또는 별도 count projection
목록에서 일부 컬렉션까지 필요id page 후 2차 조회, batch fetch
동적 검색 조건이 많음Querydsl/명시적 JPQL + 인덱스 우선순위

중요한 것은 “쿼리 수”만 보지 않는 것이다. 해결 전후로 SQL 수, 총 읽은 row 수, 응답 p95, DB CPU, 메모리 사용량을 비교한다.

실전 팁

  • 단건 상세는 fetch join이 단순하다.
  • 목록 API는 DTO query와 batch fetch를 우선 검토한다.
  • 컬렉션 fetch join과 pagination 조합은 조심한다.
  • SQL 로그에서 쿼리 횟수를 확인한다.
  • N+1 해결 후에도 실행 계획과 row 수를 본다.
  • 컬렉션 fetch join이 필요하면 pagination과 중복 제거가 어디에서 일어나는지 확인한다.
  • default_batch_fetch_size는 전역 설정이므로 특정 API 최적화가 다른 API의 SQL 모양까지 바꾸지 않는지 테스트한다.

위험 신호!

  • API 하나에서 비슷한 select가 수십 번 반복된다.
  • 컬렉션 fetch join에 Pageable을 붙인다.
  • distinct로 중복만 숨기고 row 폭증을 보지 않는다.
  • 모든 연관관계를 EAGER로 바꾼다.
  • JSON 직렬화 중 lazy loading이 발생한다.
  • 쿼리 횟수만 줄이고 조인 결과 row 폭증을 확인하지 않는다.
  • 목록 API에서 엔티티 전체와 컬렉션을 가져온 뒤 DTO로 버린다.

확인 질문

  • N+1은 왜 발생하는가?
    • 목록 조회 후 각 엔티티의 lazy 연관을 접근하면서 연관 조회 SQL이 엔티티 수만큼 추가 실행되기 때문이다.
  • 컬렉션 fetch join과 pagination이 위험한 이유는 무엇인가?
    • 조인으로 row가 컬렉션 수만큼 늘어나 DB pagination과 엔티티 중복 제거가 어긋날 수 있기 때문이다.
  • DTO query가 목록 API에 유리한 이유는 무엇인가?
    • API 응답에 필요한 컬럼만 명시적으로 조회해 불필요한 엔티티 로딩과 연관 탐색을 줄일 수 있기 때문이다.
  • N+1 해결 전후에 쿼리 수 외에 무엇을 비교해야 하는가?
    • 총 읽은 row 수, 실행 계획, 응답 p95, DB CPU/I/O, 메모리 사용량, pagination 안정성을 함께 비교해야 한다.

참고 문서