[작성일: 2023. 10. 01]
간단한 주문 조회
V1: 엔티티를 직접 노출
주문 + 배송정보 + 회원을 조회하는 API를 만들고, 지연 로딩 때문에 발생하는 성능 문제를 단계적으로 해결해 보자.
@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {
private final OrderRepository orderRepository;
@GetMapping("/api/v1/simple-orders")
public List<Order> ordersV1() {
List<Order> all = orderRepository.findAllByString(new OrderSearch());
return all;
}
}
api를 조회해 보면 양방향 관계에 문제가 생겨 오류가 발생한다.
이걸 해결하기 위해서는 order_id를 참조하고 있는 Member, OrderItem, Delivery에서 @JsonIgnore 애노테이션을 달아주어야 한다.
이렇게 하고 다시 조회하면 500 에러가 발생한다.
Order를 List로 가지고 왔는데 Order에 Member는 fetch가 LAZY로 되어있다. LAZY는 지연로딩이기 때문에 실제 엔티티 대신에 프록시가 존재한다. jackson 라이브러리는 기본적으로 이 프록시 객체를 json으로 어떻게 생성해야 하는지 모르기 때문에 예외가 발생한다.
Hibernate5JakartaModule을 스프링 빈으로 등록하면 이 문제를 해결할 수 있다.
* 하지만 엔티티를 직접 노출시키면 좋지 않으므로 이 방법은 사용할 일이 별로 없다.
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5-jakarta'
@SpringBootApplication
public class JpashopApplication {
public static void main(String[] args) {
SpringApplication.run(JpashopApplication.class, args);
}
@Bean
Hibernate5JakartaModule hibernate5JakartaModule() {
return new Hibernate5JakartaModule();
}
}
기본적으로 초기화된 프록시 객체만 노출하고, 초기화되지 않은 프록시 객체는 노출이 되지 않아 null이 된다.
지연 로딩(LAZY)을 피하기 위해 즉시 로딩(EAGAR)으로 설정하면 안 된다. 즉시 로딩 때문에 연관관계가 필요 없는 경우에도 데이터를 항상 조회해서 성능 문제가 발생할 수 있다. 즉시 로딩으로 설정하면 성능 튜닝이 매우 어려워진다.
항상 지연 로딩을 기본으로 하고, 성능 최적화가 필요한 경우에는 페치 조인(fetch join)을 사용한다.(V3)
V2: 엔티티를 DTO로 변환
@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {
private final OrderRepository orderRepository;
// ... 코드 생략
@GetMapping("/api/v2/simple-orders")
public List<SimpleOrderDto> ordersV2() {
return orderRepository.findAllByString(new OrderSearch()).stream()
.map(o -> new SimpleOrderDto(o))
.collect(Collectors.toList());
}
@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();
orderDate = order.getOrderDate();
orderStatus = order.getStatus();
address = order.getDelivery().getAddress();
}
}
}
엔티티를 DTO로 변환하는 일반적인 방법이다. 쿼리가 총 1 + n + n번 실행된다. (v1과 쿼리 결과수는 같다.)
- order 조회 1번(order 조회 결과 수가 n이 된다.)
- order ➡️ member 지연 로딩 조회 n번
- order ➡️ delivery 지연 로딩 조회 n번
- 예) order의 결과가 4개면 최악의 경우 1 + 4 + 4번이 실행된다.
- 지연 로딩은 영속성 컨텍스트에서 조회하므로 이미 조회된 경우 쿼리를 생략한다.
V3: 엔티티를 DTO로 변환 - 페치 조인 최적화
@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {
private final OrderRepository orderRepository;
// ... 코드 생략
@GetMapping("/api/v3/simple-orders")
public List<SimpleOrderDto> ordersV3() {
List<Order> orders = orderRepository.findAllWithMemberDelivery();
List<SimpleOrderDto> collect = orders.stream().map(
o -> new SimpleOrderDto(o))
.collect(Collectors.toList());
return collect;
}
}
@Repository
@RequiredArgsConstructor
public class OrderRepository {
private final EntityManager em;
// ... 코드 생략
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();
}
}
조회 결과는 v2와 똑같지만 엔티티를 페치 조인(fetch join)을 사용해서 쿼리 1번에 조회할 수 있다.
페치 조인으로 order -> member, order -> delivery는 이미 조회 된 상태이므로 지연로딩이 되지 않는다.
V4: JPA에서 DTO로 바로 조회
@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {
private final OrderRepository orderRepository;
// ... 코드 생략
@GetMapping("/api/v4/simple-orders")
public List<OrderSimpleQueryDto> ordersV4() {
return orderRepository.findOrderDtos();
}
}
@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; // LAZY 초기화
this.orderDate = orderDate;
this.orderStatus = orderStatus;
this.address = address; // LAZY 초기화
}
}
@Repository
@RequiredArgsConstructor
public class OrderRepository {
private final EntityManager em;
// ... 코드 생략
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();
}
}
- 일반적인 SQL을 사용할 때처럼 원하는 값을 선택해서 조회한다.
- new 명령어를 사용해서 JPQL의 결과를 DTO로 즉시 변환한다.
- SELECT 절에서 원하는 데이터를 직접 선택하므로 DB ➡️ 애플리케이션 네트워크 용량 최적화(생각보다 미비하다.)
- 리포지토리 재사용성이 떨어진다. API 스펙에 맞춘 코드가 리포지토리에 들어가는 단점이 있다.
정리해보면, 엔티티를 DTO로 변환하거나 DTO로 바로 조회하는 두 가지 방법은 각각 장단점이 있다.
둘 중 상황에 따라서 더 나은 방법을 선택하면 되고, 엔티티로 조회하면 리포지토리 재사용도 좋고 개발도 단순해진다.
- 우선 엔티티를 DTO로 변환하는 방법 선택하기
- 필요하면 페치 조인으로 성능을 최적화 하기(대부분 성능 이슈 해결)
- 그래도 안 되면 DTO로 직접 조회하는 방법 사용하기
- 최후의 방법은 JPA가 제공하는 네이티브 SQL이나 스프링 JDBC Template을 사용해서 SQL을 직접 사용하기
🐣 출처: 인프런 김영한님 강의
이 글은 인프런의 김영한님 JPA 강의를 보고 작성한 글입니다.
강의를 들으면서 정리한 글이므로 틀린 내용이나 오타가 있을 수 있습니다.