주문내역에서 추가로 주문한 상품 정보를 추가로 조회하는 코드를 구현하자. Order 기준으로 컬렉션인 OrderItem과 Item이 필요하다. 이번에는 컬렉션 관계가 있기 때문에 OneToMany 관계가 존재한다.
주문 조회 V1(엔티티 직접 노출)
@GetMapping("/api/v1/orders")
public List<Order> ordersV1(){
List<Order> all = orderRepository.findAll(new OrderSearch());
for (Order order : all) {
order.getMember().getName();
order.getDelivery().getAddress();
List<OrderItem> orderItems = order.getOrderItems();
orderItems.stream().forEach(o -> o.getItem().getName());
}
return all;
}
Order와 OrderItem을 같이 출력하고 싶어서 OrderItem을 강제 초기화해줬다. 이 방법은 엔티티를 직접 노출하기 때문에 절대로 사용해서는 안되는 방법이다. 엔티티가 변하면 API 스펙이 변한다. 트랜잭션 안에서 지연 로딩이 필요하다. 양방향 연관관계에서 무한 루프가 발생할 수 있기 때문에 @JsonIgnoreeh 추가해주어야 한다.
주문 조회V2(엔티티 DTO로 변환)
@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;
}
- OrderDto
@Data
static class OrderDto{
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
private List<OrderItemDto> orderItems; //OrderItem 조차도 DTO로 바꿔야함
public OrderDto(Order order){
orderId = order.getId();
name = order.getMember().getName();
orderDate = order.getOrderDate();
orderStatus = order.getStatus();
address = order.getMember().getAddress();
orderItems = order.getOrderItems().stream()
.map(orderItem -> new OrderItemDto(orderItem))
.collect(Collectors.toList());
}
}
OrderItemDto를 만들지 않고 OrderItem으로 할경우 OrderItem도 엔티티 이기 때문에 모든 요소를 노출한다. 그럴경우 엔티티를 직접 노출과 다를게 없다. 그러므로 OrderItemDto를 만들어 내가 원하는 것만 출력한다.
- OrderItemDto
@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();
}
}
문제점
- 지연 로딩이 너무 많아 쿼리문이 너무 많이 나간다.
- order에서 1번, member+adress N번(order 조회 수 만큼), orderItems N번(order 조회 수 만큼), item N번(orderItem 조회 수 만큼)
주문 조회V3(엔티티를 DTO로 변환 - 페치 조인 최적화)
@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;
}
- findAllWithItem()
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 없이 그냥 fetch join하면 결과가 4개가 나온다.
그 이유는 order이 2개이고 OrderItems 4개이므로 OrderItem 하나 당 Order이 하나씩 붙으므로 Order이 중복이 일어난다. 이런 중복은 1대다 조인이 있을때 일어난다.
해결책은 distinct를 추가하는 것 이다.
JPA의 distinct는 더해서 같은 엔티티가 조회되면, 애플리케이션 중복을 걸러준다. 결과는 오른쪽의 사진이고 중복을 제거한 모습이다.
버전2에서는 N + 1문제가 발생했지만 V3는 쿼리 하나로 끝낸다.
단점
- 페이징이 불가능하다는 것이다. OrderItem을 기준으로 패이징 처리가 되므로 페이징이 안맞는다.
- 일대다 페치 조인일 경우 메모리에서 페이징 처리를 하므로 절대 하면 안된다.
- 컬렉션 둘 이상에 페치 조인을 사용하면 안된다.
주문 조회 V3.1: 엔티티를 DTO로 변환 - 페이징과 한계 돌파
한계 돌파
페이징을 하면서 컬렉션 엔티티를 함께 조회하려면 어떻게 해야할까?
- ToOne(OneToOne, ManyToOne) 관계는 모두 패치조인을 한다. ToOne 관계는 row수를 증가시키지 않으므로 페이징 쿼리에 영향을 주지 않는다.
- 컬렉션은 지연 로딩으로 조회한다.
- 지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size , @BatchSize 를 적용한다.
- hibernate.default_batch_fetch_size: 글로벌 설정, @BatchSize: 개별 최적화
결국 member는 order와 ManyToOne, delivery는 Order와 OneToOne이기 때문에 각각 페이징 처리가 가능하다.
페이징 처리V1
@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;
}
offset과 limit를 매개변수로 받고 findAllWithMemberDelivery는 Member와 Delivery만 패치조인으로 결과를 얻었다.
하지만 쿼리가 많이 나간다. Member와 Delivery는 한방 쿼리로 조회가 가능하다.
하지만 OrderItems은 Order와 OneToMany관계이기 때문에 fetch 조인을 쓰지 않아 orderItems의 갯수만크 쿼리가 나간다.
페이징 처리V2
jpa:
hibernate:
ddl-auto: create
properties:
hibernate:
format_sql: true
default_batch_fetch_size: 100
application.yml의 jpa properties 밑에 default_batch_fetch_size를 추가한다. 이러면 한방에 인쿼리로 userA의 orderItems와 userB의 orderItems를 가져오게 된다. (총 100개 까지 가능), 아이템도 한방에 다 땡겨온다.
- 개별로 하고 싶을때는 @BatchSize(size = 1000)같이 컬렉션 위에 쓴다.
- 만약 ToOne 관계인 class 위에 추가해준다.
이렇게 하면 V3 보다 데이터 전송량이 매우 작다. 자신이 필요한 내용만 보내준다. 중복도 없이 보내준다.
정리
- 쿼리 호출 수가 1 + N 에서 1 + 1로 최적화 된다.
- 조인보다 DB 데이터 전송량이 최적화 된다. (Order와 OrderItem을 조인하면 Order가 OrderItem 만큼 중복해서 조회된다. 이 방법은 각각 조회하므로 전송해야할 중복 데이터가 없다.)
- 페치 조인 방식과 비교해서 쿼리 호출 수가 약간 증가하지만, DB 데이터 전송량이 감소한다.
- 컬렉션 페치 조인은 페이징이 불가능 하지만 이 방법은 페이징이 가능하다.
강사님 추천 방식
ToOne 관계는 페치 조인해도 페이징에 영향을 주지 않는다. 따라서 ToOne 관계는 페치조인으로 쿼리 수를 줄이고 해결하고, 나머지는 hibernate.default_batch_fetch_size 로 최적화 하자
주문 조회 V4: JPA에서 DTO 직접 조회
- OrderApiController
@GetMapping("/api/v4/orders")
public List<OrderQueryDto> ordersV4(){
return orderQueryRepository.findOrderQueryDtos();
}
- OrderItemQueryDto
@Data
public class OrderItemQueryDto {
@JsonIgnore
private Long orderId;
private String itemName;
private int orderPrice;
private int count;
public OrderItemQueryDto(Long orderId, String itemName, int orderPrice, int count) {
this.orderId = orderId;
this.itemName = itemName;
this.orderPrice = orderPrice;
this.count = count;
}
}
- OrderQueryDto
@Data
public class OrderQueryDto {
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
private List<OrderItemQueryDto> orderItems;
public OrderQueryDto(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;
}
}
- OrderQueryRepository
@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {
private final EntityManager em;
public List<OrderQueryDto> findOrderQueryDtos() {
List<OrderQueryDto> result = findOrders();
result.forEach(o -> {
List<OrderItemQueryDto> orderItems = findOrdersItems(o.getOrderId());
o.setOrderItems(orderItems);
});
return result;
}
private List<OrderItemQueryDto> findOrdersItems(Long orderId) {
return em.createQuery(
"select new jpabook.jpashop.repository.order.query.OrderItemQueryDto(oi.order.id, i.name, oi.orderPrice, oi.count)" +
" from OrderItem oi" +
" join oi.item i" +
" where oi.order.id = :orderId", OrderItemQueryDto.class)
.setParameter("orderId", orderId)
.getResultList();
}
private List<OrderQueryDto> findOrders() {
return em.createQuery(
"select new jpabook.jpashop.repository.order.query.OrderQueryDto(o.id, m.name, o.orderDate, o.status, d.address)" +
" from Order o" +
" join o.member m" +
" join o.delivery d", OrderQueryDto.class)
.getResultList();
}
}
이 부분은 DTO를 직접 조회하는 것이다. findOrderItems는OrderItem을 조회하는 것이고 findOrders는 Order를 조회하는 것이다. findOrders ToOne 관계이기 join으로 한번 찾으면 되지만, OrderItem은 OneToMany이기 때문에 여러번 조회해서 채워야한다.
정리
- Query : 루트 1번, 컬랙션은 N번 실행
- ToOne(N:1, 1:1) 관계들을 먼저 조회하고, ToMany(1:N) 관계는 각각 별도로 처리한다.
- 하지만 이경우도 N+1 문제가 발생한다.
주문 조회 V5: JPA에서 DTO 직접 조회 - 컬렉션 조회 최적화
- OrderApiController
@GetMapping("/api/v5/orders")
public List<OrderQueryDto> ordersV5(){
return orderQueryRepository.findAllByDto_optimization();
}
- OrderQueryRepository
public List<OrderQueryDto> findAllByDto_optimization() {
List<OrderQueryDto> result = findOrders();
List<Long> orderIds = result.stream()
.map(o -> o.getOrderId())
.collect(Collectors.toList());
List<OrderItemQueryDto> orderItems = em.createQuery(
"select new jpabook.jpashop.repository.order.query.OrderItemQueryDto(oi.order.id, i.name, oi.orderPrice, oi.count)" +
" from OrderItem oi" +
" join oi.item i" +
" where oi.order.id in :orderId", OrderItemQueryDto.class)
.setParameter("orderId", orderIds)
.getResultList();
Map<Long, List<OrderItemQueryDto>> orderItemMap = orderItems.stream()
.collect(Collectors.groupingBy(orderItemQueryDto -> orderItemQueryDto.getOrderId()));
result.forEach(o -> o.setOrderItems(orderItemMap.get(o.getOrderId())));
return result;
}
findOrders()로 Order Member Delivery를 검색한다. 이때 쿼리는 한번 나간다. 그 후 쿼리는 orderId가 같은 것을 모두 가져온다.
가져온 orderItems와 orderId와 일치하게 Map으로 저장한다.
그 후 for문을 돌면서 부족했던 데이터를 채운다.(orderItems를 채운다)
주문 조회 V6: JPA에서 DTO로 직접 조회, 플랫 데이터 최적화
- OrderFlatDto
public OrderFlatDto(Long orderId, String name, LocalDateTime orderDate, OrderStatus orderStatus, Address address, String itemName, int orderPrice, int count) {
this.orderId = orderId;
this.name = name;
this.orderDate = orderDate;
this.orderStatus = orderStatus;
this.address = address;
this.itemName = itemName;
this.orderPrice = orderPrice;
this.count = count;
}
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
private String itemName;
private int orderPrice;
private int count;
- OrderQueryRepository
public List<OrderFlatDto> findAllByDto_flat() {
return em.createQuery(
" select new " +
" jpabook.jpashop.repository.order.query.OrderFlatDto(o.id, m.name, o.orderDate, o.status, d.address, i.name, oi.orderPrice, oi.count)"+
" from Order o" +
" join o.member m " +
" join o.delivery d " +
" join o.orderItems oi " +
" join oi.item i", OrderFlatDto.class)
.getResultList();
}
퀴리 1번에 출력이 가능하다.
정리
- 쿼리는 한번이지만 조인으로 인해 DB에서 애플리케이션에 전달하는 데이터에 중복 데이터가 추가되므로 상황에 따라 V5 보다 더 느릴 수 도 있다.
- 애플리케이션에서 추가 작업이 크다.
- 페이징 불가능하다.
출처
실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화 - 인프런 | 강의
스프링 부트와 JPA를 활용해서 API를 개발합니다. 그리고 JPA 극한의 성능 최적화 방법을 학습할 수 있습니다., - 강의 소개 | 인프런...
www.inflearn.com
'BackEnd > 실전! 스프링 부트와 JPA 활용2' 카테고리의 다른 글
OSIV와 성능 최적화 (0) | 2023.01.11 |
---|---|
API 개발 고급 정리 (0) | 2023.01.11 |
지연 로딩과 조회 성능 최적화 (1) | 2023.01.08 |
API 개발 기본 (0) | 2023.01.08 |