Engineering Note

[SW Engineering] Stream forEach에서 map으로: 함수형 프로그래밍과 N+1 문제 해결기 본문

SW Engineering

[SW Engineering] Stream forEach에서 map으로: 함수형 프로그래밍과 N+1 문제 해결기

Software Engineer Kim 2025. 10. 5. 15:34

들어가며

장바구니에 담긴 OrderItem을 주문으로 처리하는 코드를 리팩토링하면서 겪은 여정을 공유합니다. 단순히 코드를 깔끔하게 만들려던 시도가 N+1 문제 발견과 성능 개선으로 이어진 과정을 담았습니다.

 

 

문제의 시작: forEach의 한계

초기 코드는 stream().forEach()를 사용해 주문 정보를 처리했습니다.

 

 

 

문제의 시작: forEach의 한계

초기 코드는 stream().forEach()를 사용해 주문 정보를 처리했습니다.

public Long orders(List<OrderDto> orderDtoList, String email) {
        // 1. 회원 조회 (1st Query)
        Member member = memberRepository.findByEmail(email);

        List<OrderItem> orderItemList = new ArrayList<>();

        orderDtoList.stream().forEach(orderDto -> {{

        Item item = itemRepository.findById(orderDto.getItemId()).orElseThrow(EntityNotFoundException::new);

        OrderItem orderItem = OrderItem.createOrderItem(item, orderDto.getCount());

        orderItemList.add(orderItem);

        }});

    Order order = Order.createOrder(member, orderItemList);
        orderRepository.save(order);

return order.getId();

 

문제점: 스트림 외부에서 List<OrderItem>을 미리 생성하고, 내부에서 add()로 채워나가는 방식이라 가독성이 떨어졌습니다.

1차 리팩토링: map으로 전환

함수형 프로그래밍 스타일에 맞게 map()을 사용하도록 수정했습니다.

 

List<OrderItem> orderItemList = orderDtoList.stream().map(orderDto -> {
    Item item = itemRepository.findById(orderDto.getItemId()).orElseThrow(EntityNotFoundException::new);
    OrderItem orderItem = OrderItem.createOrderItem(item, orderDto.getCount());
    return orderItem; 
}).collect(Collectors.toList());

 

개선점: 외부 상태 변경 없이 스트림 파이프라인 내에서 변환과 수집이 완료되어 코드가 명확해졌습니다.

2차 리팩토링: 정적 팩토리 메서드 추출

람다 블록 내부 로직이 복잡해 가독성을 더 높이기 위해 OrderItem의 정적 팩토리 메서드로 분리했습니다.

// Service 코드
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 생성 로직이 엔티티로 응집되었습니다.

성능 측정: N+1 문제 발견

리팩토링 후 실행 쿼리 로그를 확인해보았습니다.

 

2025-10-05T15:04:57.359  INFO : OrderDto 변환 시작
Hibernate: select ... from item i1_0 where i1_0.item_id=?  -- 상품 1
Hibernate: select ... from item i1_0 where i1_0.item_id=?  -- 상품 2
Hibernate: select ... from item i1_0 where i1_0.item_id=?  -- 상품 3
Hibernate: select ... from item i1_0 where i1_0.item_id=?  -- 상품 4
2025-10-05T15:04:57.363  INFO : OrderDto 변환 종료

 

문제: 4개 상품 주문 시 4번의 개별 SELECT 쿼리가 실행되는 전형적인 N+1 문제가 발생했습니다.

3차 리팩토링: IN 쿼리로 N+1 해결

모든 Item ID를 추출해 한 번의 IN 쿼리로 조회하도록 개선했습니다.

public Long orders(List<OrderDto> orderDtoList, String email) {
    Member member = memberRepository.findByEmail(email);

    // 1. 필요한 모든 Item ID 추출
    List<Long> itemIds = orderDtoList.stream()
        .map(OrderDto::getItemId)
        .collect(Collectors.toList());

    // 2. IN 쿼리로 한 번에 조회 후 Map으로 변환
    Map<Long, Item> itemMap = itemRepository.findAllById(itemIds).stream()
        .collect(Collectors.toMap(Item::getId, item -> item));

    // 3. Map을 활용해 OrderItem 생성 (DB 호출 없음)
    List<OrderItem> orderItemList = orderDtoList.stream()
        .map(orderDto -> OrderItem.createOrderItem(orderDto, itemMap))
        .collect(Collectors.toList());

    Order order = Order.createOrder(member, orderItemList);
    orderRepository.save(order);
    return order.getId();
}

// OrderItem 정적 팩토리 메서드
public static OrderItem createOrderItem(OrderDto orderDto, Map<Long, Item> itemMap) {
    OrderItem orderItem = new OrderItem();
    Item item = itemMap.get(orderDto.getItemId());
    
    if (item == null) {
        throw new EntityNotFoundException("상품 ID " + orderDto.getItemId() + "를 찾을 수 없습니다.");
    }
    
    orderItem.setItem(item);
    orderItem.setCount(orderDto.getCount());
    return orderItem;
}

 

개선 결과:

2025-10-05T15:12:36.175  INFO : OrderDto 변환 시작
Hibernate: select ... from item i1_0 where i1_0.item_id in (?, ?, ?, ?)
2025-10-05T15:12:36.185  INFO : OrderDto 변환 종료

 

 

N번의 쿼리가 1번으로 감소!

성능 개선의 파급 효과

쿼리 횟수 감소는 단순히 DB 작업 시간만 줄이는 게 아닙니다.

 

자원/관점N번의 쿼리 (N+1)1번의 IN 쿼리

네트워크 N배의 왕복 지연 발생 1번의 왕복으로 지연 시간 최소화
운영체제 N번의 Context Switching으로 CPU 오버헤드 Context Switching 최소화로 CPU 효율 향상
데이터베이스 N번의 쿼리 파싱 및 실행 계획 수립 1번의 파싱으로 DB 자원 절약

 

 

forEach vs map: 함수형 프로그래밍의 진짜 장점

단순히 코드가 깔끔해지는 것 이상의 가치가 있습니다.

1. 불변성과 스레드 안전성

  • forEach: 외부 상태(orderItemList)를 변경하므로 병렬 스트림 사용 시 동기화 문제 발생 가능
  • map: 새로운 스트림을 반환하고 외부 상태를 변경하지 않아 스레드 안전성 보장

2. 선언적 프로그래밍

  • forEach (명령형): "각 요소를 이 리스트에 추가해라" → 어떻게 할지 명령
  • map (선언형): "이 스트림을 저 타입으로 변환해라" → 무엇을 할지 선언

3. 연산 연결 및 재사용성

  • map: 중간 연산이므로 filter, sorted 등 다른 연산과 체이닝 가능
  • forEach: 최종 연산이므로 뒤에 연산 연결 불가

회고: 배운 점

  1. 함수형 프로그래밍은 가독성 이상의 가치를 제공합니다. 불변성, 스레드 안전성, 확장성까지 얻을 수 있습니다.
  2. 리팩토링이 성능 문제를 드러내는 계기가 되었습니다. 코드를 정리하다 보니 N+1 문제가 눈에 들어왔습니다.
  3. 쿼리 최적화의 효과는 전방위적입니다. DB뿐 아니라 네트워크, OS, 애플리케이션 전체의 자원 효율이 개선됩니다.

작은 리팩토링에서 시작해 성능 개선까지 이어진 이번 경험은 "좋은 코드는 성능도 좋다"는 말을 몸소 체감하게 해준 소중한 시간이었습니다.

Comments