관리 메뉴

나구리의 개발공부기록

API 개발 고급 - 컬렉션 조회 최적화, 주문 조회V1 - 엔터티 직접 노출, V2 - 엔터티를 DTO로 변환, V3.0 - V2 페치 조인 최적화, V3.1 - 페이징과 한계 돌파 본문

인프런 - 스프링부트와 JPA실무 로드맵/실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화

API 개발 고급 - 컬렉션 조회 최적화, 주문 조회V1 - 엔터티 직접 노출, V2 - 엔터티를 DTO로 변환, V3.0 - V2 페치 조인 최적화, V3.1 - 페이징과 한계 돌파

소소한나구리 2024. 10. 24. 18:05

출처 : 인프런 - 실전! 스프링부트와 JPA활용2 - API개발과 성능 최적화 (유료) / 김영한님  
유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용


** 컬렉션인 일대다 관계를 조회할 때 최적화는 방법 알아보기

1. 주문 조회 V1 - 엔터티 직접노출

1) 엔터티를 직접 노출하는 컨트롤러

(1) OrderApiController - ordersV1()

  • 지연로딩 성능 최적화에서 다뤄봤던 것처럼 동일하게 DB에서 꺼낸 값을 반복문으로 지연로딩 설정된 필드를 강제로 초기화하여 DB에서 데이터를 가져오도록 설정
  • 컨트롤러 등록 후 API를 호출하면 응답값은 정상적으로 반환되지만이렇게 엔터티를 직접 노출하는거는 N+1 성능문제, 확장문제, 보안문제 등등 그냥 문제 투성이기 때문에 절대 사용하지 말것
package jpabook.jpashop.api;

@RestController
@RequiredArgsConstructor
public class OrderApiController {

    private final OrderRepository orderRepository;

    /**
     * V1. 엔터티 직접노출 - 사용 금지
     *  - 엔터티가 변하면 API 스펙이 변함
     *  - 트랜잭션 안에서 지연 로딩이 필요함
     *  - 양방향 관계 문제 발생하여 @JsonIgnore처리와 Hibernate모듈 등록을 해주어야 함
     */
    @GetMapping("/api/v1/orders")
    public List<Order> ordersV1() {
        List<Order> all = orderRepository.findAllByString(new OrderSearch());
        for (Order order : all) {
            order.getMember().getName();
            order.getDelivery().getAddress();
            List<OrderItem> orderItems = order.getOrderItems();
            orderItems.forEach(orderItem -> orderItem.getItem().getName());
        }
        return all;
    }
}

2. 주문 조회 V2 - 엔터티를 DTO로 변환

1) 엔터티를 DTO로 변환하여 반환하는 컨트롤러

(1) ordersV2(), OrderDto 추가

  • ordersV2와 OrderDto클래스를 생성하여 Order Entity를 직접 반환하는 것이 아니라 OrderDto를 반환하도록 코드를 작성
  • OrderDto의 값들을 생성자로 입력한 뒤 OrderItems 필드는 OrderItem 엔터티를 반환하기 때문에 생성자에서 강제로 OrderItems의 값을 조회하는 코드를 만들어 프록시를 초기화 시켜야 API 호출 시 값들이 반환될 수 있음
  • 그러나 여기엔 함정이 존재하는데, List<OrderItem> orderItems 가 바로 그 함정의 주인공 필드임

(2) Entity를 DTO로 변환하라는 것은 컨트롤러에서 DTO로 한번 감싸서 보내라는 뜻이 아님

  • API결과를 보면 OrderItem의 엔터티의 스펙이 전부 외부에 노출되어 버렸기 때문에 v1버전과 다를바가없는 반쪽짜리 DTO를 사용하고 있는 것임
  • 여기서 OrderItem의 Entity가변경이되면 마찬가지로 API스펙이 달라져 버리게 됨
  • 결국 Entity를 DTO로 변환하라는 것은 단순하게 DTO를 감싸서 반환하라는 뜻이 아니라 DTO클래스에서 Entity와의 의존을 완전히 끊어내는 것을 말하기 때문에 OrderItem도 DTO클래스를 만들어서 반환하도록 수정해야함

** 참고

  • Java16버전 부터는 collect()로 toList()를 호출하지 않아도 바로 toList()로 최종연산할 수 있음
  • @Data는 getter, setter는 물론 RequiredArgsConstructor, toString, EqualsAndHashCode를 모두 만들어주기 때문에 상황에 따라서는 필요한 것만 적용하는 것이 더 나을 수 있으며, DTO의 경우에는 @Data를 적용하는 경우도 많으니 선택해서 사용
