jpa, spring api의 entity 조회 최적화 2 - xToMany 연관관계와 엔티티 컬렉션 조회

들어가며

  • xToOne 연관관계에 이어 xToMany 연관관계에서의 조회에 대한 최적화 방식을 정리한다.
  • xToOne은 관계형데이타베이스의 입장에서 다의 입장을 가지며, 이는 한방 쿼리와 페이징 처리가 수월하다.
  • xToMany는 일의 입장을 가지며, join을 수행할 경우 데이터 뻥튀기의 문제가 발생한다. 이로 인하여,
    • 페이징 처리가 어렵다. 페이징은 다의 입장에서 수행되기 때문이다.
    • 한 방 쿼리가 사실상 불가능하다. 다의 입장에서 레코드를 출력하면 일의 데이터가 다의 칼럼에 중복되어 출력된다. 깔끔하게 떨어지는 쿼리를 만들기 어렵다.
  • 이에 대한 해결책으로
    • 엔티티로 출력할 때는, xToOne에 대해서는 fetch 로 한 방 쿼리를 만들고, xToMany에 대해서는 batch를 통한 in 절 사용으로 최적화한다.
    • dto로 출력할 때는, 쿼리의 최소화와 자바 코드의 복잡성 사이에 적절한 선택을 통해 해소한다.
  • 지금 블로그는 컬렉션 엔티티의 출력에 대한 내용을 다루며, 다음 블로그는 dto의 출력에 대하여 다룬다.

Entity를 직접 노출하는 방식

  • Lazy 로딩이므로 프록시가 리턴된다. 이를 방지하기 위하여 Hibernate5Module 을 빈으로 등록한다.
  • 이 방식은 다양한 문제를 가지며, 이에 관련한 내용은 앞서의 블로그에 정리하였다.
@GetMapping("/api/v1/orders")
public List<Order> ordersV1(){
    final List<Order> all = orderRepository.findAllByString(new OrderSearch());

    // 프록시 초기화를 위함.
    // Modul5는 빈에 등록되어 있음.
    // Order 객체에서는 json을 허용하고, Order에 대한 ManyToOne 객체에는 모든 order에 @JsonIgnore를 하였음.
    for (Order order : all) {
        order.getMember().getName();
        order.getDelivery().getAddress();
        final List<OrderItem> orderItems = order.getOrderItems();
        orderItems.forEach(o->o.getItem().getName());
    }
    return all;
}

entity를 DTO로 전환

entity collection 의 wrapping

  • dto로 전환한다.
  • dto의 컬렉션 필드는 엔티티를 직접 받는다.
@GetMapping("/api/v2/orders")
public List<OrderDto> ordersV2(){
    final List<Order> all = orderRepository.findAllByString(new OrderSearch());

    final List<OrderDto> collect = all.stream().map(order -> new OrderDto(order)).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<OrderItem> orderItems;

    public OrderDto(Order order) {
        // 모든 연관관계는 지연로딩으로 처리되어 있다. 객체 그래프의 탐색으로 엔티티를 초기화한다. 트랜잭션 밖에지만 조회가 가능하다. 왜냐하면 open session in view 라는 기능을 스프링에서 제공하기 때문이다. 만약 그 기능을 끈다면 컨트롤러에서는 더는 객체 그래프로 엔티티를 초기화 할 수 없어 조회 불가능하다.
        orderId = order.getId();
        name = order.getMember().getName();
        orderDate = order.getOrderDate();
        orderStatus = order.getStatus();
        address = order.getMember().getAddress();;

        order.getOrderItems().stream().forEach(orderItem -> orderItem.getItem().getName()); // open session in view 이 동작하여 프록시를 초기화 한다.
        orderItem = order.getOrderItems();
    }
}
  • dto 가 orderItems의 엔티티를 필드로 가진다. 이 경우 문제가 발생한다. api의 스펙이 entity에 의존하게 된다. 엔티티를 리턴할 때와 동일한 문제에 봉착한다. 모든 entity를 dto로 변환해야 한다.
  • 모든 엔티티를 dto로 변경한 코드는 아래와 같다.

dto의 필드를 포함한 엔티티를 dto로 변환한다.

@Data
static class OrderDto {
    private Long orderId;
    private String name;
    private LocalDateTime orderDate;
    private OrderStatus orderStatus;
    private Address address;

//        private List<OrderItem> orderItems;

    private List<OrderItemDto> orderItems;

