DEV-STUDY/Spring

[JPA] 연관관계 컬렉션 필드 조회 최적화 : 엔티티/DTO 조회 방식

HwangJerry 2023. 6. 28. 17:39

엔티티 조회 방식

XToOne 필드에 대하여 JOIN FETCH를 적용하고, 연관관계 컬렉션 필드(XToMany)에 대해서는 batch size를 설정하는 방법을 이용하여 엔티티 조회 방식에 최적화를 이뤄낼 수 있었다.

 

이는 JPA에서 제공해주는 기능을 적극적으로 활용한 로직이므로, 유지보수시에 코드를 거의 수정하지 않고 옵션만 약간 변경해서 적용해가며 다양한 성능 최적화를 시도할 수 있다.

 

/**
 * 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;
}
// paging : x
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();
}

// paging : o
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();
}
spring:
  datasource:
#    url: jdbc:h2:tcp://localhost/~/jpashop
#    username: sa
#    password:
    driver-class-name: org.h2.Driver

  jpa:
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
#        show_sql: true
        format_sql: true
        default_batch_fetch_size: 1000 #최적화 옵션 - 글로벌하게 적용

 

하지만 이 방식을 적용하여 엔티티 조회를 하여도 성능이 너무 느리다고 판단될 경우, DTO 조회 방식을 고려해볼 수 있다. DTO를 직접 조회하는 방식은 조금 더 성능이 최적화될 수 있는 여지가 있지만, 적용하거나 수정할 때 많은 양의 코드를 제어해야 하므로 유지보수성이 떨어진다.

 일반적인 경우, 극한의 성능 개선을 위한 코드는 대게 복잡하기 마련이다. 이는 코드 간에 강하게 결속되는 경향이 있어서 유지보수를 할 때에도 많은 코드를 수정해줘야 한다. 운동을 할 때에도 '고중량 고반복'은 없듯이, 항상 트레이드오프를 고려하면서 성능 최적화와 코드 복잡도 사이에서 줄타기를 잘 해야 한다.

 

DTO 직접 조회 방식

QueryDSL을 사용하지 않고 DTO를 직접 조회하기 위해선 JPQL에 new 연산자를 이용하여 DTO를 생성해줘서 받아줘야 한다.

 

V4

V4는 코드가 단순하다. 특정 주문 한건만 조회하면 이 방식을 사용해도 성능이 잘 나온다. 예를 들어서 조회한 Order 데이터가 1건이면 OrderItem을 찾기 위한 쿼리도 1번만 실행하면 된다.

@GetMapping("/api/v4/orders")
public List<OrderQueryDto> ordersV4() {
    return orderQueryRepository.findOrderQueryDtos();
}
/**
 * 컬렉션은 별도로 조회
 * Query: 루트 1번, 컬렉션 N 번
 * 단건 조회에서 많이 사용하는 방식
 */
public List<OrderQueryDto> findOrderQueryDtos() {
    //루트 조회(toOne 코드를 모두 한번에 조회)
    List<OrderQueryDto> result = findOrders();

    //루프를 돌면서 컬렉션 추가(추가 쿼리 실행)
    result.forEach(o -> {
        List<OrderItemQueryDto> orderItems = findOrderItems(o.getOrderId());
        o.setOrderItems(orderItems);
    });
    return result;
}
/**
 * 1:N 관계(컬렉션)를 제외한 나머지를 한번에 조회
 */
private List<OrderQueryDto> findOrders() {
    return em.createQuery(
            "select new jpabook.jpashop.repository.order.query.OrderQueryDto(o.id, m.name, o.orderDate, o.status, d.address)" +
                    " from Order o" +
                    " join o.member m" +
                    " join o.delivery d", OrderQueryDto.class)
            .getResultList();
}
/**
 * 1:N 관계인 orderItems 조회
 */
private List<OrderItemQueryDto> findOrderItems(Long orderId) {
    return em.createQuery(
            "select new jpabook.jpashop.repository.order.query.OrderItemQueryDto(oi.order.id, i.name, oi.orderPrice, oi.count)" +
                    " from OrderItem oi" +
                    " join oi.item i" +
                    " where oi.order.id = : orderId", OrderItemQueryDto.class)
            .setParameter("orderId", orderId)
            .getResultList();
}

 

 

V5

V5는 코드가 복잡하다. 여러 주문을 한꺼번에 조회하는 경우에는 V4 대신에 이것을 최적화한 V5 방식을 사용해야 한다. 예를 들어서 조회한 Order 데이터가 1000건인데, V4 방식을 그대로 사용하면, 쿼리가 총 1 + 1000번 실행된다. 여기서 1은 Order 를 조회한 쿼리고, 1000은 조회된 Order의 row 수다. V5 방식으로 최적화 하면 쿼리가 총 1 + 1번만 실행된다. 상황에 따라 다르겠지만 운영 환경에서 100배 이상의 성능 차이가 날 수 있다.

@GetMapping("/api/v5/orders")
public List<OrderQueryDto> ordersV5() {
    return orderQueryRepository.findAllByDto_optimization();
}
/**
 * 최적화
 * Query: 루트 1번, 컬렉션 1번
 * 데이터를 한꺼번에 처리할 때 많이 사용하는 방식
 *
 */
