포스트

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

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

문제 상황

카카오테크캠퍼스에서 선물하기 API를 JDBC에서 JPA로 마이그레이션하다가 위시리스트 조회 기능에서 N+1 문제를 겪었다.

엔티티 구조

  • WishlistItem 엔티티가 Product@ManyToOne(fetch = FetchType.LAZY)로 참조하고 있었다

발생한 쿼리

위시리스트 아이템이 10개였을 때:

  • 1번: SELECT * FROM wishlist_item WHERE member_uuid = ?
  • 10번: SELECT * FROM product WHERE id = ? (각 아이템마다 한 번씩)

총 11번의 쿼리가 나갔다.

np1.png

여기서 N+1이라는 이름이 붙는 이유가 보였다. 처음 1번 조회한 다음, 연관 엔티티에 접근하는 시점에 N번이 추가로 나간다.

해결 방법 후보들

1. Fetch Join

JPQL에서 JOIN FETCH를 사용해 연관 엔티티를 한 번에 조회하는 방법이다.

1
2
@Query("SELECT w FROM WishlistItem w JOIN FETCH w.product WHERE w.member.uuid = :memberUuid")
List<WishlistItem> findByMemberUuidWithProduct(@Param("memberUuid") UUID memberUuid);
  • 장점: 쿼리 1번으로 끝난다
  • 단점: 페이징을 쓰기 어렵고, 다대다 관계에선 카테시안 곱이 생길 수 있다

2. EntityGraph

@EntityGraph(attributePaths = {"product"})로 함께 조회할 엔티티를 명시하는 방법이다.

1
2
@EntityGraph(attributePaths = {"product"})
List<WishlistItem> findByMemberUuid(UUID memberUuid);
  • 장점: 메서드 이름 기반 쿼리와 함께 쓸 수 있다
  • 단점: Fetch Join과 비슷한 한계가 있다

3. Batch Size

hibernate.default_batch_fetch_size로 IN 절을 사용해 연관 엔티티를 묶어서 조회하는 방법이다.

1
spring.jpa.properties.hibernate.default_batch_fetch_size=100
  • 장점: 페이징과 함께 쓸 수 있다
  • 단점: 쿼리 수가 (N / batch_size) + 1개라서, 1회로 정확히 떨어지진 않는다

최종 선택: EntityGraph

이 상황에서는 EntityGraph를 선택했다.

선택 이유

  1. 위시리스트는 한 사용자의 아이템만 조회해서 데이터가 많지 않다
  2. 페이징이 필요 없는 상황이었다
  3. Spring Data JPA의 메서드 이름 기반 쿼리를 유지하고 싶었다

각 방법의 적합한 상황

상황추천 방법
페이징 없이 연관 엔티티가 필요할 때Fetch Join, EntityGraph
페이징과 연관 엔티티가 둘 다 필요할 때Batch Size
컬렉션 관계 (OneToMany)Batch Size
단일 관계 (ManyToOne)Fetch Join, EntityGraph

정리

  • FetchType.LAZY는 N+1을 없애는 게 아니라, 문제가 터지는 시점을 뒤로 미루는 것에 가깝다. 연관 엔티티에 접근하는 순간 쿼리가 나간다.
  • 그래서 개발할 땐 SQL 로그를 켜두고, 정상 요청 한 번에 쿼리가 몇 번 나가는지를 보는 습관이 도움이 됐다.
  • 해결책은 하나로 고정하기보다, 페이징 여부, 관계 타입, 조회 범위를 기준으로 고르는 게 맞았다.

프로젝트: 카카오테크캠퍼스 3기 선물하기 API 클론 코딩(JDBC→JPA 마이그레이션) 관련 링크

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.