본문 바로가기

JPA

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

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