    public OrderDto(Order order) {
        orderId = order.getId();
        name = order.getMember().getName();
        orderDate = order.getOrderDate();
        orderStatus = order.getStatus();
        address = order.getMember().getAddress();       
        
//            order.getOrderItems().stream().forEach(orderItem -> orderItem.getItem().getName()); // 영속성 자체에 대한 의존성을 완전하게 끊어내야 한다.
        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();
    }
}
  • sql 은 아래와 같다. 엄청나게 많이 발생함을 확인할 수 있다.
2022-02-13 17:36:51.205 DEBUG 11160 --- [nio-8080-exec-2] org.hibernate.SQL                        : 
    select
        order0_.order_id as order_id1_6_,
        order0_.delivery_id as delivery4_6_,
        order0_.member_id as member_i5_6_,
        order0_.order_date as order_da2_6_,
        order0_.status as status3_6_ 
    from
        orders order0_ 
    inner join
        member member1_ 
            on order0_.member_id=member1_.member_id limit ?
2022-02-13 17:36:51.209 DEBUG 11160 --- [nio-8080-exec-2] org.hibernate.SQL                        : 
    select
        member0_.member_id as member_i1_4_0_,
        member0_.city as city2_4_0_,
        member0_.street as street3_4_0_,
        member0_.zipcode as zipcode4_4_0_,
        member0_.name as name5_4_0_ 
    from
        member member0_ 
    where
        member0_.member_id=?
2022-02-13 17:36:51.211 DEBUG 11160 --- [nio-8080-exec-2] org.hibernate.SQL                        : 
    select
        orderitems0_.order_id as order_id5_5_0_,
        orderitems0_.order_item_id as order_it1_5_0_,
        orderitems0_.order_item_id as order_it1_5_1_,
        orderitems0_.count as count2_5_1_,
        orderitems0_.item_id as item_id4_5_1_,
        orderitems0_.order_id as order_id5_5_1_,
        orderitems0_.order_price as order_pr3_5_1_ 
    from
        order_item orderitems0_ 
    where
        orderitems0_.order_id=?
2022-02-13 17:36:51.212 DEBUG 11160 --- [nio-8080-exec-2] org.hibernate.SQL                        : 
    select
        item0_.item_id as item_id2_3_0_,
        item0_.name as name3_3_0_,
        item0_.price as price4_3_0_,
        item0_.stock_quantity as stock_qu5_3_0_,
        item0_.artist as artist6_3_0_,
        item0_.etc as etc7_3_0_,
        item0_.author as author8_3_0_,
        item0_.isbn as isbn9_3_0_,
        item0_.actor as actor10_3_0_,
        item0_.director as directo11_3_0_,
        item0_.dtype as dtype1_3_0_ 
    from
        item item0_ 
    where
        item0_.item_id=?
2022-02-13 17:36:51.213 DEBUG 11160 --- [nio-8080-exec-2] org.hibernate.SQL                        : 
    select
        item0_.item_id as item_id2_3_0_,
        item0_.name as name3_3_0_,
        item0_.price as price4_3_0_,
        item0_.stock_quantity as stock_qu5_3_0_,
        item0_.artist as artist6_3_0_,
        item0_.etc as etc7_3_0_,
        item0_.author as author8_3_0_,
        item0_.isbn as isbn9_3_0_,
        item0_.actor as actor10_3_0_,
        item0_.director as directo11_3_0_,
        item0_.dtype as dtype1_3_0_ 
    from
        item item0_ 
    where
        item0_.item_id=?
2022-02-13 17:36:51.214 DEBUG 11160 --- [nio-8080-exec-2] org.hibernate.SQL                        : 
    select
        member0_.member_id as member_i1_4_0_,
        member0_.city as city2_4_0_,
        member0_.street as street3_4_0_,
        member0_.zipcode as zipcode4_4_0_,
        member0_.name as name5_4_0_ 
    from
        member member0_ 
    where
        member0_.member_id=?
2022-02-13 17:36:51.214 DEBUG 11160 --- [nio-8080-exec-2] org.hibernate.SQL                        : 
    select
        orderitems0_.order_id as order_id5_5_0_,
        orderitems0_.order_item_id as order_it1_5_0_,
        orderitems0_.order_item_id as order_it1_5_1_,
        orderitems0_.count as count2_5_1_,
        orderitems0_.item_id as item_id4_5_1_,
        orderitems0_.order_id as order_id5_5_1_,
        orderitems0_.order_price as order_pr3_5_1_ 
    from
        order_item orderitems0_ 
    where
        orderitems0_.order_id=?
