[API 개발 고급] 컬렉션 조회 최적화(페치 조인, 페이징)

2022. 5. 26. 21:04Java/JPA

앞의 예제에서는 toOne(OneToOne, ManyToOne) 관계만 있었습니다.

이번에는 컬렉션인 일대다 관계(OneToMany)를 조회하고, 최적화하는 방법을 알아보는 시간을 가져보겠습니다.

 

 

요구사항

"주문내역에서 추가로 주문한 상품 정보를 추가로 조회하자."

그럼 Order 기준으로 컬렉션인 OrderItem와 Item 이 필요합니다.

 

 

 

"엔티티 직접 노출"은 이전 시간에 공부했던 것처럼 많은 문제가 있으므로,

 

컬렉션 조회 최적화에서는 엔티티를 DTO로 변환 한 후 최적화하는 것에 초점을 맞추도록 하겠습니다. 

  V2 : 엔티티를 DTO로 변환(fetch join 사용 X)

  V3 : 엔티티를 DTO로 변환(fetch join 사용 O)

 

 

 

주문 조회 V2: 엔티티를 DTO로 변환(fetch join 사용 X)

OrderApiController

package jpabook.jpashop.api;

@RestController
@RequiredArgsConstructor
public class OrderApiController {

    private final OrderRepository orderRepository;

    @GetMapping("/api/v2/orders")
    public List<OrderDto> ordersV2(){
        List<Order> orders = orderRepository.findAllByString(new OrderSearch());
        List<OrderDto> collect = orders.stream()
                .map(o -> new OrderDto(o)) //생성자로 넘김
                .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<OrderItemDto> orderItems; // !! OrderItem엔티티 -> DTO 변환 !!

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


}

 

Postman 확인

 

엔티티를 외부로 노출하지 말라는 것은 겉뿐만 아니라 속까지 해당합니다.

OrderItem -> OrderItemDto로 변환 후 반환

 

  값 타입은 그대로 써도 괜찮습니다.

 

 

결과)

하지만 이 경우 지연 로딩으로 너무 많은 SQL 실행됩니다.

컬렉션의 경우 @XToOne 경우와 마찬가지로 페치 조인이 가능합니다. 

 

 

주문 조회 V3: 엔티티를 DTO로 변환 - 페치 조인 최적화(fetch join 사용 O)

OrderRepository

/**
 * 컬렉션인 일대다 관계(OneToMany) 조회 최적화(V3) - 페치 조인
 *   문제점)
 *   join fetch o.orderItems 하면 order가 2개가 아닌 4개 출력(orderItem 수 만큼 나옴, 중복 출력임)
 */
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();
}

 

Postman

[
    {
        "orderId": 4,
        "name": "userA",
        "orderDate": "2022-05-26T17:11:39.303",
        "orderStatus": "ORDER",
        "address": {
            "city": "서울",
            "street": "888",
            "zipcode": "13453"
        },
        "orderItems": [
            {
                "itemName": "JPA1 BOOK",
                "orderPrice": 10000,
                "count": 1
            },
            {
                "itemName": "JPA2 BOOK",
                "orderPrice": 20000,
                "count": 2
            }
        ]
    },
    {
        "orderId": 4,
        "name": "userA",
        "orderDate": "2022-05-26T17:11:39.303",
        "orderStatus": "ORDER",
        "address": {
            "city": "서울",
            "street": "888",
            "zipcode": "13453"
        },
        "orderItems": [
            {
                "itemName": "JPA1 BOOK",
                "orderPrice": 10000,
                "count": 1
            },
            {
                "itemName": "JPA2 BOOK",
                "orderPrice": 20000,
                "count": 2
            }
        ]
    },
    {
        "orderId": 11,
        "name": "userB",
        "orderDate": "2022-05-26T17:11:39.484",
        "orderStatus": "ORDER",
        "address": {
            "city": "제주도",
            "street": "10823",
            "zipcode": "7950"
        },
        "orderItems": [
            {
                "itemName": "SPRING1 BOOK",
                "orderPrice": 25000,
                "count": 1
            },
            {
                "itemName": "SPRING2 BOOK",
                "orderPrice": 30000,
                "count": 2
            }
        ]
    },
    {
        "orderId": 11,
        "name": "userB",
        "orderDate": "2022-05-26T17:11:39.484",
        "orderStatus": "ORDER",
        "address": {
            "city": "제주도",
            "street": "10823",
            "zipcode": "7950"
        },
        "orderItems": [
            {
                "itemName": "SPRING1 BOOK",
                "orderPrice": 25000,
                "count": 1
            },
            {
                "itemName": "SPRING2 BOOK",
                "orderPrice": 30000,
                "count": 2
            }
        ]
    }
]