public class OrderApiController {
    // ... 기존코드 생략

    /**
     * V2. 엔터티를 조회 후 DTO로 변환, fetch join 미적용
     *  - 트랜잭션 안에서 지연 로딩이 필요함
     */
    @GetMapping("/api/v2/orders")
    public List<OrderDto> ordersV2() {
        List<Order> orders = orderRepository.findAllByString(new OrderSearch());
        return orders.stream().map(OrderDto::new).toList(); // Java16부터는 collect 대신 toList()로 사용가능
    }

    @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 order) {
            orderId = order.getId();
            name = order.getMember().getName();
            orderDate = order.getOrderDate();
            orderStatus = order.getStatus();
            address = order.getDelivery().getAddress();
            order.getOrderItems().stream().forEach(o -> o.getItem().getName()); // 강제 프록시 초기화
            orderItems = order.getOrderItems();
        }
    }
}

 

(3) OrderItemDto 추가 및 OrderDto 수정

  • OrderDto에서OrderItem으로 반환하던 orderItems를 OrderItemDto를 만들어서 원하는 API스펙의 데이터만 반환하도록 변경하고, OrderDto생성자에서 OrderItem을 OrderItemDto로 변환하는 작업을거쳐서 orderItems에 저장
  • OrderItemDto는 요구사항에서 정의된 API스펙의 필드를 입력 후 생성자로 값을 저장하도록 하면되며, 여기에서는 상품명과 주문가격, 주문수량을 반환한다고 가정하고 작성
public class OrderApiController {
    // ... 기존코드 생략

    @Data
    static class OrderDto {
        // ... 기존코드 생략
        private List<OrderItemDto> orderItems;

        public OrderDto(Order order) {
            // ... 기존코드 생략
//            order.getOrderItems().stream().forEach(o -> o.getItem().getName()); // 제거
            // OrderItem -> OrderItemDto로 변환하여 저장
            orderItems = order.getOrderItems().stream().map(o -> new OrderItemDto(o)).toList();
        }
    }

    @Data
    static class OrderItemDto {

        // 클라이언트에서 OrderItemDto의 모든 정보가 필요한 것이 아닌 상품명, 주문가격, 주문수량 정보만 필요하다고 가정
        private String itemName;    // 상품명
        private int orderPrice;     // 주문가격
        private int count;          // 주문수량

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

 

(4) 적용 결과

  • 이제 응답결과를 보면 OrderItem의 필드를 직접 반환하지 않고 orderItems의 필드의 값을 보면 API스펙에서 요구한 값만 정확하게 반환되는 것을 확인할 수 있으며, 향후에 API의 요구 스펙이 변경되어도 쉽게 반영하여 반환할 수 있음
  • 즉, Address처럼 임베디드 값타입은 Entity가 아니기 때문에 직접 반환해도 되지만 Entity는 어떠한 경우에도 직접 반환하지 않도록 개발해야함
  • 해당 v2버전도 페치조인을 적용하지 않았기때문에 N+1 문제가 발생하는데 컬렉션을 사용하면 N+1쿼리가 더많이 발생하기 때문에 최적화를 꼭 적용해야함

3. 주문 조회 V3.0 -  V2를 페치 조인 최적화

** 참고

  • 강의시점에는 hibernate버전이 5.x였으므로 컬렉션 조회시 페치조인을 하면 SQL의 join결과에 따라 JPA가 각각의 엔터티를 매핑하게 되어 중복값이 발생하여 데이터가 증가되었지만 hibernate6 이상 버전에서는 컬렉션 조회시 자동으로 중복을 제거하기 때문에 문제가 없음
  • https://nagul2.tistory.com/339 해당강의내용 참고
  • 그래서 중복값이 발생하여 확인하는 강의의 내용은 생략

1) 페치 조인을 적용하여 최적화를 적용

(1) orderV3() 추가

package jpabook.jpashop.api;

@RestController
@RequiredArgsConstructor
public class OrderApiController {
    // ... 기존 코드 생략

    /**
     * V3. 엔터티를 조회 후 DTO로 변환, fetch join 적용
     *
     */
    @GetMapping("/api/v3/orders")
    public List<OrderDto> ordersV3() {
        List<Order> orders = orderRepository.findAllWithItem();
        return orders.stream().map(OrderDto::new).toList();
    }
}

 

(2) OrderRepository - findAllwithItem() 추가

