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

LeeKyoungChang·2022년 4월 14일
0
post-thumbnail

인프런 수업 강의를 듣고 정리한 내용입니다.

 

지금까지는 엔티티를 조회해서 DTO로 변환하는 과정을 거쳤는데, 이번에는 DTO로 직접 조회하는 방법을 알아보자!

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

entity가 아닌, 특정 화면을 위한 쿼리들은 따로 query 디렉터리에 저장한다.

OrderApiController 추가

	private final OrderQueryRepository orderQueryRepository;

    @GetMapping("/api/v4/orders")
    public List<OrderQueryDto> ordersV4() {
        return orderQueryRepository.findOrderQueryDtos();
    }
  • DTO 리스트를 조회해 바로 반환한다.
  • 이때, 3가지 객체를 추가한다.

 

✔️ 3가지 클래스

리포지토리 메서드인 findOrderQueryDtos()메서드에서 DTO에 직접 접근할텐대, 기존에 사용했던 컨트롤러의 inner(내부) 클래스 DTO를 그대로 사용하면 리포지토리에서 컨트롤러를 의존하기 때문에
다르게, 외부 클래스로 리포지토리 계층에 DTO를 따로 위치시켜야 한다! (OrderQueryDto, OrderItemQueryDto)

OrderQueryDto

package csjpabook.csjpashop.repository.order.query;

import csjpabook.csjpashop.domain.Address;
import csjpabook.csjpashop.domain.OrderStatus;
import lombok.Data;
import lombok.EqualsAndHashCode;

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

@Data
@EqualsAndHashCode(of = "orderId")
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;
    }
}
  • 생성자에 API를 통해 보내고자 하는 데이터를 하나하나씩 설정한다.

 

OrderItemQueryDto

package csjpabook.csjpashop.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;
    }
}
  • orderId는 나중에 사용하기 위해 미리 추가!

 

OrderQueryRepository

OrderRepository는 순수 엔티티를 다루는 용도이기 때문에 DTO 전용 리포지토리를 따로 만든다.

package csjpabook.csjpashop.repository.order.query;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import javax.persistence.EntityManager;
import java.util.List;