문제점)
 join fetch o.orderItems 하면 order가 2개가 아닌 4개 출력(orderItem 수만큼 나옴, 중복 출력임)

 

해결)

JPQL에 distinct 추가

 

 

OrderRepository

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

 

Postman

[
    {
        "orderId": 4,
        "name": "userA",
        "orderDate": "2022-05-26T17:15:13.849",
        "orderStatus": "ORDER",
        "address": {
            "city": "서울",
            "street": "888",
            "zipcode": "13453"
        },
        "orderItems": [
            {
                "itemName": "JPA1 BOOK",
                "orderPrice": 10000,
                "count": 1
            },
            {
                "itemName": "JPA2 BOOK",
                "orderPrice": 20000,
                "count": 2
            }
        ]
    },
    {
        "orderId": 11,
        "name": "userB",
        "orderDate": "2022-05-26T17:15:14.204",
        "orderStatus": "ORDER",
        "address": {
            "city": "제주도",
            "street": "10823",
            "zipcode": "7950"
        },
        "orderItems": [
            {
                "itemName": "SPRING1 BOOK",
                "orderPrice": 25000,
                "count": 1
            },
            {
                "itemName": "SPRING2 BOOK",
                "orderPrice": 30000,
                "count": 2
            }
        ]
    }
]

 

페치 조인으로 SQL이 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

 

distinct를 사용한 이유는 1대다 조인이 있으므로 데이터베이스 row가 증가합니다.
그 결과 같은 order 엔티티의 조회 수도 증가하게 됩니다. JPA의 distinct는 SQL에 distinct를 추가하고, 더해서 같은 엔티티가 조회되면, 애플리케이션에서 중복을 걸러주기에 order가 컬렉션 페치 조인 때문에 중복 조회되는 것을 막아줍니다.

 

 

하지만 치명적인 단점이 있습니다.

❗ 컬렉션 페치 조인을 사용하면 페이징이 불가능합니다.

페이징이란

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)
            //페이징 부분                
            .setFirstResult(1)
            .setMaxResults(100)
            
            .getResultList();
}

하이버네이트는 경고 로그를 남기면서 모든 데이터를 DB에서 읽어오고, 메모리에서 페이징 해버린다(매우 위험합니다).

 

 

 

 

일대다가 아니면 페치 조인 써도 되지만
일대다인 경우  페치 조인을 사용하면 페이징이 불가능하므로 결론적으로는 사용하면 안 됩니다. 

 

 

정리하자면,

  • 컬렉션을 페치 조인하면 페이징이 불가능하다.
    • 컬렉션을 페치 조인하면 일대다 조인이 발생하므로 데이터가 예측할 수 없이 증가한다.
    • 일대다에서 일(1)을 기준으로 페이징을 하는 것이 목적이다. 그런데 데이터는 다(N)를 기준으로 row 가 생성된다.
    • Order를 기준으로 페이징 하고 싶은데, 다(N)인 OrderItem을 조인하면 OrderItem이 기준이 되어버린다.
  • 이 경우 하이버네이트는 경고 로그를 남기고 모든 DB 데이터를 읽어서 메모리에서 페이징을 시도한다. 최악의 경우 장애로 이어질 수 있다

 

 

