@OneToMany 관계에서 Fetch Join의 사용과 페이징 처리 문제

kindof

·

2021. 11. 21. 22:15

1. 상황

회원(Member)과 주문(Orders), 주문상품(OrderItem), 상품(Item)이라는 네 개의 엔티티를 중심으로 @OneToMany 관계에서 Fetch Join의 사용과 페이징 처리 문제에 대해 다뤄보겠습니다. 

 

우선 간단한 엔티티 설계와 요구사항을 살펴보겠습니다.

 

  • 회원(Member)은 여러 주문(Orders)을 가질 수 있으며, 하나의 주문(Orders)은 여러 개의 주문상품(OrderItem)을 가질 수 있습니다. 주문상품은 해당 상품의 수량과 가격을 가지고 있는 엔티티이며, 상품은 단순히 상품 정보만을 가진 엔티티입니다.
  • 하나의 상품(Item)이 여러 주문상품(OrderItem)에 나타날 수 있으며, 주문상품을 통해 상품을 조회하기 때문에 단방향 연관관계를 갖습니다.

 

그리고 아래는 주문(Orders)와 주문상품(OrderItem) 엔티티의 필드와 연관관계 매핑입니다.

  • 주문(Orders)은 회원(Member)와 @ManyToOne, 배송(Delivery)와 @OneToOne, 주문상품(OrderItem)과 @OneToMany 관계입니다.
  • 주문상품(OrderItem)은 상품(Item)과 @ManyToOne 관계를 가지며, 단방향으로 연관관계를 설정했습니다.
@Entity
@Table(name = "orders")
@Getter @Setter
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)

    @Enumerated(EnumType.STRING)
    private OrderStatus status; //주문상태 [ORDER, CANCEL]
}
@Entity
@Table(name = "order_item")
@Getter
@Setter
public class OrderItem {
    @Id
    @GeneratedValue
    @Column(name = "order_item_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "item_id")
    private Item item; //주문 상품

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

    private int orderPrice; //주문 가격
    private int count; //주문 수량
}

 

2. Fetch Join을 통해 모든 주문(Orders) 조회하기

주문 목록을 보여주는 OrderDto는 아래와 같습니다. 주문번호와 주문한 회원의 이름, 주문날짜와 상태, 주소, 그리고 주문상품을 가집니다. 여기서 주문상품의 목록 역시 OrderItemDto 필드로 만들었으며, OrderItemDto는 주문상품명과 가격, 수량을 필드로 갖습니다.

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

주문 조회 Controller
OrderRepository - findAllWithItem(), distinct 키워드 사용 O

fetch join을 통해 주문과 관련된 회원, 배송, 주문상품과 상품 정보를 가져왔습니다. 

 

위 쿼리에서 distinct 키워드가 중요한데요.

 

회원과 배송은 주문과 일대일 관계이기 때문에 fetch join을 하더라도 테이블의 Row수가 증가하지 않습니다. 하지만 주문과 주문상품은 일대다 관계이기 때문에 단순히 fetch join을 하게 되면 주문이 가지고 있는 주문상품의 수만큼 Row가 증가하고, 그 결과 같은 주문 엔티티의 조회 수가 증가하게 됩니다.

 

따라서 이를 해결하기 위해 JPA의 distinct 키워드는 SQL에 distinct를 추가하고, 같은 엔티티가 조회되면 애플리케이션에서 중복을 걸러주는 기능을 합니다.

 

 

현재 두 명의 회원이 있고, 각 회원이 하나의 주문을 하고 각 주문에는 두 개의 상품이 있는 상황을 가정해보겠습니다. 위 컨트롤러를 통해 모든 주문을 조회하게 되면, 기본적으로 fetch join을 사용했기 때문에 N+1 문제는 나타나지 않고 한번의 쿼리만 실행됩니다. 

