본문 바로가기

Spring/API 성능 최적화

컬렉션 조회 API 성능 최적화 (V4. hibernate.default_batch_fetch_size)

https://praaay.tistory.com/7

 

컬렉션 조회 API 성능 최적화

https://praaay.tistory.com/6 API 조회 성능 최적화추가, 수정 API 보다도 조회 API가 성능에 가장 민감한 만큼 JPA를 사용할 때 조회 API의 성능을 최적화하는 방법을 단계별로 정리하려 합니다. 주문과 배

praaay.tistory.com

위의 글에 이어서 컬렉션 조회 API 성능을 최적화해 보겠습니다. 위 글에서 살펴본 V3은 컬렉션을 포함한 조회 API에서 JPA의 페치 조인과 함께 distinct 문법을 사용해 1번의 SQL문으로 필요한 결과를 얻었습니다. 하지만 이 방식으로는 페이징이 불가능하다는 치명적인 단점이 존재했습니다. V4에서는 V3의 단점을 보완하며 성능 최적화도 보장하는 방법을 소개하겠습니다.

 

주문 조회 V4: 엔티티를 DTO로 변환 - 페이징과 한계 돌파 

V3에서 살펴본 컬렉션을 페치 조인하는 방식으로는 페이징이 불가능합니다. 페이징이 불가능한 이유를 아래와 같습니다.

  • 컬렉션을 페치 조인하면 일대다 조인이 발생하므로 데이터가 예측할 수 없이 증가합니다.
  • 일다대에서 일(1)을 기준으로 페이징을 하는 것이 목적입니다. 그러나 데이터는 다(N)를 기준으로 row가 생성됩니다.
  • Order를 기준으로 페이징 하고 싶지만, 다(N)인 OrderItem을 조인하면 OrderItem이 기준이 되어버립니다.
  • 컬렉션을 페치 조인했을 때 페이징을 시도하며 하이버네이트는 경고 로그를 남기고 모든 DB 데이터를 읽어서 메모리에서 페이징을 시도합니다. 최악의 경우 장애로 이어질 수 있습니다.

그렇다면 페이징 기능을 사용하며 컬렉션 엔티티를 함께 조회하려면 어떻게 해야 할까요?

 

hibernate.default_batch_fetch_size 또는 @BatchSize를 적용하는 방법으로 페이징을 사용하며 컬렉션 엔티티를 함께 조회할 수 있습니다. 대부분의 페이징 + 컬렉션 엔티티 조회 문제는 이 방법으로 해결할 수 있습니다. hibernate.default_batch_fetch_size 또는 @BatchSize를 적용하는 방법은 아래와 같습니다.

  1. 먼저 xToOne(OneToOne, ManyToOne) 관계를 모두 페치 조인하여 한 방 쿼리를 만듭니다. xToOne 관계는 최종 결과의 row 수를 증가시키지 않으므로 페이징 쿼리에 영향을 주지 않습니다.
  2. 컬렉션은 지연 로딩으로 조회합니다. 이때 당연히 N + 1 문제를 마주한다고 예상하겠지만, hibernate.default_batch_fetch_size 또는 @BatchSize를 적용하여 N + 1 문제를 피해 갈 수 있습니다.
  3. 지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size 또는 @BatchSize를 적용합니다. 이때 hibernate.default_batch_fetch_size는 글로벌 설정인 반면 @BatchSize는 특정 엔티티에 설정하는 개별 설정입니다. 이 옵션을 사용하면 컬렉션이나, 프록시 객체를 한꺼번에 설정한 size만큼 IN 쿼리로 조회하게 되며 N + 1 문제를 해결합니다.

이번에는 위 방법을 코드로 살펴보겠습니다.

// OrderApiController
/**
 * V4 엔티티를 조회해서 DTO로 변환 페이징 고려
 * - xToOne 관계만 우선 모두 페치 조인으로 최적화
 * - 컬렉션 관계는 hibernate.default_batch_fetch_size, @BatchSize로 최적화
 */
@GetMapping("api/v4/orders")
public List<OrderDto> orderV4page(
    @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(toList());
    return result;
}


// OrderRespository
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)
            .setMaxResult(limit)
            .getResultList();
    )
}

 

findAllWithMemberDelivery 메서드의 offset과 limit 매개변수는 페이징을 위한 매개변수입니다. Order 엔티티와 xToOne 관계로 걸린 Member와 Delivery 엔티티는 페치 조인으로 가져옵니다. 하지만 컬렉션이 OrderItems는 페치 조인을 사용하지 않는 것을 확인할 수 있습니다. OrdersItems과 다대일 관계를 맺는 Item 엔티티도 Order이 아닌 OrderItems과 관계를 가지기 때문에 페치 조인하지 않았습니다.

 

