JPA

API 개발 고급 - 컬렉션 조회 최적화

MIN우 2023. 1. 17. 17:07
728x90

주문내역에서 추가로 주문한 상품정보를 추가로 조회해보자.

Order을 기준으로 컬렉션인 OrderItem와 Item이 필요로합니다.

 

조회 성능최적화에서는 OneToOne ,ManyToOny관계만 있었다. 이번에는 컬렉션인일대다 관계(OneToMany)를 조회하고 최적화 하는 방법을 알아보겠습니다.

 

주문 조회 V1: 엔티티 직접 노출

package jpabook.jpashop.api;

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

import java.util.List;

@RequiredArgsConstructor
@RestController
public class OrderApiController {

    private final OrderRepository orderRepository;

    @GetMapping("/api/v1/orders")
    public List<Order> ordersV1() {
        List<Order> all = orderRepository.findAllByString(new OrderSearch());
        for (Order order : all) {
            order.getMember().getName();
            order.getDelivery().getAddress();
            List<OrderItem> orderItems = order.getOrderItems();
            orderItems.stream().forEach(o -> o.getItem().getName());
        }
        return all;
    }
}

orderItem, Item 관계를 직접 초기화하면 Hibernate5Module 설정에 의해 엔티티를 JSON으로 생성한다.

양방향 연관관계면 무한루프에 걸리지않게 한곳에 @JsonIgnore을 추가해야한다.

엔티티를 직접 노출하므로 좋은방법이아니다.

주문 조회 V2: 엔티티를 DTO로 변환

 

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.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;

@RequiredArgsConstructor
@RestController
public class OrderApiController {
    private final OrderRepository orderRepository;

    @GetMapping("/api/v2/orders")
    public List<OrderDto> ordersV2() {
        List<Order> orders = orderRepository.findAllByString(new OrderSearch());
        List<OrderDto> collect = orders.stream()
                .map(o -> new OrderDto(o))
                .collect(Collectors.toList());
        return collect;
    }

    @Data
    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 o) {
            orderId = o.getId();
            name = o.getMember().getName();
            orderDate = o.getOrderDate();
            orderStatus = o.getStatus();
            address = o.getDelivery().getAddress();
            orderItems = o.getOrderItems().stream()
                    .map(orderItem -> new OrderItemDto(orderItem))
										.collect(Collectors.toList());
        }
    }

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

}

ValueObject인 Address는 그대로 조회해도 상관없음.

지연로딩이 너무많은 SQL실행

SQL실행횟수

order 1번 , member,address N번(order조회수),orderItem N번(order조회수),item N번(order조회수)

주문 조회 V3: 엔티티를 DTO로 변환 - 페치 조인 최적화

 

@GetMapping("/api/v3/orders")
    public List<OrderDto> ordersV3() {
        List<Order> allWithItem = orderRepository.findAllWithItem();
        for (Order order : allWithItem) {
            System.out.println("order ref="+order+" order.getId() = " + order.getId());
        }
        List<OrderDto> collect = allWithItem.stream()
                .map(o -> new OrderDto(o))
                .collect(Collectors.toList());
        return collect;

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

패치 조인으로 인해 SQL이 한번만 실행된다.

onyToMany관계일떄 distinct를 사용하지않으면 row 수가 One의 갯수가 아닌 Many의 갯수만큼 증가한다.

그 결과 distinct를 빼면 Order엔티티의 조회수도 증가하게된다.

 

문제점: 페이징불가능. out of memory 예외가 발생할 수 있습니다.

 

참고:컬렉션 페치조인은 1개만 사용할 수 있다. 컬렉션 둘 이상 페치조인을 사용하면 데이터의 정합성이 떨어질뿐만아니라

N*N개로 조회되는 중복된 ROW의 수가 기하급수적으로 늘어남.

 

주문 조회 V3.1: 엔티티를 DTO로 변환 - 페이징과 한계 돌파

 

페이징+컬렉션 엔티티를 함께 조회하려면 어떻게 해야할까 ?

 

먼저 ToOne(OneToOne,ManyToOne)관계를 모두 페치조인 한다. ToOne관계는 row수를 증가시키지 않으므로

페이징 쿼리에 영향을 주지않는다.

 

컬렉션은 지연로딩으로 조회한다.

 

지연 로딩 성능 최적화를 위해 hibernate.defalut_batch_fetch_size,@BatchSize를 적용한다.

이 옵션을 사용하면 컬렉션이나 ,프록시 객체를 한꺼번에 설정한 size만큼 in쿼리로 조회한다.

@GetMapping("/api/v3.1/orders")
public List<OrderDto> ordersV3_page(
        @RequestParam(value = "offset", defaultValue = "0") int offset,
        @RequestParam(value = "limit", defaultValue = "100") int limit
        ) {
    List<Order> allWithItem = orderRepository.findAllWithMemberDelivery(offset,limit);
    for (Order order : allWithItem) {
        System.out.println("order ref=" + order + " order.getId() = " + order.getId());
    }
    List<OrderDto> collect = allWithItem.stream()
            .map(o -> new OrderDto(o))
            .collect(Collectors.toList());
    return collect;
}
spring:
	jpa:
    properties:
      hibernate:
	        default_batch_fetch_size: 100

 

장점:

쿼리 호출수가 1+N -> 1+1로 최적화 

DB전송량이 감소한다.페이징가능

 

결론: ToOne관계는 페치조인해도 페이징에 영향을 주지않는다. 따라서 ToOne관계는 페치조인으로 쿼리 수를 줄여서해결하고, 나머지는 hibernate.default_batch_fetch_size 로 최적화하자.

 

참고: hibernate.default_batch_fetch_size 100~1000사이를 선택하는 것을 권장한다.

728x90