🔗 JPA

[JPA] 컬렉션 조회(1대다 관계) 최적화

비타민찌 2022. 10. 14. 19:51
728x90

이번엔 저번 포스팅에 이어 주문 api XToMany 관계인 Order(주문)과 OrderItem(주문상품)에 대한 이야기다.

 

XToOne의 경우 fetch join(left outer join)을 하면 성능 최적화가 되는데,

XToMany 관계는 join하면 다(1:다) 측 데이터랑 물려서, 데이터가 뻥튀기(?)된다. 이 뻥튀기라는 말은 이따 코드로 볼 수 있겠지만,

(예를들어 물건 3개를 산 하나의 주문내역을 확인 할 때, 1개의 결과가 나오는게 아니고 3개의 상품이 연결되어 있어서, 결과가 3개가 나와버리는 상황을 말한다.ㅜㅜ) 그래서 고려할게 많아진다.

일단 간단하게 코드를 살펴보자. 일단 처음에 짠 코드는 다음과 같았다.

 

[Order]

public class Order {

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

}

[OrderItem]

public class OrderItem {
    @Id
    @GeneratedValue
    @Column(name = "order_item_id")
    private Long id;

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

}

 

[OrderController]

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

( findAllByString은 "select o from Order o join o.member m"로 보면 될 것 같다. 저 부분 코드가 좀 더려워서 간단하게만 적어둔다..)

 

 

[OrderDto]

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


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

    @Data
    static class OrderItemDto {

        private String itemName;
        private int orderPrice;
        private int count;

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

 

여기서 회원1이 상품1, 상품2를 주문하고 / 회원2가 상품1, 상품2를 주문하여 DB에 그 정보가 저장되어 있다.

근데 이러면 저 orders 호출할 때마다 쿼리가.. 6개나 나간다. 이걸 좀 고쳐보자.

findAllByString 대신 findAllWithProduct 이라고 따로 하나 만들어준다.

    public List<Order> findAllWithProduct() {
        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.product p", Order.class)
                .getResultList();
    }
@GetMapping("/orders")
    public List<OrderDto> orders() {
        List<Order> orders = orderRepository.findAllWithProduct();

        for (Order order : orders) {
            System.out.println("order ref=" + order + " id=" + order.getId());
        }
        List<OrderDto> result = orders.stream()
                .map(o -> new OrderDto(o))
                .collect(toList());

        return result;
    }

이렇게 하면 결과가 어떨까?

이게 위에서 말한 데이터 뻥튀기다. 결과가 각각 2개씩 나와버린다.

 

콘솔에 찍힌 쿼리문을 고대로 H2 디비에 넣어보면 역시 다음처럼 나오는 것을 확인할 수 있다.

fetch join도 sql 로는 그냥 join 으로 동작하는듯하다. (살짝 다르지만 거의..)

print로 찍어본 주솟값 역시 같은 것을 확인할 수 있다.

 

이를 distinct 키워드로 해결할 수 있다. 기존 sql의 distinct과는 다르다.

public List<Order> findAllWithProduct() {
        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.product p", Order.class)
                .getResultList();
    }

이렇게 하면, 대박적이게도 쿼리가 단 한 번 밖에 안나간다.

그런데 이 fetch join 친구에게 하나 단점이 있는데, 바로 페이징을 할 수 없다는 것이다..

아래 setFirst와 setMax를 넣어서 안되는 것을 확인해보자.

 

public List<Order> findAllWithProduct() {
        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.product p", Order.class)
                .setFirstResult(1)
                .setMaxResults(10)
                .getResultList();
    }

 

WARN 28839 --- [nio-8080-exec-1] o.h.h.internal.ast.QueryTranslatorImpl   : HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!

메모리에서 페이징 처리를 한다는 경고가 나온다.. 이게 무슨 뜻일까?

예를들어 여기 데이터가 1만개가 있다면, 1만개 전부를 어플리케이션에 올리고 페이징 처리를 하는 것이다.ㅠㅠ

그래서 Out of memory error가 날 가능성이 매우 높다..

 

왜 이렇게 만들어 졌을까? 하이버네이트는 이걸 왜 이렇게 만들었을까?

이렇게 생각해 볼 수 있겠다. 지금 내가 원하는 order는 2개인데 DB 쿼리에는 데이터가 4개다. 1대 다 조인을 하는 순간, order의 기준 자체가 틀어져 버려서 페이징이 되지 않는다. (order가 아닌 orderItem 기준으로 페이징이 되어버림.) 그래서 1대 다에서 fetch join이 되면 하이버네이트가 경고를 띄우고 이렇게 동작하게끔 한 것 아닌가.. 한다. (김영한 JPA2 강의 참고)

 

# 컬렉션 fetch join 하면 페이징이 안되는 이유

- 컬렉션을 페치조인(fetch join)하면 일대다(1:N) join이 발생하므로 데이터가 너무 커진다.

- 페이징은 일대다 관계에서 1을 기준으로 페이징을 하는 것이 목적이다. 그런데 데이터가 다(N)를 기준으로 row가 생성된다.

- Order 기준으로 페이징이 되어야 하는데 다(N)측인 OrderItem을 조인하면 OrderItem이 기준이 되어버린다.

 

그래서 결론은 이 페이징 기술은 데이터가 작을 땐 가능하긴 하지만, 실무에선 상상도 하면 안되는 선택!

+ 또 1대다 fetch join은 1개만 사용할 수 있다. n:n 이 여러개가 되면 n:n:M ... 세상 너무 복잡해짐.

이것을 어떻게 해결해야할지는 다음 포스팅에 정리하겠다.

728x90