API 개발 고급 - 지연 로딩과 조회 성능 최적화
- 간단한 주문 조회 V1: 엔티티를 직접 노출
- 간단한 주문 조회 V2: 엔티티를 DTO로 변환
- 간단한 주문 조회 V3: 엔티티를 DTO로 변환 - 페치 조인 최적화
- 간단한 주문 조회 V4: JPA에서 DTO로 바로 조회
주문 + 배송정보 + 회원을 조회하는 API를 만들자
지연 로딩 때문에 발생하는 성능 문제를 단계적으로 해결해보자.
참고: 지금부터 설명하는 내용은 정말 중요합니다. 실무에서 JPA를 사용하려면 100% 이해해야 합니다. > 안그러면 엄청난 시간을 날리고 강사를 원망하면서 인생을 허비하게 됩니다.
간단한 주문 조회 V1: 엔티티를 직접 노출 ( 엔티티 노출은 사용 권장 X, 꼭 필요한 정보만 노출 )
OrderSimpleApiController
package myjpabook.jpashop.api;
import lombok.RequiredArgsConstructor;
import myjpabook.jpashop.domain.Order;
import myjpabook.jpashop.domain.OrderSearch;
import myjpabook.jpashop.repository.OrderRepository;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/*
* xToOne(ManyToOne, OneToOne)
* Order
* Order -> Member
* Order -> Delivery
* */
@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {
private final OrderRepository orderRepository;
/*
* V1. 엔티티 직접 노출
* - 양방향 관계때문에 문제 발생 -> 한쪽은 @JsonIgnore 해줘야 함
* - 지연 로딩으로 인해서 실제 엔티티가 아닌 프록시 객체가 존재해서 오류발생
* -> Hibernate5Module 모듈 등록, LAZY=null 처리
* */
@GetMapping("/api/v1/simple-orders")
public List<Order> ordersV1() {
List<Order> all = orderRepository.findAllByString(new OrderSearch());
// Hibernate5Module 강제 지연 로딩 옵션 끄고 해야함
for (Order order : all) {
order.getMember().getName(); //Lazy 강제 초기화
order.getDelivery().getAddress(); //Lazy 강제 초기화
}
return all;
}
}
엔티티를 직접 노출하는 것은 좋지 않다. (앞장에서 이미 설명)
order member 와 order address 는 지연 로딩이다. 따라서 실제 엔티티 대신에 프록시 존재
jackson 라이브러리는 기본적으로 이 프록시 객체를 json으로 어떻게 생성해야 하는지 모름 -> 예외 발생
Hibernate5Module 을 스프링 빈으로 등록하면 해결(스프링 부트 사용중)
Hibernate5Module 등록
JpashopApplication 에 다음 코드를 추가하자
@Bean
Hibernate5Module hibernate5Module() {
return new Hibernate5Module();
}
기본적으로 초기화 된 프록시 객체만 노출, 초기화 되지 않은 프록시 객체는 노출 안함
참고: build.gradle 에 다음 라이브러리를 추가하자
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5'
다음과 같이 설정하면 강제로 지연 로딩 가능
@Bean
Hibernate5Module hibernate5Module() {
Hibernate5Module hibernate5Module = new Hibernate5Module();
//강제 지연 로딩 설정
hibernate5Module.configure(Hibernate5Module.Feature.FORCE_LAZY_LOADING, true);
return hibernate5Module;
}
이 옵션을 키면 order -> member , member -> orders 양방향 연관관계를 계속 로딩하게 된다. 따라서 @JsonIgnore 옵션을 한곳에 주어야 한다.
주의: 엔티티를 직접 노출할 때는 양방향 연관관계가 걸린 곳은 꼭! 한곳을 @JsonIgnore 처리 해야 한다. 안그러면 양쪽을 서로 호출하면서 무한 루프가 걸린다.
참고: 앞에서 계속 강조했듯이 정말 간단한 애플리케이션이 아니면 엔티티를 API 응답으로 외부로 노출하는 것은 좋지 않다. 따라서 Hibernate5Module 를 사용하기 보다는 DTO로 변환해서 반환하는 것이 더 좋은 방법이다.
주의: 지연 로딩(LAZY)을 피하기 위해 즉시 로딩(EARGR)으로 설정하면 안된다! 즉시 로딩 때문에 연관관계가 필요 없는 경우에도 데이터를 항상 조회해서 성능 문제가 발생할 수 있다. 즉시 로딩으로 설정하면 성능 튜닝이 매우 어려워 진다.
항상 지연 로딩을 기본으로 하고, 성능 최적화가 필요한 경우에는 페치 조인(fetch join)을 사용해라!(V3 에서 설명)
간단한 주문 조회 V2: 엔티티를 DTO로 변환 ( 성능 좋지 않음 )
OrderSimpleApiController - 추가
/**
* V2. 엔티티를 조회해서 DTO로 변환(fetch join 사용X) * - 단점: 지연로딩으로 쿼리 N번 호출
*/
// 원래 List로 반환하는게 아닌 Result로 감싸야함
@GetMapping("/api/v2/simple-orders")
public List<SimpleOrderDto> ordersV2() {
List<Order> orders = orderRepository.findAllByString(new OrderSearch());
List<SimpleOrderDto> result = orders.stream()
.map(order -> new SimpleOrderDto(order))
.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(); // LAZY 초기화
orderDate = order.getOrderDate();
orderStatus = order.getStatus();
address = order.getDelivery().getAddress(); // LAZY 초기화
}
}
- 엔티티를 DTO로 변환하는 일반적인 방법이다.
- 쿼리가 총 1 + N + N번 실행된다. (v1과 쿼리수 결과는 같다.) -> (1 + 회원 N + 배송 N)
- order 조회 1번(order 조회 결과 수가 N이 된다.)
- order -> member 지연 로딩 조회 N 번
- order -> delivery 지연 로딩 조회 N 번
- 예) order의 결과가 4개면 최악의 경우 1 + 4 + 4번 실행된다.(최악의 경우) (N+1 문제)
- 지연로딩은 영속성 컨텍스트에서 조회하므로, 이미 조회된 경우 쿼리를 생략한다.
같은 member를 조회한다면 영속성 컨텍스트에 있어서 줄어들긴함... 거의 일어나지 않고 최악의 경우로 생각하는게 좋음
간단한 주문 조회 V3: 엔티티를 DTO로 변환 - 페치 조인 최적화 ( 페치 조인을 사용해서 한번에 조회)
OrderSimpleApiController - 추가
/**
* V3. 엔티티를 조회해서 DTO로 변환(fetch join 사용O)
* - fetch join으로 쿼리 1번 호출
* 참고: fetch join에 대한 자세한 내용은 JPA 기본편 참고(정말 중요함)
*/
@GetMapping("/api/v3/simple-orders")
public List<SimpleOrderDto> ordersV3() {
// fetch join 사용한 메서드 (member와 delivery를 한번에 가져옴)
List<Order> orders = orderRepository.findAllWithMemberDelivery();
List<SimpleOrderDto> result = orders.stream()
.map(order -> new SimpleOrderDto(order))
.collect(Collectors.toList());
return result;
}
OrderRepository - 추가 코드
public List<Order> findAllWithMemberDelivery() {
return em.createQuery(
"select o from Order o" +
" join fetch o.member" +
" join fetch o.delivery d", Order.class)
.getResultList();
}
- 엔티티를 페치 조인(fetch join)을 사용해서 쿼리 1번에 조회
- 페치 조인으로 order -> member , order -> delivery 는 이미 조회 된 상태 이므로 지연로딩이 일어나지 않음!!!
간단한 주문 조회 V4: JPA에서 DTO로 바로 조회
OrderSimpleApiController - 추가
/**
* V4. JPA에서 DTO로 바로 조회
*-쿼리1번 호출
* - select 절에서 원하는 데이터만 선택해서 조회
*/
@GetMapping("/api/v4/simple-orders")
public List<OrderSimpleQueryDto> ordersV4() {
return orderSimpleQueryRepository.findOrderDtos();
}
OrderSimpleQueryRepository 조회 전용 리포지토리
package myjpabook.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() {
// DTO로 조회
return em.createQuery("select new myjpabook.jpashop.repository.order.simplequery.OrderSimpleQueryDto(o.id, m.name, o.orderDate, o.status, d.address) " +
" from Order o" +
" join o.member m" +
" join o.delivery d", OrderSimpleQueryDto.class)
.getResultList();
}
}
OrderSimpleQueryDto 리포지토리에서 DTO 직접 조회
package myjpabook.jpashop.repository.order.simplequery;
import lombok.Data;
import myjpabook.jpashop.domain.Address;
import myjpabook.jpashop.domain.OrderStatus;
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;
}
}
- 일반적인 SQL을 사용할 때 처럼 원하는 값을 선택해서 조회
- new 명령어를 사용해서 JPQL의 결과를 DTO로 즉시 변환
- SELECT 절에서 원하는 데이터를 직접 선택하므로 DB -> 애플리케이션 네트웍 용량 최적화 (생각보다 미비)
- 리포지토리 재사용성 떨어짐, API 스펙에 맞춘 코드가 리포지토리에 들어가는 단점
정리
엔티티를 DTO로 변환하거나, DTO로 바로 조회하는 두가지 방법은 각각 장단점이 있다. 둘중 상황에 따라서 더 나은 방법을 선택하면 된다. 엔티티로 조회하면 리포지토리 재사용성도 좋고, 개발도 단순해진다. 따라서 권장하는 방법은 다음과 같다.
쿼리 방식 선택 권장 순서
- 우선 엔티티를 DTO로 변환하는 방법을 선택한다.
- 필요하면 페치 조인으로 성능을 최적화 한다. 대부분의 성능 이슈가 해결된다.
- 그래도 안되면 DTO로 직접 조회하는 방법을 사용한다.
- 최후의 방법은 JPA가 제공하는 네이티브 SQL이나 스프링 JDBC Template을 사용해서 SQL을 직접 사용한다.
https://www.inflearn.com/course/스프링부트-JPA-API개발-성능최적화/dashboard
실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화 - 인프런 | 강의
스프링 부트와 JPA를 활용해서 API를 개발합니다. 그리고 JPA 극한의 성능 최적화 방법을 학습할 수 있습니다., 본 강의는 자바 백엔드 개발의 실전 코스에 있는 활용2 강의 입니다. 스프링 부트와 J
www.inflearn.com
'Spring > [인프런] 실전! 스프링 부트와 JPA 활용2' 카테고리의 다른 글
API 개발 고급 - 실무 필수 최적화 (0) | 2021.09.28 |
---|---|
API 개발 고급 - 컬렉션 조회 최적화 (0) | 2021.09.27 |
API 개발 고급 - 조회용 샘플 데이터 입력 (0) | 2021.09.23 |
API 개발 기본 (0) | 2021.09.23 |