주문을 조회하는 API를 설계해보았다.
package jpabook.jpashop.api;
import jpabook.jpashop.domain.Order;
import jpabook.jpashop.repository.OrderRepository;
import jpabook.jpashop.repository.OrderSearch;
import lombok.RequiredArgsConstructor;
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;
@GetMapping("/api/v1/simple-orders")
public List<Order> orderV1() {
List<Order> all = orderRepository.findAllByString(new OrderSearch());
return all;
}
}
Order 컬렉션을 그대로 반환하려 했었는데 오류가 발생했다.
com.fasterxml.jackson.databind.exc.InvalidDefinitionException: No serializer found for class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor and no properties discovered to create BeanSerializer (to avoid exception, disable SerializationFeature.FAIL_ON_EMPTY_BEANS) (through reference chain: java.util.ArrayList[0]->jpabook.jpashop.domain.Order["member"]->jpabook.jpashop.domain.Member$HibernateProxy$Thl8gx2a["hibernateLazyInitializer"])
오류가 발생한 이유
해당 오류는 Json이 Lazy로딩의 프록시 객체를 읽어올 수 없기 때문에 발생하는 오류이다.
지난번 JPA 기초 강의에서 @XToOne 연관관계는 LAZY 설정했었다.
@OneToMany(mappedBy = "member")
private List<Order> orders = new ArrayList<>();
2021.02.05 - [Web/JPA] - EP5. 연관관계 매핑
2021.02.10 - [Web/JPA] - EP9. 프록시와 연관관계 관리
@XToOne 연관관계가 있는 것을 fetch = LAZY로 하게되면 쿼리를 당장 보내서 가져오는 것이 아닌 프록시 객체를 넣어두게 된다.
JSON은 프록시객체를 읽을 수 없기 때문에 오류가 발생한 것이다.
해결방법 1
Hibernate5Module 을 @Bean으로 등록해서 Lazy 로딩은 당장 읽어오지 않게 설정한다.
package jpabook.jpashop;
import com.fasterxml.jackson.datatype.hibernate5.Hibernate5Module;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
@SpringBootApplication
public class JpashopApplication {
public static void main(String[] args) {
SpringApplication.run(JpashopApplication.class, args);
}
@Bean
Hibernate5Module hibernate5Module() {
return new Hibernate5Module();
}
}
Json 라이브러리는 Hibernate5Module을 사용하는데, Hibernate5Module 는 Lazy로딩은 무시하고 넘어가게 한다.
하지만 이 방법에는 문제가 있다.
전과 마찬가지로 엔티티를 JSON에 그대로 노출하게 되면 엔티티를 수정했을때 API 스펙에 맞지 않게된다.
또한 엔티티를 그대로 노출할때는 양방향 연관관계중 한쪽에 @JsonIgnore를 설정해주어야 한다.
그렇다고 Lazy로딩을 무시하려고 Eager로 설정하는 것은 더더욱 안된다!
2021.02.10 - [Web/JPA] - EP9. 프록시와 연관관계 관리
해결방법2
엔티티를 DTO로 변환하여 반환하기.
@GetMapping("/api/v2/simple-orders")
public List<SimpleOrderDto> orderV2() {
List<Order> orders = orderRepository.findAllByString(new OrderSearch());
List<SimpleOrderDto> result = orders.stream()
.map(o -> new SimpleOrderDto(o))
.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();
orderDate = order.getOrderDate();
orderStatus = order.getStatus();
address = order.getDelivery().getAddress();
}
}
이 방법에도 문제가 있다.
주문이 2개가 있을때 쿼리가 몇개가 나가는지 살펴보자.
먼저 orders를 가져올때 쿼리가 한번 나간다.
List<Order> orders = orderRepository.findAllByString(new OrderSearch());
order를 Dto로 변환하는 코드가 실행되고
List<SimpleOrderDto> result = orders.stream()
.map(o -> new SimpleOrderDto(o))
.collect(Collectors.toList());
return result;
Dto에서는, Order에서 Member와 Delivery가 Lazy 로딩이므로 한 주문당 2번씩 쿼리가 나간다.
총 5번의 쿼리가 나가게된다.
order가 2개밖에 없는데 5번의 쿼리가 나간다.
만약 order가 10개, 100라고 생각한다면 얼마의 쿼리가 나갈까?
이것이 바로 JPA의 유명한 N + 1 문제 이다.
해결방법 3
fetch join 을 사용해서 Member, Delivery를 한번에 가져오기
orderRepository에 findAllWithMemberDelivery를 만들어준다
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();
}
join fetch을 사용해서 쿼리 한번에 Member와 delivery를 가져온다.
@GetMapping("/api/v3/simple-orders")
public List<SimpleOrderDto> orderV3() {
List<Order> orders = orderRepository.findAllWithMemberDelivery();
List<SimpleOrderDto> result = orders.stream()
.map(o -> new SimpleOrderDto(o))
.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();
orderDate = order.getOrderDate();
orderStatus = order.getStatus();
address = order.getDelivery().getAddress();
}
}
그럼 한번의 쿼리로 order와 member, delivery를 한번에 가져올 수 있다.
해결방법 4
dto를 repository에 만든다.
package jpabook.jpashop.repository;
import jpabook.jpashop.domain.Address;
import jpabook.jpashop.domain.Order;
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;
}
}
OrderRepository에 findOrderDtos를 만든다.
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();
}
jpql로 쿼리를 짤 때 주의할 점은 반환을 OrderSimpleQueryDto.class로 반환해야 하기 때문에
jpql은 OrderSimpleQueryDto.class를 모르기 때문에 select 절에서 new 패키지경로 까지 해줘야한다.
또한, Dto에 전달값으로 Order (o)를 넘기면 jpql은 식별자로 넣어버리기 때문에 값을 다 풀어서 넣어주어야 한다.
API Controller에서는
@GetMapping("/api/v4/simple-orders")
public List<OrderSimpleQueryDto> orderV4() {
return orderRepository.findOrderDtos();
}
DTO를 직접 반환한다.
이 방법도 쿼리가 한번만 실행된다.
장점
- 쿼리가 한번만 나간다.
- 원하는 데이터만 직접 선택해 반환하므로 네트워크 용량을 최적화할 수 있다. (fetch join보다도)
단점
- 코드가 지저분하다.
- 생각보다 네트워크 용량 최적화가 적다.
- API 스펙에 너무 의존한다. (재사용X, 스펙 바뀌면 바꿔줘야함)
repository에서는 순수한 엔티티 조회 로직만 들어가야 하는데 view와 관련된 로직이 들어가게 된다.
-> 이럴경우 별도의 order.simpleQueryRepository 패키지를 생성해서 레벨을 분리해야한다.
fetch join vs jpa에 DTO 직접반환
select절에서 몇개 더 가져온다고 성능이 심각하게 저하되지는 않는다. 그래서 기본적으로 fetch join으로 사용한다. 하지만 만약에 엄청나게 많은 데이터가 오고가는 API라면 JPA에 DTO를 원하는 데이터만 선택해 반환하는 것을 고려할 만 하다.
쿼리 방식 선택 순서
- 우선 엔티티를 DTO로 변환한다.
- 필요하면 페치조인으로 성능을 최적화한다.
- 그래도 안되면 DTO로 직접 조회하는 방법을 사용한다.
- 그래도 안되면 최후의 방법으로 JPA가 제공하는 네이티브 SQL이나, 스프링 JDBC Template을 사용해서 SQL을 직접 사용한다.
'Web > JPA' 카테고리의 다른 글
EP3. 컬렉션 조회 최적화 - 2 (JPA에서 DTO 직접조회) (0) | 2021.04.08 |
---|---|
EP3. 컬렉션 조회 최적화 - 1 (0) | 2021.04.07 |
EP1. 회원관리 API 만들기 (0) | 2021.04.05 |
EP13. JPQL 경로표현식, 페치조인, 다형성, named, 벌크연산 (0) | 2021.02.24 |
EP12. JPQL (SQL식 JPQL변환) (0) | 2021.02.21 |