Web/JPA

EP3. 컬렉션 조회 최적화 - 2 (JPA에서 DTO 직접조회)

2021.04.06 - [Web/JPA] - EP2. 지연 로딩과 조회 성능 최적화

2021.04.07 - [Web/JPA] - EP3. 컬렉션 조회 최적화 - 1

 

EP3. 컬렉션 조회 최적화 - 1

주문내역에서 주문한 상품정보까지 추가로 조회 해보자. EP2 에서는 @XToOne 을 조회하는 기능을 최적화 했었다. 2021.04.06 - [Web/JPA] - EP2. 지연 로딩과 조회 성능 최적화 여기서는 @XToMany인 컬렉션을

ksabs.tistory.com

 

앞에서는 엔티티를 DTO로 변환해서 컬렉션으로 반환했다.

 

 

이번에는 DTO를 그대로 반환해서 JPA가 DTO를 직접 조회하게 만들어 보겠다.

 

 

JPA에 DTO를 직접 반환 하는것의 장단점은 이미 정리를 해봤었다.

 

장점

  1. 쿼리가 한번만 나간다.
  2. 원하는 데이터만 직접 선택해 반환하므로 네트워크 용량을 최적화할 수 있다. (fetch join보다도)

 

단점

  1. 코드가 지저분하다.
  2. 생각보다 네트워크 용량 최적화가 적다.
  3. API 스펙에 너무 의존한다. (재사용X, 스펙 바뀌면 바꿔줘야함)
    repository에서는 순수한 엔티티 조회 로직만 들어가야 하는데 view와 관련된 로직이 들어가게 된다.
    -> 이럴경우 별도의 order.simpleQueryRepository 패키지를 생성해서 레벨을 분리해야한다.

2021.04.06 - [Web/JPA] - EP2. 지연 로딩과 조회 성능 최적화

 

EP2. 지연 로딩과 조회 성능 최적화

주문을 조회하는 API를 설계해보았다. package jpabook.jpashop.api; import jpabook.jpashop.domain.Order; import jpabook.jpashop.repository.OrderRepository; import jpabook.jpashop.repository.OrderSearch..

ksabs.tistory.com

 

 

 

OrderQueryRepository에서 DTO로 반환한다.

 

OrderQueryRepository

package jpabook.jpashop.repository.order.query;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import javax.persistence.EntityManager;
import java.util.List;

@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {

    private final EntityManager em;


    public List<OrderQueryDto> findOrderQueryDtos() {
        List<OrderQueryDto> result = findOrders();

        result.forEach(o -> {
            List<OrderItemQueryDto> orderItems = findOrderItems(o.getOrderId());
            o.setOrderItems(orderItems);
        });

        return result;
    }

    private List<OrderItemQueryDto> findOrderItems(Long orderId) {
        return em.createQuery(
                "select new jpabook.jpashop.repository.order.query.OrderItemQueryDto(oi.order.id, oi.item.name, oi.orderPrice, oi.count)" +
                        " from OrderItem oi" +
                        " join oi.item i" +
                        " where oi.order.id = :orderId", OrderItemQueryDto.class)
                .setParameter("orderId", orderId)
                .getResultList();
    }

    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();
    }
}

 

 

findOrderQueryDtos 에서는 Order를 조회하고 DTO로 바꿔주고 Order의 수만큼 OrderItem도 DTO로 변환해준다.

 

findOrderQueryDtos

    public List<OrderQueryDto> findOrderQueryDtos() {
        List<OrderQueryDto> result = findOrders();

        result.forEach(o -> {
            List<OrderItemQueryDto> orderItems = findOrderItems(o.getOrderId());
            o.setOrderItems(orderItems);
        });

        return result;
    }

 

 

 

findOrders에서는 ToOne 관계인 member, delivery를 fetch join으로 한번에 가져온다.

 

findOrders

    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();
    }

 

 

 

findOrderItems 에서는 orderItem에서 item을 fetch 조인으로 가져온다.

item은 Order와는 ToMany 관계이지만, orderItem과는 ToOne 관계이므로 페치조인이 가능하다.

 

findOrderItems

    private List<OrderItemQueryDto> findOrderItems(Long orderId) {
        return em.createQuery(
                "select new jpabook.jpashop.repository.order.query.OrderItemQueryDto(oi.order.id, oi.item.name, oi.orderPrice, oi.count)" +
                        " from OrderItem oi" +
                        " join oi.item i" +
                        " where oi.order.id = :orderId", OrderItemQueryDto.class)
                .setParameter("orderId", orderId)
                .getResultList();
    }

 

 

 


이제 Controller에서 Repository를 통해 DTO를 바로 반환해주면 된다.

    @GetMapping("/api/v4/orders")
    public List<OrderQueryDto> ordersV4() {
        return orderQueryRepository.findOrderQueryDtos();
    }

 

 

 

실행하는 쿼리의 수는

 

Order를 가져올때 한번, (Member, Delivery를 join으로 한번에 가져온다.)

List<OrderQueryDto> result = findOrders();

 

Order의 수 만큼 2번

        result.forEach(o -> {
            List<OrderItemQueryDto> orderItems = findOrderItems(o.getOrderId());
            o.setOrderItems(orderItems);
        });

 

