API 개발 고급 - 컬렉션 조회 최적화

이가현·2022년 11월 11일
0

컬렉션 (= 일대다 관계) 조회, 최적화 해보자.

주문 조회 v1. 엔티티 직접 노출

@GetMapping("/api/v1/orders")
public List<Order> ordersV1() {
	List<Order> all = orderRepository.findAll();
	// 직접 초기화 시키기
    for (Order order : all) {
		order.getMember().getName(); 
        order.getDelivery().getAddress(); 
		List<OrderItem> orderItems = order.getOrderItems(); 
        orderItems.stream().forEach(o -> o.getItem().getName()); // 오더아이템도 직접 초기화
	}
    return all;
}

엔티티를 직접 노출하는 것은 좋지 않음 !

주문 조회 v2. 엔티티를 DTO로 변환

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

OrderApiController

@Data
static class OrderDto {
	private Long orderId;
    ...
    // 생성자
    public OrderDto(Order order) {
    	// 초기화
    	orderId = order.getId();
        name = order.getMember().getName();
        ...
        orderItems = order.getOrderItems().stream()
                  .map(orderItem -> new OrderItemDto(orderItem))
                  .collect(toList());
    }
}
@Data
static class OrderItemDto {
	private String itemName;//상품 명
    private int orderPrice; //주문 가격 
    private int count; //주문 수량
    ...
}

지연 로딩으로 너무 많은 SQL이 실행됨.
SQL 실행 수 : 1+N

주문 조회 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;
}

OrderRepository

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

📍 distinct를 쓰는 이유?

  • 컬렉션을 페치 조인하면 일대다 조인이 발생하는데,
    데이터는 다(N)를 기준으로 row 가 생성되기 때문에
    같은 엔티티가 중복 조회된다.
  • Order를 기준으로 페이징 하고 싶은데, 다(N)인 OrderItem을 조인하면 OrderItem이 기준이 되어버리면서 발생하는 문제 !

-> Order을 기준으로 제시하면서 중복을 제거해주기 위해 distinct 추가해줌

💡 SQL 1번만 실행됨

  • sql과 다르게 JPA에서는 id값이 같으면 중복처리 해주기 때문에 페치 조인으로 인해 쿼리가 1번 조회됨 !

💡 페이징 불가능

  • 1:N 연관관계인 두 엔티티가 즉시 로딩된다면 (엔티티 그래프나 fetch join 으로 연결되어 있다면) 페이징 처리시 쿼리에 limit 가 붙지 않고 모든 데이터를 메모리에 불러오기 때문 !

▪️ 페이징?
예:

원하는 페이지 값과 사이즈를 넣어 데이터를 조회하는 것.
예를 들어 page=0, size=10 이면 첫번째 페이지의 주문 10개 결과가 나오게 된다.

페이징과 한계 돌파

페이징과 컬렉션 엔티티를 함께 조회하는 방법은?

  • 1️⃣ ToOne(OneToOne, ManyToOne) 관계를 모두 페치조인
    (row수를 증가시키지 않으므로 페이징 쿼리에 영향을 주지 X)
  • 2️⃣ 컬렉션은 지연 로딩으로 조회
  • 3️⃣ applications.yml에 설정 추가 or @BatchSize 사용
    (for 지연로딩 성능 최적화)
spring: jpa:
	properties:
    	hibernate:
        	default_batch_fetch_size: 1000
-> 주문 id 를 마지막쿼리의 in 절에 넣어 해당 주문상품만 가져오게 되는 구조 !

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

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) {
      List<Order> orders = orderRepository.findAllWithMemberDelivery(offset,limit);
      List<OrderDto> result = orders.stream()
              .map(o -> new OrderDto(o))
 			  .collect(toList());
              
      return result;
 }

1️⃣ 쿼리 호출 수가 1+N -> 1+1로 최적화됨.
2️⃣ 조인보다 DB 데이터 전송량이 최적화 됨.
(페치 조인 방식과 비교해서 쿼리 호출 수가 약간 증가하지만, DB 데이터 전송량이 감소한다.)
❗️ why ? IN절에 객체 담아서 한번에 보내기 때문!
3️⃣ 페이징 가능

주문 조회 V4: JPA에서 DTO 직접 조회

엔티티는 리포지토리, 화면이나 API에 의존관계가 있는 쿼리는 따로 클래스를 분리 !


  • 루트 1번 + 컬렉션 N 번 쿼리 실행
    (컬렉션을 루프로 직접 넣어주기 때문에 N번 걸림.)
  • ToOne(N:1, 1:1) 관계들을 먼저 조회하고, ToMany(1:N) 관계는 각각 별도로 처리
    ( row 수가 증가하지 않는 ToOne 관계는 조인으로 최적화 하기 쉬우므로 한번에 조회하고, ToMany 관계는 최적화 하기 어려우므로 findOrderItems() 같은 별도의 메서드로 조회 )

주문 조회 V5: JPA에서 DTO 직접 조회 - 컬렉션 조회 최적화

OrderQueryRepository

public List<OrderQueryDto> findAllByDto_optimization() { //루트 조회(toOne 코드를 모두 한번에 조회)
      List<OrderQueryDto> result = findOrders();
//orderItem 컬렉션을 MAP 한방에 조회
      Map<Long, List<OrderItemQueryDto>> orderItemMap =
  findOrderItemMap(toOrderIds(result));
//루프를 돌면서 컬렉션 추가(추가 쿼리 실행X) in절에 담아서 한번에 가져오기 
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));
}

1️⃣ Query: 루트 1번, 컬렉션 1번 (총 2번~)
2️⃣ ToOne 관계들을 먼저 조회하고, 여기서 얻은 식별자 orderId로 ToMany 관계인 OrderItem 을 한꺼번에 조회
3️⃣ MAP을 사용해서 매칭 성능 향상(O(1))
4️⃣ 데이터를 한꺼번에 처리할 때 많이 씀

주문 조회 V6: JPA에서 DTO로 직접 조회, 플랫 데이터 최적화

OrderQueryRepository

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();
OrderFlatDto
  package jpabook.jpashop.repository.order.query;
  import jpabook.jpashop.domain.Address;
}

1️⃣ Query: 1번
2️⃣ 쿼리는 한번이지만 조인으로 인해 DB에서 애플리케이션에 전달하는 데이터에 중복 데이터가 추가되기 때문에 상황에 따라 V5 보다 더 느릴 수도 있음
3️⃣ 애플리케이션에서 추가 작업이 큼
4️⃣ 페이징 불가능


결론 !

0개의 댓글