핵심 한 줄
주문 목록을 한 번 조회했을 뿐인데 물리적으로는 쿼리가 1+N번 나간다. 주문 3개면 4번, 1000개면 1001번 — 데이터가 늘수록 선형으로 폭증하는 성능 절벽이다.
em.clear()로 캐시를 비우고 HibernateStatistics로 **실행된 SQL 문 수(4 vs 1)**를 직접 세어 목격하고,join fetch distinct로 한 방에 끝낸다.
시리즈 — 커머스 백엔드를 카타로 쌓다 (계속 연재)
00.카타 서문 · 01.돈을 primitive로 두면 언젠가 샌다 · 02.불변식은 누가 지키나 · 03.동시에 사면 초과판매된다 · 04.두 번 취소해도 한 번만 · 05.save가 INSERT가 아니었다 · 06.기다리게 하지 말고 거절하라 · 07.트랜잭션 안에서 HTTP를 부르지 마라 · 08.응답을 못 받았다는 실패가 아니다 · 09.요청 하나에 쿼리 1+N · 10.5만 행을 1행으로 · 11.같은 걸 두 번 묻지 않기 · 12.가짜는 내가 시킨 답만 한다 · 13.분명히 잡았는데 터졌다 · 14.5개 주세요 했는데 5만 개를 읽었다 · 15.순수하게 만들었더니 저장이 사라졌다 · 16.설정 한 줄 바꿨더니 세 번 터졌다 · … 그리고 계속
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가 왜 필요한가. Order와 OrderLine을 조인하면 결과 행이 자식 수만큼 뻥튀기된다. 주문 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만 행을 풀스캔하고 있다면? 그때는 쿼리 수가 아니라 쿼리 안을 들여다봐야 한다. 실행계획을 읽을 차례다.