제육's 휘발성 코딩
article thumbnail
반응형

컬렉션 조회 최적화

컬렉션 조회(일대다) 시 페치조인을 사용하면 페이징 문제가 발생한다. 예제를 통해 상황을 살펴보자.

페이징 문제의 원인은 하단 포스팅을 참고하자.

https://sasca37.tistory.com/184?category=1218758 

 

[Spring JPA] JPQL 중급 문법

경로 표현식 .(점)을 찍어 객체 그래프를 탐색하는 것을 경로 표현식이라고 한다. 경로 표현식 용어 정리 상태 필드 단순히 값을 저장하기 위한 필드 (ex : m.username) 연관 필드 연관관계를 위한 필

sasca37.tistory.com

 

주문내역에서 추가로 주문한 상품 정보를 조회하는 예제를 진행해보자.

package jpabook.jpashop.domain;

import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import javax.persistence.*;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

@Entity
@Table(name = "orders")
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {

    @Id
    @GeneratedValue
    @Column(name = "order_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    private List<OrderItem> orderItems = new ArrayList<>();

    @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    @JoinColumn(name = "delivery_id")
    private Delivery delivery;

    private LocalDateTime orderDate;

    @Enumerated(EnumType.STRING)
    private OrderStatus status; // 주문 상태 [ORDER , CANCEL]

    //== 연관관계 편의 메서드 ==//
    public void setMember(Member member) {
        this.member = member;
        member.getOrders().add(this);
    }

    public void addOrderItem(OrderItem orderItem) {
        orderItems.add(orderItem);
        orderItem.setOrder(this);
    }

    public void setDelivery(Delivery delivery) {
        this.delivery = delivery;
        delivery.setOrder(this);
    }

    //== 생성 메서드 ==//
    public static Order createOrder(Member member, Delivery delivery, OrderItem... orderItems) {
        Order order = new Order();
        order.setMember(member);
        order.setDelivery(delivery);
        for (OrderItem orderItem : orderItems) {
            order.addOrderItem(orderItem);
        }
        order.setStatus(OrderStatus.ORDER);
        order.setOrderDate(LocalDateTime.now());
        return order;
    }

    //== 비즈니스 로직==//

    // 주문 취소
    public void cancel() {
        if (delivery.getStatus() == DeliveryStatus.COMP) {
            throw new IllegalStateException("이미 배송완료된 상품은 취소가 불가능합니다.");
        }

        this.setStatus(OrderStatus.CANCEL);
        for (OrderItem orderItem : orderItems) {
            orderItem.cancel();
        }
    }

    //== 조회 로직==//

    /**
     * 전체 주문 가격 조회
     */
    public int getTotalPrice() {
        int totalPrice = 0;
        for (OrderItem orderItem : orderItems) {
            totalPrice += orderItem.getTotalPrice();
        }
        return totalPrice;
    }
}
  • Order 기준으로 컬렉션인 OrderItemItem이 필요하다. (OrderItem의 다대다 관계를 OrderItem으로 풀어낸 상황)

 

주문 조회 V1

package jpabook.jpashop.api;

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

import java.util.List;

@RestController
@RequiredArgsConstructor
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(); // Lazy 강제 초기화
            order.getDelivery().getAddress(); // Lazy 강제 초기화
            List<OrderItem> orderItems = order.getOrderItems();
            orderItems.stream().forEach(o -> o.getItem().getName()); // Lazy 강제 초기화
        }
        return all;
    }
}
  • orderItemitem 관계를 직접 초기화하면 Hibernate5Module 설정에 의해 엔티티를 JSON으로 생성한다.
    • 양방향 연관관계에 속한 부분을 한 곳에 @JsonIgnore를 추가해야 한다.
    • 엔티티를 직접적으로 노출했으므로 좋은 방법이 아니다.

 

주문 조회 V2

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

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

@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();
  }
}
  • OrderDto를 생성해서 가져올 값들을 선정하면서 포함된 OrderItem 들도 추가로 DTO로 만들어주어야 한다.

 

 

image

  • 다음과 같이 필요한 데이터들만 반환되는 것을 확인할 수 있다.

 

단, 지연 로딩으로 너무 많은 SQL이 실행 된다. (N+1 문제 발생)

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

 

 

주문 조회 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;
}
  • 페치 조인을 적용시키기 위한 findAllWithItem() 메서드 적용

 

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();
}
  • 페치 조인을 사용한 쿼리문 구현 (distinct 사용)

 

컬렉션을 조회할 때 (일대다 관계) distinct를 사용했다. 그 이유는 데이터가 중복되기 때문이다. 이는 RDBMS에서 조회에 대해 이해해야 한다. 다음 그림을 살펴보자.





image

  • order_id 값을 가진 것을 조회할 때 그 안에서 주문 상품은 다른 경우가 있기 때문에 데이터가 중복이 된다. 즉, order_id 가 중복된 상태로 반환한다. 이 문제를 해결하기 위해 distinct 를 사용하는 것이다.

 

 

페치 조인 사용으로 SQL이 한번만 실행되어서 성능을 최적화 했다.

