카테고리 없음

API - 지연 로딩과 성능 최적화, 컬렉션 조회 최적화

팅탱팅탱 2024. 3. 25. 00:53

주문 + 배송정보 + 회원을 조회하는 API를 만들것이다.
지연 로딩 때문에 발생하는 성능 문제를 단계적으로 해결해볼 것이다
.

 

 
  * Order -> Member
 * Order -> Delivery
 *
 */
@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {
    private final OrderRepository orderRepository;
/**
* V1. 엔티티 직접 노출
* - Hibernate5Module 모듈 등록, LAZY=null 처리 * - 양방향 관계 문제 발생 -> @JsonIgnore
*/
    @GetMapping("/api/v1/simple-orders")
    public List<Order> ordersV1() {
        List<Order> all = orderRepository.findAllByString(new OrderSearch());
        for (Order order : all) {
order.getMember().getName(); //Lazy 강제 초기화
order.getDelivery().getAddress(); //Lazy 강제 초기환 }
return all; }
}

 

엔티티를 직접 노출하는 것은 좋지 않다. (앞장에서 이미 설명)
`order` `member` `order` `delivery` 는지연로딩이다.따라서실제엔티티대신에프록시존재

jackson 라이브러리는 기본적으로 이 프록시 객체를 json으로 어떻게 생성해야 하는지 모름 예외 발생

`Hibernate5Module` 을 스프링 빈으로 등록하면 해결

 

하이버네이트 모듈 등록

스프링 부트 버전에 따라서 모듈 등록 방법이 다르다. 스프링 부트 3.0 부터는 `javax -> jakarta` 로 변경되어서 지 원 모듈도 다른 모듈을 등록해야 한다.

 

다음으로는 JpashopApplication 에 다음 코드를 추가해줄것이다.

@Bean
 Hibernate5Module hibernate5Module() {
     return new Hibernate5Module();
 }

 

만약 스프링 부트 3.0 이상을 사용하면 다음을 참고해서 모듈을 변경해야 한다. 그렇지 않으면 다음과 같은 예외가 발생 한다.

java.lang.ClassNotFoundException: javax.persistence.Transient

 

스프링 부트 3.0 이상: Hibernate5JakartaModule 등록
build.gradle 에 다음 라이브러리를 추가해야한다.
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5- jakarta'

 

 

다음과 같이 설정해준다면 강제로 지연 로딩이 가능해진다.

@Bean
 Hibernate5Module hibernate5Module() {
Hibernate5Module hibernate5Module = new Hibernate5Module();
//강제 지연 로딩 설정 hibernate5Module.configure(Hibernate5Module.Feature.FORCE_LAZY_LOADING,
 true);
     return hibernate5Module;
}

 

지연 로딩(LAZY)을 피하기 위해 즉시 로딩(EARGR)으로 설정하면 안된다! 즉시 로딩 때문에 연관관계가 필요 없는 경우에도 데이터를 항상 조회해서 성능 문제가 발생할 수 있다. 즉시 로딩으로 설정하면 성능 튜닝이 매 우 어려워 진다.
항상 지연 로딩을 기본으로 하고, 성능 최적화가 필요한 경우에는 페치 조인(fetch join)을 사용해라!

 

package jpabook.jpashop.repository.order.simplequery;
 import jpabook.jpashop.domain.Address;
 import jpabook.jpashop.domain.OrderStatus;
 import lombok.Data;
 import java.time.LocalDateTime;
 @Data
 public class OrderSimpleQueryDto {
private Long orderId;
private String name;
private LocalDateTime orderDate; //주문시간 private OrderStatus orderStatus;
private Address address;
     public OrderSimpleQueryDto(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;
    }
}

OrderSimpleQueryDto 리포지토리에서 DTO 직접 조회해주었다.

 

일반적인 SQL을 사용할 때 처럼 원하는 값을 선택해서 조회 `new` 명령어를 사용해서 JPQL의 결과를 DTO로 즉시 변환

SELECT 절에서 원하는 데이터를 직접 선택하므로 DB 애플리케이션 네트웍 용량 최적화(생각보다 미비) 리포지토리 재사용성 떨어짐, API 스펙에 맞춘 코드가 리포지토리에 들어가는 단점

 

package jpabook.jpashop.repository.order.simplequery;
 import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Repository;
 import javax.persistence.EntityManager;
 import java.util.List;
  
 @Repository
@RequiredArgsConstructor
public class OrderSimpleQueryRepository {
    private final EntityManager em;
    public List<OrderSimpleQueryDto> findOrderDtos() {
        return em.createQuery(
                "select new
jpabook.jpashop.repository.order.simplequery.OrderSimpleQueryDto(o.id, m.name,
o.orderDate, o.status, d.address)" +
} }

OrderSimpleQueryRepository 조회 전용 리포지토리

 

 

OrderRepository에 다음 추가 코드를 적어준다.

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

 

엔티티를 DTO로 변환하거나, DTO로 바로 조회하는 두가지 방법은 각각 장단점이 있다. 둘중 상황에 따라서 더 나은 방법을 선택하면 된다. 엔티티로 조회하면 리포지토리 재사용성도 좋고, 개발도 단순해진다. 따라서 권장하는 방법은 다음과 같다.

 

쿼리 방식 선택 권장 순서

