[스프링부트JPA_활용2] Section4 - API 개발 고급- 컬렉션 조회 최적화 2️⃣

JiMin LEE·2022년 11월 10일
0

[Spring]JPA활용2

목록 보기
5/6

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

  • OrderRepository : Order 엔티티 조회용으로 사용하는 것
  • Query 관련 Repository : 화면이나 API에 의존관계가 있는 것들 전용

❓ OrderDto(OrderApiConroller 속)가 아닌 OrderQueryDto를 사용하는 이유

→ OrderDto를 참조하면 Repository가 Controller를 의존하게 되는 순환관계가 생겨버린다.
→ 어차피 OrderQueryRepository가 알아야 하기에 같은 패키지에 다시 만들었다.

  • Query : 루트 1번(order 가져오기), 컬렉션 N번 실행
  • ToOne 관계는 먼저 조회하고 ToMany 관계는 각각 처리했다
    • ToOne 관계는 조인해도 데이터 row 수가 증가하지 않는다.
    • ToMany 관계는 조인하면 데이터 row 수 증가한다.
  • row 수가 증가하지 않는 ToOne 관계는 조인으로 최적화 하기 쉽고,
    row 수가 증가하는 ToMany 관계는 최적화 하기 어려우므로 ‘findOrderItems()’ 같은 별도의 메서드로 조회한다

api.OrderApiController

package jpabook.jpashop.api;

...

@RestController
@RequiredArgsConstructor
public class OrderApiController {

    private final OrderRepository orderRepository;
    private final OrderQueryRepository orderQueryRepository;

    ...

    @GetMapping("/api/v4/orders")
    public List<OrderQueryDto> orderV4(){

        return orderQueryRepository.findOrderQueryDtos();

    }
}

repository.order.query.OrderQueryRepository

package jpabook.jpashop.repository.order.query;

...

@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {

    private final EntityManager em;

    public List<OrderQueryDto> findOrderQueryDtos(){
        List<OrderQueryDto> result = findOrders(); // order query 1번 -> order 데이터 N개

        result.forEach(o -> {
            List<OrderItemQueryDto> orderItems = findOrderItems(o.getOrderId()); // 각 order에 대한 orderItem N개 쿼리 발생 -> N + 1
            o.setOrderItems(orderItems);
        });
        return result;
    }

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

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

}

repository.order.query.OrderQueryDto

package jpabook.jpashop.repository.order.query;

import jpabook.jpashop.domain.Address;
import jpabook.jpashop.domain.OrderStatus;
import lombok.Data;

import java.time.LocalDateTime;
import java.util.List;

@Data
public class OrderQueryDto {

    private Long orderId;
    private String name;
    private LocalDateTime orderDate;
    private OrderStatus orderStatus;
    private Address address;
    private List<OrderItemQueryDto> orderItems;

    public OrderQueryDto(Long orderId, String name, LocalDateTime orderDate, OrderStatus orderStatus, Address address) {
        this.orderId = orderId;
        this.name = name;
        this.orderDate = orderDate;
        this.orderStatus = orderStatus;
        this.address = address;
    }
}

repository.order.query.OrderItemQueryDto

package jpabook.jpashop.repository.order.query;

import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Data;

@Data
public class OrderItemQueryDto {

    @JsonIgnore
    private Long orderId;
    private String itemName;
    private int orderPrice;
    private int count;

    public OrderItemQueryDto(Long orderId, String itemName, int orderPrice, int count) {
        this.orderId = orderId;
        this.itemName = itemName;
        this.orderPrice = orderPrice;
        this.count = count;
    }
}

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

  • 데이터 select 하는 양은 줄어든다.
  • 쿼리 수가 줄어든다
  • ToOne 관계들(member, delivery)을 먼저 조회(findOrders)하고, 여기서 얻은 orderId로 ToMany 관계인 OrderItem 을 한꺼번에 조회(findAllByDto_optimization)한다.
  • Map을 이용해서 매칭 성능 향상

api.OrderApiController

package jpabook.jpashop.api;

...

@RestController
@RequiredArgsConstructor
public class OrderApiController {

    private final OrderRepository orderRepository;
    private final OrderQueryRepository orderQueryRepository;

    ...