2022-02-13 17:36:51.216 DEBUG 11160 --- [nio-8080-exec-2] org.hibernate.SQL                        : 
    select
        item0_.item_id as item_id2_3_0_,
        item0_.name as name3_3_0_,
        item0_.price as price4_3_0_,
        item0_.stock_quantity as stock_qu5_3_0_,
        item0_.artist as artist6_3_0_,
        item0_.etc as etc7_3_0_,
        item0_.author as author8_3_0_,
        item0_.isbn as isbn9_3_0_,
        item0_.actor as actor10_3_0_,
        item0_.director as directo11_3_0_,
        item0_.dtype as dtype1_3_0_ 
    from
        item item0_ 
    where
        item0_.item_id=?
2022-02-13 17:36:51.217 DEBUG 11160 --- [nio-8080-exec-2] org.hibernate.SQL                        : 
    select
        item0_.item_id as item_id2_3_0_,
        item0_.name as name3_3_0_,
        item0_.price as price4_3_0_,
        item0_.stock_quantity as stock_qu5_3_0_,
        item0_.artist as artist6_3_0_,
        item0_.etc as etc7_3_0_,
        item0_.author as author8_3_0_,
        item0_.isbn as isbn9_3_0_,
        item0_.actor as actor10_3_0_,
        item0_.director as directo11_3_0_,
        item0_.dtype as dtype1_3_0_ 
    from
        item item0_ 
    where
        item0_.item_id=?

fetch join

fetch join 의 사용

  • fetch join으로 최적화 한다.
public List<Order> findAllWithItem() {
    final String query = "" +
            " 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";
    return em.createQuery(query, Order.class).getResultList();
}
  • sql은 아래와 같다.
  • 이전과 달리, 단 한 번의 쿼리로 해결된다!
2022-02-13 17:45:01.788 DEBUG 11160 --- [nio-8080-exec-2] org.hibernate.SQL                        : 
    select
        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
  • 다만, 레코드의 뻥튀기 문제가 발생한다. json의 응답값은 아래와 같다. 동일한 값이 두 번 반복된다.
  • 관계형 데이타베이스와 jpa 간 패러다임의 간격으로 인한 문제가 발생한다.
  • order를 기준으로 데이터를 꺼낸다. order은 one이며 join 된 데이터는 many(orderItem, item)이다. 관계형 데이터베이스는 데이터의 출력 기준이 order라 하더라도 하나의 order에 연결된 many의 레코드가 여러 개이면, many의 갯수에 의존하여 one이 반복 출력된다.
  • jpa의 출력 결과는 기본적으로 관계형 데이타베이스의 쿼리에 의존한다. 그 결과는 아래와 같다.
[
    {
        "orderId": 4,
        "name": "userA",
        "orderDate": "2022-02-13T17:44:51.037102",
        "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": "2022-02-13T17:44:51.037102",
        "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": "2022-02-13T17:44:51.042109",
        "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": "2022-02-13T17:44:51.042109",
        "orderStatus": "ORDER",
        "address": {
            "city": "진주",
            "street": "2",
            "zipcode": "2222"
        },
        "orderItems": [
            {
                "itemName": "SPRING1 BOOK",
                "orderPrice": 20000,
                "count": 3
            },
            {
                "itemName": "SPRING2 BOOK",
                "orderPrice": 40000,
                "count": 4
            }
        ]
    }
]
  • 컨트롤러에 List 를 로그로 찍어보면 아래와 같이 나온다. 동일한 엔티티가 반복됨을 확인할 수 있다.
for (Order order : all) {
    log.info("order = " + order);
    log.info("order.getId() = " + order.getId());
}
order = jpabook.jpashop.domain.Order@7b4be4ce
order.getId() = 4
order = jpabook.jpashop.domain.Order@7b4be4ce
order.getId() = 4
order = jpabook.jpashop.domain.Order@79415a8a
order.getId() = 11
order = jpabook.jpashop.domain.Order@79415a8a
order.getId() = 11

distinct 의 사용

  • 이를 해소하기 위하여 distinct를 사용한다. distinct는 엔티티 입장에서 중복을 제거한다. 관계형 데이터베이스의 distinct와 jpa의 distinct는 다르다.
  • 관계형 데이터베이스는 단 하나의 칼럼이라도 다르면 distinct가 동작하지 않는다. 하지만 jpa에게 distinct는 one의 입장에서 중복을 제거한다. 그러니까 order 객체의 중복이 제거된다.
