[JPA] 컬렉션 조회 API 최적화 - DTO로 조회

3Beom's 개발 블로그·2023년 7월 3일
0

SpringJPA

목록 보기
20/21
post-custom-banner

출처

본 글은 인프런의 김영한님 강의 실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화 을 수강하며 기록한 필기 내용을 정리한 글입니다.

-> 인프런
-> 실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화 강의


주문 조회 V4 : JPA 에서 DTO로 직접 조회

  • 먼저 직접 조회할 DTO들을 생성한다.
    • OrderQueryDto
      @Data
      public class OrderQueryDto {
      
          private Long orderId;
          private String name;
          private LocalDateTime orderDate;
          private OrderStatus status;
          private Address address;
          private List<OrderItemQueryDto> orderItems;
      
          public OrderQueryDto(Long orderId, String name, LocalDateTime orderDate, OrderStatus orderStatus, Address address) {
              this.orderId = orderId;
              this.name = name;
              this.orderDate = orderDate;
              this.status = orderStatus;
              this.address = address;
          }
      }
    • OrderItemQueryDto
      @Data
      @AllArgsConstructor
      public class OrderItemQueryDto {
      
          private String itemName;
          private int orderPrice;
          private int count;
      }
  • 다음과 같이 order/dao/dtorepository 패키지에 다음 두가지를 생성한다.
    • OrderQueryRepository
      public interface OrderQueryRepository {
      
          List<OrderQueryDto> findOrderQueryDtos();
          List<OrderQueryDto> findOrders();
      }
    • OrderQueryRepositoryImpl
      @Repository
      @RequiredArgsConstructor
      public class OrderQueryRepositoryImpl implements OrderQueryRepository {
      
          private final EntityManager em;
      
          @Override
          public List<OrderQueryDto> findOrderQueryDtos() {
              List<OrderQueryDto> result = findOrders();
      
              result.forEach(o -> {
                  List<OrderItemQueryDto> orderItems = findOrderItems(o.getOrderId());
                  o.setOrderItems(orderItems);
              });
      
              return result;
          }
      
          @Override
          public List<OrderQueryDto> findOrders() {
              return em.createQuery(
                      "SELECT new jpabook.jpashop.domain.order.dao.dtorepository.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();
          }
      
          private List<OrderItemQueryDto> findOrderItems(Long orderId) {
              return em.createQuery(
                              "SELECT new jpabook.jpashop.domain.order.dao.dtorepository.OrderItemQueryDto(i.name, oi.orderPrice, oi.count) "
                                      + "FROM OrderItem oi "
                                      + "JOIN oi.item i "
                                      + "WHERE oi.order.id = :orderId", OrderItemQueryDto.class
                      )
                      .setParameter("orderId", orderId)
                      .getResultList();
          }
      }
  • 위 OrderQueryRepositoryImpl 코드를 보면, findOrderQueryDtos() 를 통해 조회 로직이 수행되며, 다음과 같이 동작한다.
    1. findOrders() 실행 : List<OrderQueryDto> result = findOrders();
      • Order 엔티티와 @~~ToOne 연관관계를 갖는 엔티티들만 JOIN 해서 JPA에서 DTO로 직접 조회하도록 설정한다.
      • OrderQueryDto 생성자를 통해 데이터들이 들어간다.
      • 아직 OrderQueryDto 의 orderItems 필드는 비어있다.
    2. 조회된 결과에 대해 각각 findOrderItems() 실행 : List<OrderItemQueryDto> orderItems = findOrderItems(o.getOrderId());
      • 1번 과정에서 조회된 Order 엔티티 각각에 대해 orderItems를 넣어주기 위한 과정을 수행한다.
      • OrderItem : Item = N : 1 이기 때문에 @~~ToOne 관계이므로 JOIN을 해도 결과 row 수가 뻥튀기 되지 않는다.
  • 본 과정을 거칠 경우, 처음 Order 엔티티 조회하는 쿼리 1번, 그 결과에 대해 각각 orderItems를 채우기 위한 쿼리 N번이 DB로 보내진다.

→ N + 1 번 쿼리가 나간다.

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

  • V4의 경우, OrderQueryDto 조회 결과 N개에 대해 각각 for문을 돌면서 OrderItemQueryDto 조회 쿼리를 수행했다.
    • 1 + N 번 쿼리가 나간다.
  • 이를 보완하기 위해 for문을 돌리는게 아닌, OrderQueryDto 로부터 orderId 값들을 뽑아 List로 만든 후, 해당 List를 파라미터로 전달하고, OrderItemQueryDto 를 조회할 때 해당 파라미터를 받아 IN 조건 절을 활용하도록 한다.
  • 먼저 코드를 보면,
    	@Override
        public List<OrderQueryDto> findOrderQueryDtosOptimized() {
            List<OrderQueryDto> result = findOrders();
    
            Map<Long, List<OrderItemQueryDto>> orderItemMap = findOrderItemMap(toOrderIds(result));
    
            result.forEach(o -> o.setOrderItems(orderItemMap.get(o.getOrderId())));
    
            return result;
        }
    	private List<Long> toOrderIds(List<OrderQueryDto> result) {
            List<Long> orderIds = result.stream()
                    .map(o -> o.getOrderId())
                    .collect(Collectors.toList());
            return orderIds;
        }
    	private Map<Long, List<OrderItemQueryDto>> findOrderItemMap(List<Long> orderIds) {
            Map<Long, List<OrderItemQueryDto>> orderItemMap = em.createQuery(
                            "SELECT new jpabook.jpashop.domain.order.dao.dtorepository.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()
                    .stream()
                    .collect(Collectors.groupingBy(OrderItemQueryDto::getOrderId));
            return orderItemMap;
        }
  • 위 로직은 다음과 같은 과정으로 수행된다.
    1. findOrders() 메서드로 OrderQueryDto 를 조회한다. : List<OrderQueryDto> result = findOrders();
      • 아직 OrderQueryDto의 orderItems는 비어있다.
    2. toOrderIds() 메서드로 OrderQueryDto 조회 결과로부터 orderId 값들을 추출하여 List로 묶는다.
      • OrderItemQueryDto 를 조회할 때 IN 절에 활용하기 위함이다.
    3. findOrderItemMap() 메서드로 OrderItemQueryDto를 조회하고, orderId와 매핑시켜 Map 을 생성한다.
      • Map<Long, List<OrderItemQueryDto>> orderItemMap 을 다음 과정으로 완성시킨다.
        • OrderItemQueryDto를 조회하는데, IN 절을 활용하고 파라미터로 앞서 추출한 orderId List를 전달한다.
        • stream 기능 중 groupingBy() 메서드를 통해 Map으로 변환한다.
        • 해당 메서드는 전달된 인자(OrderItemQueryDto::getOrderId) 를 key 값으로 두고, key 값에 해당하는 value 값들을 Map으로 묶어주는 역할을 수행한다.
        • 즉, orderId : List<OrderItemQueryDto> 로 key : value 쌍이 만들어진다.
    4. 만들어진 OrderItemQueryDto Map을 활용하여 OrderQueryDto의 orderItems를 채운다.
      • result.forEach(o -> o.setOrderItems(orderItemMap.get(o.getOrderId())));
      • Map 자료구조를 활용하기 때문에 매칭 성능이 최적화된다. : O(1)
  • 기본적으로 OrderQueryDto를 조회할 때 쿼리 1번, OrderItemQueryDto를 조회할 때 IN 절을 활용하므로 쿼리 1번, 총 1 + 1 번 조회 쿼리가 전송된다.
  • V6는 1번의 쿼리만으로 다 가져올 수 있는 방법이다.

주문 조회 V6 : JPA 에서 DTO로 직접 조회 플랫 데이터 최적화

  • 쿼리 1번만으로 다 가져올 수 있는 방식이다.
  • Order, Member, Delivery, OrderItem, Item 테이블 전체에 대해 필요한 데이터들을 뽑아내기 위한 join 쿼리를 그대로 짜고, 그 결과를 그대로 받아내는 것.
  • 따라서 DB 조회 결과에 완전히 맞춘 DTO를 추가로 생성해야 한다. : OrderFlatDto
    • OrderFlatDto
      @Data
      @AllArgsConstructor
      public class OrderFlatDto {
      
          private Long orderId;
          private String name;
          private LocalDateTime orderDate;
          private OrderStatus status;
          private Address address;
      
          private String itemName;
          private int orderPrice;
          private int count;
      }
  • 이후 다음과 같이 그냥 전체 다 join 시켜서 해당 DTO에 맞춘 형태로 가져오도록 한다.
    	@Override
        public List<OrderFlatDto> findOrderQueryDtosFlat() {
            return em.createQuery(
                    "SELECT new jpabook.jpashop.domain.order.dao.dtorepository.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();
        }
  • 이렇게 가져오면 DB로 전송되는 쿼리는 하나지만, 다음 두가지 문제가 발생한다.
    • 문제 1. 가장 많은 데이터가 조회되는 N측, 즉, OrderItem에 맞춘 결과 데이터가 생성되고, 그 외 다른 엔티티들은 모두 중복된 상태로 반환된다.
    • 문제 2. 기존에 OrderQueryDto로 반환하던 반환 스펙이 OrderFlatDto로 변경된다.
  • 이에 따라 다음과 같이 반환된 결과를 가지고 직접 (중복 제거 + OrderQueryDto로 스펙 맞추기) 작업을 수행해야 한다.
  • OrderApiController
    	@GetMapping("/order/v6")
        public OrderResponseFormat<List<OrderQueryDto>> ordersV6() {
            List<OrderFlatDto> flats = orderService.findOrderQueryDtosFlat();
    
            return new OrderResponseFormat<>("조회 완료",
                    flats
                            .stream()
                            .collect(groupingBy(o -> new OrderQueryDto(o.getOrderId(), o.getName(), o.getOrderDate(), o.getStatus(), 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().getStatus(), e.getKey().getAddress(), e.getValue()))
                            .collect(toList())
            );
        }

⇒ 엔티티로 조회하던 V3.1까지의 과정과 비교해 보면, JPA에서 DTO로 바로 조회하는 V4~V6과정이 좀 더 복잡한 것을 확인할 수 있다. 하지만 V4~V6 과정을 적용하면 DB로 전송되는 쿼리에서 SELECT 절을 줄일 수 있다.

(불필요한 컬럼들까지 조회할 필요가 없어진다. 하지만 그만큼 API 스펙에 딱 맞춰진 조회 메서드로 구성될 것이다.)

  • V6 방식의 장단점
    • 장점 : Query 1번만 나간다!
    • 단점
      • 쿼리는 1번이지만, JOIN 으로 인해 DB에서 애플리케이션으로 전달하는 데이터에 중복 데이터가 추가되기 때문에 상황에 따라 V5보다 느릴 수 있다.
      • 애플리케이션에서 수행해야 하는 추가 작업이 크다.
      • 페이징이 불가능하다.
        • 조회할 때 아예 N 측에 맞춰져 조회되기 때문!
profile
경험과 기록으로 성장하기
post-custom-banner

0개의 댓글