Spring/API

[API] 컬렉션 조회 최적화

김긍수 2021. 3. 25. 16:45

xToOne 관계는 패치조인 등으로 성능 최적화를 할 수 있다.

컬렉션인 일대다 관계 (OneToMany)를 조회하고 최적화하는 방법은 무엇일까!

 

버전 1. 엔티티를 직접 노출하는 방식은 피해야한다.

버전 2. DTO를 만들어서 사용하는 방식

package jpabook.jpashop.api;

import jpabook.jpashop.domain.Address;
import jpabook.jpashop.domain.Order;
import jpabook.jpashop.domain.OrderItem;
import jpabook.jpashop.domain.OrderStatus;
import jpabook.jpashop.repository.OrderRepository;
import jpabook.jpashop.repository.OrderSearch;
import lombok.Data;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

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

@RestController
@RequiredArgsConstructor
public class OrderApiController {

    private final OrderRepository orderRepository;

    /**
     * V1. 엔티티 직접 노출
     * order정보와 orderItem 정보를 함께 출력
     * V1은 엔티티를 직접 노출하는 방법이므로 가급적 피해야한다.
     */
    @GetMapping("/api/v1/orders")
    public List<Order> ordersV1() {
        List<Order> all = orderRepository.findAllByString(new OrderSearch());
        for (Order order : all) {
            order.getMember().getName(); // LAZY 강제초기화
            order.getDelivery().getAddress();

            List<OrderItem> orderItems = order.getOrderItems();
            orderItems.stream().forEach(o -> o.getItem().getName()); // LAZY 강제 초기화
//            for (OrderItem orderItem : orderItems) {
//                orderItem.getItem().getName();
//            }
        }
        return all;
    }

    /**
     * V2. DTO 사용
     *
     */
    @GetMapping("/api/v2/orders")
    public List<OrderDto> ordersV2() {
        List<Order> orders = orderRepository.findAllByString(new OrderSearch());
        // orders를 orderDto로 변환
        List<OrderDto> collect = orders.stream().map(o -> new OrderDto(o))
                .collect(Collectors.toList());

        return collect;

    }

    @Getter
    static class OrderDto {
        private Long orderId;
        private String name;
        private LocalDateTime orderDate;
        private OrderStatus orderStatus;
        private Address address;
        private List<OrderItem> orderItems;

        public OrderDto(Order order) {
            orderId = order.getId();
            name = order.getMember().getName();
            orderDate = order.getOrderDate();
            orderStatus = order.getStatus();
            address = order.getDelivery().getAddress();
            orderItems = order.getOrderItems();
        }
    }   
}

위 코드의 문제점

1. orderItem은 엔티티여서 "orderItems"null로 나온다.

 

 @Getter
    static class OrderDto {
        private Long orderId;
        private String name;
        private LocalDateTime orderDate;
        private OrderStatus orderStatus;
        private Address address;
        private List<OrderItem> orderItems;

        public OrderDto(Order order) {
            orderId = order.getId();
            name = order.getMember().getName();
            orderDate = order.getOrderDate();
            orderStatus = order.getStatus();
            address = order.getDelivery().getAddress();
            order.getOrderItems().stream().forEach(o -> o.getItem().getName()); //프록시 초기화
            orderItems = order.getOrderItems();
        }
    }

이렇게 다시 프록시 초기화를 하도록 변경해주면 원하는 결과는 나온다.

하지만 OrderItem자체는 엔티티 그대로 이기때문에 외부에 엔티티가 노출되므로 적합하지 않다.

OrderItem도 DTO를 생성해서 바꿔주어야한다!

 

package jpabook.jpashop.api;

import jpabook.jpashop.domain.Address;
import jpabook.jpashop.domain.Order;
import jpabook.jpashop.domain.OrderItem;
import jpabook.jpashop.domain.OrderStatus;
import jpabook.jpashop.repository.OrderRepository;
import jpabook.jpashop.repository.OrderSearch;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

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

@RestController
@RequiredArgsConstructor
public class OrderApiController {

    private final OrderRepository orderRepository;

