이번 강의 목표는 주문 + 배송정보 + 회원을 조회하는 API를 만드는 것이다.
지연 로딩 때문에 발생하는 성능 문제를 단계적으로 해결해 나가는 것이다.
간단한 주문 조회V1(엔티티를 직접 노출)
- 에러 1
@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {
private final OrderRepository orderRepository;
@GetMapping("/api/v1/simple-orders")
public List<Order> orderV1() {
List<Order> all = orderRepository.findAll(new OrderSearch());
return all;
}
}
이렇게 할 경우 Order가 Member를 조인하고 Member에 또 Order가 존재해 무한루프를 발생시킨다. 이것은 옛날 기본편에서 배운기억이 난다. 이 에러를 처리할 방법은 무한루프가 발생할 곳중 한 곳에 @JsonIgnore를 올려야 한다.(양방향이 걸리는 곳에 모두 추가)
- 애러 2
하지만 저렇게 양방향인 곳 중 한 곳에 @JsonIgnore를 추가해도 에러가 발생한다. 그 이유는 지연로딩에 있다.
Order를 가져올 때, Member는 지연로딩이다. 지연로딩이므로 멤버는 proxy멤버를 가져온다. 그러므로 bytebuddy라는 프록시가 들어가 있다. 이 문제를 해결할 방법은 Hibernate5Module을 스프링 빈으로 등록하는 것이다.
나는 스프링 부트 3.0이상 이기 때문에 build.grade 아래의 코드를 추가 그 아래의 코드를 JpashopApplication에 추가했다.
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5-jakarta'
@Bean
Hibernate5JakartaModule hibernate5Module() {
return new Hibernate5JakartaModule();
}
@Bean
Hibernate5JakartaModule hibernate5Module() {
Hibernate5JakartaModule hibernate5JakartaModule = new Hibernate5JakartaModule();
//강제 지연 로딩 설정
hibernate5JakartaModule.configure(Hibernate5JakartaModule.Feature.FORCE_LAZY_LOADING, true);
return hibernate5JakartaModule;
}
지연 로딩을 무시안하게 하기 위해 강제 지연로딩을 설정하면 모든 요소가 사진처럼 잘 출력된다.
하지만 이렇게 하면 모든 요소들이 출력된다. 내가 원하는 것만 출력하기 위해서 강제 지연로딩 초기화를 Controller에서도 할 수 있다.
@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {
private final OrderRepository orderRepository;
@GetMapping("/api/v1/simple-orders")
public List<Order> orderV1() {
List<Order> all = orderRepository.findAll(new OrderSearch());
for (Order order : all) {
order.getMember().getName(); //LAZY가 강제 초기화
order.getDelivery().getAddress(); //LAZY가 강제 초기화
}
return all;
}
}
위의 코드에서 getMember까지는 프록시이지만 getName을 한 순간부터 지연로딩 강제 초기화를 실행한다. 그러므로 원하는 것만 출력 가능하게 할 수는 있다.
문제점
- 엔티티의 하나라도 요소가 바뀌만 API 자체가 망가진다.
- 성능도 않 좋다. 왜냐하면 필요없는 모든 요소들이 다 출력되기 때문이다.
- 좋은 방법은 DTO로 변환해서 반환하는 거이 제일 좋다.
- 엔티티를 외부에 노출하면 많은 문제가 발생한다.
간단한 주문 조회V2 (엔티티를 DTO로 반환)
@Data
static class SimpleOrderDto{
public SimpleOrderDto(Order order) {
this.orderId = order.getId();
this.name = order.getMember().getName();
this.localDateTime = order.getOrderDate();
this.orderStatus = order.getStatus();
this.address = order.getDelivery().getAddress();
}
private Long orderId;
private String name;
private LocalDateTime localDateTime;
private OrderStatus orderStatus;
private Address address;
}
@GetMapping("/api/v2/simple-orders")
public List<SimpleOrderDto> ordersV2(){
List<Order> orders = orderRepository.findAll(new OrderSearch());
List<SimpleOrderDto> result = orders.stream()
.map(o -> new SimpleOrderDto(o))
.collect(Collectors.toList());
return result;
}
SimpleOrderDto를 만들어 내가 원하는 것만 출력하게 만든다. 그 후 저번에 한 것처럼 List로 Order를 찾은 다음에 그것을 SimpleOrderDto로 바꿔주는 코드를 실행 시킨다.
내가 원하는 요소만 출력된 모습을 볼 수 있다.
문제점
버전1과 버전2의 제일 큰 문제점은 레이지 로딩으로 인한 데이터베이스 쿼리가 너무 많이 호출된다.
order.getMember().getName() 과 order.getDelivery().getAddress()일 때 LAZY가 초기화 된다. 그래서 영속성 컨텍스트에 있는지 검사하고 없으면 쿼리를 날린다.
- orders를 검색할 때 쿼리 1개, 주문 2개 찾음
- 첫번째 order.getMember().getName()할 때, Member 쿼리 1개
- 첫번째 order.getDelivery().getAddress()할 때, Deliver쿼리 1개
- 두번째 order.getMember().getName()할 때, Member 쿼리 1개
- 두번째 order.getDelivery().getAddress()할 때, Deliver쿼리 1개
총 쿼리가 5번 나간다. (N + 1 문제 발생)
간단한 주문 조회V3(엔티티를 DTO로 변환 - 패치 조인 최적화)
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();
}
@GetMapping("/api/v3/simple-orders")
public List<SimpleOrderDto> ordersV3(){
List<Order> orders = orderRepository.findAllWithMemberDelivery();
List<SimpleOrderDto> result = orders.stream()
.map(o -> new SimpleOrderDto(o))
.collect(Collectors.toList());
return result;
}
fetch join을 사용하여 버전2를 업그레이드 했다. Order와 Member 이 결과와 delivery를 패치 조인함으로 써 아까는 N+1개의 쿼리가 나갔지만 지금은 쿼리 1개로 모든 것을 처리한다. 지연로딩 자체가 일어나지 않는다.
간단한 주문 조회V4(JPA에서 DTO로 바로 조회)
- repository 패키
@Data
public class OrderSimpleQueryDto {
private Long orderId;
private String name;
private LocalDateTime localDateTime;
private OrderStatus orderStatus;
private Address address;
public OrderSimpleQueryDto(Long orderId, String name, LocalDateTime localDateTime, OrderStatus orderStatus, Address address) {
this.orderId = orderId;
this.name = name;
this.localDateTime = localDateTime;
this.orderStatus = orderStatus;
this.address = address;
}
}
- OrderRepository
public List<OrderSimpleQueryDto> findOrderDtos() {
return em.createQuery("select new jpabook.jpashop.repository.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();
}
- OrderSimpleApiController
@GetMapping("/api/v4/simple-orders")
public List<OrderSimpleQueryDto> ordersV4(){
return orderRepository.findOrderDtos();
}
버전 4쿼리의 select문이 조금더 적게 나온것을 알 수 있다.
하지만 둘다 장단점이 존재한다. 버전 4는 재사용이 불가능하다. 지금 이 API에 필요한 것을 SQL로 짠 형식이라고 생각할 수 있다. 하지만 버전3은 다른 조회에서도 사용이 가능하다.
즉 버전 4는 리포지토리 재사용성 떨어지고, API 스펙에 맞춘 코드가 리포지토리에 들어가는 것이 단점이다.
강사님이 추천하는 쿼리 방식 선택 권장 순서
- 우선 엔티티를 DTO로 변환하는 방법을 선택한다.
- 필요하면 페치 조인으로 성능을 최적화 한다. 대부분의 성능 이슈가 해결된다.
- 그래도 안되면 DTO로 직접 조회하는 방법을 사용한다.
- 최후의 방법은 JPA가 제공하는 네이티브 SQL이나 스프링 JDBC Template을 사용해서 SQL을 직접 사용한다.
출처
'Spring JPA 공부 > 실전! 스프링 부트와 JPA 활용2' 카테고리의 다른 글
OSIV와 성능 최적화 (0) | 2023.01.11 |
---|---|
API 개발 고급 정리 (0) | 2023.01.11 |
컬렉션 조회 최적화 (0) | 2023.01.10 |
API 개발 기본 (0) | 2023.01.08 |