하지만 컬렉션 페치 조인의 경우 페이징이 불가능하다. (모든 데이터를 메모리에서 페이징 및 정합성 문제)

https://sasca37.tistory.com/184?category=1218758 해당 이슈에 대한 포스팅을 참고하자.

 

 

컬렉션 페치 조인 한계 돌파

 

대부분의 페이징 + 컬렉션 엔티티 조회 문제는 이 방법으로 해결할 수 있다.

  • ToOne(@OneToOne, @ManyToOne) 관계를 모두 페치조인 한다. (ToOne 관계는 row수를 증가시키지 않으므로 페이징 쿼리에 영향을 주지 않는다.)
  • 컬렉션은 지연 로딩으로 조회한다. (페치 조인 사용 X)
  • 지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size, @BatchSize 를 적용한다.
    • hibernate.default_batch_fetch_size : 글로벌 설정
    • @BatchSize : 개별 최적화
    • 이 옵션을 사용하면 컬렉션이나, 프록시 객체를 한꺼번에 설정한 size만큼 IN 쿼리로 조회한다.

 

@GetMapping("/api/v3.1/orders")
public List<OrderDto> orderV3_page(
  @RequestParam(value = "offset", defaultValue = "0") int offset,
  @RequestParam(value = "limit", defaultValue = "100") int limit) {
  List<Order> orders = orderRepository.findAllWithMemberDelivery(offset, limit);
  List<OrderDto> result = orders.stream().map(o -> new OrderDto(o)).collect(Collectors.toList());
  return result;
}
  • 다음과 같이 offset, limit 를 파라미터로 받아와서 findAllWithMemberDelivery 메서드에 넣어주자.

 

public List<Order> findAllWithMemberDelivery(int offset, int limit) {
  return em.createQuery(
    "select o from Order o " +
    "join fetch o.member m " +
    "join fetch o.delivery d", Order.class)
    .setFirstResult(offset)
    .setMaxResults(limit)
    .getResultList();
}
  • 넘어온 offsetlimit 을 페이징 처리에 넣어주자.
  • orderItemitem일대다 관계로 지연로딩으로 가져올 것이기 때문에 페치 조인 하지 않는다. (한방 쿼리, OrderItem, Item 총 3번의 조회 쿼리가 실행된다.

 

spring: jpa:
  properties:
    hibernate:
      default_batch_fetch_size: 1000
  • application.yml 에 다음과 같이 설정한다. 개별 설정 (엔티티 클래스 또는 컬렉션 필드)은 @BatchSize를 적용하면 된다.

 

image

  • 다음과 같이 조회 문제의 최적화가 된 것을 볼 수 있다.

 

ToOne 관계는 페치조인하고 나머지는 hibernate.default_batch_size 로 최적화 하자.

default_batch_fetch_size 의 크기는 적당한 사이즈를 골라야하는데, 100~1000 사이를 권장한다.

DB나 애플리케이션의 순간 부하를 어디까지 견딜 수 있는 지를 판별해서 결정하면 된다.

 

주문 조회 V4

@GetMapping("/api/v4/orders")
public List<OrderQueryDto> ordersV4() {
  return orderQueryRepository.findOrderQueryDtos();
}
  • DTO를 생성해서 반환하는 메서드를 생성하자.

 

package jpabook.jpashop.repository.order.query;

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

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

@Data
@EqualsAndHashCode(of = "orderId")
public class OrderQueryDto {

    private Long orderId;
    private String name;
    private LocalDateTime orderDate;
    private OrderStatus orderStatus;
    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.orderStatus = orderStatus;
        this.address = address;
    }
}
  • Order를 대신할 OrderQueryDto 를 만들었다. 이 안에는 OrderItem 의 정보가 들어있기 때문에 OrderItemQueryDto 를 추가로 생성하자.
  • @EqualsAndHashCode(of = "orderId") 는 상호 참조하는 필드에서 무한 참조를 방지하기 위해 orderId 만 참조하도록 설정하였다.

 

package jpabook.jpashop.repository.order.query;

import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Data;

@Data
public class OrderItemQueryDto {

    @JsonIgnore
    private Long orderId;
    private String itemName;
    private int orderPrice;
    private int count;

    public OrderItemQueryDto(Long orderId, String itemName, int orderPrice, int count) {
        this.orderId = orderId;
        this.itemName = itemName;
        this.orderPrice = orderPrice;
        this.count = count;
    }
}
  • OrderItemQueryDto는 다음과 같다.

 

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

    private List<OrderItemQueryDto> findOrderItems(Long orderId) {
        return em.createQuery(
                "select new jpabook.jpashop.repository.order.query.OrderItemQueryDto" +
                        "(oi.order.id, i.name, oi.orderPrice, oi.count) " +
                        "from OrderItem  oi " +
                        "join oi.item i " +
                        "where oi.order.id = :orderId", OrderItemQueryDto.class
        ).setParameter("orderId", orderId).getResultList();
    }
}
  • findOrders() 에서 Order 의 정보를 DTO로 변환하고, findOrdersItems() 에서 OrderItem 의 정보를 DTO로 변환하여 findOrderQueryDtos() 에서 변환된 값을 반환하자.
  • join fetch를 사용하지 않은 이유는 DTO는 엔티티가 아니기 때문이다. 즉, DTO를 사용하는 경우(new 연산자) SQL의 JOIN문을 사용해서 처음부터 원하는 데이터를 모두 선택해서 조회하도록 구성해야한다.

 

 

