본문 바로가기
개발일지/JPA

JPA 사용 시 쿼리 방식 선택 권장 순서(컬렉션 조회 할 때)

2022. 8. 14.

정리

엔티티 조회

  • 엔티티를 조회해서 그대로 반환: V1
  • 엔티티 조회 후 DTO로 변환: V2
  • 페치 조인으로 쿼리 수 최적화: V3
  • 컬렉션 페이징과 한계 돌파: V3.1
    • 컬렉션은 페치 조인시 페이징이 불가능
    • ToOne 관계는 페치 조인으로 쿼리 수 최적화
    • 컬렉션은 페치 조인 대신에 지연 로딩을 유지하고, hibernate.default_batch_fetch_size , @BatchSize 로 최적화
  • DTO 직접 조회
    • JPA에서 DTO를 직접 조회: V4
    • 컬렉션 조회 최적화 - 일대다 관계인 컬렉션은 IN 절을 활용해서 메모리에 미리 조회해서 최적화: V5
    • 플랫 데이터 최적화 - JOIN 결과를 그대로 조회 후 애플리케이션에서 원하는 모양으로 직접 변환: V6

 

권장 순서

1. 엔티티 조회 방식으로 우선 접근

  • 1. 페치조인으로 쿼리 수를 최적화
  • 2. 컬렉션 최적화
    • 1. 페이징 필요 hibernate.default_batch_fetch_size , @BatchSize 로 최적화
    • 2. 페이징 필요X 페치 조인 사용

2. 엔티티 조회 방식으로 해결이 안되면 DTO 조회 방식 사용

 

3. DTO 조회 방식으로 해결이 안되면 NativeSQL or 스프링 JdbcTemplate

 

 

참고: 엔티티 조회 방식은 페치 조인이나, hibernate.default_batch_fetch_size , @BatchSize 같이 코드를 거의 수정하지 않고, 옵션만 약간 변경해서, 다양한 성능 최적화를 시도할 수 있다. 반면에 DTO를 직접 조회하는 방식은 성능을 최적화 하거나 성능 최적화 방식을 변경할 때 많은 코드를 변경해야 한다.

참고: 개발자는 성능 최적화와 코드 복잡도 사이에서 줄타기를 해야 한다. 항상 그런 것은 아니지만, 보통 성능 최적화는 단순한 코드를 복잡한 코드로 몰고간다. > 엔티티 조회 방식은 JPA가 많은 부분을 최적화 해주기 때문에, 단순한 코드를 유지하면서, 성능을 최적화 할 수 있다. > 반면에 DTO 조회 방식은 SQL을 직접 다루는 것과 유사하기 때문에, 둘 사이에 줄타기를 해야 한다.

 

대부분의 어플리케이션은 fetch join으로 해결이 되지만 정말 사용자가 많은 어플리케이션은 DTO 방법보다는

캐시를 사용하는게 더 좋은 방법일 수 있다.

참고로 엔티티는 직접 캐싱을 하면 안된다.

DTO로 캐시를 해야한다.

 

이 포스트에서는 fetch join 만 코드로 구현해본다.

아래 v3 버전은 distinct로 fetch join하지만 페이징 처리가 불가능하다.

// fetch join(패치 조인)으로 쿼리 수 최적화 V3
@GetMapping("/api/v3/orders")
public List<OrderDto> ordersV3() {
    List<Order> orders = orderRepository.findAllWithItem();
    List<OrderDto> result = orders.stream()
            .map(OrderDto::new)
            .collect(toList());

    return result;
}
// /api/v3/orders 에서 호출 ( 컬렉션 조회할 때(일대다) fetch join ), 근데 페이징이 안됨
public List<Order> findAllWithItem() {
    /*
    페치 조인으로 SQL이 1번만 실행됨
    distinct 를 사용한 이유는 1대다 조인이 있으므로 데이터베이스 row가 증가한다.
    그 결과 같은 order 엔티티의 조회 수도 증가하게 된다.
    JPA의 distinct는 SQL에 distinct를 추가하고, 더해서 같은 엔티티가 조회되면,
    애플리케이션에서 중복을 걸러준다. 이 예에서 order가 컬렉션 페치 조인 때문에 중복 조회 되는것을 막아준다.
    단점 - 페이징 불가능
     */
    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", Order.class)
            .getResultList();
}

 

페이징 처리를 하려면 V3.1 버전을 해야한다.

이버전은 default_batch_fetch_size 나 @BatchSize를 사용한다.

/**
 * V3.1 엔티티를 조회해서 DTO로 변환 페이징 고려
 * - ToOne 관계만 우선 모두 페치 조인으로 최적화
 * - 컬렉션 관계는 hibernate.default_batch_fetch_size, @BatchSize로 최적화
 */
@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(toList());

    return result;
}
// 컬렉션 조회할 때 페이징 처리 버전 - batch size 가 있어야 한다.
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();
}

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