@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {

    private final EntityManager em;
    /**
     * 컬렉션은 별도로 조회
     * Query: 루트 1번, 컬렉션 N 번
     * 단건 조회에서 많이 사용하는 방식
     */
    public List<OrderQueryDto> findOrderQueryDtos() {
        //루트 조회(toOne 코드를 모두 한번에 조회) : 1번
        List<OrderQueryDto> result = findOrders();
        //루프를 돌면서 컬렉션 추가(추가 쿼리 실행) : n번
        result.forEach(o -> {
            List<OrderItemQueryDto> orderItems = findOrderItems(o.getOrderId());
            o.setOrderItems(orderItems);
        });
        return result;
    }

    /**
     * 1:N 관계인 orderItems 조회
     */
    private List<OrderItemQueryDto> findOrderItems(Long orderId) {
        return em.createQuery(
                        "select new csjpabook.csjpashop.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();
    }
    /**
     * 1:N 관계(컬렉션)를 제외한 나머지를 한번에 조회
     */
    private List<OrderQueryDto> findOrders() {
        return em.createQuery(
                        "select new csjpabook.csjpashop.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();
    }

}

 

 

  • Query : 루트 1번, 컬렉션 N 번 실행
  • xxxToOne(N:1, 1:1) 관계들을 먼저 조회하고, xxxToMany(1:N) 관계는 각각 별도로 처리한다.
    • 이유는?
    • xxxToOne 관계는 조인해도 데이터 row 수가 증가하지 않는다.
    • ToMany(1:N) 관계는 조인하면 row 수가 증가한다.
  • row 수가 증가하지 않는 xxxToOne 관계는 조인으로 최적화 하기 쉬우므로 한번에 조회하고, xxxToMany 관계는 최적화 하기 어려우므로 findOrderItems() 같은 별도의 메서드로 조회한다.

 

실행 결과
GET
http://localhost:8080/api/v4/orders

[
    {
        "orderId": 4,
        "name": "userA",
        "orderDate": "2022-04-14T16:26:31.049041",
        "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-04-14T16:26:31.178155",
        "orderStatus": "ORDER",
        "address": {
            "city": "진주",
            "street": "2",
            "zipcode": "2222"
        },
        "orderItems": [
            {
                "itemName": "SPRING1 BOOK",
                "orderPrice": 20000,
                "count": 3
            },
            {
                "itemName": "SPRING2 BOOK",
                "orderPrice": 40000,
                "count": 4
            }
        ]
    }
]

 

✔️ 쿼리 세번 실행 된다.

OrderQueryRepository

(1) findOrders() 메서드를 통해 `*ToOne엔티티들을 조인한 값들을 포함한List`를 반환한다.**

쿼리 한 번 실행 된다.

스크린샷 2022-04-20 오후 5 17 15
    private List<OrderQueryDto> findOrders() {
        return em.createQuery(
                        "select new csjpabook.csjpashop.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();
    }
스크린샷 2022-04-20 오후 5 20 07 스크린샷 2022-04-14 오후 4 28 05

***ToMany(컬렉션) 관계는 데이터를 증가시키기 때문에 join하지 않고 따로 메서드를 만들 것이다! (밑에)
(실제로 OrderQueryDto 생성자에서는 OrderItem이 포함되어 있지 않다.)

 

(2) findOrderItems() 호출

스크린샷 2022-04-14 오후 4 28 46
  • List<OrderQueryDto>에서 각각 OrderQueryDto마다 findOrderItems() 메서드를 호출해서 List<OrderItemQueryDto>OrderQueryDto의 필드에 저장한다.
    private List<OrderItemQueryDto> findOrderItems(Long orderId) {
        return em.createQuery(
                        "select new csjpabook.csjpashop.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();
    }
  • orderItemitem***ToOne 관계이다.
  • where절을 보면, Order에서 OrderItemOneToMany 관계이다.

forEach문으로 두 번 호출된다.

스크린샷 2022-04-14 오후 4 28 31 스크린샷 2022-04-14 오후 4 30 31

 

✏️ 결론

  • row 수가 증가하지 않는 ***ToOne 관계는 조인으로 최적화 하기 쉬우므로 한 번에 조회한다.
  • ***ToMany 관계는 최적화 하기 어려우므로 findOrderItems() 같은 별도의 메서드로 조회한다.
  • N + 1 문제
    • findOrders()에서 query 1번, N건 조회
    • findOrderItems()에서 query N번 조회

 

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

OrderApiController에 추가

    @GetMapping("/api/v5/orders")
    public List<OrderQueryDto> ordersV5(){
        return orderQueryRepository.findAllByDto_optimization();
    }

 

OrderQueryRepository에 추가


    /**
     * 최적화
     * 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 csjpabook.csjpashop.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번
  • xxxToOne 관계들을 먼저 조회하고, 여기서 얻은 식별자 orderIdxxxToMany 관계인 OrderItem 을 한꺼번에 조회
  • MAP을 사용해서 매칭 성능 향상 (O(1))

 

실행 결과

[
    {
        "orderId": 4,
        "name": "userA",
        "orderDate": "2022-04-14T17:03:06.680695",
        "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-04-14T17:03:06.802302",
        "orderStatus": "ORDER",
        "address": {
            "city": "진주",
            "street": "2",
            "zipcode": "2222"
        },
        "orderItems": [
            {
                "itemName": "SPRING1 BOOK",
                "orderPrice": 20000,
                "count": 3
            },
            {
                "itemName": "SPRING2 BOOK",
                "orderPrice": 40000,
                "count": 4
            }
        ]
    }
]
스크린샷 2022-04-14 오후 5 08 51

1번
스크린샷 2022-04-14 오후 5 08 05

2번
스크린샷 2022-04-14 오후 5 10 34

 

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

OrderApiController에 추가

    @GetMapping("/api/v6/orders")
    public List<OrderQueryDto> ordersV6() {
        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());
    }

 

OrderQueryDto에 생성자 추가

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

 

OrderQueryRepository에 추가

    public List<OrderFlatDto> findAllByDto_flat() {
        return em.createQuery(
                        "select new " +
                                " csjpabook.csjpashop.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 csjpabook.csjpashop.repository.order.query;

import csjpabook.csjpashop.domain.Address;
import csjpabook.csjpashop.domain.OrderStatus;
import lombok.Data;

import java.time.LocalDateTime;

@Data
public class OrderFlatDto {
    private Long orderId;
    private String name;
    private LocalDateTime orderDate; //주문시간
    private Address address;
    private OrderStatus orderStatus;
    private String itemName;//상품 명
    private int orderPrice; //주문 가격
    private int count; //주문 수량

    public OrderFlatDto(Long orderId, String name, LocalDateTime orderDate,
                        OrderStatus orderStatus, Address address, String itemName, int orderPrice, int
                                count) {
        this.orderId = orderId;
        this.name = name;
        this.orderDate = orderDate;
        this.orderStatus = orderStatus;
        this.address = address;
        this.itemName = itemName;
        this.orderPrice = orderPrice;
        this.count = count;
    }
}

 

(1) 장점
Query : 1번

(2) 단점

  • 쿼리는 한번이지만 조인으로 인해 DB에서 애플리케이션에 전달하는 데이터에 중복 데이터가 추가되므로 상황에 따라 V5 보다 더 느릴 수 도 있다.
  • 애플리케이션에서 추가 작업이 크다.
  • 페이징 불가능하다.

 

실행 결과

[
    {
        "orderId": 11,
        "name": "userB",
        "orderDate": "2022-04-14T17:44:40.092839",
        "orderStatus": "ORDER",
        "address": {
            "city": "진주",
            "street": "2",
            "zipcode": "2222"
        },
        "orderItems": [
            {
                "itemName": "SPRING1 BOOK",
                "orderPrice": 20000,
                "count": 3
            },
            {
                "itemName": "SPRING2 BOOK",
                "orderPrice": 40000,
                "count": 4
            }
        ]
    },
    {
        "orderId": 4,
        "name": "userA",
        "orderDate": "2022-04-14T17:44:39.958653",
        "orderStatus": "ORDER",
        "address": {
            "city": "서울",
            "street": "1",
            "zipcode": "1111"
        },
        "orderItems": [
            {
                "itemName": "JPA1 BOOK",
                "orderPrice": 10000,
                "count": 1
            },
            {
                "itemName": "JPA2 BOOK",
                "orderPrice": 20000,
                "count": 2
            }
        ]
    }
]

query 1번

스크린샷 2022-04-14 오후 6 13 33

 

💡 참고
OrderQueryDto 클래스에 @EqualsAndHashCode(of = "orderId") : orderId를 기준으로 공통인 것을 묶어준다.

 

📚 8. API 개발 고급 정리

📌 지금까지 한 내용 정리
(1) 엔티티 조회

  • 엔티티를 조회해서 그대로 반환: V1
  • 엔티티 조회 후 DTO로 변환: V2
  • 페치 조인으로 쿼리 수 최적화: V3
  • 컬렉션 페이징과 한계 돌파: V3.1
    • 컬렉션은 페치 조인시 페이징이 불가능
    • xxxToOne 관계는 페치 조인으로 쿼리 수 최적화
    • 컬렉션은 페치 조인 대신에 지연 로딩을 유지하고, hibernate.default_batch_fetch_size, @BatchSize 로 최적화

 

(2) DTO 직접 조회

  • JPA에서 DTO를 직접 조회: V4
  • 컬렉션 조회 최적화 - 일대다 관계인 컬렉션은 IN 절을 활용해서 메모리에 미리 조회해서 최적화: V5 (DTO 직접 조회할 때 많이 사용한다. 사실 hibernate.default_batch_fetch_size와 유사한 방식이지만 소스가 복잡하다.)
  • 플랫 데이터 최적화 - JOIN 결과를 그대로 조회 후 애플리케이션에서 원하는 모양으로 직접 변환: V6

 

🔔 권장 순서

  1. 엔티티 조회 방식으로 우선 접근
    1. 페치조인으로 쿼리 수를 최적화
    2. 컬렉션 최적화
      1. 페이징 필요 hibernate.default_batch_fetch_size, @BatchSize 로 최적화
      2. 페이징 필요X → 페치 조인 사용
  2. 엔티티 조회 방식으로 해결이 안되면 DTO 조회 방식 사용
  3. DTO 조회 방식으로 해결이 안되면 NativeSQL or 스프링 JdbcTemplate

 

💡 참고

  • 엔티티 조회 방식은 페치 조인이나, hibernate.default_batch_fetch_size , @BatchSize 같이 코드를 거의 수정하지 않고, 옵션만 약간 변경해서, 다양한 성능 최적화를 시도할 수 있다.
  • 반면에 DTO를 직접 조회하는 방식은 성능을 최적화 하거나 성능 최적화 방식을 변경할 때 많은 코드를 변경해야 한다.
  • 엔티티는 직접 캐시를 하면 안된다. (DTO로 캐시를 해야 한다.)

 

💡 참고

  • 개발자는 성능 최적화와 코드 복잡도 사이에서 줄타기를 해야 한다.
    • 항상 그런 것은 아니지만, 보통 성능 최적화는 단순한 코드를 복잡한 코드로 몰고간다.
  • 엔티티 조회 방식은 JPA가 많은 부분을 최적화 해주기 때문에, 단순한 코드를 유지하면서, 성능을 최적화 할 수 있다.
  • 반면에 DTO 조회 방식은 SQL을 직접 다루는 것과 유사하기 때문에, 둘 사이에 줄타기를 해야 한다.

 

✔️ DTO 조회 방식의 선택지

  • DTO로 조회하는 방법도 각각 장단점이 있다. V4, V5, V6에서 단순하게 쿼리가 1번 실행된다고 V6이 항상 좋은 방법인 것은 아니다.
  • V4는 코드가 단순하다. 특정 주문 한건만 조회하면 이 방식을 사용해도 성능이 잘 나온다. 예를 들어서 조회한 Order 데이터가 1건이면 OrderItem을 찾기 위한 쿼리도 1번만 실행하면 된다.
  • V5는 코드가 복잡하다. 여러 주문을 한꺼번에 조회하는 경우에는 V4 대신에 이것을 최적화한 V5 방식을 사용해야 한다. 예를 들어서 조회한 Order 데이터가 1000건인데, V4 방식을 그대로 사용하면, 쿼리가 총 1 + 1000번 실행된다. 여기서 1은 Order 를 조회한 쿼리고, 1000은 조회된 Order의 row 수다. V5 방식으로 최적화 하면 쿼리가 총 1 + 1번만 실행된다. 상황에 따라 다르겠지만 운영 환경에서 100배 이상의 성능 차이가 날 수 있다. (V5를 많이 사용한다. V5는 소스가 복잡한데, hibernate.default_batch_fetch_size를 사용시, 소스를 생략할 수 있다.)
  • V6는 완전히 다른 접근방식이다. 쿼리 한번으로 최적화 되어서 상당히 좋아보이지만, Order를 기준으로 페이징이 불가능하다. 실무에서는 이정도 데이터면 수백이나, 수천건 단위로 페이징 처리가 꼭 필요하므로, 이 경우 선택하기 어려운 방법이다. 그리고 데이터가 많으면 중복 전송이 증가해서 V5와 비교해서 성능 차이도 미비하다.

 

profile
"야, (오류 만났어?) 너두 (해결) 할 수 있어"

0개의 댓글