총 3번의 쿼리가 나가게 된다.

 

 

 

Order 1번 -> N개의 주문 -> N번의 쿼리

N+1 문제가 발생하게 된다.

 

 

 

 

 

 

해결방법

N개의 주문을 where 절에 in 을 사용해서 한번에 조회해온다.

 

  1. result에 order들을 담는다.
  2. orderId를 담은 리스트를 만든다.
  3. 이 리스트를 JPQL의 in을 통해서 한번에 조회해온다.
  4. 조회해온 order에서 orderIem을 Map<orderId, ItemDto>형식으로 Map에 담는다.
  5. Map에 있는 itemDto 를 result에 담아서 반환한다.

 

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

        List<Long> orderIds = result.stream()
                .map(o -> o.getOrderId())
                .collect(Collectors.toList());

        List<OrderItemQueryDto> orderItems = em.createQuery(
                "select new jpabook.jpashop.repository.order.query.OrderItemQueryDto(oi.order.id, oi.item.name, oi.orderPrice, oi.count)" +
                        " from OrderItem oi" +
                        " join oi.item i" +
                        " where oi.order.id in :orderIds", OrderItemQueryDto.class)
                .setParameter("orderIds", orderIds)
                .getResultList();

        Map<Long, List<OrderItemQueryDto>> orderItemMap = orderItems.stream()
                .collect(Collectors.groupingBy(OrderItemQueryDto::getOrderId));

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

        return result;
    }

 

 

그러면 Order를 가져오는 쿼리 1번, 가져온 Order들을 한번에 가져오는 쿼리 1번

총 2번의 쿼리만 나가게 되어서 N + 1 문제를 해결할 수 있다.

 

 

 

JPA에 DTO를 직접 반환하는 것이 마냥 편하지만은 않다.

하지만 저번과 마찬가지로 원하는 데이터만 골라 select하므로 select 하는 양이 적어진다는 이점이 있다.

 

 

 

 

 

 

쿼리를 한번으로 줄일수도 있다.

 

Controller

    @GetMapping("/api/v6/orders")
    public List<OrderQueryDto> ordersV6() {
        List<OrderFlatDto> flats = orderQueryRepository.findAllByDto_flat();

        return flats.stream()
                .collect(groupingBy(o -> new OrderQueryDto(o.getOrderId(), o.getName(), o.getOrderDate(), o.getOrderStatus(), o.getAddress()),
                        mapping(o -> new OrderItemQueryDto(o.getOrderId(), o.getItemName(), o.getOrderPrice(), o.getCount()), toList())
                )).entrySet().stream()
                .map(e -> new OrderQueryDto(e.getKey().getOrderId(), e.getKey().getName(), e.getKey().getOrderDate(), e.getKey().getOrderStatus(), e.getKey().getAddress(), e.getValue()))
                .collect(toList());
    }

 

 

findAllByDto_flat()

    public List<OrderFlatDto> findAllByDto_flat() {
        return em.createQuery(
                "select new " +
                        " jpabook.jpashop.repository.order.query.OrderFlatDto(o.id, m.name, o.orderDate, o.status, d.address, i.name, oi.orderPrice, oi.count)" +
                        " from Order o" +
                        " join o.member m" +
                        " join o.delivery d" +
                        " join o.orderItems oi" +
                        " join oi.item i", OrderFlatDto.class)
                .getResultList();
    }

 

 

 

OrderFlatDto

package jpabook.jpashop.repository.order.query;

import jpabook.jpashop.domain.Address;
import jpabook.jpashop.domain.OrderStatus;
import lombok.Data;

import java.time.LocalDateTime;
import java.util.List;

@Data
public class OrderFlatDto {

    private Long orderId;
    private String name;
    private LocalDateTime orderDate;
    private OrderStatus orderStatus;
    private Address address;

    private String itemName;
    private int orderPrice;
    private int count;

    public OrderFlatDto(Long orderId, String name, LocalDateTime orderDate, OrderStatus orderStatus, Address address, String itemName, int orderPrice, int count) {
        this.orderId = orderId;
        this.name = name;
        this.orderDate = orderDate;
        this.orderStatus = orderStatus;
        this.address = address;
        this.itemName = itemName;
        this.orderPrice = orderPrice;
        this.count = count;
    }
}

 

 

OrderFlatDto에는 한번에 가져올 데이터들이 다 들어가있고,

findAllByDto_flat()에서 fetch join으로 orderItem의 Item까지 가져온다.

컬렉션을 fetch join 했기 때문에 중복데이터가 발생하는데,

Controller에서 이 중복데이터를 원래의 API 스펙에 맞게 바꿔준다.

 

그러면 쿼리 한번에 데이터들을 중복없이 보내줄 수 있다.

 

 

하지만 이 방법은 애플리케이션에서 추가 작업이 있고 페이징도 불가능하다.

 

 

 

'Web > JPA' 카테고리의 다른 글

EP5. OSIV와 성능 최적화  (0) 2021.04.09
EP4. API 개발 순서  (0) 2021.04.08
EP3. 컬렉션 조회 최적화 - 1  (0) 2021.04.07
EP2. 지연 로딩과 조회 성능 최적화  (0) 2021.04.06
EP1. 회원관리 API 만들기  (0) 2021.04.05