Web/JPA

EP3. 컬렉션 조회 최적화 - 1

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

 

EP2 에서는 @XToOne 을 조회하는 기능을 최적화 했었다.

2021.04.06 - [Web/JPA] - EP2. 지연 로딩과 조회 성능 최적화

 

여기서는 @XToMany인 컬렉션을 조회하는 기능을 구현하고 최적화 해보자.

 

 

Order 엔티티에서 컬렉션인 OrderItem과

OrderItem의 Item이 필요하다. Item도 Order 기준으로는 컬렉션이다.

 

Order

package jpabook.jpashop.domain;

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;

import static javax.persistence.CascadeType.*;
import static javax.persistence.FetchType.LAZY;

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

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

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

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

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

    private LocalDateTime orderDate; // 주문 시간

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

 

Item

package jpabook.jpashop.domain;

import com.fasterxml.jackson.annotation.JsonIgnore;
import jpabook.jpashop.domain.Item.Item;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import javax.persistence.*;

import static javax.persistence.FetchType.*;

@Entity
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OrderItem {

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

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "item_id")
    private Item item;

    @JsonIgnore
    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "order_id")
    private Order order;

    private int orderPrice;

    private int count;

    //==생성 메서드==//
    public static OrderItem createOrderItem(Item item, int orderPrice, int count) {
        OrderItem orderItem = new OrderItem();
        orderItem.setItem(item);
        orderItem.setOrderPrice(orderPrice);
        orderItem.setCount(count);

        item.removeStock(count);
        return orderItem;
    }

    //==비즈니스 로직==//
    public void cancel() {
        getItem().addStock(count);
    }

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

    /**
     * 주문상품 전체 가격 조회
     */
    public int getTotalPrice() {
        return getOrderPrice() * getCount();
    }
}

 

 

엔티티를 직접 노출하면 안되므로 Order를 DTO로 반환해보았다.

    @GetMapping("/api/v2/orders")
    public List<OrderDto> ordersV2() {
        List<Order> orders = orderRepository.findAllByString(new OrderSearch());
        List<OrderDto> result = orders.stream()
                .map(OrderDto::new)
                .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<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();
        }
    }

 

POSTMAN으로 조회요청을 보냈을때의 결과에서 orderItems 부분이 null 이 나왔다.

[
    {
        "orderId": 4,
        "name": "userA",
        "orderDate": "2021-04-07T18:52:53.179531",
        "orderStatus": "ORDER",
        "address": {
            "city": "서울",
            "street": "1",
            "zipcode": "1111"
        },
        "orderItems": null
    },
    {
        "orderId": 11,
        "name": "userB",
        "orderDate": "2021-04-07T18:52:53.23564",
        "orderStatus": "ORDER",
        "address": {
            "city": "진주",
            "street": "2",
            "zipcode": "2222"
        },
        "orderItems": null
    }
]

orderItems가 컬렉션, @OneToMany이므로 지연로딩이 되어있어서 데이터가 없었다.

 

그래서 프록시 객체를 초기화 하고,

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

 

 

다시 요청해보았을 때, orderItems 정보가 나왔다.

하지만 여기서 문제는 orderItems는 DTO로 변환하지 않았기 때문에 엔티티가 그대로 노출되는 상황이 되었다.

[
    {
        "orderId": 4,
        "name": "userA",
        "orderDate": "2021-04-07T18:56:13.624688",
        "orderStatus": "ORDER",
        "address": {
            "city": "서울",
            "street": "1",
            "zipcode": "1111"
        },
        "orderItems": [
            {
                "id": 6,
                "item": {
                    "id": 2,
                    "name": "JPA1 BOOK",
                    "price": 10000,
                    "stockQuantity": 99,
                    "categories": null,
                    "author": null,
                    "isbn": null
                },
                "orderPrice": 10000,
                "count": 1,
                "totalPrice": 10000
            },
            {
                "id": 7,
                "item": {
                    "id": 3,
                    "name": "JPA2 BOOK",
                    "price": 20000,
                    "stockQuantity": 98,
                    "categories": null,
                    "author": null,
                    "isbn": null
                },
                "orderPrice": 20000,
                "count": 2,
                "totalPrice": 40000
            }
        ]
    },
    {
        "orderId": 11,
        "name": "userB",
        "orderDate": "2021-04-07T18:56:13.670374",
        "orderStatus": "ORDER",
        "address": {
            "city": "진주",
            "street": "2",
            "zipcode": "2222"
        },
        "orderItems": [
            {
                "id": 13,
                "item": {
                    "id": 9,
                    "name": "SPRING1 BOOK",
                    "price": 20000,
                    "stockQuantity": 197,
                    "categories": null,
                    "author": null,
                    "isbn": null
                },
                "orderPrice": 20000,
                "count": 3,
                "totalPrice": 60000
            },
            {
                "id": 14,
                "item": {
                    "id": 10,
                    "name": "SPRING2 BOOK",
                    "price": 40000,
                    "stockQuantity": 296,
                    "categories": null,
                    "author": null,
                    "isbn": null
                },
                "orderPrice": 40000,
                "count": 4,
                "totalPrice": 160000
            }
        ]
    }
]

 

 

 

