본문 바로가기
Back-end/JPA 개념

API 조회 성능 최적화 - 컬렉션(DTO 변환)

by devraphy 2022. 6. 1.

0. 개요

- 이전 포스팅까지 Many To One 또는 One To One 관계에 대한 조회 방식을 알아보았다. 

- To One 관계는 Join 대상이 하나인 관계로, Fetch Join을 이용하여 N + 1 문제를 해결하였다.

- To Many의 관계는 Join 대상이 다수인 관계로, 중복 데이터가 발생하는 문제를 가진다.

- 이번 포스팅에서 To Many 관계를 조회할 때 발생하는 문제와 해결책에 대해서 알아보자. 

 

 

1. 컬렉션 조회 시 DTO 변환 방식

a) 예시 코드

- 다음과 같이 DTO를 사용하여 컬렉션 조회를 수행하려고 한다.

 

@RestController
@RequiredArgsConstructor
public class OrderApiController {

    private final OrderRepository orderRepository;

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

    @Data
    static class OrderDto {

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

        public OrderDto(Order o) {
            orderId = o.getId();
            name = o.getMember().getName();
            orderDate = o.getOrderDate();
            orderStatus = o.getStatus();
            address = o.getDelivery().getAdderss();
            
            // OrderItem 또한 Entity 이므로 Lazy Loading을 수행해야된다.
            o.getOrderItems().stream().forEach(order -> order.getItem().getName()); //
            orderItems = o.getOrderItems();
        }
    }
}

 

- 위의 코드를 실행하여 Postman으로 확인해보면 문제없이 결과가 잘 나오는 것을 확인할 수 있다.

- 다만, 여기서 한 가지 간과하는 것이 있다.

 

 

b) DTO 내부에 Entity가 존재한다.

- DTO의 코드를 살펴보면 내부에 OrderItem이 그대로 사용된 것을 확인할 수 있다.

 

 

- 이처럼 DTO 내부에 Entity를 그대로 사용하는 것은 Entity를 그대로 노출하는 것과 동일한 방식이다.

- 그러므로 DTO 내부에서 사용되는 Entity 또한 DTO로 만들어줘야 한다. 

- 왜냐면 OrderItem의 내용이 변경되는 순간, API 스펙도 변경되어야 하기 때문이다. 

- 이 부분이 골치가 아파지는 부분이다. 그렇다면 어떻게 해결해야 할까? 

 

 

c) DTO를 만든다.

- 해결방법은 간단하다. 해당 Entity에 대한 DTO를 생성하면 된다.

- 이는 다음과 같이 작성할 수 있다. 

 

@Data
static class OrderItemDto {

    // OrderItem 내부의 속성 중 필요한 것만 선언한다.
    private String itemName;
    private int orderPrice;
    private int count;

    public OrderItemDto(OrderItem orderItem) {
        itemName = orderItem.getItem().getName();
        orderPrice = orderItem.getOrderPrice();
        count = orderItem.getCount();
    }
}

 

- 이제 기존의 DTO에서 Entity가 직접 사용된 부분을 생성한 DTO로 변경하면 된다. 

 

@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 o) {
        orderId = o.getId();
        name = o.getMember().getName();
        orderDate = o.getOrderDate();
        orderStatus = o.getStatus();
        address = o.getDelivery().getAdderss();
        orderItems = o.getOrderItems().stream()
                .map(orderItem -> new OrderItemDto(orderItem))
                .collect(Collectors.toList());

    }
}

 

 

d) DTO 변환 방식의 문제점

- 위의 코드를 실행하면 몇 개의 SQL이 발행될까? 

- 우선 Order 테이블이 조회되고 내부의 Member, Delivery, OrderItem, Item이 조회된다.

- 여기서 끝이 아니다. Member, Delivery, OrderItem, Item 테이블이 주문을 요청한 회원의 수만큼 조회된다.

 

- 예를 들어, 2명의 회원이 각각 유니크한 2가지 종류의 아이템을 주문한다.(총 4가지 종류의 아이템이 존재함)

- 그렇게 총 2개의 주문이 존재한다고 해보자.

 

- 우선 최초에 Order를 1번 조회하는 SQL이 발행된다.

- 그리고 첫 번째 회원의 정보를 얻기 위해 Member를 조회하는 SQL이 발행된다.

- 회원 정보를 기반으로 Delivery와 OrderItem을 조회하는 SQL이 각 1번씩 발행된다.

- OrderItem 내부의 Item을 찾기 위해서 Item을 조회하는 SQL이 2번 발행된다.(하나의 주문당 아이템이 2개 존재함) 

- 결과적으로 Member부터 Item을 조회화는 과정이 한번 더 반복된다. (회원이 총 2명이기 때문) 

 

- 이 과정을 정리해보자면 다음과 같다.

- Order 조회 1번(= 2개의 주문을 찾음), Member 조회 2번(= 회원이 2명)

- Delievery 조회 2번(= 주문이 2건), OrderItem 조회 2번(= 주문이 2건)

- Item 조회 4번(= 주문당 유니크한 아이템이 2가지, 총 2개의 주문이므로 4가지 종류의 아이템이 존재)

- 총 11개의 SQL이 발행되는 것이다.  

 

 

e) 해결 방법 

- 그렇다면 이 문제를 어떻게 해결할 수 있을까?

- 아마 Fetch Join을 사용해서 해결할 수 있지 않을까 생각이 들 것이다.

- 다음 포스팅에서 컬렉션 조회 시 Fetch Join을 사용하는 방법에 대해서 알아보자. 

댓글