빙응의 공부 블로그

[Spring] 컬렉션 조회 최적화 본문

Spring/인프런_JPA

[Spring] 컬렉션 조회 최적화

빙응이 2024. 7. 4. 16:49

📝 개요 

저번 시간에는 1대1, 다대1에 대해 페치 조인을 통해 최적화를 해보았다.
그렇다면 일대다 관계 조회는 어떻게 최적화해야할까?

📝 컬렉션 조회 최적화

🚩 V1. 엔티티 직접 노출

 /**
     * V1. 엔티티 직접 노출
     * - Hibernate5Module 모듈 등록, LAZY=null 처리
     * - 양방향 관계 문제 발생 -> @JsonIgnore
     */
    @GetMapping("/api/v1/orders")
 	public List<Order> ordersV1() {
 		List<Order> all = orderRepository.findAllByString(new OrderSearch());
 		for (Order order : all) {
            		order.getMember().getName(); //Lazy 강제 초기화
            		order.getDelivery().getAddress(); //Lazy 강제 초기환
			List<OrderItem> orderItems = order.getOrderItems();
            		orderItems.stream().forEach(o -> o.getItem().getName()); //Lazy 강제 초기화
        }
		 return all;
    }

 

저번과 같게 엔티티를 직접 노출하는 방법은 좋은 방법이 아니므로 그냥 눈으로만 보자 

 

🚩 V2 엔티티를 DTO로 변환

 @GetMapping("/api/v2/orders")
 public List<OrderDto> ordersV2() {
 List<Order> orders = orderRepository.findAllByString(new OrderSearch());
 List<OrderDto> result = orders.stream()
            .map(o -> new OrderDto(o))
            .collect(toList());
 return result;
 }

엔티티를 DTO로 변환하여 반환하자. 이러면 필요한 정보만 보내줄 수 있다. 

 @Data
 static class OrderDto {
 private Long orderId;
 private String name;
 private LocalDateTime orderDate; //주문시간
private OrderStatus orderStatus;
 private Address address;
 private List<OrderItemDto> orderItems;
 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(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();
    }
 }

이렇게 DTO롤 만들어서 내부 수행을 해 변환 시키면 된다. 

 

결과 
  • 지연 로딩으로 너무 많은 SQL이 실행된다. 

 

🚩 V3 페치 조인 최적화

 @GetMapping("/api/v3/orders")
public List<OrderDto> ordersV3() {
 List<Order> orders = orderRepository.findAllWithItem();
 List<OrderDto> result = orders.stream()
            .map(o -> new OrderDto(o))
            .collect(toList());
 return result;
 }

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

페치 조인을 통해 SQL을 1번만 실행할 수 있다.

distinct를 사용하는 이유는 1대다 조인이 있으므로 데이터베이스의 크기가 매우 증가하여 distinct를 추가해 중복을 걸러준다.

결론
  • 페치 조인으로 SQL이 1번만 실행되어 편해진다. 
  • 그러나 페이징이 불가능하다.

🚩 V3.1 페이징하는법

  • 컬렉션을 페치 조인하면 페이징이 불가능하다. 
    • 컬렉션을 페치 조인하면 일대다 조인이 발생하므로 데이터가 예측할 수 없이 증가한다.
    • 일대다에서 일을 기준으로 페이징을 하는 것이 목적이다, 그런데 데이터는 다를 기준으로 생성된다.
  • 한계돌파
    • 그렇다면 페이징 + 컬렉션 엔티티를 함께 조회하려면 어떻게 해야할까?
      1.  먼저 ToOne 관계를 모두 패치조인한다. ToOne 관계는 row 수를 증가시키지 않기 때문이다. 
      2.  컬렉션은 지연 로딩으로 조회한다.
      3.  지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size , @BatchSize  를 적용한다.
        1. hibernate.default_batch_fetch_size: 글로벌 설정
        2. @BatchSize: 개별 최적화
        3. 이 옵션을 사용하면 컬렉션이나, 프록시 객체를 한꺼번에 설정한 size 만큼 IN 쿼리로 조회한다.
 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();
 }

위처럼 일단 모든 ToOne 관계를 페치 조인하고 컬렉션은 나몰라라 지연 로딩으로 조회하면 된다. 

 

 @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;
 }
결론
  • 장점
    • 퀄리 호출 수가 1+1이다.
    • 조인보다 DB 데이터 전송량이 최적화 된다.
    • 페치 조인 방식과 비교해서 쿼리 호출 수는 약간 증가하지만, DB 데이터 전송량이 감소한다. 
    • 이 방법은 페이징 가능 

 

🚩 V4 JPA에서 DTO 직접 조회

/**
 *
 컬렉션은 별도로 조회
 * 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;
}

위처럼 DTO를 조회하고 별도로 컬렉션을 DTO에 추가하는 방식을 사용하면 된다. 

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

자세한 로직은 지연로딩때와 동일하다. DTO로 직접 참조하는 것 

 

결과
  • 쿼리는 루트 1번, 컬렉션 N번을 실행한다.
  • ToOne 관계들을 먼저 조회하고, ToMany 관계는 각각 별도로 처리한다.
    • 이러한 방식을 선택하는 이유는 각각의 관계는 row 증가 유무가 다르기 때문이다. 

 

🚩 V5 컬렉션 조회 최적화

/**
 * 최적화
 * Query: 루트 1번, 컬렉션 1번
 * 데이터를 한꺼번에 처리할 때 많이 사용하는 방식
 *
 */
public List<OrderQueryDto> findAllByDto_optimization() {
    //루트 조회(toOne 코드를 모두 한번에 조회)
    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));
}

개선사항은 SQL의 IN으로 한번의 쿼리를 날려 데이터를 조회하게 하는 것이다. 전의 V4는 스트림을 이용해 하나씩 쿼리를 날려 조회했지만 한번에 조회해서 1번만 쿼리를 날리게 한다. 

 

결과
  • 쿼리는 루트 1번, 컬렉션 1번

 

 

📝 정리

  1.  엔티티를 조회해서 그대로 반환 : 엔티티 직접 노출은 피해야함
  2. 엔티티 조회 후 DTO 변환 : 지연 로딩으로 인한 쿼리 증가
  3. 페치 조인으로 쿼리 수 최적화 : 페이징 불가능
  4. JPA에서 DTO를 직접 조회 : 쿼리 1번에 컬렉션 N번
  5. 컬렉션 조회 최적화 - SQL의 IN을 통해 쿼리 1번에 컬렉션 1번