하지만 OrderItems를 지연 로딩 처리하면 컬렉션의 내부 row 개수만큼 하나씩 SELECT 쿼리가 나가며 N + 1 문제가 생깁니다. 실제로 OrderItems을 단순히 지연 로딩 처리할 경우 Order * OrderItems * Items 개수만큼 쿼리가 찍힙니다.

최적화 옵션!!!

// application.yml
spring:
  jpa:
    properties:
      hibernates:
        default_batch_fetch_size: 1000

 

hibernate.default_batch_fetch_size를 1000으로 설정하면 1000개의 데이터를 한 번에 IN 쿼리를 사용해 가져옵니다. 이 설정이 없이 컬렉션의 프록시를 일일이 초기화했다면 N + 1 문제가 발생했겠지만, IN 쿼리를 사용해 한 번에 1000개의 데이터를 가져오며 한 번의 쿼리문으로 OrderItems 컬렉션과 OrderItems과 연관된 Item 엔티티 데이터까지 가져올 수 있게 되었습니다. 이와 같은 설정으로 아래와 같이 where in절이 포함된 쿼리문이 나갑니다.

 

만약 데이터가 1000개이고 hibernate.default_batch_fetch_size를 100으로 설정했다면 IN 쿼리를 통해 데이터를 한 번에 100개씩 가져오기 때문에 10번의 네트워크 통신만으로 데이터를 가져올 수 있는 것입니다.

hibernate.default_batch_fetch_size 설정은 글로벌로 설정하는 방법이고 개별로 설정하려면 @BatchSize를 적용하면 됩니다. 컬렉션은 컬렉션 필드에, 엔티티는 엔티티 클래스에 적용해야 합니다.

 

컬렉션의 페치 조인하지 않았기 때문에 중복된 뻥튀기 결과를 걱정할 필요도 없고 hibernate.default_batch_fetch_size 설정을 통해 지연 로딩으로 인해 컬렉션의 프록시들을 모두 초기화하며 생기는 성능 문제도 없어졌습니다. hibernate.default_batch_fetch_size 설정으로 얻게 된 장점은 아래와 같습니다.

  • 쿼리 호출 수가 1 + N -> 1 + 1로 최적화되었습니다.
  • 컬렉션을 조인할 경우 데이터가 뻥튀기되었지만, 이제는 그렇지 않습니다.
  • 페치 조인 방식과 비교해서 쿼리 호출 수가 약간 증가했지만, 데이터가 뻥튀기되지 않아 DB 데이터 전송량이 감소했습니다.
  • 컬렉션 페치 조인은 페이징이 불가능했지만 이 방법은 페이징이 가능합니다.

결론적으로 xToOne 관계는 페치 조인을 해도 페이징에 영향을 주지 않습니다. 따라서 xToOne 관계는 페치 조인으로 쿼리 수를 줄이고, 나머지는 hibernate.default_batch_fetch_size로 최적화합시다.

 

default_batch_fetch_size 얼마가 적당할까?

hibernate.default_batch_fetch_size의 크기는 100 ~ 1000 사이를 선택하는 것을 권장합니다.

default_batch_fetch_size을 설정하는 전략은 SQL IN 절을 사용하는데, 데이터베이스에 따라 IN 절 파라미터를 1000으로 제한하는 경우도 있습니다. 그리고 크기를 1000으로 잡으면 한 번에 1000개를 DB에서 애플리케이션으로 불러와 네트워크 통신의 횟수는 줄일 수 있지만, DB에 순간 부하를 증가시킵니다.

 

애플리케이션은 hibernate.default_batch_fetch_size의 크기가 100이든 1000이든 결국 전체 데이터를 로딩해야 하므로 메모리 사용량은 같습니다. 1000으로 설정하는 게 네트워크 통신 측면에서는 가장 효율이 좋지만 DB가 견딜 수 있는 순간 부하를 고려해서 설정하는 게 좋습니다.

 

정리하자면

  • Where In 쿼리가 1000개가 넘어가면 DB 오류를 일으키는 경우가 종종 있어서 그 이상은 권장하지 않습니다.
  • 개수가 너무 많으면 한 번에 처리해야 할 요청 데이터가 상당히 커집니다. 이는 DB와 앱 서버에 부담을 줄 수 있습니다.
  • 개수가 작으면 DB, 앱서버에 부하는 줄겠지만, 그만큼 요청 횟수가 많아져서 네트워크 대기시간이 길어집니다.

앱서버(WAS), DB에 부담을 주지 않게 100으로 설정해 놓고, 나중에 변경하는 걸 추천합니다.