    @GetMapping("/api/v5/orders")
    public List<OrderQueryDto> orderV5(){

        return orderQueryRepository.findAllByDto_optimization();

    }
}

repository.order.query.OrderQueryRepository

package jpabook.jpashop.repository.order.query;

...

@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {

    private final EntityManager em;

    ...

    public List<OrderQueryDto> findAllByDto_optimization() {
        **List<OrderQueryDto> result = findOrders(); // order에 관한 쿼리 1번

        Map<Long, List<OrderItemQueryDto>> orderItemMap = findOrderItemMap(toOrderIds(result));

        result.forEach(o ->o.setOrderItems(orderItemMap.get(o.getOrderId())));

        return result;
    }

    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) // 1번의 쿼리를 날려 모두 가져온다.
                .setParameter("orderIds", orderIds)
                .getResultList();

        Map<Long, List<OrderItemQueryDto>> orderItemMap = orderItems.stream() // 메모리에 map으로 다 가져온 다음 메모리에서 매칭을 해서 값을 세팅해 주는 것 -> 쿼리 총 2번
                .collect(Collectors.groupingBy(orderItemQueryDto -> orderItemQueryDto.getOrderId()));
        return orderItemMap;
    }

    private List<Long> toOrderIds(List<OrderQueryDto> result) {
        return result.stream()
                .map(o -> o.getOrderId())
                .collect(Collectors.toList());
    }**
}
  • V4
    • order에 관한 쿼리 1번 날림 ( findOrders ) → 데이터 개수 n개
    • 각 orderid와 관련된 orderitems 쿼리 날림 → n번
    • 총 N + 1번 쿼리
  • V5
    • order에 관한 쿼리 1번 날림 ( findOrders ) → 데이터 개수 n개
    • orderid를 컬렉션으로 list형식으로 묶어버림 → map을 이용해서
    • 그 리스트에 있는 모든 orderid와 관련된 orderitem을 한 번에 가져오기 → 쿼리 1번

  • 결과
[
    {
        "orderId": 4,
        "name": "userA",
        "orderDate": "2022-11-09T23:44:57.963477",
        "orderStatus": "ORDER",
        "address": {
            "city": "서울",
            "street": "1",
            "zipcode": "1111"
        },
        "orderItems": [
            {
                "itemName": "JPA1 BOOK",
                "orderPrice": 10000,
                "count": 1
            },
            {
                "itemName": "JPA2 BOOK",
                "orderPrice": 20000,
                "count": 2
            }
        ]
    },
    {
        "orderId": 11,
        "name": "userB",
        "orderDate": "2022-11-09T23:44:57.985578",
        "orderStatus": "ORDER",
        "address": {
            "city": "진주",
            "street": "2",
            "zipcode": "2222"
        },
        "orderItems": [
            {
                "itemName": "SPRING BOOK1",
                "orderPrice": 20000,
                "count": 3
            },
            {
                "itemName": "SPRING BOOK2",
                "orderPrice": 40000,
                "count": 4
            }
        ]
    }
]

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

  • Order와 OrderItem join하고, OrderItem이랑 Item이랑 join해서 1번에 다 가져오는 방법
  • 장점 : 쿼리가 1번만 나간다.
  • 단점 : 페이징을 할 수 없다

api.OrderApiController

package jpabook.jpashop.api;

...

@RestController
@RequiredArgsConstructor
public class OrderApiController {

    private final OrderRepository orderRepository;
    private final OrderQueryRepository orderQueryRepository;

    ...

    @GetMapping("/api/v6/orders")
    public List<OrderFlatDto> orderV6(){

        return orderQueryRepository.findAllByDto_flat();

    }
}

repository.order.query.OrderQueryRepository

package jpabook.jpashop.repository.order.query;

...

