N+1 문제가 발생하는 컬렉션을 DTO로 조회하는 상황에서 최적화하는 방법을 알아보겠습니다.

주문 조회 V5: JPA에서 DTO 직접 조회 - 컬렉션 조회 최적화

@RestController
@RequiredArgsConstructor
public class OrderApiController {

    private final OrderQueryRepository orderQueryRepository;

    @GetMapping("/api/v5/orders")
    public List<OrderQueryDto> ordersV5() {
        return orderQueryRepository.findAllByDto_optimization();
    }

}
@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {

    private final EntityManager em;

    public List<OrderQueryDto> findAllByDto_optimization() {
        List<OrderQueryDto> results = findOrders();

        Map<Long, List<OrderItemQueryDto>> orderItemMap = findOrderItemMap(toOrderIds(results));

        results.forEach(o -> o.setOrderItems(orderItemMap.get(o.getOrderId())));

        return results;
    }

    private List<OrderQueryDto> findOrders() {
        return em.createQuery(
                        "select new jpabook.jpashop.repository.order.query.OrderQueryDto(o.id, m.name, o.orderDate, o.status, d.address)" +
                                " from Order o" +
                                " join o.member m" +
                                " join o.delivery d", OrderQueryDto.class)
                .getResultList();
    }

		//...1
    private List<Long> toOrderIds(List<OrderQueryDto> results) {
        return results.stream()
                .map(OrderQueryDto::getOrderId)
                .collect(Collectors.toList());
    }

    private Map<Long, List<OrderItemQueryDto>> findOrderItemMap(List<Long> orderIds) {
				//...2
        List<OrderItemQueryDto> orderItems = em.createQuery(
                        "select new jpabook.jpashop.repository.order.query.OrderItemQueryDto(oi.order.id, i.name, oi.orderPrice, oi.count)" +
                                " from OrderItem oi" +
                                " join oi.item i" +
                                " where oi.order.id in :orderIds", OrderItemQueryDto.class)
                .setParameter("orderIds", orderIds)
                .getResultList();
        //...3
        return orderItems.stream()
                .collect(Collectors.groupingBy(OrderItemQueryDto::getOrderId));
    }

}

  1. in 절에 넣어줄 orderId 리스트 데이터를 조회된 Order에서 추출합니다.

  2. 이전 코드에서는 루프를 돌면서 OrderItem 을 하나씩 가져왔습니다.

    하지만 in 절로 한 번에 가져오도록 수정합니다.

  3. 조회한 OrderItem 들을 조회한 Order 에 넣어줘야 합니다. 이 때 OrderItem 이 가지고 있는 orderId 와 Order 가 가지고 있는 id 가 동일할 때 넣어줘야합니다.

    이 작업을 최적화를 하기 위해서 조회한 OrderItem 들을 key가 orderId 인 Map 으로 변경해서 key 값으로 넣어주면 됩니다.

    리스트로 하나씩 뽑아서 매칭하려면 굉장히 불편하겠죠?

이제 총 쿼리는 몇 번 발생할까요?

Query는 루트 1번, 컬렉션 1번 실행됩니다.

ToOne 관계들을 먼저 조회하고, 여기서 얻은 식별자 orderId로 ToMany 관계인 OrderItem 을 in 절로 한꺼번에 조회하기 때문입니다.

그리고 조금 더 성능 최적화를 위해서 MAP을 사용해서 매칭 성능 향상(O(1))시켰습니다.

이전 코드는 N+1 문제가 발생했지만 지금은 컬렉션을 in 절로 한 방에 조회하기 때문에 최적화가 된 것이죠.