V4에서 DTO를 사용해서 원하는 결과 값을 얻을 수 있었다.

XToOne 의 관계(데이터 row 증가 X)들을 한번에 먼저 조회하고, XToMany 관계(데이터 row 증가)는 별도로 처리했다.

현재는 루트 (한방 쿼리) 한 번, 컬렉션 N 번이 실행되었다.(N+1 문제 발생) V5에서 개선해보자.


 

주문 조회 V5

@GetMapping("/api/v5/orders")
public List<OrderQueryDto> ordersV5() {
  return orderQueryRepository.findAllByDto_optimization();
}
  • V4에서 N+1 문제를 개선한 findAllByDto_optimization() 를 만들어보자.

 

package jpabook.jpashop.repository.order.query;

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

import javax.persistence.EntityManager;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

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

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

    public List<OrderQueryDto> findAllByDto_optimization() {

        // 루트 조회 (XToOne 한방 쿼리)
        List<OrderQueryDto> result = findOrders();

        // orderItem 컬렉션을 MAP 한방에 조회
        Map<Long, List<OrderItemQueryDto>> orderItemMap = findOrderItemMap(toOrderIds(result));

        // 루프를 돌면서 컬렉션 추가 (추가 쿼리 실행 X)
        result.forEach(o -> o.setOrderItems(orderItemMap.get(o.getOrderId())));
        return result;
    }

    private Map<Long, List<OrderItemQueryDto>> findOrderItemMap(List<Long> orderIds) {
        List<OrderItemQueryDto> orderItems = em.createQuery(
                "select new jpabook.jpashop.repository.order.query.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();

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

    private List<Long> toOrderIds(List<OrderQueryDto> result) {
        List<Long> orderIds = result.stream().map(o -> o.getOrderId()).collect(Collectors.toList());
        return orderIds;
    }
}
  • V4와 다르게 MAP을 사용하여 루트 1번, 컬렉션 1번으로 쿼리 성능이 최적화 된 것을 볼 수 있다.

 

주문 조회 V6

@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());
}
  • 모든 데이터를 받는 OrderFlatDto 만들어서 OrderQueryDto 로 변환하여 반환하는 버전이다.

 

public OrderQueryDto(Long orderId, String name, LocalDateTime orderDate, OrderStatus orderStatus, Address address, List<OrderItemQueryDto> orderItems) {
  this.orderId = orderId;
  this.name = name;
  this.orderDate = orderDate;
  this.orderStatus = orderStatus;
  this.address = address;
  this.orderItems = orderItems;
}
  • OrderQueryDto 에 다음과 같은 생성자를 추가해주자.
  • @EqualsAndHashCode(of = "orderId") 를 클래스 레벨에서 추가해줘야한다.

 

package jpabook.jpashop.repository.order.query;

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

import java.time.LocalDateTime;

@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 를 생성하자.

 

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();
}
  • findAllByDto_flat() 을 통해 OrderFlatDto를 만들어서 반환한다.

 

image

  • 다음과 같이 Query 1번으로 모든 데이터를 정리할 수 있다.
  • 단점
    • 쿼리는 한번이지만, 조인으로 인해 DB에서 애플리케이션으로 전달하는 데이터에 중복 데이터가 추가되므로 상황에 따라 V5보다 더 느릴 수 있다. (HashCode 를 설정한 이유와 동일하다.)
    • 애플리케이션의 추가 작업이 크며, 페이징이 불가능(중복 데이터)

 

정리


권장 순서

  • 엔티티 조회 방식으로 우선 접근
    • 페치 조인으로 쿼리 수 최적화
    • 컬렉션 최적화
      • 페이징 필요 시 배치 사이즈를 설정하여 최적화 (지연 로딩 유지하되 페치 조인 사용 X)
      • 페이징이 필요 없는 경우 페치 조인 사용
  • 엔티티 조회 방식으로 해결이 안되는 경우 DTO 조회 방식을 사용
  • DTO 조회 방식으로 해결이 안되면 NativeSQL or JdbcTemplate 사용

 

엔티티 조회 방식은 JPA가 많은 부분을 최적화 해주기 때문에, 단순한 코드를 유지하면서, 성능을 최적화 할 수 있다.

반면 DTO 조회 방식은 SQL을 직접 다루는 것과 유사하기 때문에, 둘 사이에 고민을 해야 한다.

엔티티 조회 방식을 우선적으로 사용하며, 해결이 안될 경우 DTO , Native SQL 방식을 선택하자.

 


본 포스팅은 인프런 김영한님 강의(실전! 스프링 부트와 JPA 활용2)를 토대로 정리한 내용입니다.

반응형
profile

제육's 휘발성 코딩

@sasca37

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요! 맞구독은 언제나 환영입니다^^