주문내역에서 주문한 상품정보까지 추가로 조회 해보자.
EP2 에서는 @XToOne 을 조회하는 기능을 최적화 했었다.
2021.04.06 - [Web/JPA] - EP2. 지연 로딩과 조회 성능 최적화
여기서는 @XToMany인 컬렉션을 조회하는 기능을 구현하고 최적화 해보자.
Order 엔티티에서 컬렉션인 OrderItem과
OrderItem의 Item이 필요하다. Item도 Order 기준으로는 컬렉션이다.
Order
package jpabook.jpashop.domain;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import javax.persistence.*;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import static javax.persistence.CascadeType.*;
import static javax.persistence.FetchType.LAZY;
@Entity
@Table(name = "orders")
@Getter
@Setter
@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 = ALL)
private List<OrderItem> orderItems = new ArrayList<>();
@OneToOne(fetch = LAZY, cascade = ALL)
@JoinColumn(name = "delivery_id")
private Delivery delivery;
private LocalDateTime orderDate; // 주문 시간
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 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.setStatus(OrderStatus.ORDER);
order.setOrderDate(LocalDateTime.now());
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() {
int totalPrice = 0;
for (OrderItem orderItem : orderItems) {
totalPrice += orderItem.getTotalPrice();
}
return totalPrice;
}
}
Item
package jpabook.jpashop.domain;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jpabook.jpashop.domain.Item.Item;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import javax.persistence.*;
import static javax.persistence.FetchType.*;
@Entity
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OrderItem {
@Id @GeneratedValue
@Column(name = "order_item_id")
private Long id;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "item_id")
private Item item;
@JsonIgnore
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "order_id")
private Order order;
private int orderPrice;
private int count;
//==생성 메서드==//
public static OrderItem createOrderItem(Item item, int orderPrice, int count) {
OrderItem orderItem = new OrderItem();
orderItem.setItem(item);
orderItem.setOrderPrice(orderPrice);
orderItem.setCount(count);
item.removeStock(count);
return orderItem;
}
//==비즈니스 로직==//
public void cancel() {
getItem().addStock(count);
}
//==조회 로직==//
/**
* 주문상품 전체 가격 조회
*/
public int getTotalPrice() {
return getOrderPrice() * getCount();
}
}
엔티티를 직접 노출하면 안되므로 Order를 DTO로 반환해보았다.
@GetMapping("/api/v2/orders")
public List<OrderDto> ordersV2() {
List<Order> orders = orderRepository.findAllByString(new OrderSearch());
List<OrderDto> result = orders.stream()
.map(OrderDto::new)
.collect(Collectors.toList());
return result;
}
@Data
static class OrderDto {
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
private List<OrderItem> orderItems;
public OrderDto(Order order) {
orderId = order.getId();
name = order.getMember().getName();
orderDate = order.getOrderDate();
orderStatus = order.getStatus();
address = order.getDelivery().getAddress();
orderItems = order.getOrderItems();
}
}
POSTMAN으로 조회요청을 보냈을때의 결과에서 orderItems 부분이 null 이 나왔다.
[
{
"orderId": 4,
"name": "userA",
"orderDate": "2021-04-07T18:52:53.179531",
"orderStatus": "ORDER",
"address": {
"city": "서울",
"street": "1",
"zipcode": "1111"
},
"orderItems": null
},
{
"orderId": 11,
"name": "userB",
"orderDate": "2021-04-07T18:52:53.23564",
"orderStatus": "ORDER",
"address": {
"city": "진주",
"street": "2",
"zipcode": "2222"
},
"orderItems": null
}
]
orderItems가 컬렉션, @OneToMany이므로 지연로딩이 되어있어서 데이터가 없었다.
그래서 프록시 객체를 초기화 하고,
@Data
static class OrderDto {
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
private List<OrderItem> orderItems;
public OrderDto(Order order) {
orderId = order.getId();
name = order.getMember().getName();
orderDate = order.getOrderDate();
orderStatus = order.getStatus();
address = order.getDelivery().getAddress();
order.getOrderItems().stream().forEach(o -> o.getItem().getName());
orderItems = order.getOrderItems();
}
}
다시 요청해보았을 때, orderItems 정보가 나왔다.
하지만 여기서 문제는 orderItems는 DTO로 변환하지 않았기 때문에 엔티티가 그대로 노출되는 상황이 되었다.
[
{
"orderId": 4,
"name": "userA",
"orderDate": "2021-04-07T18:56:13.624688",
"orderStatus": "ORDER",
"address": {
"city": "서울",
"street": "1",
"zipcode": "1111"
},
"orderItems": [
{
"id": 6,
"item": {
"id": 2,
"name": "JPA1 BOOK",
"price": 10000,
"stockQuantity": 99,
"categories": null,
"author": null,
"isbn": null
},
"orderPrice": 10000,
"count": 1,
"totalPrice": 10000
},
{
"id": 7,
"item": {
"id": 3,
"name": "JPA2 BOOK",
"price": 20000,
"stockQuantity": 98,
"categories": null,
"author": null,
"isbn": null
},
"orderPrice": 20000,
"count": 2,
"totalPrice": 40000
}
]
},
{
"orderId": 11,
"name": "userB",
"orderDate": "2021-04-07T18:56:13.670374",
"orderStatus": "ORDER",
"address": {
"city": "진주",
"street": "2",
"zipcode": "2222"
},
"orderItems": [
{
"id": 13,
"item": {
"id": 9,
"name": "SPRING1 BOOK",
"price": 20000,
"stockQuantity": 197,
"categories": null,
"author": null,
"isbn": null
},
"orderPrice": 20000,
"count": 3,
"totalPrice": 60000
},
{
"id": 14,
"item": {
"id": 10,
"name": "SPRING2 BOOK",
"price": 40000,
"stockQuantity": 296,
"categories": null,
"author": null,
"isbn": null
},
"orderPrice": 40000,
"count": 4,
"totalPrice": 160000
}
]
}
]
OrderDto 안에서 OrderItem도 DTO로 감싸주어야 한다.
@GetMapping("/api/v2/orders")
public List<OrderDto> ordersV2() {
List<Order> orders = orderRepository.findAllByString(new OrderSearch());
List<OrderDto> result = orders.stream()
.map(OrderDto::new)
.collect(Collectors.toList());
return result;
}
@Data
static class OrderDto {
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
private List<OrderItemDto> orderItems;
public OrderDto(Order order) {
orderId = order.getId();
name = order.getMember().getName();
orderDate = order.getOrderDate();
orderStatus = order.getStatus();
address = order.getDelivery().getAddress();
orderItems = order.getOrderItems().stream()
.map(OrderItemDto::new)
.collect(Collectors.toList());
}
}
@Getter
static class OrderItemDto {
private String itemName; // 상품 명
private int orderPrice; // 주문 가격
private int count; // 주문 수량
public OrderItemDto(OrderItem orderItem) {
itemName = orderItem.getItem().getName();
orderPrice = orderItem.getOrderPrice();
count = orderItem.getCount();
}
}
그렇다면 정상적으로 orderItems 까지 엔티티가 노출되지 않고 원하는정보만 DTO로 내보낼 수 있게 된다.
[
{
"orderId": 4,
"name": "userA",
"orderDate": "2021-04-07T19:00:13.152114",
"orderStatus": "ORDER",
"address": {
"city": "서울",
"street": "1",
"zipcode": "1111"
},
"orderItems": [
{
"itemName": "JPA1 BOOK",
"orderPrice": 10000,
"count": 1
},
{
"itemName": "JPA2 BOOK",
"orderPrice": 20000,
"count": 2
}
]
},
{
"orderId": 11,
"name": "userB",
"orderDate": "2021-04-07T19:00:13.211254",
"orderStatus": "ORDER",
"address": {
"city": "진주",
"street": "2",
"zipcode": "2222"
},
"orderItems": [
{
"itemName": "SPRING1 BOOK",
"orderPrice": 20000,
"count": 3
},
{
"itemName": "SPRING2 BOOK",
"orderPrice": 40000,
"count": 4
}
]
}
]
그렇다면 이 코드에서 주문조회시 SQL은 몇번이나 나갈까?
1. 처음에 order 가져올때 한번 나간다.. ( 누적 1번 )
2. Member와 Delivery 프록시 객체 초기화시 한번씩 나간다. (누적 3번)
3. OrderItem을 가져올때 프록시 객체 초기화시 한번 나간다. (누적 4번)
5. orderItem이 2개이기 때문에 각 item의 정보를 가져오기 위해 또 2번 더 나간다. (누적 6번)
6. 처음에 order가 두개였기 때문에 위와 같은 방식으로 member, delivery, OrderItem, Item, Item 총 5개가 더 나간다.
누적 11번
N + 1 문제가 또 발생한 것이다.
해결방법
fetch join을 사용해보자
JPQL로 LAZY인 엔티티들을 미리 가져오자
public List<Order> findAllWithItem() {
return em.createQuery(
"select o from Order o" +
" join fetch o.member m" +
" join fetch o.delivery d" +
" join fetch o.orderItems oi" +
" join fetch oi.item i", Order.class)
.getResultList();
}
orderItems의 item도 객체그래프 탐색을 통해서 가져올 수 있다.
@GetMapping("/api/v3/orders")
public List<OrderDto> ordersV3() {
List<Order> orders = orderRepository.findAllWithItem();
List<OrderDto> result = orders.stream()
.map(OrderDto::new)
.collect(Collectors.toList());
return result;
}
그런데 결과가 조금 이상했다.
같은 데이터가 두개씩 나왔다.
[
{
"orderId": 4,
"name": "userA",
"orderDate": "2021-04-07T19:14:56.573159",
"orderStatus": "ORDER",
"address": {
"city": "서울",
"street": "1",
"zipcode": "1111"
},
"orderItems": [
{
"itemName": "JPA1 BOOK",
"orderPrice": 10000,
"count": 1
},
{
"itemName": "JPA2 BOOK",
"orderPrice": 20000,
"count": 2
}
]
},
{
"orderId": 4,
"name": "userA",
"orderDate": "2021-04-07T19:14:56.573159",
"orderStatus": "ORDER",
"address": {
"city": "서울",
"street": "1",
"zipcode": "1111"
},
"orderItems": [
{
"itemName": "JPA1 BOOK",
"orderPrice": 10000,
"count": 1
},
{
"itemName": "JPA2 BOOK",
"orderPrice": 20000,
"count": 2
}
]
},
{
"orderId": 11,
"name": "userB",
"orderDate": "2021-04-07T19:14:56.623691",
"orderStatus": "ORDER",
"address": {
"city": "진주",
"street": "2",
"zipcode": "2222"
},
"orderItems": [
{
"itemName": "SPRING1 BOOK",
"orderPrice": 20000,
"count": 3
},
{
"itemName": "SPRING2 BOOK",
"orderPrice": 40000,
"count": 4
}
]
},
{
"orderId": 11,
"name": "userB",
"orderDate": "2021-04-07T19:14:56.623691",
"orderStatus": "ORDER",
"address": {
"city": "진주",
"street": "2",
"zipcode": "2222"
},
"orderItems": [
{
"itemName": "SPRING1 BOOK",
"orderPrice": 20000,
"count": 3
},
{
"itemName": "SPRING2 BOOK",
"orderPrice": 40000,
"count": 4
}
]
}
]
그 이유는 컬렉션 (@OneToMany) 이기 때문에
join은 관계형 데이터베이스에서 "많은" 쪽의 데이터에 맞춰진다.
그래서 관계형 DB에서 준 데이터를 그대로 뿌려서 JSON에서도 Order가 4개가 나온 것이다.
이것을 해결하려면 JPQL에 distinct를 붙여주면 된다.
public List<Order> findAllWithItem() {
return em.createQuery(
"select distinct o from Order o" +
" join fetch o.member m" +
" join fetch o.delivery d" +
" join fetch o.orderItems oi" +
" join fetch oi.item i", Order.class)
.getResultList();
}
[
{
"orderId": 4,
"name": "userA",
"orderDate": "2021-04-07T19:29:41.35265",
"orderStatus": "ORDER",
"address": {
"city": "서울",
"street": "1",
"zipcode": "1111"
},
"orderItems": [
{
"itemName": "JPA1 BOOK",
"orderPrice": 10000,
"count": 1
},
{
"itemName": "JPA2 BOOK",
"orderPrice": 20000,
"count": 2
}
]
},
{
"orderId": 11,
"name": "userB",
"orderDate": "2021-04-07T19:29:41.402457",
"orderStatus": "ORDER",
"address": {
"city": "진주",
"street": "2",
"zipcode": "2222"
},
"orderItems": [
{
"itemName": "SPRING1 BOOK",
"orderPrice": 20000,
"count": 3
},
{
"itemName": "SPRING2 BOOK",
"orderPrice": 40000,
"count": 4
}
]
}
]
order가 전과 마찬가지로 2개만 나오는 것을 볼 수 있다.
쿼리도 1개만 실행되고 select문에는 distinct가 붙여진다.
그런데 데이터베이스에서는 distinct를 붙여도 그대로 나온다.
데이터베이스에서의 distinct는 데이터가 완전히 중복일때만 생략해주기 때문이다.
JPQL의 distinct는
- SQL문의 selete 절에 dintinct를 붙여주기도 하고
- 같은객체가 여러개가 오면 애플리케이션에서 나머지를 생략해준다.
fetch join을 이용해서 SQL문을 1번만 실행하게 했고, distinct를 이용해서 중복 데이터도 제거했다.
그런데 여기서도 치명적인 단점이 있다.
페이징이 불가능하다.
1 대 다 를 fetch join 하면 페이징이 불가능하다.
이 전과 마찬가지로 컬렉션을 join하면 관계형 데이터베이스에서는 Order의 개수만큼 데이터가 뻥튀기된다.
우리가 원하는 페이징은 Order 2개 기준의 페이징인데, 관계형 데이터베이스에서는 뻥튀기가 되어 4개의 order가 있다.
그래서 페이징 기준이 다르기 때문에 할 수 없는 것이다.
추가로, 페이징 처리를 하지 않더라도 컬렉션 페치조인은 최대 1개만 사용해야한다. 여러개의 컬렉션을 페치조인할경우 데이터가 이상하게 조합될 수 있다.
정리
컬렉션을 가져올때 페이징 처리를 할 필요가 없다면 최대 1개의 컬렉션을 fetch join 할 수 있다.
페이징 + 컬렉션 엔티티를 함께 조회하는 방법
- ToOne 관계는 fetch join을 건다. (데이터베이스에서 row의 수와 관계가 없어 페이징에 영향을 미치지 않기 때문에)
- ToMany 관계 (컬렉션) 은 지연로딩으로 가져온다. (대신 지연로딩 성능을 최적화 하는 방법 사용)
ToOne 관계는 fetch join으로 부담없이 가져오고, 페이징 처리도 해버린다.
public List<Order> findAllWithMemberDelivery(int offset, int limit) {
return em.createQuery(
"select o from Order o" +
" join fetch o.member m" +
" join fetch o.delivery d", Order.class)
.setFirstResult(offset)
.setMaxResults(limit)
.getResultList();
}
@GetMapping("/api/v3.1/orders")
public List<OrderDto> ordersV3_page(@RequestParam(value = "offset", defaultValue = "0") int offset,
@RequestParam(value = "limit", defaultValue = "100") int limit) {
List<Order> orders = orderRepository.findAllWithMemberDelivery(offset, limit);
List<OrderDto> result = orders.stream()
.map(OrderDto::new)
.collect(Collectors.toList());
return result;
}
하지만 컬렉션들은 fetch join이 안되어 있고 지연로딩으로 되어있기 때문에 쿼리가 여러번 나간다. ( 7번 )
여기서 application.yml 의 jpa global 설정을 바꿔보자.
jpa.properties.hibernate.default_batch_fetch_size: 100 을 추가
spring:
datasource:
url: jdbc:h2:tcp://localhost/~/jpashop
username: sa
password:
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: create
properties:
hibernate:
# show_sql: true
format_sql: true
default_batch_fetch_size: 100
logging.level:
org.hibernate.SQL: debug
org.hibernate.type: trace
그리고 다시 실행해보면
7개 나가던 쿼리가 3개로 줄었다.
2번째 사진에 있는 orderItems를 가져오는 쿼리를 보면 뒤에 in 이 추가되어있다.
3번째 사진에 있는 item을 가져오는 쿼리에도 in 이 추가되어 있다.
in에 들어간 파라미터를 보면,
yml 파일에서 설정한 batch 사이즈 만큼 in쿼리로 한번에 가져온다.
orderItem과 Item을 지연로딩으로 가져올때 발생하던 N+1 문제가 1+1로 해결된 것이다.
디테일하게 적용하는 방법
@BatchSize(size = 1000)
@OneToMany(mappedBy = "order", cascade = ALL)
private List<OrderItem> orderItems = new ArrayList<>();
ToMany관계일때 디테일하게 적용하려면 위에 @BatchSize를 적어주면 된다.
그런데 Item은 OrderItem에서 ToOne 관계이기 때문에 클래스레벨에 붙여주어야 한다.
package jpabook.jpashop.domain;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jpabook.jpashop.domain.Item.Item;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.BatchSize;
import javax.persistence.*;
import static javax.persistence.FetchType.*;
@BatchSize(size = 100) // ToOne 관계여서 여기에 적음
@Entity
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OrderItem {
@Id @GeneratedValue
@Column(name = "order_item_id")
private Long id;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "item_id")
private Item item;
@JsonIgnore
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "order_id")
private Order order;
private int orderPrice;
private int count;
//==생성 메서드==//
public static OrderItem createOrderItem(Item item, int orderPrice, int count) {
OrderItem orderItem = new OrderItem();
orderItem.setItem(item);
orderItem.setOrderPrice(orderPrice);
orderItem.setCount(count);
item.removeStock(count);
return orderItem;
}
//==비즈니스 로직==//
public void cancel() {
getItem().addStock(count);
}
//==조회 로직==//
/**
* 주문상품 전체 가격 조회
*/
public int getTotalPrice() {
return getOrderPrice() * getCount();
}
}
보통은 yml에 그냥 글로벌하게 BatchSize를 지정해놓고 사용한다.
그렇다면, 적당한 BatchSize는 어떻게 정해야할까
보통 Maximum은 1000개로 두어야 한다. 1000개가 넘으면 순간 부하가 걸릴 수 있다.
하지만 1000개로 하더라도 DB나 애플리케이션에 따라 상황이 달라질 수 있기 때문에
100 ~ 1000개 사이에서 DB나 애플리케이션이 순간 부하를 견딜 수 있을 정도로 정하는 것이 좋다.
페이징 + 컬렉션 엔티티 조회 정리
ToOne은 fetch join 한다.
컬렉션은 지연로딩으로 처리하고 글로벌 BatchSize(100~1000개 사이)를 설정해서 가져온다.
'Web > JPA' 카테고리의 다른 글
EP4. API 개발 순서 (0) | 2021.04.08 |
---|---|
EP3. 컬렉션 조회 최적화 - 2 (JPA에서 DTO 직접조회) (0) | 2021.04.08 |
EP2. 지연 로딩과 조회 성능 최적화 (0) | 2021.04.06 |
EP1. 회원관리 API 만들기 (0) | 2021.04.05 |
EP13. JPQL 경로표현식, 페치조인, 다형성, named, 벌크연산 (0) | 2021.02.24 |