public List<Order> findAllWithItem() {
    final String query = "" +
            " 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";
    return em.createQuery(query, Order.class).getResultList();
}
2022-02-13 17:58:37.868 DEBUG 9812 --- [nio-8080-exec-3] org.hibernate.SQL                        : 
    select
        distinct order0_.order_id as order_id1_6_0_, -- 쿼리에 distinct를 사용한다. 하지만 레코드의 값(orderItem, item)이 다르기 때문에 사실상 distinct 가 먹지 않는다. 
        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
  • 객체가 두 개만 출력됨을 확인할 수 있다. 4개의 레코드를 두 개의 엔티티로 jpa가 전환한다.
order = jpabook.jpashop.domain.Order@65124dda
order.getId() = 4
order = jpabook.jpashop.domain.Order@7df59680
order.getId() = 11

fetch join 의 페이징 처리의 한계

  • 하나의 쿼리로 해결하기 때문에 DB와의 통신 횟수로 인한 최적화가 가능하다.
  • 다만 치명적인 단점이 있다. 페이징 처리가 안된다.
public List<Order> findAllWithItem() {
    final String query = "" +
            " 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";
    return em.createQuery(query, Order.class)
            .setFirstResult(1)
            .setMaxResults(100)
            .getResultList();
}
  • limit 이 없다.
  • 모든 데이터를 가지고 온 다음 메모라 차원에서 페이징한다. firstResult/maxResults specified with collection fetch; applying in memory!. 오더의 레코드가 엄청 많다면, 성능 상 매우 큰 문제가 발생한다.
  • 관계형 데이타베이스를 기준으로 order를 페이징할 수 없다. 관계형 데이타베이스를 기준으로, item 과 orderItem 기준으로 레코드가 발생하며 이를 기준으로 페이징이 가능하다. order 입장에서의 페이징을 할 기준이 존재하지 않기 때문이다.
  • 추가적으로 jpa의 한계가 존재한다. xToMany에서 join fetch를 할 경우 데이터 정합성의 문제가 발생한다고 한다.
2022-02-13 19:30:00.999  WARN 9812 --- [nio-8080-exec-2] o.h.h.internal.ast.QueryTranslatorImpl   : HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!
2022-02-13 19:30:00.999 DEBUG 9812 --- [nio-8080-exec-2] org.hibernate.SQL                        : 
    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

  • 결론적으로, join fetch로 페이징을 하면 안된다!!.

xToMany 는 fetch join을 쓰지 않는다. lazy loading과 batch를 선택한다.

  • 관계형 데이타베이스에서는 many를 기준으로 row가 생성된다. 우리는 one을 기준으로 페이징을 하고 싶다.
  • 코드도 단순하고 성능도 해소할 수 있는 batch를 활용한다. 사실 이것 이외의 다른 대안이 없다고 한다.

페이징 처리의 방향

  • 1) xToOne 관계는 fetch join을 한다. xToOne 만 존재하는 쿼리에서는 fetch join을 여러 번 사용하더라도 페이징에 전혀 문제가 없다. 이를 기준으로 먼저 페이징 한다.
  • 2) xToMany의 데이터는 따로 쿼리한다. 지연로딩(lazy loading)과 batch를 사용하여 엔티티를 초기화한다.

코드

  • 배치를 위하여 아래와 같이 설정한다.
spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100
  • 컨트롤러
@GetMapping("/api/v3.1/orders")
public List<OrderDto> ordersV3_lazyAndBatch(
        @RequestParam(value = "offset", defaultValue = "0") int offset,
        @RequestParam(value = "limit", defaultValue = "100") int limit){

    // xToMany의 fetch join을 가져온다.
    final List<Order> all = orderRepository.findAllWithMemberDelivery(offset, limit);


    // 지연로딩과 함께 dto를 생성한다.
    final List<OrderDto> collect = all.stream()
            .map(order -> new OrderDto(order))
            .collect(Collectors.toList());

    return collect;
}
  • 리포지토리
public List<Order> findAllWithMemberDelivery(int offset, int limit) {
    final String query = "" +
            "select o " +
            "from Order o " +
            "join fetch o.member m " +
            "join fetch o.delivery d ";

    return em.createQuery(query, Order.class)
            .setFirstResult(offset)
            .setMaxResults(limit)
            .getResultList();
}
  • 주문과 고객은 1:다 관계이다. 주문과 배송은 1:1 관계이다. 이 세 개의 엔티티는 fetch join 으로 출력한다. 그리고 페이징 처리를 이 때 수행한다.
  • new OrderDto(order) 에서 객체 그래프를 사용하여 엔티티를 초기화한다. batch로 인하여 단 건마다 쿼리하지 않고 한 번에 처리한다.
  • 그 결과는 아래와 같다.
