Engineering Note

[SW Engineering] N+1 문제 해결: Map 캐싱을 이용한 상품 데이터베이스 조회 최적화 사례 본문

SW Engineering

[SW Engineering] N+1 문제 해결: Map 캐싱을 이용한 상품 데이터베이스 조회 최적화 사례

Software Engineer Kim 2025. 10. 5. 17:01

상황 설명

OrderDto

주문을 위해 상품 id와 상품 주문 수량 count값을 담는 DTO 클래스

@Getter @Setter
@AllArgsConstructor
@NoArgsConstructor
public class OrderDto {
    @NotNull(message = "상품 아이디는 필수 입력 값입니다.")
    private Long itemId;

    @Min(value = 1 ,message = "최소 주문 수량은 1개입니다.")
    @Max(value = 999, message = "최대 주문 수량은 999개입니다.")
    private int count;
}

 

 

OrderItem Entity

- Order Entity와 Item Entity의 릴레이션 테이블이면서 주문 상품 정보를 담는 Entity

@Entity
@Table(name = "order_item")
@Getter @Setter
public class OrderItem extends BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "order_item_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "item_id")
    private Item item;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "order_id")
    private Order order;

    private int orderPrice;

    private int count;

    public static  OrderItem createOrderItem(Item item, int count) {
        OrderItem orderItem = new OrderItem();
        orderItem.setItem(item);
        orderItem.setCount(count);
        orderItem.setOrderPrice(item.getPrice());

        item.removeStock(count);
        return orderItem;
    }

    public static OrderItem createOrderItem(OrderDto orderDto, Map<Long, Item> itemMap) {
        OrderItem orderItem = new OrderItem();
        // DB 호출 대신 Map에서 O(1)의 속도로 Item을 조회
        Item item = itemMap.get(orderDto.getItemId());

        if (item == null) {
            // DTO에 포함된 Item ID가 DB에 없는 경우 처리 (필수 예외 처리)
            throw new EntityNotFoundException("요청된 상품 ID " + orderDto.getItemId() + "를 찾을 수 없습니다.");
        }

        orderItem.setItem(item);
        orderItem.setCount(orderDto.getCount());
        return orderItem;
    }

    public int getTotalPrice() {
        return orderPrice * count;
    }

    public void cancel() {
        this.getItem().addStock(count);
    }
}

 

OrderDTO 클래스를 OrderItem Entity로 변환해서 Order Entity, 주문을 생성하는 상황에서 지나치게 쿼리가 많이 수행되는 로직을 최적화하는 과정을 공유합니다.

 

 

1. 문제 발생: stream map 변환 로직 속의 N+1 문제
 

함수형 프로그래밍 스타일을 따르기 위해 stream().map()을 사용하여 OrderDto 리스트를 OrderItem 리스트로 변환하는 과정에서, 성능 저하의 징후를 발견했습니다.
 
N+1 문제 발생 코드:
 
// OrderService.orders() 메서드 내부

// OrderService.orders() 메서드 내부
List<OrderItem> orderItemList = orderDtoList.stream()
        .map(orderDto -> OrderItem.createOrderItem(orderDto, itemRepository)) // 문제의 원인
        .collect(Collectors.toList());

 
// OrderItem 인스턴스 생성 정적팩토리 메서드

public static OrderItem createOrderItem(OrderDto orderDto, ItemRepository itemRepository) {
    OrderItem orderItem = new OrderItem();
    Item item = itemRepository.findById(orderDto.getItemId())
            .orElseThrow(EntityNotFoundException::new);

    orderItem.setItem(item);
    orderItem.setCount(orderDto.getCount());
    return orderItem;
}

 
OrderItem을 생성하는 OrderItem.createOrderItem() 정적 팩토리 메서드 내부에서 매번 itemRepository.findById(...)를 호출하면서 OrderItem의 Item 속성을 세팅하면서 쿼리가 요소개수만큼 실행되는 문제가 있었습니다.


로그 분석 결과:
4개의 상품 주문 시 SELECT 쿼리가 4번 실행됨을 확인했습니다.
(즉, N개의 상품 → N번의 쿼리)
 

2. 해결 전략: IN 쿼리 + Map 캐싱

N+1 문제를 해결하는 핵심 원칙은 반복적인 단일 조회를 하나의 효율적인 대량 조회로 대체하는 것입니다.
다음의 3단계 접근 방식을 통해 문제를 해결했습니다.

  1. ID 추출: 모든 OrderDto에서 필요한 Item ID를 List로 추출
  2. IN 쿼리 실행: JPA의 itemRepository.findAllById(itemIds)를 호출하여 단 1회의 IN 쿼리로 모든 Item 데이터를 조회
  3. Map 캐싱: 조회된 Item 리스트를 Map<ID, Item> 형태로 변환해 메모리에 캐싱

 

3. 최종 최적화 코드 및 Map 활용

A. 주문 서비스 (orders 메서드) 최적화

서비스 메서드 내에서 Item 조회 로직이 Map 생성 로직으로 대체되었습니다.
 

public Long orders(List<OrderDto> orderDtoList, String email) {
    // 1. 회원 조회 및 ID 추출 (DB 쿼리 1회)
    Member member = memberRepository.findByEmail(email);
    List<Long> itemIds = orderDtoList.stream()
            .map(OrderDto::getItemId)
            .collect(Collectors.toList());

    // 2. Map 캐싱: IN 쿼리로 모든 Item을 조회 후 Map<ID, Item>으로 변환 (DB 쿼리 1회)
    Map<Long, Item> itemMap = itemRepository.findAllById(itemIds).stream()
            // Item::getId로 Key 생성, item -> item으로 Value(객체 자신) 생성
            .collect(Collectors.toMap(Item::getId, item -> item)); 

    // 3. Entity 변환: Map에서 O(1)로 Item을 조회하며 OrderItem 생성 (DB 접근 없음)
    List<OrderItem> orderItemList = orderDtoList.stream()
            .map(orderDto -> OrderItem.createOrderItem(orderDto, itemMap)) // Map을 인수로 전달
            .collect(Collectors.toList());

    // 4. 주문 생성 및 저장
    Order order = Order.createOrder(member, orderItemList);
    orderRepository.save(order);
    return order.getId();
}

 

B. OrderItem 정적 팩토리 메서드 수정

DB Repository 대신 Item Map을 사용하여 데이터 접근 책임을 분리했습니다.
 

// ItemRepository 대신 Map을 인수로 받아 사용
public static OrderItem createOrderItem(OrderDto orderDto, Map<Long, Item> itemMap) {
    // DB 호출 대신 Map에서 O(1)로 Item 조회
    Item item = itemMap.get(orderDto.getItemId());

    if (item == null) {
        throw new EntityNotFoundException("Item not found: " + orderDto.getItemId());
    }
    
    // 조회된 Item으로 OrderItem 생성
    return new OrderItem(item, orderDto.getCount());
}

 
 

4. 최종 결과 및 성능 향상

 

항목 N+1 문제 발생 최적화
Item 조회 쿼리 횟수 N회 1회 (WHERE IN)
성능 효과 DB 왕복 N회 → 네트워크 지연 DB 왕복 1회 → 응답 시간 축소

 정리

  • Stream의 map() 내부의 repository 반복 실행과 지연 로딩 설정으로 인해 DTO 수만큼 DB 조회가 발생할 수 있음
  • 이를 IN 쿼리 + Map 캐싱으로 조회 횟수를 1회로 통합
  • DB, 네트워크, OS 레벨의 자원 낭비가 줄고, 시스템 전체 성능이 향상됨
Comments