@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {

    private final EntityManager em;

    ...

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

  • 결과
[
    {
        "orderId": 4,
        "name": "userA",
        "orderDate": "2022-11-09T23:44:57.963477",
        "orderStatus": "ORDER",
        "address": {
            "city": "서울",
            "street": "1",
            "zipcode": "1111"
        },
        "itemName": "JPA1 BOOK",
        "orderPrice": 10000,
        "count": 1
    },
    {
        "orderId": 4,
        "name": "userA",
        "orderDate": "2022-11-09T23:44:57.963477",
        "orderStatus": "ORDER",
        "address": {
            "city": "서울",
            "street": "1",
            "zipcode": "1111"
        },
        "itemName": "JPA2 BOOK",
        "orderPrice": 20000,
        "count": 2
    },
    {
        "orderId": 11,
        "name": "userB",
        "orderDate": "2022-11-09T23:44:57.985578",
        "orderStatus": "ORDER",
        "address": {
            "city": "진주",
            "street": "2",
            "zipcode": "2222"
        },
        "itemName": "SPRING BOOK1",
        "orderPrice": 20000,
        "count": 3
    },
    {
        "orderId": 11,
        "name": "userB",
        "orderDate": "2022-11-09T23:44:57.985578",
        "orderStatus": "ORDER",
        "address": {
            "city": "진주",
            "street": "2",
            "zipcode": "2222"
        },
        "itemName": "SPRING BOOK2",
        "orderPrice": 40000,
        "count": 4
    }
]
  • api 스펙 - OrderQueryDto로 하고 DB Dto는 OrderFlatDto로 하려면?

    • 장점 : 쿼리가 1번이다.

    • 단점
      - 페이징이 불가능하다.
      - 애플리케이션에서 조각조각 다시 맞춰야 한다는 점에서 복잡하다.
      - 조인으로 인해 DB에서 애플리케이션에 전달하는 데이터에 중복 데이터가 추가되므로 상황에 따라 느릴 수 도 있다.

      api.OrderApiController

      package jpabook.jpashop.api;
      
      ...
      
      @RestController
      @RequiredArgsConstructor
      public class OrderApiController {
      
          private final OrderRepository orderRepository;
          private final OrderQueryRepository orderQueryRepository;
      
          ...
      
          @GetMapping("/api/v6/orders")
          public List<OrderQueryDto> orderV6(){
      
              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());
      
          }
      }

  • 결과
[
  {
      "orderId": 11,
      "name": "userB",
      "orderDate": "2022-11-10T00:11:58.2532",
      "orderStatus": "ORDER",
      "address": {
          "city": "진주",
          "street": "2",
          "zipcode": "2222"
      },
      "orderItems": [
          {
              "itemName": "SPRING BOOK1",
              "orderPrice": 20000,
              "count": 3
          },
          {
              "itemName": "SPRING BOOK2",
              "orderPrice": 40000,
              "count": 4
          }
      ]
  },
  {
      "orderId": 4,
      "name": "userA",
      "orderDate": "2022-11-10T00:11:58.232188",
      "orderStatus": "ORDER",
      "address": {
          "city": "서울",
          "street": "1",
          "zipcode": "1111"
      },
      "orderItems": [
          {
              "itemName": "JPA1 BOOK",
              "orderPrice": 10000,
              "count": 1
          },
          {
              "itemName": "JPA2 BOOK",
              "orderPrice": 20000,
              "count": 2
          }
      ]
  }
]

8️⃣ API 개발 고급 정리

  • 엔티티 조회

    • 엔티티를 조회해서 그대로 반환 : 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 조회 방식으로 해결이 안 되면 Native SQL or JdbcTemplate

  • DTO 조회 방식
    • V4
      • 코드가 단순하다.
      • 특정 주문 한 건만 조회하면 이 방식을 사용해도 성능이 잘 나온다.
        ex - order 데이터가 1건이면 orderitem을 찾기 위한 쿼리도 1번만 실행하면 된다.
    • V5
      • 코드가 복잡하다
      • 여러 주문을 한꺼번에 조회하는 경우에 최적화된 방법이다.
        ex - 조회한 order 데이터가 1000건이면 V4는 쿼리 수가 1+ 1000, V5는 1 +1 번이다.
    • V6
      • 쿼리는 1번이다.
      • Order를 기준으로 페이징이 불가능하다.
      • 데이터가 많으면 중복 전송이 증가해서 V5와 비교해서 성능차이가 미비하다.
➕ 엔티티는 직접 캐싱하면 안 된다.
  • 영속성 컨텍스트를 관리하고 있는데 영속성 컨텍스트가 캐시에 있으면 삭제가 불가능하므로
  • 캐시하는 것은 오직 DTO로 해야 한다.

0개의 댓글