일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |
- C programming
- buffer
- 윤성우의 열혈 자료구조
- Graph
- 이스케이프 문자
- 혼자 공부하는 C언어
- 메모리구조
- C 언어 코딩 도장
- insertion sort
- Serialization
- coding test
- Algorithm
- stream
- Stack
- 이것이 자바다
- 알기쉬운 알고리즘
- R
- Selection Sorting
- datastructure
- list 컬렉션
- JSON
- 윤성우 열혈자료구조
- s
- Today
- Total
Engineering Note
[SW Engineering] N+1 문제 해결: Map 캐싱을 이용한 상품 데이터베이스 조회 최적화 사례 본문
[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단계 접근 방식을 통해 문제를 해결했습니다.
- ID 추출: 모든 OrderDto에서 필요한 Item ID를 List로 추출
- IN 쿼리 실행: JPA의 itemRepository.findAllById(itemIds)를 호출하여 단 1회의 IN 쿼리로 모든 Item 데이터를 조회
- 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 레벨의 자원 낭비가 줄고, 시스템 전체 성능이 향상됨
'SW Engineering' 카테고리의 다른 글
[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 |
[SW Engineering] 구체적인 사례로 보는 JavaScript Stack Trace 읽는 법 (0) | 2025.09.27 |