  • JPQL쿼리를 페치 조인을 사용하여 작성
package jpabook.jpashop.repository;

public class OrderRepository {
    // ... 기존코드 생략

    // 컬렉션 조회를 페치 조인으로 최적화 적용
    public List<Order> findAllWithItem() {
        return em.createQuery("select o from Order o" +
                " join fetch o.member m" +
                " join fetch o.delivery d" +
                " join fetch o.orderItems oi" +    // 컬렉션의 페치 조인
                " join fetch oi.item", Order.class).getResultList();
    }
}

 

(3) API 호출결과와 쿼리 비교, 그리고 문제점

  • 페치조인을 적용한 컨트롤러로 API를 호출하면 v2의 응답결과와 동일한 결과를 반환하지만 발생된 SQL쿼리를 보면 무수히 많이 발생되었던 SQL쿼리들이 한 방 쿼리로 튜닝되면서 N+1문제가 깔끔하게 해결
  • 그러나, 컬렉션 관계에서 페치조인을 사용한뒤 JPA의 페이징관련 기능을 사용하면 안되는 문제가 있음
  • 만약 쿼리에 페이징기능을 사용한뒤 로그를 확인해보면 발생한 SQL에 페이징 쿼리가 발생되는게아니라 모든데이터를 가져온뒤에 메모리에서 페이징을 해버리면서 하이버네이트가 경고문구를 띄움(해당내용도 JPQL 강의에서 자세히 다루고있으며 절대 사용하면 안됨)
  • 그리고 컬렉션 페치 조인은 1개만 사용해야 안전하며 둘이상을 사용하게되면 데이터가 부정확하게 조회될 수 있기 때문에 주의가 필요한데, 하이버네이트 6.2 버전에서 사용이 가능하도록 변경되었지만 여전히 성능 문제와 메모리사용량이 늘어날 수 있기 때문에 사용하지 않는것을 권장함

4. 주문 조회 V3.1 -  페이징과 한계 돌파

1) 한계 돌파

(1) 문제점 재정리

  • 컬렉션을 페치 조인한뒤 페이징기능을 사용하면 절대 안됨, 즉 불가능하다라고 이해해야함
  • 일대다 조인이 발생하여 데이터가 예측할 수 없이 증가하고 일대다에서 일(1)을 기준으로 페이징을 하는 것이 목적인데 데이터가 다(N)을 기준으로 row가 생성되기 때문에 제대로된 페이징이 되지않음
  • 하이버네이트가 경고로그를 남기며 모든 DB데이터를 읽어서 메모리에서 페이징을 시도하기때문에 최악의 경우 장애가 날 수 있음

(2) 가장 단순하고 성능최적화도 보장하는 해결방법, 1+1쿼리

  • 대부분의 페이징 + 컬렉션 엔터티 조회 문제는 이 방법으로 문제를 해결함
  • 먼저 ToOne관계의 Entity들은 v2에서 적용한것처럼 페치조인한 뒤 컬렉션은 지연로딩으로 조회하고 그 이후에 성능 최적화를 @BatchSize 애노테이션이나 글로벌설정으로 BatchSize를 적용
  • 컬렉션이나 프록시 객체를 한꺼번에 설정한 size만큼 IN 쿼리로 조회하는 최적화 기능하며 @BatchSize로 개별필드에 적용하거나 설정파일에 hibernate.default_batch_fetch_size를 적용하여 글로벌로 적용할 수 있음

(3) ordersV3_page 추가

  • @RequestParam으로 페이징값들을 받는 컨트롤러를 생성
  • 받아온 파라미터들을 OrderRepository의 메서드의 인수로 입력하여 페이징 정보를 전달한 뒤 연산결과를 반환
package jpabook.jpashop.api;

@RestController
@RequiredArgsConstructor
public class OrderApiController {
    // ... 기존 코드 생략

    @GetMapping("/api/v3.1/orders")
    public List<OrderDto> ordersV3_page(@RequestParam(value = "offset", defaultValue = "0") int offset,
                                        @RequestParam(value = "limit", defaultValue = "100") int limit) {
        
        // ToOne관계의 Entity들은 fetch join 적용한 쿼리로 데이터를 조회, 페이징을 적용해도 문제없음
        List<Order> orders = orderRepository.findAllWithMemberDelivery(offset, limit);
        return orders.stream().map(OrderDto::new).toList();
    }
}

 