  1. 우선 엔티티를 DTO로 변환하는 방법을 선택한다.
  2. 필요하면 페치 조인으로 성능을 최적화 한다. 대부분의 성능 이슈가 해결된다.
  3. 그래도 안되면 DTO로 직접 조회하는 방법을 사용한다.
  4. 최후의 방법은 JPA가 제공하는 네이티브 SQL이나 스프링 JDBC Template을 사용해서 SQL을 직접 사용한다.

 

주문내역에서 추가로 주문한 상품 정보를 추가로 조회하자. Order 기준으로 컬렉션인 OrderItem와 Item이 필요하다.

 

package jpabook.jpashop.api;
 import jpabook.jpashop.domain.Address;
 import jpabook.jpashop.domain.Order;
 import jpabook.jpashop.domain.OrderItem;
 import jpabook.jpashop.domain.OrderStatus;
 import jpabook.jpashop.repository.*;
 import lombok.Data;
 import lombok.RequiredArgsConstructor;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.RestController;
 import java.time.LocalDateTime;
 import java.util.List;
    
 import static java.util.stream.Collectors.*;
/**
* V1. 엔티티 직접 노출
* - 엔티티가 변하면 API 스펙이 변한다.
* - 트랜잭션 안에서 지연 로딩 필요
* - 양방향 연관관계 문제 *
* V2. 엔티티를 조회해서 DTO로 변환(fetch join 사용X)
* - 트랜잭션 안에서 지연 로딩 필요
* V3. 엔티티를 조회해서 DTO로 변환(fetch join 사용O)
* - 페이징 시에는 N 부분을 포기해야함(대신에 batch fetch size? 옵션 주면 N -> 1 쿼리로 변경 가
능) *
* V4. JPA에서 DTO로 바로 조회, 컬렉션 N 조회 (1 + N Query)
* - 페이징 가능
* V5. JPA에서 DTO로 바로 조회, 컬렉션 1 조회 최적화 버전 (1 + 1 Query)
* - 페이징 가능
* V6. JPA에서 DTO로 바로 조회, 플랫 데이터(1Query) (1 Query)
* - 페이징 불가능... *
*/
@RestController
@RequiredArgsConstructor
public class OrderApiController {
    private final OrderRepository orderRepository;
/**
* V1. 엔티티 직접 노출
* - Hibernate5Module 모듈 등록, LAZY=null 처리 * - 양방향 관계 문제 발생 -> @JsonIgnore
*/
    @GetMapping("/api/v1/orders")
    public List<Order> ordersV1() {
        List<Order> all = orderRepository.findAllByString(new OrderSearch());
        for (Order order : all) {
order.getMember().getName(); //Lazy 강제 초기화 order.getDelivery().getAddress(); //Lazy 강제 초기환
List<OrderItem> orderItems = order.getOrderItems(); orderItems.stream().forEach(o -> o.getItem().getName()); //Lazy 강제
초기화

 }
return all; }
}

orderItem , item 관계를 직접 초기화하면 Hibernate5Module 설정에 의해 엔티티를 JSON으로 생성한 다.
양방향 연관관계면 무한 루프에 걸리지 않게 한곳에 @JsonIgnore를 추가해야 한다.
엔티티를 직접 노출하므로 좋은 방법은 아니다.

 

엔티티를 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(toList());
     return result;
 }

 

다음으로 OrderApiController에 추가해준다.

 

@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 order) {
         orderId = order.getId();
         name = order.getMember().getName(); 
 } }
orderStatus = order.getStatus();
address = order.getDelivery().getAddress();
orderItems = order.getOrderItems().stream()
        .map(orderItem -> new OrderItemDto(orderItem))
        .collect(toList());
@Data
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();
} }

지연 로딩은 영속성 컨텍스트에 있으면 영속성 컨텍스트에 있는 엔티티를 사용하고 없으면 SQL을 실행한 다. 따라서 같은 영속성 컨텍스트에서 이미 로딩한 회원 엔티티를 추가로 조회하면 SQL을 실행하지 않는다.

 

생성자 추가해주기

 

 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
 jpabook.jpashop.repository.order.query.OrderFlatDto(o.id, m.name, o.orderDate,
 o.status, d.address, i.name, oi.orderPrice, oi.count)" +
}
```
**OrderFlatDto** ```java
        " from Order o" +
        " join o.member m" +
        " join o.delivery d" +
        " join o.orderItems oi" +
        " join oi.item i", OrderFlatDto.class)
.getResultList();
 package jpabook.jpashop.repository.order.query;
import jpabook.jpashop.domain.Address;
import jpabook.jpashop.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;
} }

 

OrderFlatDto 만들어주기

 

 

정리

엔티티 조회
엔티티를 조회해서 그대로 반환
: V1

엔티티 조회 후 DTO로 변환: V2 페치 조인으로 쿼리 수 최적화: V3 컬렉션 페이징과 한계 돌파: V3.1

컬렉션은 페치 조인시 페이징이 불가능
ToOne 관계는 페치 조인으로 쿼리 수 최적화
컬렉션은 페치 조인 대신에 지연 로딩을 유지하고
, `hibernate.default_batch_fetch_size` ,

`@BatchSize` 로 최적화 DTO 직접 조회
JPA에서 DTO를 직접 조회: V4

컬렉션 조회 최적화 - 일대다 관계인 컬렉션은 IN 절을 활용해서 메모리에 미리 조회해서 최적화: V5 플랫 데이터 최적화 - 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로 조회하는 방법도 각각 장단이 있다. V4, V5, V6에서 단순하게 쿼리가 1번 실행된다고 V6이 항상 좋은 방법인 것은 아니다.
V4
는 코드가 단순하다. 특정 주문 한건만 조회하면 이 방식을 사용해도 성능이 잘 나온다. 예를 들어서 조회한 Order 데이터가 1건이면 OrderItem을 찾기 위한 쿼리도 1번만 실행하면 된다.

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