사례

  • Order 100건을 조회한 뒤 order.getMember().getName()을 찍었더니 쿼리가 101번 나갔다.
  • Team 20건을 페이징으로 조회한 뒤 각 team의 members를 화면에 뿌리려다가 N+1이 발생했다.
  • 주문 목록 API에서 select order 1번 뒤에 select member where id=?가 주문 수만큼 반복됐다.
  • 화면에는 orderItems도 같이 보여줘야 했고, 페이징은 필수였다.

왜 N+1이 생겼는지

  • N+1은 부모 엔티티 1개를 조회한 뒤, 각 부모의 연관 엔티티를 조회하는 순간 추가 쿼리가 n번 발생해 총 n+1번이 되는 현상이다.
  • lazy loading에서 주로 발생하지만, eager도 조회 방식에 따라 불필요한 쿼리가 생길 수 있다.
  • 주문 1회 조회 이후 member 조회가 n번 반복되는 구조가 문제였다.
  • orderItem은 oneToMany 관계라 조회 방식에 따라 영향이 더 커졌다.

해결 선택지

  • ToOne N+1은 fetch join으로 보통 해결 가능하고 안전했다.
  • 같은 구간에서 EntityGraphbatch size를 주는 방법도 쓸 수 있었다.
  • Batch Fetching(@BatchSize, hibernate.default_batch_fetch_size)를 적용하는 선택지도 있었다.
  • 필요한 모양으로 한 번에 조회하는 DTO 프로젝션도 가능했다.

페이징과 fetch join 충돌 지점

  • 페이징 구간에서 fetch join을 쓰면 row가 부모*자식만큼 늘어날 수 있다.
  • 그래서 중복이나 메모리, 페이징 이슈가 같이 발생할 수 있다.
  • 이 경우 batch size를 쓰거나, 부모에서 memberIds를 조회한 뒤 IN 절로 다시 조회하는 방식으로 풀 수 있다.

정리

  • 문제의 핵심은 부모 조회 1번 뒤에 연관 조회가 n번 붙는 구조였다.
  • ToOne과 ToMany를 같은 방식으로 처리하면 페이징에서 바로 부딪힌다.
  • 조회 구간별로 fetch join, EntityGraph, batch, IN 재조회 중에서 선택해서 가져가야 한다.