| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 | 31 |
- list 컬렉션
- Stack
- Serialization
- datastructure
- Selection Sorting
- coding test
- buffer
- C programming
- JSON
- insertion sort
- 메모리구조
- 윤성우 열혈자료구조
- s
- stream
- 이것이 자바다
- 알기쉬운 알고리즘
- 이스케이프 문자
- 윤성우의 열혈 자료구조
- 혼자 공부하는 C언어
- Algorithm
- C 언어 코딩 도장
- Graph
- R
- Today
- Total
Engineering Note
[SW Engineering] 이커머스 프로젝트 주문내역 조회 N+1 문제 해결 과정 본문
[SW Engineering] 이커머스 프로젝트 주문내역 조회 N+1 문제 해결 과정
Software Engineer Kim 2025. 10. 21. 16:56Native Query로 작성한 코드를 QueryDsl로 리팩토링하는 과정에서 테스트코드를 작성하고 성능 측정하는 상황에서 N+1문제 상황을 발견하여 문제를 해결한 과정을 정리하려고 한다.
문제 상황을 먼저 소개하면 사용자가 주문내역을 조회 하는 코드에서 여러 테이블을 조인하고 있었고 이 과정에서 쿼리가 예상보다 많이 요청되는 JPA의 N+1문제가 발생해서 코드를 개선한 과정을 정리하였습니다. N+1문제를 통한 현재의 직접적인 성능의 문제는 없었지만, 데이터가 늘어나면 결국 Disk I/O로 인한 성능 저하 문제가 발생할 것으로 예상하여 코드를 개선하기로 결정하였습니다.
목차
1. 문제 상황
2. N+1 문제 발생 원인
3. 1차 해결 시도(실패)
4. 최종 해결 방법
5. 성능 테스트
1. 문제 상황
1.1 요구사항
주문 내역 조회를 위해서는 다음 테이블의 정보를 취합해야 한다.
- Order
- OrderItem
- Item
- ItemImg
1.2 엔티티 관계
Order (1) ──< (N) OrderItem (N) >── (1) Item (1) ──< (N) ItemImg
2. N+1 문제 발생 원인
2.1 초기 코드
@Override
public List<Order> findOrdersByEmail(String email, Pageable pageable) {
return queryFactory
.selectFrom(order)
.where(order.member.email.eq(email))
.orderBy(order.orderDate.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch(); // Order만 조회
}
@Transactional(readOnly = true)
public Page<OrderHistDto> getOrderList(String email, Pageable pageable) {
// 1. Order 조회 (1번)
List<Order> orders = orderRepository.findOrdersByEmail(email, pageable);
// 2. 전체 개수 조회 (1번)
Long totalCount = orderRepository.countOrdersByEmail(email);
List<OrderHistDto> orderHistDtoList = new ArrayList<>();
// 3. N+1 문제 발생 구간
for(Order order : orders){
OrderHistDto orderHistDto = new OrderHistDto(order);
List<OrderItem> orderItems = order.getOrderItems(); // N번 쿼리
for(OrderItem orderItem : orderItems){
// ItemImg 조회 시 추가 쿼리 발생
ItemImg itemImg = itemImgRepository
.findByItemIdAndRepImgYn(orderItem.getItem().getId(), "Y"); // N번 쿼리
OrderItemDto orderItemDto = new OrderItemDto(orderItem, itemImg.getImgUrl());
orderHistDto.addOrderItemDto(orderItemDto);
}
orderHistDtoList.add(orderHistDto);
}
return new PageImpl<>(orderHistDtoList, pageable, totalCount);
}
2.2 실행된 쿼리 분석
주문 4개라고 가정, 각 주문당 상품 2-3개 가정 시:
-- 1. Order 조회 (1번)
SELECT * FROM orders WHERE member_id = ? ORDER BY order_date DESC LIMIT 4;
-- 2. Count 쿼리 (1번)
SELECT COUNT(*) FROM orders WHERE member_id = ?;
-- 3. OrderItem 조회 (4번 - 각 Order마다)
SELECT * FROM order_item WHERE order_id = ?;
SELECT * FROM order_item WHERE order_id = ?;
SELECT * FROM order_item WHERE order_id = ?;
SELECT * FROM order_item WHERE order_id = ?;
-- 4. Item 조회 (8번 - 각 OrderItem마다)
SELECT * FROM item WHERE item_id = ?;
...
-- 5. ItemImg 조회 (8번 - 각 Item마다)
SELECT * FROM item_img WHERE item_id = ? AND rep_img_yn = 'Y';
...
-- 총: 21번의 쿼리 실행!
문제점:
- Order 개수(N)에 비례하여 추가 쿼리 발생
- 데이터가 많아질수록 성능 저하 심화
3. 1차 해결 시도 (실패)
@Override
public List<Order> findOrdersByEmail(String email, Pageable pageable) {
// 1단계: 페이징을 위한 ID 조회
List<Long> orderIds = queryFactory
.select(order.id)
.from(order)
.where(order.member.email.eq(email))
.orderBy(order.regTime.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
if (orderIds.isEmpty()) {
return List.of();
}
// 2단계: Fetch Join으로 모든 연관 엔티티 한 번에 조회
return queryFactory
.selectFrom(order)
.distinct()
.join(order.orderItems, orderItem).fetchJoin() // List ①
.join(orderItem.item, item).fetchJoin()
.leftJoin(item.itemImgs, itemImg).fetchJoin() // List ② ← 문제
.where(order.id.in(orderIds))
.orderBy(order.regTime.desc())
.fetch();
}
3.2 MultipleBagFetchException 발생
에러 메시지:
org.hibernate.loader.MultipleBagFetchException:
cannot simultaneously fetch multiple bags:
[com.shop.entity.Order.orderItems, com.shop.entity.Item.itemImgs]
발생 원인:
Order → orderItems (List) ← Collection 1
Item → itemImgs (List) ← Collection 2
Hibernate는 2개 이상의 Collection을 동시에 Fetch Join 불가(X)
카디널리티 곱셈으로 데이터 중복 및 정합성 문제 발생(X)
왜 2개 이상의 Collection Fetch Join이 불가능한가?
Order 1개 → OrderItem 3개 → Item 각각 → ItemImg 각 2개
결과 행 수: 1 × 3 × 2 = 6행
→ Order 데이터가 6번 중복!
→ OrderItem도 중복!
→ 데이터 정합성 깨짐
MultipleBagFetchException이 발생하는 이유:
- Order → orderItems (List)
- Item → itemImgs (List)
- 즉, **2개의 컬렉션(List)**이 동시에 있습니다.
Hibernate는 기본적으로 2개 이상의 컬렉션을 동시에 Fetch Join할 수 없습니다. 왜냐하면 데이터 중복과 정합성 문제가 발생하기 때문입니다.
왜 2개 컬렉션 Fetch Join이 불가능한가?
예를 들어봅시다:
Order 1개 (id=1)
├─ OrderItem 3개 (A, B, C)
├─ Item A → ItemImg 2개 (A1, A2)
├─ Item B → ItemImg 2개 (B1, B2)
└─ Item C → ItemImg 2개 (C1, C2)
만약 2개의 컬렉션을 동시에 Fetch Join하면:
SELECT * FROM Order o
JOIN OrderItem oi ON o.id = oi.order_id
JOIN Item i ON oi.item_id = i.id
JOIN ItemImg img ON i.id = img.item_id
결과는:
OrderOrderItemItemItemImg
| 1 | A | A | A1 |
| 1 | A | A | A2 |
| 1 | B | B | B1 |
| 1 | B | B | B2 |
| 1 | C | C | C1 |
| 1 | C | C | C2 |
**총 6행(레코드)**이 조회됩니다!
문제점
- Order 데이터가 6번 중복 → Order 객체가 6개 생성될 수 있음
- OrderItem도 중복 → 정확한 개수를 알 수 없음
- 데이터 정합성 깨짐 → 실제로는 Order 1개인데 6개처럼 보임
결과적으로:
- 예상: Order 1개 + OrderItem 3개
- 실제: 데이터베이스에서 6행 조회
- Hibernate가 이걸 올바른 객체 구조로 만들기 어려움!
해결 방법
단계적으로 Fetch Join 실행:
// 1단계: Order + OrderItem만 먼저 조회
SELECT o FROM Order o
JOIN FETCH o.orderItems
// 2단계: Item + ItemImg는 별도로 조회 (또는 LAZY 로딩)
이렇게 하면 1 × 3 = 3행만 조회되고, 데이터 정합성도 유지됩니다!
4. 최종 해결 방법
4.1 단계
1. OrderItem까지만 Fetch Join
2. ItemImg Batch Size로 해결
4.2 구현 코드
OrderRepositoryCustomImpl.java
@Override
public List<Order> findOrdersByEmail(String email, Pageable pageable) {
// 1단계: 페이징을 위한 ID 조회
List<Long> orderIds = queryFactory
.select(order.id)
.from(order)
.where(order.member.email.eq(email))
.orderBy(order.regTime.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
if (orderIds.isEmpty()) {
return List.of();
}
// 2단계: OrderItem까지만 Fetch Join
return queryFactory
.selectFrom(order)
.distinct()
.join(order.orderItems, orderItem).fetchJoin() // OrderItem만 Fetch Join
.join(orderItem.item).fetchJoin() // Item은 ToOne이므로 가능
.where(order.id.in(orderIds))
.orderBy(order.regTime.desc())
.fetch();
// ItemImg는 Lazy Loading + Batch Size로 처리
}
application.yml
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100 # Batch Size 설정
4.3 동작 원리
Before (N+1):
-- ItemImg를 개별 조회
SELECT * FROM item_img WHERE item_id = 1 AND rep_img_yn = 'Y';
SELECT * FROM item_img WHERE item_id = 2 AND rep_img_yn = 'Y';
SELECT * FROM item_img WHERE item_id = 3 AND rep_img_yn = 'Y';
...
After (Batch Size):
-- ItemImg를 IN 절로 한 번에 조회
SELECT * FROM item_img
WHERE item_id IN (1, 2, 3, 4, ...)
AND rep_img_yn = 'Y';
4.4 최적화된 쿼리 흐름
주문 4개 기준:
-- 1. Order ID 조회 (1번)
SELECT order_id FROM orders WHERE email = ? LIMIT 4;
-- 2. Order + OrderItem + Item Fetch Join (1번)
SELECT o.*, oi.*, i.*
FROM orders o
JOIN order_item oi ON o.order_id = oi.order_id
JOIN item i ON oi.item_id = i.item_id
WHERE o.order_id IN (1, 2, 3, 4);
-- 3. ItemImg Batch 조회 (1번)
SELECT * FROM item_img
WHERE item_id IN (1, 2, 3, 4, 5, 6, 7, 8)
AND rep_img_yn = 'Y';
-- 4. Count 쿼리 (1번)
SELECT COUNT(*) FROM orders WHERE email = ?;
-- 총: 4번의 쿼리! (기존 21번 → 4번)
5. 성능 개선 결과
5.1 테스트 환경
- 테스트 도구: Apache JMeter
- 동시 사용자: 500명
- 요청 횟수: 총 10,000번
- 테스트 데이터: 주문 37개, 상품 34개, 주문상품 104개
5.2 성능 측정 결과
구분Before (N+1)After (최적화)개선율
| 구분 | Before(N+1) | After(최적화) | 개선율 |
| 평균 응답시간 | 12ms | 4ms | 66.7% ↓ |
| 쿼리 수 | 21개 | 4개 | 81% ↓ |
| 처리량 (TPS) | 16.8/sec | 16.8/sec | - |
| 에러율 | 0% | 0% | - |
5.3 분석
응답시간 개선:
- 쿼리 실행 횟수 감소로 DB 부하 81% 감소
- 네트워크 왕복 횟수 감소
- 평균 응답시간 3배 개선
처리량 동일한 이유:
- 테스트 데이터가 적어 캐시 효과
- 병목이 네트워크/동시성에 있음
- 실제 운영 환경에서는 차이 더 클 것으로 예상
확장성:
데이터 증가 시 예상 효과:
주문 1000개 기준:
- Before: 약 120초 (느림)
- After: 약 40초 (쾌적)
→ 데이터 많을수록 효과 극대화
6. 핵심 학습 내용
6.1 N+1 문제 해결 전략
- Fetch Join: ToOne 관계 또는 단일 Collection
- Batch Size: 추가 Collection 로딩
- @EntityGraph: 간단한 경우
- DTO 직접 조회: 읽기 전용 최적화
6.2 Collection Fetch Join 제약사항
- 단일 Collection만 가능: 2개 이상 시 MultipleBagFetchException
- 페이징 불가: Collection Fetch Join 시 메모리 페이징
- 카디널리티 주의: 데이터 중복 발생 가능
6.3 Batch Size 장점
- 간단한 설정: application.yml만 수정
- 자동 최적화: Hibernate가 IN 절로 변환
- 부작용 없음: 기존 코드 수정 불필요
7. 결론
QueryDSL Fetch Join과 Hibernate Batch Size를 조합하여 N+1 문제를 효과적으로 해결했다.
주요 성과:
- 쿼리 수 81% 감소 (21개 → 4개)
- 응답 시간 66.7% 개선 (12ms → 4ms)
- MultipleBagFetchException 해결
- 확장 가능한 구조 확립
실무 적용 가치:
- 대용량 데이터 처리 시 필수적인 최적화 기법
- DB 부하 감소로 서버 비용 절감
- 사용자 경험 개선
'SW Engineering' 카테고리의 다른 글
| [SW Engineering] N+1 문제 해결: Map 캐싱을 이용한 상품 데이터베이스 조회 최적화 사례 (0) | 2025.10.05 |
|---|---|
| [SW Engineering] Stream forEach에서 map으로: 함수형 프로그래밍과 N+1 문제 해결기 (0) | 2025.10.05 |
| [SW Engineering] 게시판 댓글 좋아요 기능 DB 설계 - UNIQUE 제약조건 활용하기 (0) | 2025.10.04 |
| [SW Engineering] 쇼핑몰 도메인 모델 설계를 통해 알아보는 JPA 엔티티 설계 의사결정 과정 (0) | 2025.09.30 |
| [SW Engineering] GitHub Actions + Docker를 활용한 Spring Boot 자동 배포 파이프라인 구축 과정 (0) | 2025.09.28 |