JPA/JPA Basic

[JPA] 조회API 성능최적화하기 ( XToMany ) (1) - DTO, JOIN FETCH

IT록흐 2023. 6. 29. 16:50
반응형
 

[JPA] 조회 API 성능 최적화하기 ( XToOne )

JPA에서 조회(SELECT)는 성능최적화가 반드시 고려되어야 한다. 엔티티는 다른 엔티티와 연관관계를 맺고 있기에 엔티티를 조회하는 과정에서 예상치 못한 쿼리가 다량으로 발생할 수 있다.( N+1 문

lordofkangs.tistory.com

 

 

조회(SELECT)는 성능 최적화가 반드시 고려되어야 한다. 

 

엔티티는 다른 엔티티와 연관관계를 맺고 있어, 엔티티를 메모리에 로딩하는 과정에서 연관된 엔티티 로딩하는 전략을 생각해야 한다. 지난 포스팅에서는 일대일,다대일 관계(XToOne)에서 조회 API를 성능최적화를 해보았다. 이번 포스팅에서는 컬렉션 개념이 등장하는 일대다, 다대다 관계( XToMany )에서 조회 API의 성능을 최적화 해보겠다. 

 

 

1. 엔티티가 외부로 노출되는 코드

    @GetMapping("api/v1/orders") // 엔티티를 노출하는 방식이라 부적절
    public List<Order> ordersV1(){
        List<Order> all = orderRepository.findAllByString(new OrderSearch());
        // Lazy전략, 연관된 엔티티 참조로 로딩하기 
        for( Order order : all ){
            order.getMember().getName();
            order.getDelivery().getAddress();
            List<OrderItem> orderItems = order.getOrderItems();
            orderItems.stream()
                    .forEach(o -> o.getItem().getName());
        }
        return all; // 엔티티 반환하기
    }

 

 

위 조회API는 데이터 엑세스 계층의 Repository에서 주문 엔티티 리스트를 반환 받아 외부로 반환한다. 연관된 엔티티는 Lazy 전략으로 연관관계를 맺고 있기에 for문으로 연관된 엔티티를 참조하여 메모리에 로딩한다. 일대다 관계인 주문아이템의 경우, stream을 이용하여 연관된 엔티티를 하나씩 모두 참조한다.

 

그렇게 연관된 엔티티가 메모리에 로딩되면 주문 엔티티 리스트를 외부로 반환한다.  프레젠테이션 계층은 외부화면과 통신하는 계층으로 통신객체는 외부에 노출된다. 그러나 엔티티는 테이블과 매핑되는 클래스이다. 민감한 데이터를 가진 엔티티가 외부로 노출되면 상당히 위험하다. 

 

 

 

 

위 API를 포스트맨으로 호출해보았다. 엔티티가 JSON 방식으로 모두 노출되었다. 엔티티는 화면에 데이터를 전송하는 객체가 아니다. 화면에 데이터를 전송하는 객체는 DTO(Data Transfer Obejct)이다. DTO는 엔티티를 내부로 은닉하고 화면에 필요한 데이터만 전송할 수 있다. 

 

 

 

[JPA] DTO의 필요성

스프링은 3계층으로 나뉜다. 프레젠테이션 계층은 Client(화면)과 네트워크 통신을 하며 화면구현을 담당한다. 서비스 계층은 데이터를 처리하는 비즈니스 로직을 가지고 있다. 데이터 엑세스 계

lordofkangs.tistory.com

 

 

2. DTO로 최적화 하기

    @GetMapping("api/v2/orders")
    public List<OrderDto> ordersV2(){ // DTO를 사용하였으나 N+1 문제 발생
        System.out.println("OrderApiController.ordersV2");
        return orderRepository.findAllByString(new OrderSearch()).stream()
                .map(OrderDto::new)
                .collect(toList());
    }

 

위 조회 API는 반환을 DTO로 한다. 데이터 엑세스 계층(Repository)에서 주문 엔티티 리스트를 받아오고 Stream으로 DTO로 변환하여 외부로 반환한다. 

 

OrderDto 

    @Getter
    static class OrderDto{
        private Long orderId;
        private String name;
        private LocalDateTime localDateTime;
        private OrderStatus orderStatus;
        private Address address;
        private List<OrderItemDto> orderItems; // DTO 안에 엔티티가 있으면 엔티티가 외부로 노출된다.OrderItem도 OrderItemDto를 만들어줘야 한다.

        public OrderDto(Order order) {
            orderId = order.getId();
            name = order.getMember().getName();
            localDateTime = order.getOrderDate();
            orderStatus = order.getStatus();
            address = order.getDelivery().getAddress();
            orderItems = order.getOrderItems().stream() // xToMany 쪽 DTO도 필요!!
                    .map(OrderItemDto::new)
                    .collect(toList());
        }
    }

 

OrderDto는 주문엔티티의 데이터 중에 화면에 필요한 데이터만 저장한다. 이때 눈여겨 봐야하는 부분은 주문아이템 엔티티 리스트이다. 주문아이템은 xToMany이다. 주문아이템도 엔티티로 있으면 안된다. 주문아이템 DTO가 필요하다. 

 

OrderItemDto

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

 

OrderItemDto도 만들어 주었다.  이로써 엔티티가 외부로 노출될 위험은 사라졌다.

 

 

 

포스트맨으로 위 API를 호출해보았다. 엔티티가 외부로 노출되지 않고 DTO가 외부로 노출된다. 그리고 DTO는 화면에 필요한 데이터를 가지고 있기에 훨씬 단순해졌다. 이렇듯 DTO를 사용하면 전송데이터를 최적화 할 수 있다. 

 

그러나 여기에는 한 가지 문제가 있다. 

연관된 엔티티를 Lazy 전략으로 메모리에 로딩하므로 N+1 문제가 발생한다. 

 

 

개발자는 조회하는 쿼리문 하나만 실행했는데, JPA는 추가로 SELECT문 여러 개(N개)를 실행하였다. 이렇게 예상치 못한 순간에 반복적으로 쿼리문이 실행되면 성능에 좋지 못하다. 개발자가 쿼리를 하나 실행 했으면 어플리케이션도 하나만 실행해야 예상할 수 있고 성능도 좋아진다. 

 

 

3. 페치 조인으로 최적화하기 

 

N+1 문제를 방지하는 대표적인 방법은 JOIN FETCH(페치 조인)를 사용하는 것이다. 페치 조인은 JPQL문법으로 연관된 엔티티를 하나의 SELECT문으로 가져오는 JPQL 연산이다. 

 

Controller API

    @GetMapping("api/v3/orders")
    public List<OrderDto> ordersV3(){
        List<Order> orders = orderRepository.findAllWithItem(); //하나의 쿼리로 연관 엔티티 가져오기
        return orders.stream() // 엔티티 -> DTO
                .map(OrderDto::new)
                .collect(toList());
    }

 

Repository가 제공하는 findAllWithItem 메소드는 JOIN FECTH 연산을 사용한다. 

 

Repository findAllWithItem 메소드

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

 

JOIN FETCH는 연관된 엔티티를 하나의 쿼리로 묶어 메모리에 로딩한다. 실제 실행되는 쿼리를 확인해보자. 

 

실제 실행된 SELECT문

    select
        distinct o1_0.order_id,
        d1_0.deliver_id,
        d1_0.city,
        d1_0.street,
        d1_0.zipcode,
        d1_0.status,
        m1_0.member_id,
        m1_0.city,
        m1_0.street,
        m1_0.zipcode,
        m1_0.name,
        o1_0.order_date,
        o2_0.order_id,
        o2_0.id,
        o2_0.count,
        i1_0.item_id,
        i1_0.dtype,
        i1_0.name,
        i1_0.price,
        i1_0.stock_quantity,
        i1_0.artist,
        i1_0.etc,
        i1_0.author,
        i1_0.isbn,
        i1_0.actor,
        i1_0.director,
        o2_0.order_price,
        o1_0.status 
    from
        orders o1_0 
    join
        member m1_0 
            on m1_0.member_id=o1_0.member_id 
    join
        delivery d1_0 
            on d1_0.deliver_id=o1_0.delivery_id 
    join
        order_item o2_0 
            on o1_0.order_id=o2_0.order_id 
    join
        item i1_0 
            on i1_0.item_id=o2_0.item_id

 

JOIN FETCH는 연관된 엔티티의 모든 데이터를 가져와 메모리에 로딩한다. 이렇게 엔티티를 하나의 쿼리로 가져오면 추후에 추가로 SELECT문이 실행되는 일을 방지할 수 있다. 

 

그러나 여기에도 한 가지 문제가 있다.

일대다 관계에서 '다'를 JOIN FETCH로 가져오면 '페이징'이 안 된다. JOIN FETCH의 목적은 연관된 엔티티를 전부 메모리에 로딩하여 추가적인 쿼리실행을 막아 성능을 최적화하는데 있다. 그런데 페이징은 전체가 아닌 '부분'을 가져오는 기능이다. 일대다 관계에서 '다'의 부분만 로딩하면 추후에 추가적인 쿼리생성을 유발할 수 있다. 

 

그래서 일대다관계에서 JOIN FETCH에 페이징을 걸면 JPA는 경고메시지를 출력한다. 

HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory

 

일단 전부 메모리에 로딩하고 페이징을 하겠다는 의미이다. 페이징은 DB에서 원하는 부분만 추려서 메모리로 가져와야 하는데, 데이터 전부를 메모리로 가져온 다음에  페이징을 한다. 이는 OOM(Out Of Memory)를 유발할 수 있다. JOIN FETCH의 기능을 위해 발생하는 어쩔수 없는 트레이드-오프이다. 

 

OOM을 방지하고 페이징을 적용하는 방법은 다음 포스팅에서 다루어 보겠다.

 

 

+ DISTINCT ( 중복제거 )

 

JOIN FETCH를 할 때, JPQL을 보면 DISTINCT 연산을 확인할 수 있다. DISTINCT는 일대다 관계에서 발생하는 중복을 제거하는 역할을 한다. 객체지향 App과 관계형DB는 패러다임이 다르다. 객체A 하나가 여러 개의 객체B와 연관관계를 맺을 수 있다. 그러나 관계형DB에서는 이를 표현하지 못한다. 그래서 일대다 관계에서 '다' 쪽에 맞추어 레코드가 생성된다. 

 

 위 SELECT문을 H2 콘솔에 실행했더니 결과가 아래와 같았다. 

 

 

주문(Order)1은 두 개의 아이템(Item)과 연관되어 있다. 객체지향에서는 Order 객체 한 개 그리고 Item 객체 2개만 생성하면 된다. 그러나 관계형 DB에서는 주문(일)과 아이템(다)를 JOIN하면  '다'에 맞추어 레코드가 생성된다. 그래서 주문은 한 개이지만 '다'인 아이템에 맞추다보니 두 개의 레코드에 주문1이 생성되었다. 이로인해 APP에서는 동일한 키를 가진 주문 엔티티가 두 개 생성된다.

 

JPQL에서 DISTINCT 연산은 동일한 키를 가진 엔티티의 중복 생성을 막는다. 주문1은 두 개의 레코드에 들어갔지만 어플리케이션에서 엔티티로 생성하는 과정에서 중복방지가 되어 하나만 생성된다. 이처럼 일대다 관계에서 JOIN-FETCH 사용시 패러다임 불일치를 고려해야 한다. Hibernate6 이상부터는 DISTINCT 연산이 없어도 중복제거가 된다고 하니 참고 바란다.

 

 

 


 

참고자료

 

실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화 - 인프런 | 강의

스프링 부트와 JPA를 활용해서 API를 개발합니다. 그리고 JPA 극한의 성능 최적화 방법을 학습할 수 있습니다., - 강의 소개 | 인프런

www.inflearn.com

 

반응형