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번의 쿼리가 나갔다.
여기서 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를 선택했다.
선택 이유
- 위시리스트는 한 사용자의 아이템만 조회해서 데이터가 많지 않다
- 페이징이 필요 없는 상황이었다
- Spring Data JPA의 메서드 이름 기반 쿼리를 유지하고 싶었다
각 방법의 적합한 상황
| 상황 | 추천 방법 |
|---|---|
| 페이징 없이 연관 엔티티가 필요할 때 | Fetch Join, EntityGraph |
| 페이징과 연관 엔티티가 둘 다 필요할 때 | Batch Size |
| 컬렉션 관계 (OneToMany) | Batch Size |
| 단일 관계 (ManyToOne) | Fetch Join, EntityGraph |
정리
FetchType.LAZY는 N+1을 없애는 게 아니라, 문제가 터지는 시점을 뒤로 미루는 것에 가깝다. 연관 엔티티에 접근하는 순간 쿼리가 나간다.- 그래서 개발할 땐 SQL 로그를 켜두고, 정상 요청 한 번에 쿼리가 몇 번 나가는지를 보는 습관이 도움이 됐다.
- 해결책은 하나로 고정하기보다, 페이징 여부, 관계 타입, 조회 범위를 기준으로 고르는 게 맞았다.
프로젝트: 카카오테크캠퍼스 3기 선물하기 API 클론 코딩(JDBC→JPA 마이그레이션) 관련 링크
- GitHub: Neibce/spring-gift-wishlist
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.