2022-02-13 19:52:53.681 DEBUG 13436 --- [nio-8080-exec-2] org.hibernate.SQL                        : 
    select
        order0_.order_id as order_id1_6_0_,
        member1_.member_id as member_i1_4_1_,
        delivery2_.delivery_id as delivery1_2_2_,
        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_ 
    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 limit ?
2022-02-13 19:52:53.701 DEBUG 13436 --- [nio-8080-exec-2] org.hibernate.SQL                        : 
    select
        orderitems0_.order_id as order_id5_5_1_,
        orderitems0_.order_item_id as order_it1_5_1_,
        orderitems0_.order_item_id as order_it1_5_0_,
        orderitems0_.count as count2_5_0_,
        orderitems0_.item_id as item_id4_5_0_,
        orderitems0_.order_id as order_id5_5_0_,
        orderitems0_.order_price as order_pr3_5_0_ 
    from
        order_item orderitems0_ 
    where
        orderitems0_.order_id in (
            ?, ?
        )
2022-02-13 19:52:53.714 DEBUG 13436 --- [nio-8080-exec-2] org.hibernate.SQL                        : 
    select
        item0_.item_id as item_id2_3_0_,
        item0_.name as name3_3_0_,
        item0_.price as price4_3_0_,
        item0_.stock_quantity as stock_qu5_3_0_,
        item0_.artist as artist6_3_0_,
        item0_.etc as etc7_3_0_,
        item0_.author as author8_3_0_,
        item0_.isbn as isbn9_3_0_,
        item0_.actor as actor10_3_0_,
        item0_.director as directo11_3_0_,
        item0_.dtype as dtype1_3_0_ 
    from
        item item0_ 
    where
        item0_.item_id in (
            ?, ?, ?, ?
        )
  • 코드로 작성하기 어려운 최적화를 아주 간단하게 처리해준다. batch 설정만 하면 해결된다.
  • fetch join 한 번, orderItem, item 에 대한 3 번의 쿼리만 발생한다. 1:n:n 이 1:1:1이 되었다.
  • 지연로딩과 배치를 통한 방식의 장점은 매우 크다. 페이징이 가능하며, 중복된 데이터가 없다.
  • 이정도까지 최적화를 하면, 거의 대부분의 문제가 해소된다.

[추가] fetch join을 아예 사용하지 않는다면?

  • 아래와 같이 Order만 쿼리한다.
  • 이 경우 member, delivery 가 추가적인 배치 조인으로 동작한다. order, member, delivery, orderItem, item 에 대하여 총 5개의 조인이 발생한다.
  • 가능한 최대한의 엔티티를 fetch join을 한다. 그리고 불가능한 부분에서 lazy + batch 를 사용한다.
final String query = "" +
        "select o " +
        "from Order o " +
        // "join fetch o.member m " +
        // "join fetch o.delivery d ";

lazy + batch 정리

  • batch는 n+1의 문제를 1+1로 바꿔버린다.
  • 다만, 쿼리 호출수가 fetch join보다 많다. 한 방 쿼리가 아니다.
  • fetch join과 달리 페이징이 가능하다.
  • 결론은,
    • xToOne은 한 번에 fetch join을 사용하며, 이때 페이징을 한다. 그 다음에,
    • xToMany 객체에 대하여 지연로딩 + batch 로 해결한다.

batch size는?

  • batch 의 최대 갯수는 기본적으로 1000 개이다. DB의 in 절의 인자의 갯수가 1000개인 DB가 있기 때문이다.
  • 다만, 1000개는 어플리케이션과 DB 모두에게 큰 영향이 갈 수 있다. 100개를 할 경우 시간을 더 걸리는 대신 부하가 줄어든다.
  • 권장하는 내용은 최대치(1000개)이다. 만약 부하가 우려된다면, 최소한 100개를 기준으로 차차 올린다.
  • 다만, was의 메모리 입장에서는 어떤 방식이나 별 차이가 없다. 왜냐하면 메모리 사용량은 어플리케이션에서 요청한 갯수에 의존한다. 이것은 배치 사이즈와 관계 없이 언제나 같다. 이 부분이 걱정이라면 로직 자체에서 필요로 한 갯수를 줄어야 한다.
  • 사실상 이러한 전략으로 거의 대부분의 문제를 해소할 수 있다.