Engineering Note

[SW Engineering] 이커머스 프로젝트 주문내역 조회 N+1 문제 해결 과정 본문

SW Engineering

[SW Engineering] 이커머스 프로젝트 주문내역 조회 N+1 문제 해결 과정

Software Engineer Kim 2025. 10. 21. 16:56

Native 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하면:

 
 
sql
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행(레코드)**이 조회됩니다!

문제점

  1. Order 데이터가 6번 중복 → Order 객체가 6개 생성될 수 있음
  2. OrderItem도 중복 → 정확한 개수를 알 수 없음
  3. 데이터 정합성 깨짐 → 실제로는 Order 1개인데 6개처럼 보임

결과적으로:

  • 예상: Order 1개 + OrderItem 3개
  • 실제: 데이터베이스에서 6행 조회
  • Hibernate가 이걸 올바른 객체 구조로 만들기 어려움!

해결 방법

단계적으로 Fetch Join 실행:

 
 
java
// 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):

 
 
sql
-- 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):

 
 
sql
-- ItemImg를 IN 절로 한 번에 조회
SELECT * FROM item_img 
WHERE item_id IN (1, 2, 3, 4, ...) 
AND rep_img_yn = 'Y';

4.4 최적화된 쿼리 흐름

주문 4개 기준:

 
 
sql
-- 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 문제 해결 전략

  1. Fetch Join: ToOne 관계 또는 단일 Collection
  2. Batch Size: 추가 Collection 로딩
  3. @EntityGraph: 간단한 경우
  4. 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 부하 감소로 서버 비용 절감
  • 사용자 경험 개선
Comments