[스프링 부트와 JPA 활용2] 4. API 개발 고급 - 컬렉션 조회 최적화

jada·2024년 7월 23일
0

Spring 스터디

목록 보기
32/35

주문내역에서 추가로 주문한 상품 정보를 추가로 조회하자. Order 기준으로 컬렉션인 OrderItemItem 이 필요하다.

앞의 예제에서는 toOne(OneToOne, ManyToOne) 관계만 있었다. 이번에는 컬렉션인 일대다 관계(OneToMany) 를 조회하고, 최적화하는 방법을 알아보자.


주문 조회 V1: 엔티티 직접 노출

@RestController
@RequiredArgsConstructor
public class OrderApiController {
    private final OrderRepository orderRepository;
/**
* 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; 
}
}
 
  • orderItem , item 관계를 직접 초기화하면 Hibernate5Module 설정에 의해 엔티티를 JSON으로 생성한다.

  • 양방향 연관관계면 무한 루프에 걸리지 않게 한곳에 @JsonIgnore 를 추가해야 한다.

  • 엔티티를 직접 노출하므로 좋은 방법은 아니다.



주문 조회 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;
 }
@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();
 } 
}
  • 지연 로딩으로 너무 많은 SQL 실행

  • SQL 실행 수

    • order 1번
    • member , address N번(order 조회 수 만큼)
    • orderItem N번(order 조회 수 만큼)
    • item N번(orderItem 조회 수 만큼)

참고: 지연 로딩은 영속성 컨텍스트에 있으면 영속성 컨텍스트에 있는 엔티티를 사용하고 없으면 SQL을 실행한다. 따라서 같은 영속성 컨텍스트에서 이미 로딩한 회원 엔티티를 추가로 조회하면 SQL을 실행하지 않는다.



주문 조회 V3: 엔티티를 DTO로 변환 - 페치 조인 최적화


 @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대다 조인이 있으므로 데이터베이스 row가 증가한다. 그 결과 같은 order 엔티티의 조회 수도 증가하게 된다. JPA의 distinct는 SQL에 distinct를 추가하고, 더해서 같은 엔티티가 조회되면, 애 플리케이션에서 중복을 걸러준다. 이 예에서 order가 컬렉션 페치 조인 때문에 중복 조회 되는 것을 막아준다.

  • 단점

    • 페이징 불가능


주문 조회 V3.1: 엔티티를 DTO로 변환 - 페이징과 한계 돌파

페이징과 한계 돌파

  • 컬렉션을 페치 조인하면 페이징이 불가능하다.

    • 컬렉션을 페치 조인하면 일대다 조인이 발생하므로 데이터가 예측할 수 없이 증가한다.

    • 일다대에서 일(1)을 기준으로 페이징을 하는 것이 목적이다. 그런데 데이터는 다(N)를 기준으로 row가 생성된다.

    • Order를 기준으로 페이징 하고 싶은데, 다(N)인 OrderItem을 조인하면 OrderItem이 기준이 되어버린다.

    • (더 자세한 내용은 자바 ORM 표준 JPA 프로그래밍 - 페치 조인 한계 참조)

  • 이 경우 하이버네이트는 경고 로그를 남기고 모든 DB 데이터를 읽어서 메모리에서 페이징을 시도한다. 최악의 경우 장애로 이어질 수 있다.


한계 돌파
그러면 페이징 + 컬렉션 엔티티를 함께 조회하려면 어떻게 해야할까?

지금부터 코드도 단순하고, 성능 최적화도 보장하는 매우 강력한 방법을 소개하겠다. 대부분의 페이징 + 컬렉션 엔티티 조회 문제는 이 방법으로 해결할 수 있다.

  • 먼저 ToOne(OneToOne, ManyToOne) 관계를 모두 페치조인 한다. ToOne 관계는 row수를 증가시키지 않 으므로 페이징 쿼리에 영향을 주지 않는다.

  • 컬렉션은 지연 로딩으로 조회한다.

  • 지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size , @BatchSize 를 적용한다.

    • hibernate.default_batch_fetch_size: 글로벌 설정
    • @BatchSize: 개별 최적화
    • 이 옵션을 사용하면 컬렉션이나, 프록시 객체를 한꺼번에 설정한 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();
}
/**
* 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;
}

최적화 옵션

spring: 
 jpa:
  properties:
   hibernate:
     default_batch_fetch_size: 1000
  • 개별로 설정하려면 @BatchSize 를 적용하면 된다. (컬렉션은 컬렉션 필드에, 엔티티는 엔티티 클래스에 적용)

장점

  • 쿼리 호출 수가 1 + N 1 + 1 로 최적화 된다.

  • 조인보다 DB 데이터 전송량이 최적화 된다. (Order와 OrderItem을 조인하면 Order가 OrderItem 만큼 중복해서 조회된다. 이 방법은 각각 조회하므로 전송해야할 중복 데이터가 없다.)

  • 페치 조인 방식과 비교해서 쿼리 호출 수가 약간 증가하지만, DB 데이터 전송량이 감소한다.

  • 컬렉션 페치 조인은 페이징이 불가능 하지만 이 방법은 페이징이 가능하다.

결론

  • ToOne 관계는 페치 조인해도 페이징에 영향을 주지 않는다.

  • 따라서 ToOne 관계는 페치조인으로 쿼리 수 를 줄이고 해결하고, 나머지는hibernate.default_batch_fetch_size 로 최적화하자.


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



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

  • Query: 루트 1번, 컬렉션 N 번 실행

  • ToOne(N:1, 1:1) 관계들을 먼저 조회하고, ToMany(1:N) 관계는 각각 별도로 처리한다.

    • 이런 방식을 선택한 이유는 다음과 같다.
    • ToOne 관계는 조인해도 데이터 row 수가 증가하지 않는다.
    • ToMany(1:N) 관계는 조인하면 row 수가 증가한다.
  • row 수가 증가하지 않는 ToOne 관계는 조인으로 최적화 하기 쉬우므로 한번에 조회하고, ToMany 관계는 최적 화 하기 어려우므로 findOrderItems() 같은 별도의 메서드로 조회한다.



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

/**
* 최적화
* 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));
}
  • Query: 루트 1번, 컬렉션 1번

  • ToOne 관계들을 먼저 조회하고, 여기서 얻은 식별자 orderId로 ToMany 관계인 OrderItem 을 한꺼번에 조 회

  • MAP을 사용해서 매칭 성능 향상(O(1))



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

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();
}
  • Query: 1번
  • 단점
    -쿼리는 한번이지만 조인으로 인해 DB에서 애플리케이션에 전달하는 데이터에 중복 데이터가 추가되므로 상황에 따라 V5 보다 더 느릴 수도 있다.
    • 애플리케이션에서 추가 작업이 크다.
    • 페이징 불가능

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 조회 방식으로 해결이 안되면 NativeSQL or 스프링 JdbcTemplate
profile
꾸준히 발전하는 개발자가 되자 !

0개의 댓글