OrderDto 안에서 OrderItem도 DTO로 감싸주어야 한다.

 

    @GetMapping("/api/v2/orders")
    public List<OrderDto> ordersV2() {
        List<Order> orders = orderRepository.findAllByString(new OrderSearch());
        List<OrderDto> result = orders.stream()
                .map(OrderDto::new)
                .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(OrderItemDto::new)
                    .collect(Collectors.toList());
        }
    }

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

 

그렇다면 정상적으로 orderItems 까지 엔티티가 노출되지 않고 원하는정보만 DTO로 내보낼 수 있게 된다.

[
    {
        "orderId": 4,
        "name": "userA",
        "orderDate": "2021-04-07T19:00:13.152114",
        "orderStatus": "ORDER",
        "address": {
            "city": "서울",
            "street": "1",
            "zipcode": "1111"
        },
        "orderItems": [
            {
                "itemName": "JPA1 BOOK",
                "orderPrice": 10000,
                "count": 1
            },
            {
                "itemName": "JPA2 BOOK",
                "orderPrice": 20000,
                "count": 2
            }
        ]
    },
    {
        "orderId": 11,
        "name": "userB",
        "orderDate": "2021-04-07T19:00:13.211254",
        "orderStatus": "ORDER",
        "address": {
            "city": "진주",
            "street": "2",
            "zipcode": "2222"
        },
        "orderItems": [
            {
                "itemName": "SPRING1 BOOK",
                "orderPrice": 20000,
                "count": 3
            },
            {
                "itemName": "SPRING2 BOOK",
                "orderPrice": 40000,
                "count": 4
            }
        ]
    }
]

 

 

 

 

그렇다면 이 코드에서 주문조회시 SQL은 몇번이나 나갈까?

 

1. 처음에 order 가져올때 한번 나간다.. ( 누적 1번 )

2. Member와 Delivery 프록시 객체 초기화시 한번씩 나간다. (누적 3번)

 

3. OrderItem을 가져올때 프록시 객체 초기화시 한번 나간다. (누적 4번)

5. orderItem이 2개이기 때문에 각 item의 정보를 가져오기 위해 또 2번 더 나간다. (누적 6번)

 

6. 처음에 order가 두개였기 때문에 위와 같은 방식으로 member, delivery, OrderItem, Item, Item 총 5개가 더 나간다.

 

누적 11번

 

N + 1 문제가 또 발생한 것이다.

 

 

 

해결방법

fetch join을 사용해보자

 