public List<OrderQueryDto> findAllByDto_optimization() {

    //루트 조회(toOne 코드를 모두 한번에 조회 with fetch join)
    List<OrderQueryDto> result = findOrders();

    //orderItem 컬렉션을 MAP 한방에 조회
    Map<Long, List<OrderItemQueryDto>> orderItemMap = findOrderItemMap(toOrderIds(result));

    //루프를 돌면서 컬렉션 추가(추가 쿼리 실행X)
    result.forEach(o -> o.setOrderItems(orderItemMap.get(o.getOrderId())));

    return result;
}
private List<Long> toOrderIds(List<OrderQueryDto> result) {
    return result.stream()
            .map(o -> o.getOrderId())
            .collect(Collectors.toList());
}

private Map<Long, List<OrderItemQueryDto>> findOrderItemMap(List<Long> orderIds) {
    List<OrderItemQueryDto> orderItems = em.createQuery(
            "select new jpabook.jpashop.repository.order.query.OrderItemQueryDto(oi.order.id, i.name, oi.orderPrice, oi.count)" +
                    " from OrderItem oi" +
                    " join oi.item i" +
                    " where oi.order.id in :orderIds", OrderItemQueryDto.class)
            .setParameter("orderIds", orderIds)
            .getResultList();

    return orderItems.stream()
            .collect(Collectors.groupingBy(OrderItemQueryDto::getOrderId));
}

 

V6

V6는 완전히 다른 접근방식이다. 쿼리 한번으로 최적화 되어서 상당히 좋아보이지만, Order를 기준으로 페이징이 불가능하다. 실무에서는 이정도 데이터면 수백이나, 수천건 단위로 페이징 처리가 꼭 필요하므로, 이 경우 선택하기 어려운 방법이다. 그리고 데이터가 많으면 중복 전송이 증가해서 V5와 비교해서 성능 차이도 미비하다.

@GetMapping("/api/v6/orders")
public List<OrderQueryDto> ordersV6() {
    List<OrderFlatDto> flats = orderQueryRepository.findAllByDto_flat();

    return flats.stream()
            .collect(groupingBy(o -> new OrderQueryDto(o.getOrderId(), o.getName(), o.getOrderDate(), o.getOrderStatus(), o.getAddress()),
                    mapping(o -> new OrderItemQueryDto(o.getOrderId(), o.getItemName(), o.getOrderPrice(), o.getCount()), toList())
            )).entrySet().stream()
            .map(e -> new OrderQueryDto(e.getKey().getOrderId(), e.getKey().getName(), e.getKey().getOrderDate(), e.getKey().getOrderStatus(), e.getKey().getAddress(), e.getValue()))
            .collect(toList());
}
public List<OrderFlatDto> findAllByDto_flat() {
    return em.createQuery(
            "select new jpabook.jpashop.repository.order.query.OrderFlatDto(o.id, m.name, o.orderDate, o.status, d.address, i.name, oi.orderPrice, oi.count)" +
                    " from Order o" +
                    " join o.member m" +
                    " join o.delivery d" +
                    " join o.orderItems oi" +
                    " join oi.item i", OrderFlatDto.class)
            .getResultList();
}

 

결론

1. 엔티티 조회 방식으로 충분히 성능이 나오는지 우선 체크한다. 성능이 괜찮다면 구태여 DTO 직접 조회 방식을 적용하기보단, 유지보수성을 위해 엔티티 조회 방식 안에서 최적화를 이뤄내는 것이 적절할 수 있다.

2. 만약 성능이 너무 안나올 경우에는 DTO 직접 조회 방식을 고려해볼 수 있다. 이 때에는 여러 방식을 적용해볼 수 있으므로 단건 조회인지, 여러 건 조회인지 등 각 상황에 따라 적절하게 선택하는 것이 중요하다.

3. DTO 직접 조회로도 성능이 충분하지 않을 경우에는 개발 측면에서 해결해볼 수 있는 여지는 네이티브 SQL 또는 JDBC Template을 이용하는 것인데, 사실 위 방법들에서 최적화가 이루어져지 않은 경우에는 스키마 설계가 너무 복잡하거나 또는 트래픽이 너무 방대하여 Redis 등을 통해 인프라 측면에서 캐시를 도입하는 것이 더욱 적절할 수 있다.

캐시 구현 방식을 적용하는 경우, 하이버네이트 2차 캐시로 구현하는 것이 아니라 Redis 등을 통해 캐시를 구현한다면, 캐시에 올라가는 것 또한 엔티티가 아니라 반드시 Dto여야 한다. 영속성 컨텍스트에 의해 관리되는 엔티티가 캐시에 있으면 이 것이 지워지지 않아서 꼬이게 된다. 아울러 하이버네이트 2차 캐시는 실무 적용에 어려운 부분이 많아, 일반적으로 Redis 등을 통해 캐시를 구현하며, 이러한 경우 엔티티가 아니라 Dto를 올려야 한다.