    select
        distinct order0_.order_id as order_id1_6_0_,
        member1_.member_id as member_i1_4_1_,
        delivery2_.delivery_id as delivery1_2_2_,
        orderitems3_.order_item_id as order_it1_5_3_,
        item4_.item_id as item_id2_3_4_,
        order0_.delivery_id as delivery4_6_0_,
        order0_.member_id as member_i5_6_0_,
        order0_.order_date as order_da2_6_0_,
        order0_.status as status3_6_0_,
        member1_.city as city2_4_1_,
        member1_.street as street3_4_1_,
        member1_.zipcode as zipcode4_4_1_,
        member1_.name as name5_4_1_,
        delivery2_.city as city2_2_2_,
        delivery2_.street as street3_2_2_,
        delivery2_.zipcode as zipcode4_2_2_,
        delivery2_.status as status5_2_2_,
        orderitems3_.count as count2_5_3_,
        orderitems3_.item_id as item_id4_5_3_,
        orderitems3_.order_id as order_id5_5_3_,
        orderitems3_.order_price as order_pr3_5_3_,
        orderitems3_.order_id as order_id5_5_0__,
        orderitems3_.order_item_id as order_it1_5_0__,
        item4_.name as name3_3_4_,
        item4_.price as price4_3_4_,
        item4_.stock_quantity as stock_qu5_3_4_,
        item4_.artist as artist6_3_4_,
        item4_.etc as etc7_3_4_,
        item4_.author as author8_3_4_,
        item4_.isbn as isbn9_3_4_,
        item4_.actor as actor10_3_4_,
        item4_.director as directo11_3_4_,
        item4_.dtype as dtype1_3_4_ 
    from
        orders order0_ 
    inner join
        member member1_ 
            on order0_.member_id=member1_.member_id 
    inner join
        delivery delivery2_ 
            on order0_.delivery_id=delivery2_.delivery_id 
    inner join
        order_item orderitems3_ 
            on order0_.order_id=orderitems3_.order_id 
    inner join
        item item4_ 
            on orderitems3_.item_id=item4_.item_id

그런데 h2 콘솔을 열어서 위 쿼리를 그대로 실행시켜보면 아래와 같이 중복된 주문(Order)이 찍히는 것을 볼 수 있는데요. 이는 JPA의 distinct 키워드가 조회 결과를 '애플리케이션으로 끌고오는 시점'에 중복을 걸러주기 때문입니다.

따라서, 다수의 @...ToMany 컬렉션을 fetch join을 통해 가져오게 되면 DB에서 테이블의 Row가 급격히 늘어나서 장애가 발생할 수 있는 문제가 생길 수 있음을 주의해야 합니다.

 

그래서 지금처럼 @OneToMany 관계가 하나만 존재할 때에만 컬렉션에 대한 fetch join은 @xxxToOne 관계와 마찬가지로 N+1 문제를 해결해주고, distinct 키워드를 통해 중복 조회 문제도 해결할 수 있습니다.

 

 

3. Fetch Join을 통해 모든 주문(Orders) 조회하고 페이징 처리하기

한편, 컬렉션에 대한 fetch join을 사용할 때는 페이징이 불가능합니다. 이유가 무엇일까요?

 

Hibernate는 기본적으로 페이징을 할 때 DB에서 데이터를 읽어오고, 메모리에서 페이징을 합니다. 그런데 Order - OrderItem은 일대다 관계이기 때문에 fetch join을 할 때 필연적으로 DB의 Row 수가 증가하게 되고, 이 상황에서 페이징을 시도하면 Order 숫자를 기준으로 페이징이 되는 것이 아니라 OrderItem 수에 의존적으로 페이징이 되는 것이죠.

 

따라서 실제로 페이징 쿼리를 함께 실행해보면 아래와 같이 WARN 경고 로그를 남기고 모든 데이터를 DB에서 읽은 뒤 페이징을 시도하게 됩니다.

Paging
WARN 경고 로그

 

그렇다면 @xxxToMany 관계에서 컬렉션 엔티티를 조회하면서 페이징을 해야할 때는 어떻게 해야할까요?

 

4. hibernate.default_batch_fetch_size

우선 조회하고자 하는 엔티티에서 @xxxToOne 관계를 갖는 엔티티는 그대로 fetch join을 통해 가져올 수 있습니다. 이 녀석들은 fetch join을 하더라도 테이블의 Row수에 영향을 주지 않기 때문에 페이징에 영향을 미치는 않죠.

 

다만, 위에서 말한 컬렉션 엔티티에 대한 조회를 할 때에는 hibernate.default_batch_fetch_size를 적용하여 페이징 처리를 할 수 있습니다.

 

hibernate.default_batch_fetch_size는 application.yml 파일에서 아래와 같이 설정을 해줄 수 있는데요.

hibernate.default_batch_fetch_size

batch-size옵션은 하위 엔티티를 로딩할때 한번에 상위 엔티티 ID를 지정한 숫자만큼in Query로 로딩해줍니다. 예를 들어 batch_size를 위처럼 100으로 설정하면 이후에 Order_id 100개에 대한 In Query를 날려서 OrderItem을 조회해오는 것이죠.

 

따라서 주문(Order)을 조회할 때는 아래와 같이 두 번의 동작을 거치게 됩니다.

 

[1] 회원(Member)과 배송(Delivery)을 fetch join을 통해 우선적으로 가져온다. 이 데이터를 바탕으로 페이징 처리를 한다.

회원, 배송 정보를 fetch join으로 가져온다.

[2] 이후에 OrderItem을 따로 정해진 사이즈만큼씩 IN 쿼리로 조회하여 가져온다.

 

 

4. 정리

이번 포스팅에서는 @OneToMany 관계를 갖는 엔티티를 조회하는 기본적인 방법과 이 때 발생할 수 있는 주의점에 대해 살펴봤는데요. 지금까지 이야기한 내용은 크게 두 가지로 요약될 것 같습니다.

 

1. @...ToMany 관계에 있는 엔티티는 fetch join으로 가져올 때 DB 테이블의 Row가 증가하기 때문에 한 개 이상은 조인하지 않는다.

2. @...ToMany 관계에 있는 엔티티는 fetch join으로 가져와서 페이징이 불가능하기 때문에 @...ToOne 관계의 엔티티만 가져오고 hibernate.default_batch_fetch_size 설정을 이용하여 IN 쿼리로 데이터를 불러온다.

 

감사합니다.