![[Spring] OneToMany 관계에서 페치 조인 최적화](https://img1.daumcdn.net/thumb/R750x0/?scode=mtistory2&fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbqdlGJ%2FbtsdGnPKv7N%2FJM3KrsxZMMlLjXRgqPRWN0%2Fimg.png)
인프런 김영한 님의 "실전! 스프링 부트와 jPA 활용2 - API 개발과 성능 최적화"를 참고하였습니다.
https://kyko.tistory.com/15 에서 XToOne(OneToOne, ManyToOne) 관계일 때, 조회 성능 최적화에 대해서 다뤘습니다.
이번에는 OneToMany관계에서의 조회 성능 최적화에 대해서 다뤄보겠습니다.
지난번 예제와 동일하게 Order와 Member는 ManyToOne 관계, Order와 Delivery는 OneToOne 관계입니다.
여기에 OrderItem을 추가하겠습니다. Order와 OrderItem은 OneToMany 관계입니다.
엔티티를 DTO로 변환
Order 엔티티 정보를 가져온 뒤 OrderDto로 변환합니다. 마찬가지로 OrderDto에 있는 OrderItem 엔티티 또한 OrderItemDto로 변환합니다.
@GetMapping("/api/v2/orders")
public List<OrderDto> ordersV2(){
List<Order> orders = orderRepository.findAll(new OrderSearch());
List<OrderDto> collect = orders.stream().map(o -> new OrderDto(o)).collect(Collectors.toList());
return collect;
}
@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 o){
orderId = o.getId();
name = o.getMember().getName();
orderDate = o.getOrderDate();
orderStatus = o.getStatus();
address = o.getDelivery().getAddress();
orderItems = o.getOrderItems().stream().map(orderItem -> new OrderItemDto(orderItem)).collect(Collectors.toList());
}
}
@Data
static class OrderItemDto {
private String itemName;
private int orderPrice;
private int count;
public OrderItemDto(OrderItem orderItem){
itemName = orderItem.getItem().getName();
orderPrice = orderItem.getItem().getPrice();
count = orderItem.getItem().getStockQuantity();
}
}
위와 같이 결과는 잘 가져오는 것을 확인할 수 있습니다. 하지만 이 경우 지연 로딩으로 너무 많은 SQL이 실행됩니다.
SQL 실행수는 order 1번, member, address, orderItem, item이 각각 N번씩 실행됩니다.
위의 예시는 order 조회 수가 2이고, 이 경우 쿼리는 총 1 + 2 + 2 + 2 + 2 = 9번 실행됩니다.
다만, 같은 영속성 컨텍스트에서 이미 로딩한 엔티티를 추가로 조회하면 SQL을 실행하지 않습니다.
엔티티를 DTO로 변환 - 페치 조인 최적화
join fetch를 사용해서 한 번에 조회를 해보겠습니다.
@GetMapping("/api/v3/orders")
public List<OrderDto> ordersV3(){
List<Order> orders = orderRepository.findAllWithItem();
List<OrderDto> collect = orders.stream().map(o -> new OrderDto(o)).collect(Collectors.toList());
return collect;
}
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();
}
페치 조인으로 SQL은 1번만 실행됩니다. 위의 결과를 보면 order가 중복되는 것을 확인할 수 있습니다.
SQL을 H2 DB에서 실행하면 아래와 같은 결과가 나옵니다. 이런 결과가 나오는 이유는 Order와 OrderItem은 일대다 관계이기 때문입니다. Order의 결과는 2개지만, 상품이 총 4개이기 때문에 조인을 하면 4개의 row를 출력한다.
중복문제 해결을 위한 distinct를 사용
위의 postman 중복 문제를 해결하기 위해 distinct를 사용합니다. JPA의 distinct는 SQL에 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();
}
distinct를 사용하면 H2 DB에서는 위와 마찬가지로 4개가 조회되지만, postman을 통해서 확인을 하면 Order가 2개만 조회되는 것을 확인할 수 있습니다.
하지만 페치 조인을 이용해 최적화하는 경우 페이징이 불가능하다는 단점이 있습니다.
만약, 주문을 2개만 조회하려고 할 때, 페이징을 하게 되면 위의 H2 DB에서 첫 번째, 두 번째 주문의 OrderID가 4인 주문 2개만 조회될 것입니다.
그렇기에 이 경우, 하이버네이트는 경고 로그를 남기면서 모든 데이터를 DB에서 읽어오고, 메모리에서 페이징을 해버리기에 OutOfMemory가 발생할 수 있습니다.
엔티티를 DTO로 변환 - 페이징과 한계 돌파
페이징 + 컬렉션 엔티티 조회 문제를 해결하기 위해서는 다음 과정을 거쳐야 합니다.
1. ToOne 관계를 모두 페치 조인을 합니다.
ToOne 관계는 row 수를 증가시키지 않으므로 페이징 쿼리에 영향을 주지 않습니다.
Order와 Member, Delivery는 ToOne 관계이기에 모두 패치 조인을 사용해 가져옵니다.
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();
}
setFirstResult(offset) : offset번째부터
setMaxResults(limit) : limit개 가져오기
2. 컬렉션을 지연 로딩으로 조회합니다.
컬렉션을 지연로딩으로 조회하면 N+1문제가 발생합니다. 이 문제를 해결하기 위해서 hibernate.default_batch_fetch_size를 적용합니다.
@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> collect = orders.stream().map(o -> new OrderDto(o)).collect(Collectors.toList());
return collect;
}
spring:
datasource:
url: jdbc:h2:tcp://localhost/~/jpashop
username: sa
password:
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: create
properties:
hibernate:
default_batch_fetch_size: 100 #추가됨
format_sql: true
logging:
level:
org.hibernate.SQL: debug
org.hibernate.type: trace
default_batch_fetch_size 설정은 지연로딩으로 발생하는 쿼리를 IN절로 한 번에 모아 보내는 기능입니다.
그렇기에 쿼리 호출 수가 1 + N에서 1 + 1로 최적화됩니다.
default_batch_fetch_size는 100~1000 사이를 선택하는 것을 권장합니다.
*엔티티별로 설정하려면 @BatchSize(size = ?)를 적용합니다.
쿼리 호출 수는 페치 조인 방식에 비해 약간 증가하지만, DB 데이터 전송량은 감소합니다.
참고자료
Spring Batch JPA에서 N+1 문제 해결
안녕하세요? 이번 시간엔 Spring batch에서 N+1 문제 해결을 진행해보려고 합니다. 모든 코드는 Github에 있기 때문에 함께 보시면 더 이해하기 쉬우실 것 같습니다. (공부한 내용을 정리하는 Github와
jojoldu.tistory.com
'Spring' 카테고리의 다른 글
[Spring] 쿼리 메서드 기능 이해 및 응용1 (0) | 2023.06.07 |
---|---|
[Spring]OSIV와 성능 최적화 (0) | 2023.06.06 |
[Spring] spring.datasource.driver-class-name 에러 (0) | 2023.04.25 |
[Spring] 지연 로딩과 조회 성능 최적화 (0) | 2023.04.25 |
[Spring] 양방향 순환참조 해결하기 (0) | 2023.04.15 |
느리더라도 단단하게 성장하고자 합니다!
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!