본문 바로가기
ORM/JPA

[JPA] 컬렉션 조회 최적화

by 젊은오리 2023. 4. 17.
728x90

[김영한님의 실전! 스프링 부트와 JPA 활용2 강의 학습 후 정리한 내용입니다.]

서론

  • OneToMany관계에서 엔티티를 조회하는 방법을 통해 성능 최적화를 해보자.
  • 여기에서는 Order에서 Member, Delivery에 추가로 OrderItems을 조회하는 API를 예시로 든다.
  • 참고로, Order와 OrderItems가 OneToMany관계이다.

순서

  1. 주문 조회 V2 (엔티티를 DTO로 변환)
  2. 주문 조회 V3 (엔티티를 DTO로 변환 - 페치 조인 최적화)
  3. 주문 조회 V3.1 (엔티티를 DTO로 변환 - 페이징과 한계 돌파)

 

1. 주문 조회 V2(엔티티를 DTO로 변환)

  • 엔티티를 DTO로 변환하는 일반적인 방법이다.
@GetMapping("/api/v2/orders")
public List<OrderDto> ordersV2() {
    List<Order> orders = orderRepository.findAllByString(new OrderSearch());
    List<OrderDto> result = orders.stream()
            .map(order -> new OrderDto(order))
            .collect(Collectors.toList());
    return result;
}

 

이전 예제에 OrderItem collection을 추가로 조회한다. 하지만 여기서 OrderItem 엔티티를 그대로 응답하면 안되므로 OrderItemDto로 또 감싸준다.

[OrderDto]

@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(Collectors.toList());
    }
}

 

[OrderItemDto]

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

 

  • 이렇게 완성된 API는 지연 로딩으로 너무 많은 쿼리를 실행하게 된다.
  • 쿼리가 총 1 + N + N + N + N번 실행된다. (orders 뽑아내는 쿼리 1개 + order.member 지연 로딩 조회 N번 + order.delivery 지연 로딩 조회 N번 + orderItems 쿼리 N번 + orderItem의 item N번) → 코드 주석 참고

 

2. 주문 조회 V3 (엔티티를 DTO로 변환 - 페치 조인 최적화)

  • v2와 비슷해 보이지만, findAllWithItem()메서드가 다르다. fetch join을 사용하기 위해 새롭게 정의해보자.
@GetMapping("/api/v3/orders")
public List<OrderDto> ordersV3() {
    List<Order> orders = orderRepository.findAllWithItem();
    List<OrderDto> result = orders.stream()
            .map(o -> new OrderDto(o))
            .collect(Collectors.toList());
    return result;
}

 

[OrderRepository]

public List<Order> findAllWithItem(){
    return em.createQuery(
            "select distinct o from Order o" + //distinct 키워드 중요
                    " join fetch o.member m" +
                    " join fetch o.delivery d" +
                    " join fetch o.orderItems oi" +
                    " join fetch oi.item i", Order.class)
            .getResultList();
}

위와 같이 fetch join을 사용해서 컬렉션 orderItem과 orderItem의 item을 전부 join했다. 컬렉션을 join하게 되면, 일대다 조인이므로, 당연히 데이터베이스의 row는 증가하게 된다. 따라서 우리가 원하는 Order는 중복되는 row가 많을 것이므로, distict 키워드를 사용하여 중복 조회를 막아주었다.

※참고 - 컬렉션을 페치 조인하면 페이징이 불가능하다. 컬렉션을 페치 조인하면 일대다 조인이 발생하므로 데이터가 예측할 수 없이 증가한다. 일대다에서 일(1)을 기준으로 페이징을 하는 것이 목적이다. 그런데 데이터는 다(N)를 기준으로 row가 생성된다.여기서는 Order를 기준으로 페이징 하고 싶은데, 다(N)인 OrderItem을 조인하면 OrderItem이 기준이 되어버리는 문제가 발생하는 것이다.

아래 사진과 같이 쿼리 한방에 정보를 얻을 수 있다.

 

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

V3.1은 페이징과 컬렉션 엔티티를 함께 조회할 수 있는 방법이다. 대부분의 페이징 + 컬렉션 엔티티 조회 문제는 이 방법으로 해결이 가능하다. 구현 과정은 다음와 같다.

  1. ToOne(OneToOne, ManyToOne)관계를 모두 fetch join한다. (ToOne관계는 row수를 증가시키지 않으므로 페이징 쿼리에 영향을 주지 않는다.)
  2. 컬렉션은 지연 로딩으로 조회한다.
  3. 지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size를 적용한다.

controller에서는 페이징 처리를 위해 다음과 같이 offset과 limit을 정해 findAllWIthMemberDelivery()메서드를 호출한다. 이 메서드는 1)ToOne관계를 모두 fetch join하여 페이징 처리를 해준다.

다음 OrderDto를 만드는 과정에서는 2)컬렉션은 지연 로딩으로 조회하기 위해 이전 방식과 똑같은 방식을 취한다. 대신, application.yml에 전역으로 3)hibernate.default_batch_fetch_size을 붙여준다.

@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(Collectors.toList());
    return result;
}

 

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

 

[application.yml]

jpa:
  hibernate:
    ddl-auto: create
  properties:
    hibernate:
      default_batch_fetch_size: 1000

 

Postman으로 API를 실행시킨 결과 아래 사진과 같이 쿼리 세 개로 원하는 정보를 얻을 수 있다.

  • fetch join을 통해 order, member, delivery를 모두 찾는 쿼리 1개
  • orderitem을 찾는 과정에서 in절로 모든 orderitem을 가져오는 쿼리 1개
  • item을 찾는 과정에서 in절로 모든 item을 가져오는 쿼리 1개

이 방식은 쿼리 호출 수가 기존의 1+N에서 1+1로 최적화된다.

fetch join 방식과 비교하여 쿼리 호출 수가 약간 증가하지만, DB데이터 전송량이 감소한다는 장점이 있다. 또한, 모든 테이블을 fetch join한 V3과 달리 이 방법은 페이징이 가능하다.

 

정리

우선 fetch join으로 쿼리 수를 최적화 한다. 컬렉션을 조회할 시에 최적화를 하려고 하니 페이징이 필요한 경우hibernate.default_batch_fetch_size 또는 @BatchSize로 최적화를 한다. 페이징이 필요 없다면 fetch join만을 사용하자.사실 실무에서는 페이징이 굉장히 많이 쓰이므로, API개발 시에 hibernate.default_batch_fetch_size와 같은 옵션은 디폴트로 사용한다고 봐도 무방하다. 

728x90

'ORM > JPA' 카테고리의 다른 글

[JPA] 엔티티 매핑  (0) 2023.04.24
[JPA] OSIV 성능 최적화 정리  (0) 2023.04.18
[JPA] 지연 로딩과 조회 성능 최적화  (0) 2023.04.16
[JPA] 더티 체킹(dirty checking) 정리  (0) 2023.04.16
[JPA] JPA Auditing 정리 및 구현  (0) 2023.04.10

댓글