핵심 한 줄

주문 목록을 한 번 조회했을 뿐인데 물리적으로는 쿼리가 1+N번 나간다. 주문 3개면 4번, 1000개면 1001번 — 데이터가 늘수록 선형으로 폭증하는 성능 절벽이다. em.clear()로 캐시를 비우고 Hibernate Statistics로 **실행된 SQL 문 수(4 vs 1)**를 직접 세어 목격하고, join fetch distinct로 한 방에 끝낸다.

시리즈 — 커머스 백엔드를 카타로 쌓다 (계속 연재)


1. 배경 — 주문 하나에 라인 여럿

주문(Order)은 여러 개의 주문 라인(OrderLine)을 가진다. 1:N이다. 이걸 진짜 JPA 연관관계로 묶는다.

@Entity
@Table(name = "orders")   // 'order'는 MySQL 예약어(ORDER BY) → 테이블명 변경 필수
public class Order {
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<OrderLine> orderLineList = new ArrayList<>();
}
 
@Entity
public class OrderLine {
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "order_id")
    private Order order;
}

@OneToMany기본이 LAZY다. 즉 Order를 조회해도 orderLineList는 아직 DB에서 읽지 않는다. 프록시만 걸어두고, 실제로 그 리스트를 만지는 순간 SELECT를 날린다. 편해 보인다. 문제는 이 “만지는 순간”이 루프 안에 있을 때다.

2. 문제 — 논리적 1, 물리적 1+N

주문 “목록 하나”를 화면에 뿌린다고 하자. 각 주문의 라인 개수를 세는 평범한 코드다.

List<Order> orders = orderRepository.findAll();   // (1) 주문 목록 쿼리 1번
for (Order o : orders) {
    lineTotal += o.getOrderLineList().size();      // (N) 주문마다 라인 SELECT
}

findAll()주문만 가져온다. 여기까지 쿼리 1번. 그런데 루프에서 o.getOrderLineList()를 만지는 순간, LAZY 프록시가 깨어나 그 주문의 라인을 별도로 SELECT한다. 주문마다 한 번씩.

  • 주문 3개 → 1 + 3 = 4쿼리
  • 주문 1000개 → 1 + 1000 = 1001쿼리

내 코드에는 반복문이 하나뿐인데, DB로는 데이터 개수에 비례해 쿼리가 나간다. 이게 N+1이다. 논리적으로는 요청 1번인데 물리적으로는 1+N번. 데이터가 늘수록 선형으로 폭증하는 성능 절벽이다.

3. 목격의 기술 — 캐시를 비우고 SQL을 센다

여기서 함정이 하나 있다. 그냥 테스트를 짜면 N+1이 안 보인다. 방금 save()한 엔티티가 영속성 컨텍스트(1차 캐시)에 그대로 살아있기 때문이다. 라인이 이미 메모리에 있으니 재조회해도 DB로 쿼리가 안 나간다.

그래서 조회 전에 캐시를 강제로 비워야 진짜 DB를 때린다.

em.flush();   // 쌓인 INSERT를 DB로 밀어냄
em.clear();   // ★ 1차 캐시 비움 → 이후 조회는 진짜 DB로 (안 하면 N+1이 안 보임)

그리고 “쿼리가 나갈 것이다”라는 추측 대신, Hibernate Statistics실제 실행된 SQL 문 수를 센다.

Statistics stats = emf.unwrap(SessionFactory.class).getStatistics();
stats.setStatisticsEnabled(true);
stats.clear();
 
List<Order> orders = orderRepository.findAll();
for (Order o : orders) {
    lineTotal += o.getOrderLineList().size();
}
 
long queryCount = stats.getPrepareStatementCount();
// >>> 주문수=3 라인합=6 실행된SQL문수=4

주문수=3, 실행된SQL문수=4. 추측이 아니라 측정이다. 1(주문) + 3(라인) = 4. N+1을 눈으로 목격했다.

4. 결정 — fetch join으로 한 방에

부모와 자식을 한 번의 쿼리로 같이 읽어오면 된다. JPQL의 join fetch다.

@Query("select distinct o from Order o join fetch o.orderLineList")
List<Order> findAllWithLines();

join fetch는 연관 엔티티(orderLineList)까지 즉시 함께 로딩한다. LAZY를 이 쿼리 한정으로 즉시 로딩으로 바꾸는 셈이다. 그래서 나중에 라인을 만져도 추가 SELECT가 없다.

distinct가 왜 필요한가. OrderOrderLine을 조인하면 결과 행이 자식 수만큼 뻥튀기된다. 주문 1개에 라인 2개면 조인 결과는 2행이고, 같은 Order가 2번 나온다. distinct가 이 중복 Order 객체를 걷어낸다.

같은 목격 코드를 findAllWithLines()로 바꿔 다시 센다.

List<Order> orders = orderRepository.findAllWithLines();
// >>> 주문수=3 라인합=6 실행된SQL문수=1

실행된SQL문수=1. 4에서 1로. 주문이 1000개든 100만 개든 이 쿼리는 여전히 1이다.

5. 실무 경고 — fetch join의 함정

만능은 아니다. 컬렉션 fetch join에는 함정이 있다.

  • 페이징과 같이 쓰면 위험하다. 컬렉션을 fetch join하면 결과가 뻥튀기되므로, Hibernate는 DB에서 LIMIT으로 자르지 못한다. 대신 전부 메모리로 읽어들인 뒤 메모리에서 자른다. 로그에 HHH000104: firstResult/maxResults specified with collection fetch; applying in memory 경고가 뜨면 이거다. 데이터가 크면 그대로 OOM으로 간다.
  • 컬렉션을 둘 동시에 fetch join하면 MultipleBagFetchException이 터진다. 카테시안 곱이 감당 안 되기 때문이다.

대안이 있다.

  • @EntityGraph로 fetch 전략을 선언적으로 지정.
  • default_batch_fetch_size(또는 @BatchSize) — LAZY를 유지하되, 라인을 주문마다 하나씩이 아니라 IN (?, ?, ...)으로 한 방에 모아 읽는다. 1+N이 1+1로 줄고, 페이징과도 안전하게 공존한다.

즉 fetch join은 단건·소수 목록엔 최적, 페이징 목록엔 batch fetch가 정석이다.

6. 원리 — ORM은 쿼리를 조용히 감춘다

핵심은 관점이다. “요청당 쿼리 수”라는 축으로 코드를 보면, 논리적 1이 물리적 1+N임이 드러난다. 반복문 안에서 연관 엔티티를 만지는 코드는 전부 이 냄새를 풍긴다.

ORM은 SQL을 안 써도 되게 해준다는 점에서 편하다. 하지만 그 편함이 쿼리가 몇 번 나가는지를 감춘다. 객체 그래프를 자연스럽게 순회하는 코드가, DB 입장에서는 폭격이다. ORM을 쓴다는 건 이 감춰진 쿼리를 의식적으로 되짚을 책임을 진다는 뜻이다. Statistics로 세어보는 습관 하나가 그 책임을 갚는다.

그럼 그 “한 방 쿼리”가 이번엔 느리면? 조인 하나에 5만 행을 풀스캔하고 있다면? 그때는 쿼리 수가 아니라 쿼리 안을 들여다봐야 한다. 실행계획을 읽을 차례다.

10.5만 행을 1행으로