JPA N+1 — fetch join은 정답이 아니다
fetch join을 쓰면 N+1은 사라지지만, paging이 깨지고 distinct가 필요해진다. BatchSize와의 진짜 차이를 정리한다.
문제 상황
주문 목록 화면에서 갑자기 응답이 8초로 늘었다는 알람이 왔다. 페이지당 20건을 보여주는 단순한 화면이었는데, 쿼리 로그를 켜 보니 한 페이지를 그릴 때마다 SELECT 쿼리가 60번 넘게 나가고 있었다. 우리 모두가 한 번씩은 만나는, 이름도 친절하게 붙여둔 N+1 문제다.
첫 본능은 당연히 fetch join이다. 사람들이 가장 먼저 권하는 답이고, 실제로 쿼리는 1개로 줄어든다. 그런데 그게 정말로 정답인가? 이 글은 그 질문에 대한 1년간의 시행착오를 정리한 것이다.
LAZY 로딩과 N+1
엔티티는 다음과 같이 정의되어 있다. 모든 연관관계는 LAZY로 잡혀있다 — 그게 좋은 기본값이다.
@Entitypublic class Order { @Id @GeneratedValue private Long id; @ManyToOne(fetch = LAZY) private Member member; @OneToMany(mappedBy = "order") private List<OrderItem> items = new ArrayList<>();}그러면 주문 20개를 가져오는 코드를 보자. orders.forEach(o -> o.getItems().size()) 같은 식으로 컬렉션을 한 번이라도 건드리면, 그 순간 20번의 추가 쿼리가 나간다. 이게 N+1이다.
fetch join의 한계
JPQL에서 JOIN FETCH를 쓰면 단일 쿼리로 묶을 수 있다. 그런데 다음 코드에는 두 가지 함정이 있다.
SELECT DISTINCT oFROM Order oJOIN FETCH o.itemsWHERE o.status = 'PAID'ORDER BY o.createdAt DESC페이징과의 충돌
setMaxResults(20)을 호출해도 SQL에 LIMIT이 붙지 않는다. 대신 모든 행을 가져와서 애플리케이션 메모리에서 잘라낸다. 데이터가 100만 건이라면? 100만 건이 다 올라오고, 그중 20건만 반환한다. 운영 환경에서 이건 사고다.
BatchSize 전략
그래서 보통은 다음 절충안으로 간다 — ToOne 관계만 fetch join 하고, ToMany는 BatchSize로 묶는다.
spring: jpa: properties: hibernate: default_batch_fetch_size: 100 # 조회 시 한 번에 100개씩 IN으로 묶어 가져옴정리
결국 정답은 "fetch join이 정답이 아니라, 상황에 맞는 도구를 골라 쓰는 게 정답"이라는 평범한 결론이다. ToOne은 fetch join, ToMany는 BatchSize, 페이징이 진짜 필요한 곳에는 별도 쿼리. 셋을 머릿속에 두고 코드를 짜면 N+1은 거의 만나지 않는다.