    /**
     * V2. DTO 사용
     *
     */
    @GetMapping("/api/v2/orders")
    public List<OrderDto> ordersV2() {
        List<Order> orders = orderRepository.findAllByString(new OrderSearch());
        // orders를 orderDto로 변환
        List<OrderDto> collect = orders.stream().map(o -> new OrderDto(o))
                .collect(Collectors.toList());

        return collect;

    }

    @Getter
    static class OrderDto {
        private Long orderId;
        private String name;
        private LocalDateTime orderDate;
        private OrderStatus orderStatus;
        private Address address;
        private List<OrderItemDto> orderItems;

        public OrderDto(Order order) {
            orderId = order.getId();
            name = order.getMember().getName();
            orderDate = order.getOrderDate();
            orderStatus = order.getStatus();
            address = order.getDelivery().getAddress();
            orderItems = order.getOrderItems().stream().map(orderItem -> new OrderItemDto(orderItem))
                    .collect(Collectors.toList());

            /*
                order.getOrderItems().stream().forEach(o -> o.getItem().getName());
                orderItems = order.getOrderItems();
                원하는 결과가 나오지만, 결국 orderItems는 엔티티 그대로 노출되기 때문에 적합하지 않다.
                orderItem도 DTO로 변경해주어야한다. (List<OrderItem> X)
            */
        }
    }

    @Getter
    static class OrderItemDto {
        // 상품명, 가격, 개수만 필요한 경우
        private String itemName;
        private int orderPrice;
        private int count;

        public OrderItemDto(OrderItem orderItem) {
            itemName = orderItem.getItem().getName();
            orderPrice = orderItem.getOrderPrice();
            count = orderItem.getCount();
        }
    }

}

하지만 이 V2 코드는 지연로딩에 의해 너무 많은 쿼리가 실행된다.

 

버전 3. 패치조인을 이용해서 최적화하는 방식

/**
     * V3 패치조인
     */
    @GetMapping("/api/v3/orders")
    public List<OrderDto> ordersV3() {
        List<Order> orders = orderRepository.findAllWithItem();
        List<OrderDto> result = orders.stream().map(o -> new OrderDto(o))
                .collect(Collectors.toList());

        return result;
    }
public List<Order> findAllWithItem() {
        return em.createQuery(
                "select distinct o from Order o" +
                        " join fetch o.member m" +
                        " join fetch o.delivery d" +
                        " join fetch o.orderItems oi" +
                        " join fetch oi.item i", Order.class)
                .getResultList();
        /**
         * 일대다 관계에서 DB에서 조인을 하게되면 다(n)만큼 데이터 양이 증가한다.
         * order가 2개고 각각의 orderItem이 2개라고 했을 때, 결과는 order가 4개로 나오게 되는 것이다. (DB 상 조인
         * 해결을 위해 distinct를 추가하면 중복이 제거된다.
         * 1. DB에서 쿼리에 distinct 키워드를 추가한다.  : 한 레코드의 결과가 모두 똑같이 중복되야 제거가 된다.
         * 2. JPA가 루트 Entity가 중복인 경우 중복을 제거한 후 컬렉션에 담는다.
        **/
    }

1개의 쿼리로 결과를 얻을 수 있다. 성능 업업!!

하지만 페이징을 할 수 없다는 단점이 있다.

컬렉션 둘 이상에 패치 조인을 사용하면 안된다. 데이터가 부정합하게 조회될 수 있다.

컬렉션을 패치 조인하면 다(N)을 기준으로 결과 row가 생성되기 때문에 데이터가 증가하게 된다.

하이버네이트는 경고 로그를 남기면서 모든 데이터를 DB에서 읽어오고, 메모리에서 페이징해버리기 때문이다.

일대다 패치 조인에서는 페이징을 해선 안된다. (절대)

 

컬렉션 패치 조인은 1개만 사용할 수 있다. 

둘 이상 사용하게 되면 데이터가 완전히 뻥튀기(많아짐)된다!!!!!!!  

 

버전 3-1. 엔티티를 DTO로 변환 - 페이징과 한계 돌파