JPQL로 LAZY인 엔티티들을 미리 가져오자

    public List<Order> findAllWithItem() {
        return em.createQuery(
                "select 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();
    }

orderItems의 item도 객체그래프 탐색을 통해서 가져올 수 있다.

 

    @GetMapping("/api/v3/orders")
    public List<OrderDto> ordersV3() {
        List<Order> orders = orderRepository.findAllWithItem();
        List<OrderDto> result = orders.stream()
                .map(OrderDto::new)
                .collect(Collectors.toList());
        return result;
    }

 

그런데 결과가 조금 이상했다.

같은 데이터가 두개씩 나왔다.

[
    {
        "orderId": 4,
        "name": "userA",
        "orderDate": "2021-04-07T19:14:56.573159",
        "orderStatus": "ORDER",
        "address": {
            "city": "서울",
            "street": "1",
            "zipcode": "1111"
        },
        "orderItems": [
            {
                "itemName": "JPA1 BOOK",
                "orderPrice": 10000,
                "count": 1
            },
            {
                "itemName": "JPA2 BOOK",
                "orderPrice": 20000,
                "count": 2
            }
        ]
    },
    {
        "orderId": 4,
        "name": "userA",
        "orderDate": "2021-04-07T19:14:56.573159",
        "orderStatus": "ORDER",
        "address": {
            "city": "서울",
            "street": "1",
            "zipcode": "1111"
        },
        "orderItems": [
            {
                "itemName": "JPA1 BOOK",
                "orderPrice": 10000,
                "count": 1
            },
            {
                "itemName": "JPA2 BOOK",
                "orderPrice": 20000,
                "count": 2
            }
        ]
    },
    {
        "orderId": 11,
        "name": "userB",
        "orderDate": "2021-04-07T19:14:56.623691",
        "orderStatus": "ORDER",
        "address": {
            "city": "진주",
            "street": "2",
            "zipcode": "2222"
        },
        "orderItems": [
            {
                "itemName": "SPRING1 BOOK",
                "orderPrice": 20000,
                "count": 3
            },
            {
                "itemName": "SPRING2 BOOK",
                "orderPrice": 40000,
                "count": 4
            }
        ]
    },
    {
        "orderId": 11,
        "name": "userB",
        "orderDate": "2021-04-07T19:14:56.623691",
        "orderStatus": "ORDER",
        "address": {
            "city": "진주",
            "street": "2",
            "zipcode": "2222"
        },
        "orderItems": [
            {
                "itemName": "SPRING1 BOOK",
                "orderPrice": 20000,
                "count": 3
            },
            {
                "itemName": "SPRING2 BOOK",
                "orderPrice": 40000,
                "count": 4
            }
        ]
    }
]

 

 

그 이유는 컬렉션 (@OneToMany) 이기 때문에

join은 관계형 데이터베이스에서 "많은" 쪽의 데이터에 맞춰진다.

 

그래서 관계형 DB에서 준 데이터를 그대로 뿌려서 JSON에서도 Order가 4개가 나온 것이다.

 

 

이것을 해결하려면 JPQL에 distinct를 붙여주면 된다.

    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();
    }
[
    {
        "orderId": 4,
        "name": "userA",
        "orderDate": "2021-04-07T19:29:41.35265",
        "orderStatus": "ORDER",
        "address": {
            "city": "서울",
            "street": "1",
            "zipcode": "1111"
        },
        "orderItems": [
            {
                "itemName": "JPA1 BOOK",
                "orderPrice": 10000,
                "count": 1
            },
            {
                "itemName": "JPA2 BOOK",
                "orderPrice": 20000,
                "count": 2
            }
        ]
    },
    {
        "orderId": 11,
        "name": "userB",
        "orderDate": "2021-04-07T19:29:41.402457",
        "orderStatus": "ORDER",
        "address": {
            "city": "진주",
            "street": "2",
            "zipcode": "2222"
        },
        "orderItems": [
            {
                "itemName": "SPRING1 BOOK",
                "orderPrice": 20000,
                "count": 3
            },
            {
                "itemName": "SPRING2 BOOK",
                "orderPrice": 40000,
                "count": 4
            }
        ]
    }
]

 

order가 전과 마찬가지로 2개만 나오는 것을 볼 수 있다.

 

 

쿼리도 1개만 실행되고 select문에는 distinct가 붙여진다.

 

 

그런데 데이터베이스에서는 distinct를 붙여도 그대로 나온다.

데이터베이스에서의 distinct는 데이터가 완전히 중복일때만 생략해주기 때문이다.

 

 

JPQL의 distinct는

  1. SQL문의 selete 절에 dintinct를 붙여주기도 하고
  2. 같은객체가 여러개가 오면 애플리케이션에서 나머지를 생략해준다.

 

 

 

fetch join을 이용해서 SQL문을 1번만 실행하게 했고, distinct를 이용해서 중복 데이터도 제거했다.

 

그런데 여기서도 치명적인 단점이 있다.

 

페이징이 불가능하다.

 

1 대 다 를 fetch join 하면 페이징이 불가능하다.

 

이 전과 마찬가지로 컬렉션을 join하면 관계형 데이터베이스에서는 Order의 개수만큼 데이터가 뻥튀기된다.

 

우리가 원하는 페이징은 Order 2개 기준의 페이징인데, 관계형 데이터베이스에서는 뻥튀기가 되어 4개의 order가 있다.

 

그래서 페이징 기준이 다르기 때문에 할 수 없는 것이다.

 

 

추가로, 페이징 처리를 하지 않더라도 컬렉션 페치조인은 최대 1개만 사용해야한다. 여러개의 컬렉션을 페치조인할경우 데이터가 이상하게 조합될 수 있다.

 

 

