컬렉션 조회 최적화 - 엔티티를 DTO로 변환 + "페이징" + 최적화

HotFried·2023년 11월 24일
0

이전 글 : 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화(컬렉션 조회 최적화 - 엔티티를 DTO로 변환 + 최적화)

문제점

  1. 컬렉션을 페치 조인하면 페이징이 불가능하다.
  2. 컬렉션을 페치 조인하면 일대다 조인이 발생하므로 데이터가 예측할 수 없이 증가한다.
  3. 일다대에서 일(1)을 기준으로 페이징을 하는 것이 목적이다. 그런데 데이터는 다(N)를 기준으로 row가 생성된다.
  4. Order를 기준으로 페이징 하고 싶은데, 다(N)인 OrderItem을 조인하면 OrderItem이 기준이 되어 버린다.

-> 이 경우 하이버네이트는 경고 로그를 남기고 모든 DB 데이터를 읽어서 메모리에서 페이징을 시도한다.

해결 방법

최적화를 위해 hibernate.default_batch_fetch_size , @BatchSize 적용

- hibernate.default_batch_fetch_size: 글로벌 설정
- @BatchSize: 개별 최적화

Batch_Size 적용 전

코드

OrderApiController

@RestController
@RequiredArgsConstructor
public class OrderApiController {

    @GetMapping("/api/v3.1/orders")
    public List<OrderDto> ordersV3_page() {
        return orderRepository.findAllWithMemberDelivery().stream()
        .map(OrderDto::new)
        .collect(Collectors.toList());
    }
}

OrderRepository - X To One은 Fetch Join

@Repository
@RequiredArgsConstructor
public class 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();
    }
}

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

제일 먼저 Order를 조회할 때 X To One 관계에 해당하는 member, Delivery 엔티티를 한 번의 쿼리로 조회하기 때문에 성능 개선이 된다.

하지만, 컬렉션인 OrderItems를 조회할 경우 수 많은 쿼리가 아래 처럼 발생한다.

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 = ?

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 = ?

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 = ?

OrderItem 그리고 item의 개수에 따라 수 많은 쿼리가 나가기 때문에 성능이 저하된다.


Batch_Size 적용 후

코드

OrderApiController

@RestController
@RequiredArgsConstructor
public class 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) {
        return orderRepository.findAllWithMemberDelivery(offset, limit).stream()
                .map(OrderDto::new)
                .collect(Collectors.toList());
    }
}

OrderRepository

@Repository
public class 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.yml - Batch 글로벌 적용

  jpa:
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
        format_sql: true
        default_batch_fetch_size: 100 // 추가한 부분

필드 별로 Batch_Size를 적용하고 싶으면 해당 엔티티의 필드에
@BatchSize어노테이션을 달아준다.

참고: default_batch_fetch_size 의 크기는 100~1000 사이를 선택한다.
데이터베이스에 따라 IN 절 파라미터를 1000으로 제한하기에 최대 값을 1000으로 둔다.

어떤 크기를 설정하더라도, 결국 모든 데이터를 로딩해야 하므로 메모리 사용량은 같다.
따라서, 순간 부하를 견딜 수 있는 정도로 결정한다.


결과

...delivery_id=delivery2_.delivery_id limit 100 offset 1;

쿼리를 살펴보면 Batch_Size를 지정한 뒤에 정상적으로 페이징이 작동한 것을 확인할 수 있다.

item0_.dtype as dtype1_3_0_ from item item0_ where item0_.item_id in (9, 10);

또한 Batch_Size에 맞게 데이터를 in절로 가져온다.


장점

  • 쿼리 호출 수가 1 + N -> 1 + 1 로 최적화

  • 조인보다 DB 데이터 전송량이 최적화
    (컬렉션을 조회하면 1에 해당하는 엔티티가 N만큼 중복 조회된다.
    X To One만 Fetch Join하면 중복 데이터가 없다.)

  • 컬렉션 Fetch Join 방식과 비교해서 쿼리 호출 수가 약간 증가하지만, DB 데이터 전송량이 감소한다.

  • 컬렉션 Fetch Join은 페이징이 불가능 하지만 이 방법은 페이징이 가능하다.


결론

X To One 관계는 Fetch Join을 해도 페이징에 영향을 주지 않는다.
-> X To One 관계는 Fetch Join으로 쿼리 수를 줄인다.

X To Many 관계는 지연 로딩으로 페이징을 가능하게 하고,
hibernate.default_batch_fetch_size로 쿼리 수를 줄인다.


참고 :

실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화

profile
꾸준하게

0개의 댓글