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

API 조회 성능 최적화 - DTO 변환 방식(Fetch Join)

by devraphy 2022. 5. 30.

1. 조회 API에서 DTO를 사용하는 이유 

- 이전 포스팅에서 Entity를 직접 사용한 조회 API를 구성하는 방법에 대해 알아보았다.

- Entity를 직접 노출하는 방식은 Entity의 내용이 변경됐을 때, API 스펙에 직접적인 영향을 미치기 때문이다. 

- 그러므로 Entity 대신 DTO를 사용하여 이를 해결한다. 

 

 

2. DTO를 이용한 조회 API 구성 방법

- API에서 DTO를 구성하는 방식은 매우 간단하다.

- 기존에 Entity가 직접 사용된 부분을 DTO로 교체하면 된다.

- 다음 코드를 살펴보자. 

@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {

    private final OrderRepository orderRepository;

    @GetMapping("api/order-with-dto")
    public List<OrderDto> orderWithDto() {

        List<Order> orders = orderRepository.findAllByString(new OrderSearch());
        List<OrderDto> result = orders.stream()
                .map(o -> new SimpleOrderDto(o))
                .collect(Collectors.toList());

        // o -> new SimpleOrderDto(o) 이 부분을 SimpleOrderDto::new 이렇게 바꿔도 동일하다.
        return result;
    }

    @Data
    static class OrderDto {
    
        private Long orderId;
        private String name;
        private LocalDateTime orderDate;
        private OrderStatus orderStatus;
        private Address address;

        public OrderDto(Order order) {
            // this 는 메서드의 매개변수가 필드명과 동일한 경우, 이를 구분짓기 위함이다.
            // 아래에 this 를 사용해도 무관하다.
            orderId = order.getId();
            name = order.getMember().getName();
            orderDate = order.getOrderDate();
            orderStatus = order.getStatus();
            address = order.getDelivery().getAdderss();
        }
    }
}

 

 

3. DTO 사용 시 발생하는 문제점 

- 위의 코드에서 OrderDto의 생성자 부분을 살펴보자.

- Order는 Order, Member, Delivery 총 3가지 Entity와 연관되어 있는 객체다.

- 이처럼 연관 객체에 모두 접근하면 Lazy Loading이 실행되고, 이는 N + 1 문제를 발생시킨다.

 

- 예를 들어 Order 테이블에 2명의 회원이 요청한 주문 정보가 각 1개씩(= 총 2개) 있다고 가정해보자.  

- 각 주문의 회원 이름을 찾기 위해, orderId를 이용한 Member 조회 쿼리가 2개 발행된다. 

- 각 주문의 배송 주소를 찾기 위해, orderId를 이용한 Delievery 조회 쿼리가 2개 발행된다. 

- 결과적으로 총 5개의 쿼리가 발행된다. (N + 1 문제)

 

- 만약 Order 테이블의 주문이 동일한 회원 1명의 주문이라면 Member는 1번, Delivery는 2번 조회한다.

- 왜냐면 JPA는 DB에 접근하기 전에 영속성 Context를 우선적으로 살펴보기 때문이다.

    → Delivery를 2번 조회하는 이유는 주문마다 deliveryId 값이 다르기 때문이다. 

 

- 그러나 실제 운영되는 서비스는 다수의 회원이 존재한다.  

- 그러므로 이처럼 N + 1 문제가 발생해서는 안된다. 

 

- 이를 해결하기 위해 Fetch Join을 사용한다.

 

 

4. Fetch Join 사용하기 

- 우선, Fetch Join 개념을 모른다면 다음 포스팅을 꼭 읽는 것을 권장합니다.

 

https://devraphy.tistory.com/601

 

31. JPQL - Fetch Join 개념

0. 개요 - 이번 포스팅은 JPQL의 주요 기능 중 하나인 Fetch Join에 대해서 알아보자. - Fetch Join을 설명하기 위해 다음 Entity를 예시로 사용할 예정이다. @Entity public class Player { @Id @GeneratedValue..

devraphy.tistory.com

 

 

- DB를 조회 시 OrderRepository의 조회 메서드를 이용한다.

- OrderRepository의 조회 메서드의 쿼리에서 N + 1 문제가 발생하므로, 새로운 쿼리가 필요하다.

- 그러므로 OrderRepository에 다음과 같이 Fetch Join 쿼리를 사용한 새로운 메서드를 생성해준다. 

@Repository
@RequiredArgsConstructor
public class OrderRepository {
    private final EntityManager em;

    public List<Order> findAllUsingFetchJoin() {
        return em.createQuery(
                "select o from Order o" +
                        " join fetch o.member m " +
                        " join fetch o.delivery d", Order.class).getResultList();
    }
}

 

 

- 이제 API 컨트롤러에서 위에 작성한 findAllUsingFetchJoin() 메서드를 사용하면 된다.

- 다음 예시를 살펴보자.

@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {

    private final OrderRepository orderRepository;

    @GetMapping("api/v3/simple-orders")
    public List<SimpleOrderDto> orderV3() {
        List<Order> orders = orderRepository.findAllUsingFetchJoin();
        List<SimpleOrderDto> result = orders.stream()
                .map(o -> new SimpleOrderDto(o)).collect(Collectors.toList());

        return result;
    }

    @Data
    static class SimpleOrderDto {
        private Long orderId;
        private String name;
        private LocalDateTime orderDate;
        private OrderStatus orderStatus;
        private Address address;

        public SimpleOrderDto(Order order) {
            orderId = order.getId();
            name = order.getMember().getName();
            orderDate = order.getOrderDate();
            orderStatus = order.getStatus();
            address = order.getDelivery().getAdderss();
        }
    }

}

 

 

 

- 위의 메서드를 실행하면 다음과 같은 쿼리가 발행된다.

 

- 이처럼 Fetch Join을 이용하여 N + 1 문제를 해결할 수 있다.

- 그러나 여전히 개선사항은 남아있다.

 

 

5. 개선 사항

- 다음 쿼리를 살펴보자.

 

- Select 절을 살펴보면 필요하지 않은 데이터까지 모두 조회하는 것을 확인할 수 있다. 

- 이처럼 필요하지 않는 데이터까지 모두 조회되는 이유는 객체를 대상으로 조회했기 때문이다.

- 즉, 객체의 모든 데이터를 조회하고 필요한 데이터를 DTO에 담는 방식을 사용하기 때문이다.

  

- 그렇다면 필요한 데이터만 선택한다면 이 문제를 해결할 수 있지 않을까? 

- 이 부분을 최적화하기 위해서 DTO를 통해 직접 조회 방법을 사용할 수 있다.

댓글