JUN0.DEV
JUN0.DEV

JPA N+1 문제: 원인 분석부터 해결까지

Published on
  • avatarJunyoung Yang

카카오테크캠퍼스 선물하기 API를 구현하면서 상품, 옵션, 위시리스트처럼 연관 데이터가 있는 조회 API를 다뤘다. 단순히 기능이 동작하는 것만 보면 문제가 없어 보였지만, 실제 쿼리를 확인해보니 조회 한 번에 예상보다 많은 쿼리가 나갈 수 있었다.

JPA를 사용할 때 자주 만나는 N+1 문제였다. 처음에는 객체 그래프를 따라가면 자연스럽게 데이터가 조회된다고 느껴지지만, 실제로는 지연 로딩 시점마다 추가 쿼리가 발생할 수 있었다.

이 글은 N+1 문제가 왜 생기는지, 그리고 어떤 해결 방법을 어떤 기준으로 봤는지 정리한 기록이다.

처음 보인 문제

예를 들어 위시리스트 목록을 조회하면서 각 항목의 상품 정보를 함께 보여줘야 한다고 가정했다. 위시리스트 항목 N개를 조회한 뒤, 각 항목의 상품에 접근할 때 상품 조회 쿼리가 추가로 N번 나갈 수 있다.

처음에는 데이터가 정상적으로 보이기 때문에 문제를 놓치기 쉽다. 하지만 데이터가 늘어나면 요청 하나가 만드는 쿼리 수가 함께 증가한다. 응답 시간이 느려질 수 있고, DB에도 불필요한 부하가 생긴다.

그래서 기능 확인뿐 아니라 실제 SQL 로그를 함께 확인해야 했다.

해결 방안 1: Fetch Join

가장 먼저 볼 수 있는 방법은 Fetch Join이다. 필요한 연관 데이터를 한 번의 쿼리로 함께 가져올 수 있다.

Fetch Join은 명확하고 강력하지만, 항상 편한 선택은 아니었다. 페이징과 함께 사용할 때 제약이 생길 수 있고, 여러 컬렉션을 동시에 가져오면 결과가 불어나거나 중복 문제가 생길 수 있다.

그래서 단일 연관 객체를 명확히 함께 조회해야 하는 경우에는 좋지만, 모든 조회에 무작정 적용하기에는 조심해야 했다.

해결 방안 2: EntityGraph

EntityGraph는 조회 시점에 어떤 연관을 함께 가져올지 선언적으로 지정할 수 있는 방법이다. Repository 메서드에 적용해 필요한 연관만 가져오도록 만들 수 있다.

Fetch Join보다 쿼리 의도가 분리되어 보이고, 특정 조회 API에서 필요한 연관을 지정하기 좋았다. 프로젝트에서는 목록 조회에서 특정 연관만 함께 가져오고 싶을 때 EntityGraph를 후보로 봤다.

다만 EntityGraph도 결국 어떤 연관을 가져올지 명확히 알아야 한다. 무분별하게 적용하면 필요하지 않은 데이터까지 가져올 수 있다.

해결 방안 3: Batch Size

Batch Size는 지연 로딩을 유지하면서도 연관 엔티티를 일정 크기 단위로 묶어서 조회하는 방식이다. N개의 추가 쿼리를 1개 또는 몇 개의 쿼리로 줄일 수 있다.

이 방식은 모든 연관을 즉시 가져오고 싶지는 않지만, 지연 로딩이 반복될 가능성이 있는 경우에 도움이 된다. 다만 쿼리 수를 줄여주는 방식이지, 어떤 데이터를 명확히 함께 조회한다는 의도를 드러내는 방식은 아니다.

정리한 기준

해결 방법을 고를 때는 아래 기준을 봤다.

  • 이 API에서 항상 필요한 연관 데이터인가
  • 페이징과 함께 쓰는 조회인가
  • 컬렉션 연관을 함께 가져오는가
  • 쿼리 의도를 코드에서 명확히 드러내야 하는가
  • 전체 설정으로 완화할 문제인가, 특정 조회에서 해결할 문제인가

프로젝트에서는 조회 API의 목적이 명확한 경우 EntityGraph나 Fetch Join을 우선 검토하고, 반복적인 지연 로딩 완화에는 Batch Size를 함께 검토할 수 있다고 정리했다.

마무리

N+1 문제는 JPA를 쓰면 자동으로 해결되는 문제가 아니었다. 객체 그래프를 편하게 탐색할 수 있다는 장점 뒤에서 실제 SQL이 어떻게 나가는지 계속 확인해야 했다.

이번 경험을 통해 조회 API를 만들 때는 반환 데이터만 보는 것이 아니라, 그 데이터를 만들기 위해 어떤 쿼리가 몇 번 실행되는지도 함께 확인해야 한다는 기준이 생겼다.