(4) 파라미터로 페이징 정보를 받는 메서드를 추가

  • 파라미터로 넘겨받은 값들을 페치 조인을 적용한 쿼리에 페이징정보로 활용하여 DB에서 값을 조회
package jpabook.jpashop.repository;

@Repository
@RequiredArgsConstructor
public class OrderRepository {
    // ... 기존 코드 생략
    
    // 파라미터로 offset, limit정보를 입력받아 페이징을 적용하는 메서드를 생성(메서드 오버로딩을 적용)
    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();
    }
}

 

(5) 설정파일에 최적화 옵션 적용 - application.yml 수정

  • 설정파일에 default_batch_fetch_size: 를 적용하면 글로벌하게 배치사이즈를 적용하며 적용한 개수만큼 IN쿼리를 날려서 DB의 값을 한번에 가져옴
  • 즉, DB의 데이터가 1000개이고 size를 100으로 설정했다면 10번의 IN쿼리가 발생되는메커니즘임
  • 이렇게 글로벌하게 적용해도되고 개별적으로 필드에 @BatchSize를 적용할 수도 있음

** 참고

  • BatchSize는 적당한 사이즈를 입력해야하는데 보통 100 ~ 1000 사이의 값을 선택하는 것을 권장함
  • SQL의 IN절을 사용하여 동작하는데 데이터베이스에따라 다르지만 보통 IN절 파라미터를 1000을 기본값으로 제한하기 때문
  • 그러나 1000으로 잡으면 한번에 1000개의 데이터를 DB에서 애플리케이션에 전송하기 때문에 DB에 순간적인 부하가 걸릴 수 있음
  • 애플리케이션은 100이든 1000이든 어쨋든 전체 데이터를 로딩해야하기때문에 메모리사용량은 같음
  • 1000으로 설정하는 것이 가장 성능상에는 가장 좋지만 DB나 애플리케이션에서 순간적인 부하를 얼마나 견딜 수 있는지 파악 후 적절하게 조절하면 됨
  jpa: 
    hibernate: 
      ddl-auto: create 
    properties:
      hibernate:
        format_sql: true 
        default_batch_fetch_size: 100  #BatchSize 설정 추가

 

(6) 결과

  • API의 요청시 파라미터로 페이징 정보를 입력해보면 최초 페치조인을 적용하여 Order,Member,Deliver를 조회하는 쿼리 1번, OrderItem을 조회하는쿼리1번, Item을 조회하는 쿼리1번으로 엄청난 최적화가 진행됨
  • N+M+1의 무수히 많은 쿼리가 발생되는 것을 1+1+1으로 최적화가 되었으며 이정도만으로도 대부분의 성능문제는 해결이되며 컬렉션 조회에서 페이징까지 적용하게 되었음
  • 물론 v3버전처럼 한번에 모두 적용하는 것보다 쿼리가 추가로 발생하긴하지만 이정도의 쿼리는 성능에 큰 문제가 없음
  • 페이징을 적용하려면 해당 방식밖에 없으므로 페이징을 사용하지 않으면 전체 연관된 필드들을 페치 조인을 적용하고, 페이징을 사용하면 ToOne의 관계인 엔터티들만 페치 조인을 적용하고, 컬렉션으로 가져오는 필드는 BatchSize로 최적화를 하는 방식을 사용하면 됨

** 참고

  • 하이버네이트 6.2부터는 동작하는 IN쿼리가 where in이 아닌 array_contains를 사용하여 동작하도록 쿼리가 변경되어 쿼리의 로그를 보면 IN쿼리의 파라미터로 ?가 배치사이즈로 적용된 수만큼 날라가는 것 확인할 수 있음
  • 기존의 where in 방식으로 동작하는 것보다 성능이 더 좋게 동작하도록 최적화가 된 것이며, 적용한 size의 개수만큼 보이는 ?에는 실제 쿼리에 들어갈 값만 저장이되고 나머지는 null이 적용되어 실제 전송되는 쿼리는 조회할 데이터가 있는 값만 전송이되는 것이기 때문에 ?가 많이 보인다고 걱정할 필요는 없음
  • 디테일하게 들어가면 SQL 구문 캐싱에 대한 내용인데 디테일한 내용이 교안에는 나와있으나 글에서는 생략함