우당탕탕
JPA N+1 문제 실제 발생 상황과 제가 해결한 방법 경험담 본문
처음 JPA를 쓸 때는 N+1 문제가 뭔지도 몰랐는데, 개발하다 보니 어느 순간 쿼리가 엄청 많이 나가서 성능이 떨어지는 걸 겪었어요. 이걸 해결하려고 이것저것 시도하다가 결국 방법을 찾은 경험을 공유하려고 합니다.
글에서는 실제 제가 겪은 N+1 문제 상황, 원인 분석, 그리고 해결을 위해 적용한 코드와 설정까지 차근차근 풀어볼게요.
JPA N+1 문제 실제 발생 상황과 해결 방법 관련 정보
개발 환경 / 버전 정보
제가 사용한 환경은 Java 17, Spring Boot 3.1.4, 그리고 JPA (Hibernate 6.2)입니다. DB는 MySQL 8.0을 썼고요.
이렇게 N+1 문제가 쉽게 발생했습니다
사실 N+1 문제는 단순히 연관관계 매핑한다고 무조건 생기거든요. 제가 만든 주문-상품 연관관계 코드가 그랬어요. 주문 목록 10개를 조회하는데, 각 주문의 상품 목록을 지연로딩(LAZY)으로 설정해뒀거든요.
그런데 실제로 API를 호출해보니 주문 10개를 가져올 때, 추가로 상품을 조회하는 쿼리가 10번 더 나가는 걸 로그로 봤어요. 총 11번 쿼리가 나가니 데이터가 많아지면 성능 엄청 떨어지겠더라고요.
// 주문 엔티티 주문 - 상품 연관관계 예시
@Entity
public class Order {
@Id
private Long id;
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List orderItems = new ArrayList<>();
// ... 기타 필드 및 메서드
}
@Entity
public class OrderItem {
@Id
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id")
private Order order;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "product_id")
private Product product;
// ... 기타 필드
}
이 부분에서 막혔는데, fetch를 LAZY로 해놓으니 주문 조회 후에 각 주문 아이템, 그리고 아이템에 연결된 상품까지 가는 쿼리가 계속 나갔던 거예요.
저는 이렇게 문제를 해결했습니다
가장 먼저 시도한 게 JPA의 fetch join이었어요. 쿼리를 직접 짤 수 있는 Repository 메서드에서 아래처럼 했죠.
@Query("select o from Order o join fetch o.orderItems oi join fetch oi.product where o.id in :ids")
List findOrdersWithItemsAndProducts(@Param("ids") List ids);
이렇게 하니 한방 쿼리로 주문, 주문 아이템, 상품까지 한번에 가져올 수 있었어요. 실제 쿼리 개수가 11번에서 1번으로 줄어서 엄청 쾌적해졌죠.
다만 fetch join 쓸 때 주의할 점도 있어서, 너무 많이 join 하면 쿼리 결과가 중복되거나 데이터가 부풀릴 수 있다는 점이에요. 그래서 distinct 키워드로 중복 제거하거나 DTO 프로젝션도 고민했는데, 먼저는 fetch join만으로 큰 효과 봤습니다.
여기서 막혔던 부분과 해결 과정
한참 헤맸던 게, fetch join을 걸었는데도 쿼리 수가 줄지 않는 경우였어요. 알고 보니 페이징 쿼리와 fetch join이랑 같이 쓸 때 Hibernate가 경고 주면서 join된 결과를 제대로 페이징 못 하더라고요.
org.hibernate.QueryException: query specified join fetching, but the owner entity has a bag fetch...
이 에러 메시지 보고 한참 고민했는데, JPA는 컬렉션(fetch join)과 페이징을 같이 쓰면 안 된다는 걸 깨달았죠. 그래서 페이징은 쿼리를 분리해서 먼저 Order id 목록만 조회하고, 그 id 리스트로 fetch join 쿼리 따로 실행하는 식으로 해결했습니다.
// 1. 페이징용 주문 ID 조회
@Query("select o.id from Order o order by o.orderDate desc")
Page findOrderIds(Pageable pageable);
// 2. 주문 ID 리스트로 fetch join 조회
@Query("select distinct o from Order o join fetch o.orderItems oi join fetch oi.product where o.id in :ids")
List findOrdersWithItemsAndProducts(@Param("ids") List ids);
두 단계로 나누니까 페이징도 되고, N+1 문제도 해결되니 실제 서비스 수준에 딱 맞는 성능이 나왔어요.
이것도 알면 좋은 팁들
JPA의 N+1 문제는 fetch join만으로 다 해결 안 되는 경우가 많아서, 상황에 따라 다음 방법도 고려해보세요.
- 엔티티 그래프(EntityGraph) 사용해 필요한 연관관계만 선택적으로 fetch
- DTO 프로젝션으로 원하는 데이터만 쿼리해서 가져오기
- 배치 사이즈(Batch Size) 설정으로 지연로딩 시 쿼리 횟수 줄이기
- QueryDSL 같은 도구로 복잡한 쿼리를 깔끔하게 작성하기
자주 물어보시는 것들
Q. 페이징도 하고 N+1 문제도 걱정된다면 어떻게 해야 하나요?
A. 네, 페이징과 fetch join은 잘 안 맞아서 보통 두 단계로 쿼리를 나누는 방식을 씁니다. 먼저 ID만 페이징 조회하고, 그 ID 리스트로 fetch join하는 식이죠.
Q. Batch Size 설정은 어떻게 하는 건가요?
A. application.properties에 spring.jpa.properties.hibernate.default_batch_fetch_size=100 같은 설정을 하면 LAZY 로딩 시 N+1 쿼리를 최대 100개 묶어서 한꺼번에 날립니다.
JPA N+1 문제 실제 발생 상황과 해결 방법 관련 정보
결국 N+1 문제는 JPA를 쓰는 대부분 프로젝트에서 한 번쯤 겪는 이슈인데요, fetch join과 페이징 분리, 엔티티 그래프 등 여러 방법을 조합해 해결할 수 있더라고요. 저도 직접 겪으면서 배운 내용이라 여러분께도 도움이 될 것 같아요.
'Tech > Spring' 카테고리의 다른 글
| Spring Boot JWT 로그인 구현하다 막힌 부분과 해결한 경험담 (0) | 2026.05.18 |
|---|---|
| Spring Batch 처음 써보면서 겪은 시행착오와 해결 과정을 공유합니다 (0) | 2026.05.18 |
| [Spring] Spring Boot 가상 스레드(Virtual Threads) 적용하기 2편 -@Async + JMeter 고급 벤치마킹 (0) | 2025.12.31 |
| [Spring] Spring Boot 가상 스레드(Virtual Threads) 적용하기 1편 (0) | 2025.12.31 |
| [Spring Boot] Spring Security + Redis로 DDoS 방어 구축하기 (0) | 2025.12.17 |