정리

컬렉션을 가져올때 페이징 처리를 할 필요가 없다면 최대 1개의 컬렉션을 fetch join 할 수 있다.

 

 

 

 

페이징 + 컬렉션 엔티티를 함께 조회하는 방법

  1. ToOne 관계는 fetch join을 건다. (데이터베이스에서 row의 수와 관계가 없어 페이징에 영향을 미치지 않기 때문에)
  2. ToMany 관계 (컬렉션) 은 지연로딩으로 가져온다. (대신 지연로딩 성능을 최적화 하는 방법 사용)

 

ToOne 관계는 fetch join으로 부담없이 가져오고, 페이징 처리도 해버린다.

    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();
    }
    @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> orders = orderRepository.findAllWithMemberDelivery(offset, limit);

        List<OrderDto> result = orders.stream()
                .map(OrderDto::new)
                .collect(Collectors.toList());

        return result;
    }

 

하지만 컬렉션들은 fetch join이 안되어 있고 지연로딩으로 되어있기 때문에 쿼리가 여러번 나간다. ( 7번 )

 

 

 

여기서 application.yml 의 jpa global 설정을 바꿔보자.

 

jpa.properties.hibernate.default_batch_fetch_size: 100 을 추가

spring:
  datasource:
    url: jdbc:h2:tcp://localhost/~/jpashop
    username: sa
    password:
    driver-class-name: org.h2.Driver

  jpa:
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
#        show_sql: true
        format_sql: true
        default_batch_fetch_size: 100

logging.level:
  org.hibernate.SQL: debug
  org.hibernate.type: trace

 

그리고 다시 실행해보면

 

 

7개 나가던 쿼리가 3개로 줄었다.

 

2번째 사진에 있는 orderItems를 가져오는 쿼리를 보면 뒤에 in 이 추가되어있다.

3번째 사진에 있는 item을 가져오는 쿼리에도 in 이 추가되어 있다.

 

in에 들어간 파라미터를 보면,

yml 파일에서 설정한 batch 사이즈 만큼 in쿼리로 한번에 가져온다.

 

orderItem과 Item을 지연로딩으로 가져올때 발생하던 N+1 문제가 1+1로 해결된 것이다.

 

 

디테일하게 적용하는 방법

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

ToMany관계일때 디테일하게 적용하려면 위에 @BatchSize를 적어주면 된다.

 

 

 

그런데 Item은 OrderItem에서 ToOne 관계이기 때문에 클래스레벨에 붙여주어야 한다.

package jpabook.jpashop.domain;

import com.fasterxml.jackson.annotation.JsonIgnore;
import jpabook.jpashop.domain.Item.Item;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.BatchSize;

import javax.persistence.*;

import static javax.persistence.FetchType.*;

@BatchSize(size = 100) // ToOne 관계여서 여기에 적음
@Entity
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OrderItem {

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

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "item_id")
    private Item item;

    @JsonIgnore
    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "order_id")
    private Order order;

    private int orderPrice;

    private int count;

    //==생성 메서드==//
    public static OrderItem createOrderItem(Item item, int orderPrice, int count) {
        OrderItem orderItem = new OrderItem();
        orderItem.setItem(item);
        orderItem.setOrderPrice(orderPrice);
        orderItem.setCount(count);

        item.removeStock(count);
        return orderItem;
    }

    //==비즈니스 로직==//
    public void cancel() {
        getItem().addStock(count);
    }

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

    /**
     * 주문상품 전체 가격 조회
     */
    public int getTotalPrice() {
        return getOrderPrice() * getCount();
    }
}

 

 

보통은 yml에 그냥 글로벌하게 BatchSize를 지정해놓고 사용한다.

 

 

 

그렇다면, 적당한 BatchSize는 어떻게 정해야할까

보통 Maximum은 1000개로 두어야 한다. 1000개가 넘으면 순간 부하가 걸릴 수 있다.

하지만 1000개로 하더라도 DB나 애플리케이션에 따라 상황이 달라질 수 있기 때문에

100 ~ 1000개 사이에서 DB나 애플리케이션이 순간 부하를 견딜 수 있을 정도로 정하는 것이 좋다.

 

 

 

 

페이징 + 컬렉션 엔티티 조회 정리

ToOne은 fetch join 한다.

컬렉션은 지연로딩으로 처리하고 글로벌 BatchSize(100~1000개 사이)를 설정해서 가져온다.