주문, 배송정보, 회원을 조회하는 API를 만들어 볼 것이다. 지연로딩으로 발생하는 성능문제를 단계적으로 해결해본다.
참고::지금부터 설명하는 내용은 정말 중요하며, JPA를 실무에서 사용하려면 100%이해해야한다.
/*
* xToOne의 성능 최적화를 해보자
* -Order
* -Order->Member(ManyToOne)
* -Order->Delivery (OneToOne)
* 이러한 연관관계 관련 API를 생성할 것이다.
* */
@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에 대한 리스트만 출력하려고 했는데, Order 클래스에는 @XToOne 연관관계 매핑이 형성되어있는 상태다. 이 경우 계속해서 서로를 호출하며 에러가 발생하게 된다.
@Entity @Getter
@Table(name = "orders")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {
@Id
@GeneratedValue
@Column(name = "order_id")
private Long id;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "member_id")
private Member member;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderItem> orderItems = new ArrayList<>();
/*
* 1) cascade 쓰지않았을때:
* persist(orderItemA)
* persist(orderItemB)
* persist(orderItemC)
* persist(order)
*
* 2) cascade 쓰고 있을때:
* persist(order)만 해도 orderItems까지 persist된다.
* */
@OneToOne(fetch = LAZY, cascade = CascadeType.ALL)
@JoinColumn(name = "delivery_id")
private Delivery delivery;
/*
* cascade 쓴다면:
* persist(order) 만 해도 delivery까지 persist된다.
* */
private LocalDateTime orderDate; //주문시간
@Enumerated(EnumType.STRING)
private OrderStatus status; //주문 상태 order, cancel
public void setMember(Member member){
this.member = member;
member.getOrders().add(this);
}
public void addOrderItem(OrderItem orderItem){
orderItems.add(orderItem);
orderItem.setOrder(this);
}
public void setDelivery(Delivery delivery){
this.delivery = delivery;
delivery.setOrder(this);
}
public void setOrderDate(LocalDateTime orderDate) {
this.orderDate = orderDate;
}
public void setStatus(OrderStatus status) {
this.status = status;
}
//==생성메서드==//
public static Order createOrder(Member member, Delivery delivery, OrderItem... orderItems){
Order order = new Order();
order.setMember(member);
order.setDelivery(delivery);
for(OrderItem orderItem:orderItems){
order.addOrderItem(orderItem);
}
order.setOrderDate(LocalDateTime.now());
order.setStatus(OrderStatus.ORDER);
return order;
}
//==비즈니스로직==//
/*주문취소*/
public void cancel(){
if(delivery.getStatus() == DeliveryStatus.COMP){
throw new IllegalStateException("이미 배송완료된 상품은 취소가 불가합니다.");
}
this.setStatus(OrderStatus.CANCEL);
for(OrderItem orderItem: orderItems){
orderItem.cancel();
}
}
//==조회로직==//
/*전체 주문 가격 조회*/
public int getTotalPrice(){
return orderItems.stream().mapToInt(OrderItem::getTotalPrice).sum();
}
}
따라서 member ▶️ order와 orderItem ▶️ order를 호출하는 부분에 @JsonIgnore
어노테이션을 붙여야 무한 루프 관계를 끊어낼 수 있고 JSON에 표현하는 부분에 해당하지 않게 된다.
이렇게 보이는 것처럼 Type definition error: [simple type, class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor];
라는 ByteBuddyInterceptor 관련 프록시 에러를 보이는 것을 확인할 수 있다.
jackson 라이브러리는 스프링 내부에 포함된 라이브러리로, 찾아보니 다음과 같은 역할을 해주고 있다.
그런데, jackson라이브러리는 기본적으로 이 프록시 객체를 json으로 어떻게 생성해야할지 몰라서 위와같은 에러가 발생하는 것이다.
➡️Hibernate5Module을 스프링 빈으로 등록하면 해결된다.
스프링부트 3.0미만이면 build.gradle에 implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5'
와 Application 클래스에 아래의 빈을 추가로 등록해주면 된다.
@Bean
Hibernate5Module hibernate5Module(){
return new Hibernate5Module();
}
그러면 postman으로 응답 데이터를 받아보면~~
[
{
"id": 15,
"member": null,
"orderItems": null,
"delivery": null,
"orderDate": "2023-11-03T01:23:13.872511",
"status": "ORDER",
"totalPrice": 50000
},
{
"id": 22,
"member": null,
"orderItems": null,
"delivery": null,
"orderDate": "2023-11-03T01:23:13.95703",
"status": "ORDER",
"totalPrice": 220000
},
{
"id": 36,
"member": null,
"orderItems": null,
"delivery": null,
"orderDate": "2023-11-03T15:04:49.109243",
"status": "ORDER",
"totalPrice": 50000
},
{
"id": 43,
"member": null,
"orderItems": null,
"delivery": null,
"orderDate": "2023-11-03T15:04:49.234908",
"status": "ORDER",
"totalPrice": 220000
},
{
"id": 50,
"member": null,
"orderItems": null,
"delivery": null,
"orderDate": "2023-11-03T15:08:24.148089",
"status": "ORDER",
"totalPrice": 50000
},
{
"id": 57,
"member": null,
"orderItems": null,
"delivery": null,
"orderDate": "2023-11-03T15:08:24.243985",
"status": "ORDER",
"totalPrice": 220000
},
{
"id": 64,
"member": null,
"orderItems": null,
"delivery": null,
"orderDate": "2023-11-03T15:15:06.16109",
"status": "ORDER",
"totalPrice": 50000
},
{
"id": 71,
"member": null,
"orderItems": null,
"delivery": null,
"orderDate": "2023-11-03T15:15:06.241141",
"status": "ORDER",
"totalPrice": 220000
},
{
"id": 78,
"member": null,
"orderItems": null,
"delivery": null,
"orderDate": "2023-11-03T15:21:27.535452",
"status": "ORDER",
"totalPrice": 50000
},
{
"id": 85,
"member": null,
"orderItems": null,
"delivery": null,
"orderDate": "2023-11-03T15:21:27.634098",
"status": "ORDER",
"totalPrice": 220000
},
{
"id": 92,
"member": null,
"orderItems": null,
"delivery": null,
"orderDate": "2023-11-03T15:30:16.032354",
"status": "ORDER",
"totalPrice": 50000
},
{
"id": 99,
"member": null,
"orderItems": null,
"delivery": null,
"orderDate": "2023-11-03T15:30:16.107897",
"status": "ORDER",
"totalPrice": 220000
},
{
"id": 106,
"member": null,
"orderItems": null,
"delivery": null,
"orderDate": "2023-11-03T15:37:43.061378",
"status": "ORDER",
"totalPrice": 50000
},
{
"id": 113,
"member": null,
"orderItems": null,
"delivery": null,
"orderDate": "2023-11-03T15:37:43.152075",
"status": "ORDER",
"totalPrice": 220000
}
]
이렇게 fetch가 LAZY대상인 프록시 엔티티는 NULL로 표현하게 된다. 기본적으로 위의 라이브러리를 추가하게 되면 초기화된 프로젝시 객체만 노출하고, 그렇지 않은 프록시 객체는 노출하지 않기 때문에 그렇다고 한다.
@GetMapping("/api/v1/simple-orders")
public List<Order> orderV1(){
List<Order> all = orderRepository.findAllByString(new OrderSearch());
for (Order order : all) {
order.getMember().getName(); //LAZY강제 초기화
order.getDelivery().getAddress(); // " "
}
return all;
}
이렇게 .getName()이나 getAddress()로 강제 초기화를 하면 필요한 객체만 LAZY로딩으로 초기화하여 값을 null이 아닌 실제 값을 추출할 수 있다.
1. 엔티티를 노출하지 않는 것에 집중하도록 한다!
2. 엔티티를 직접 노출할 때는 양방향 연관관계가 걸린 곳은 꼭! 한곳을 @JsonIgnore
처리해야한다.
안그러면양쪽을서로호출하면서 무한루프가걸린다.
@GetMapping("/api/v2/simple-orders")
public List<SimpleOrderDto> orderV2(){
/*
* N+1 문제
*
* 1번의 쿼리 결과로 2개가 추가로 실행되었다.
* 1+회원N(2)+배송N(2) = 총 5개의 쿼리 실행(최악의 경우)
*
* */
List<Order> orders = orderRepository.findAllByString(new OrderSearch());
List<SimpleOrderDto> result = orders.stream().map(SimpleOrderDto::new)
.collect(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로 변환하는 일반적인 방법이지만, 쿼리가 총 N+1번 발생한다. 다시 말해 order 조회 1번+order.getMember().getName()의 LAZY 조회 N번+order.getDelivery().getAddress()의 LAZY조회 N번으로 최악의 경우에 해당하게 된다.
여기서는 member 2명, order 각각 하나씩 주문했기 때문에 여기서는 1+2(memeber.getName())+2(delivery.getAddress())=총 5개의 쿼리가 실행되어 N+1문제
가 발현된다.