최적화 방법
- 먼저 @ManyToOne, @OneToOne 의 경우
- fetch join을 이용해서 쿼리 수를 최적화 한다.
- @OneToMany 의 경우
- 페이징이 필요 없는 경우 -> fetch join을 이용해서 쿼리 수를 최적화 한다.
- 페이징이 필요한 경우 -> fetch join 을 사용하지 않는다. -> hibernate.default_batch_fetch_size 를 500 정도로 설정해서 최적화 한다.
Entity 관계도 와 성능 문제 상황
- 위와 같이 주문, 주문상품, 고객, 배달, 상품 엔티티가 있다고 가정하자.
- 만약 주문 목록을 조회하는데 3개의 주문이 조회됐다.
- 여기서 주문과 고객, 배달이 toOne 으로 연결 되어있다. -> 몇번의 쿼리가 나갈까?
- order 목록을 조회하는 쿼리 1번
- order 와 연관된 고객 조회하는 쿼리 3번(N번)
- order 와 연관된 배달 조회하는 쿼리 3번(N번)
- 결국은 1 + 3 + 3 (1 + N + N) = 7번의 쿼리가 나간다.
- 만약 연관관계가 1개 더 추가된다면?
- 1 + 3 + 3 + 3 이 될 것이다. (1 + N + N + N + N)
- 만약 이 상황에서 주문 갯수가 1개 더 추가된다면?
- 1 + 4 + 4 + 4 가 될 것이다.
- 여기서 주문과 고객, 배달이 toOne 으로 연결 되어있다. -> 몇번의 쿼리가 나갈까?
- 위의 문제를 1 + N 문제라고 한다.
- 위의 문제를 fetch join으로 해결할 수 있다.
- 물론 최악의 경우로 생각한 것이다. -> 영속성 컨텍스트에 있는 엔티티의 경우 쿼리가 나가지 않기 때문이다.
Entity 코드
- 주문 Entity
@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;
@OneToOne(fetch = LAZY, cascade = ALL)
@JoinColumn(name = "delivery_id")
private Delivery delivery;
@OneToMany(mappedBy = "order", cascade = ALL)
private List<OrderItem> orderItems = new ArrayList<>();
private LocalDateTime orderDate;
@Enumerated(EnumType.STRING)
private OrderStatus status;
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;
}
}
- 고객 Entity
@Entity
@Getter @Setter
public class Member {
@Id @GeneratedValue
@Column(name = "member_id")
private Long id;
private String name;
@Embedded
private Address address;
// orders 테이블의 member 필드, read-only, 맵핑된 거울
@JsonIgnore
@OneToMany(mappedBy = "member")
private List<Order> orders = new ArrayList<>();
}
- 주문상품 Entity
@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;
}
}
- 상품 Entity
@Entity
@Getter @Setter
public class Item {
@Id
@GeneratedValue
@Column(name = "item_id")
private Long id;
private String name;
private int price;
private int stockQuantity;
@ManyToMany(mappedBy = "items")
private List<Category> categories = new ArrayList<>();
}
컬렉션 조회 최적화
- 만약 @ManyToOne에서 주문 1개가 orderItem 3개를 갖는다면 조인 시점에서 3개로 뻥튀기된다.
- Order 기준으로 컬렉션인 OrderItem 와 Item 이 필요하다면?
- Member, Delivery 와는 다르게 @ManyToOne 이다.
- 이런경우 컬렉션을 포함하고 있다.
- 먼저 양방향 연관관계면 무한 루프에 걸리지 않게 한곳에 @JsonIgnore 를 추가해야 한다.
- 쿼리 수
- order 1번
- orderItem N번(order 조회 수 만큼)
- item N번(orderItem 조회 수 만큼)
- 위와 같이 N * N 이 되기 때문에 성능적인 문제가 발생할 수 있다.
- 위의 쿼리수 문제를 해결하기 위해서 fetch join 을 하게 된다면?
- 조인으로 인해 중복된 데이터가 뻥튀기 된다. -> 1 : N 에서 N만큼 뻥튀기 된다.
- 위의 문제를 해결하기 위해서 distinct 를 사용할 수 있다.
- JPA 에서의 distinct 는 ID 값이 같으면 중복을 제거해준다. -> 모든 필드에 값이 다같아야 중복을 제거해주는 디비상에서 distinct 를 붙여서 쿼리를 날리는 거랑은 다르다.
- JPA 에서의 distinct 는 ID 값이 같으면 중복을 제거해준다. -> 모든 필드에 값이 다같아야 중복을 제거해주는 디비상에서 distinct 를 붙여서 쿼리를 날리는 거랑은 다르다.
컬렉션 + 페이징 문제
- 컬렉션 페치 조인을 사용하면 페이징이 불가능하다.
- 하이버네이트는 경고 로그를 남기면서 모든 데이 터를 DB에서 읽어오고, 메모리에서 페이징 해버린다(매우 위험하다).
- 컬렉션 페치 조인은 1개만 사용할 수 있다.
- 컬렉션 둘 이상에 페치 조인을 사용하면 안된다. 데이터가 부정합하게 조회될 수 있다.
- 그렇기 때문에 컬렉션 패치 조인은 사용하면 안된다.
컬렉션 + 페이징 문제 해결
- 우선 컬렉션은 지연 로딩으로 조회한다.
- 지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size , @BatchSize 를 적용한다.
- hibernate.default_batch_fetch_size: 글로벌 설정
- @BatchSize: 개별 최적화
- 이 옵션을 사용하면 컬렉션이나, 프록시 객체를 한꺼번에 설정한 size 만큼 IN 쿼리로 조회한다.
- 쿼리호출수가 1+N 에서 1+1 로 최적화된다.
- 조인보다 DB 데이터 전송량이 최적화 된다.
- 페치 조인 방식과 비교해서 쿼리 호출 수가 약간 증가하지만, DB 데이터 전송량이 감소한다.
- 컬렉션 페치 조인은 페이징이 불가능 하지만 이 방법은 페이징이 가능하다.
default_batch_fetch_size 의 크기 설정
- 100~1000 사이를 선택 하는 것을 권장한다.
- 이 전략을 SQL IN 절을 사용하는데, 데이터베이스에 따라 IN 절 파라미터를 1000으 로 제한하기도 한다.
- 1000으로 잡으면 한번에 1000개를 DB에서 애플리케이션에 불러오므로 DB에 순간 부하가 증가할 수 있다.
- 하지만 애플리케이션은 100이든 1000이든 결국 전체 데이터를 로딩해야 하므로 메모리 사용량이 같다.
- 1000으로 설정하는 것이 성능상 가장 좋지만, 결국 DB든 애플리케이션이든 순간 부 하를 어디까지 견딜 수 있는지로 결정하면 된다.
- 결론은 맥시멈 1000을 넘기면 안되고 100~1000 을 권장하기 때문에 그냥
500
으로 설정하면 마음 편하게 사용할 수 있다.
결론
@GetMapping("/api/v3/simple-orders")
public List<SimpleOrderDto> ordersV3() {
List<Order> orders = orderRepository.findAllWithMemberDelivery();
List<SimpleOrderDto> result = orders.stream()
.map(SimpleOrderDto::new)
.collect(Collectors.toList());
return result;
}
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();
}
- order 입장에서 고객과 배달은 @ManyToOne, @OneToOne 이기 때문에 fetch join 을 진행한다.
- 페이징 처리가 필요하다면
- 그리고 @OneToMany 인 OrderItem은 fetch join을 진행하지 않는다.
- 당연히 OrderItem와 @ManyToOne 인 Item 도 fetch join 이 진행될 수 없다. -> OrderItem 에서 끊겼기 때문이다.
- 대신 default_batch_fetch_size 를 설정한다.
spring: jpa: properties: hibernate: default_batch_fetch_size: 500
- 그리고 @OneToMany 인 OrderItem은 fetch join을 진행하지 않는다.
REFERENCES
- 김영한님의 스프링 부트와 JPA 활용2
'Spring Jpa' 카테고리의 다른 글
JPA Id 생성전략 설정하기 (0) | 2022.02.27 |
---|---|
영속성 컨텍스트 (0) | 2022.02.27 |
값타입 (0) | 2022.02.27 |
OSIV (0) | 2022.02.27 |
Spring Data Jpa (0) | 2022.02.27 |