❓ 그럼 페이징 하면서 컬렉션 엔티티를 함께 조회하려면 어떻게 해야 할까?

지금부터 코드도 단순하고, 성능 최적화도 보장하는 매우 강력한 방법이 존재합니다.

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

 

 

 

 

순서)

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

 

2가지 경우의 수로 나눠서 쿼리 개수 비교

 

 

case 1. 

OrderApiController

@GetMapping("/api/v3.1/orders")
    public List<OrderDto> ordersV3_page() {
        List<Order> orders = orderRepository.findAllWithMemberDelivery(); //페이징 영향 안 줌(페치 조인)

        List<OrderDto> result = orders.stream()
                .map(o -> new OrderDto(o)) //생성자로 넘김
                .collect(Collectors.toList());
        return result;
    }

 

OrderRepository

public List<Order> findAllWithMemberDelivery() {
    return em.createQuery(
                    "select o from Order o" +
                            " join fetch o.member m" +
                            " join fetch o.delivery d", Order.class) 
            .getResultList();
}

 

쿼리 수

member와 delivery를 페치 조인으로 한 번에 가져옴

orderitems

  item1

  item2

orderitems

  item1

  item2

총 : 1 + N(orderitems) + M(item)

  * orderitems과 item N+1문제 터짐

 

 

case 2.

OrderApiController

@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(o -> new OrderDto(o)) //생성자로 넘김
                .collect(Collectors.toList());
        return result;
    }

 

OrderRepository

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

 

application.properties 추가

# JPA Settings
spring.jpa.properties.hibernate.default_batch_fetch_size=100

 

맨 뒤 숫자의 의미: IN쿼리 개수

  예) 10 : 총 데이터 100건이라면, 쿼리가 10번 나가는 것

       100 : 총 데이터 1000건이라면, 쿼리가 10번 나가는 것

 

 

쿼리 수

                             
/* select o from Order o join fetch o.member m join fetch o.delivery d */ 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 ?
/* select o from Order o join fetch o.member m join fetch o.delivery d */ 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 100;
                       
/* load one-to-many jpabook.jpashop.domain.Order.orderItems */ 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 (?, ?)
/* load one-to-many jpabook.jpashop.domain.Order.orderItems */ 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 (4, 11);
                                   
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 (?, ?, ?, ?)
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 (2, 3, 9, 10);

member와 delivery를 페치 조인으로 한 번에 가져옴

 

orderitems, item

  IN쿼리로 한 번에 다 가져옴

 

총 : 1+1+1

페이징 가능 + 성능 최적화

 

 

 

 

결론:

ToOne 관계는 페치 조인해도 페이징에 영향을 주지 않는다. 따라서 ToOne 관계는 페치 조인으로 쿼리 수를 줄이고

나머지는 hibernate.default_batch_fetch_size 로 최적화 하자.

 

 

 

참고: default_batch_fetch_size 의 크기는 적당한 사이즈를 골라야 하는데, 100~1000 사이를 선택하는 것을 권장한다. 이 전략을 SQL IN 절을 사용하는데, 데이터베이스에 따라 IN 절 파라미터를 1000으로 제한하기도 한다.
1000으로 잡으면 한 번에 1000개를 DB에서 애플리케이션에 불러오므로 DB에 순간 부하가 증가할 수 있다.
하지만 애플리케이션은 100이든 1000이든 결국 전체 데이터를 로딩해야 하므로 메모리 사용량이 같다.
1000으로 설정하는 것이 성능상 가장 좋지만, 결국 DB든 애플리케이션이든 순간 부하를 어디까지 견딜 수 있는지로 결정하면 된다.

 

참고 :개별로 설정하려면 @BatchSize 를 적용하면 된다. (컬렉션은 컬렉션 필드에, 엔티티는 엔티티 클래스에 적용)