주문 목록을 조회하는 과정에서 성능 문제를 경험했다.
초기 구현에서는 각 주문에 대해 메뉴 정보를 조회하고, 각 메뉴에 대해 옵션 정보를 조회하는 방식으로 구성되어 있었다.
이 과정에서 발생하는 중첩 반복문은 O(N M K)의 시간 복잡도를 가지며(N은 주문의 수, M은 메뉴의 수, K는 옵션의 수), 많은 수의 데이터베이스 쿼리를 발생시켜 성능 저하의 주요 원인이 되었다.
List<OrderForm> orderForms = findOrders.stream()
.map(order -> {
Delivery delivery = order.getDelivery();
OrderForm orderForm = new OrderForm(order, delivery);
List<MenuForm> menuForms = order.getOrderMenus().stream()
.map(orderMenu -> {
MenuForm menuFrom = new MenuForm(orderMenu);
List<OptionForm> optionForms = orderMenu.getOrderMenuOptions().stream()
.map(orderMenuOption -> new OptionForm(orderMenuOption))
.toList();
menuFrom.setOptions(optionForms);
return menuFrom;
})
.collect(Collectors.toList());
orderForm.setMenus(menuForms);
return orderForm;
})
.toList();
최초에는 Java Stream을 활용하여 데이터를 처리하는 로직을 구현하는 것이었다.
이 방법은 코드의 가독성을 높이는 데에는 성공했으나, 시간 복잡도를 개선하는 데에는 한계가 있었다.
또한 데이터를 순차적으로 처리함으로써 발생하는 여러 쿼리로 인해 N+1 쿼리 문제가 여전히 발생했다.
public List<OrderForm> readAllOrder(User user) {
// 주문 조회;
System.out.println("// ============ 주문 조회 ============ //");
List<Order> findOrders = findAllOrders(user);
// 주문 폼 생성
List<OrderForm> orderForms = findOrders.stream()
.map(order -> {
Delivery delivery = order.getDelivery();
return new OrderForm(order, delivery);
}).collect(Collectors.toList());
// 주문 메뉴 폼 생성
System.out.println("// ============ 주문 목록 조회 ============ //");
List<OrderMenu> findOrderMenus = orderMenuRepository.findAllOrderMenu(findOrderIds(orderForms));
List<MenuForm> menuForms = findOrderMenus.stream()
.map(MenuForm::new)
.toList();
Map<Long, List<MenuForm>> orderMenusMap = menuForms.stream()
.collect(Collectors.groupingBy(MenuForm::getOrderId));
// 주문 옵션 폼 생성
System.out.println("// ============ 주문 옵션 조회 ============ //");
List<OrderMenuOption> findOrderMenuOptions = orderMenuOptionRepository.findAllOrderOption(findOrderMenuIds(menuForms));
List<OptionForm> optionForms = findOrderMenuOptions.stream()
.map(OptionForm::new)
.toList();
Map<Long, List<OptionForm>> orderOpionsMap = optionForms.stream()
.collect(Collectors.groupingBy(OptionForm::getOrderMenuId));
// 최종 Dto 매핑
menuForms.forEach(mf -> mf.setOptions(orderOpionsMap.get(mf.getOrderMenuId())));
orderForms.forEach(of -> of.setMenus(orderMenusMap.get(of.getOrderId())));
return orderForms;
}
성능 문제를 근본적으로 해결하기 위해, 쿼리 최적화에 초점을 맞춘 두 번째 접근 방식을 선택했다.
이 접근 방식은 다음과 같은 변경을 포함한다.
fetchJoin을 사용하여 주문과 관련된 배송 정보를 한 번의 쿼리로 가져오는 방법을 도입.
주문 ID를 기반으로 모든 주문 메뉴를 한 번의 쿼리로 조회하여, 각 주문별 메뉴 정보를 효율적으로 가져옴.
주문 메뉴 ID를 기반으로 모든 옵션을 한 번의 쿼리로 조회하여, 각 메뉴별 옵션 정보를 효율적으로 가져옴.
이를 통해 중첩 반목문을 피함으로써 시간 복잡도를 개선할 수 있었고, 쿼리 성능 또한 개선되었다.
주문 10개, 주문 목록 100개, 주문 옵션 1,000개를 설정하였다.
1. Execution time: 96.21 ms
2. Execution time: 53.76 ms
3. Execution time: 48.38 ms
4. Execution time: 44.93 ms
5. Execution time: 52.64 ms
6. Execution time: 43.12 ms
7. Execution time: 49.06 ms
8. Execution time: 49.99 ms
9. Execution time: 51.63 ms
10. Execution time: 51.53 ms
평균 응답 시간은 54.13ms 정도 소요됐다
1. Execution time: 75.21 ms
2. Execution time: 42.02 ms
3. Execution time: 37.67 ms
4. Execution time: 37.66 ms
5. Execution time: 32.22 ms
6. Execution time: 31.27 ms
7. Execution time: 38.43 ms
8. Execution time: 37.16 ms
9. Execution time: 34.71 ms
10. Execution time: 26.26 ms
최적화 후에는 39.26ms로 줄어들어, 약 27.46%의 성능 향상을 달성했다.
복잡한 쿼리를 최적화하고, 중첩 반복문을 피함으로써 시간 복잡도를 대폭 줄일 수 있음을 배웠다.
이번 학습을 통해 성능 최적화의 중요성과 시간 복잡도에 대한 이해가 깊어졌으며, 앞으로 개발 과정에서 이러한 지식을 적극적으로 활용할 계획이다.