2021.04.06 - [Web/JPA] - EP2. 지연 로딩과 조회 성능 최적화
2021.04.07 - [Web/JPA] - EP3. 컬렉션 조회 최적화 - 1
앞에서는 엔티티를 DTO로 변환해서 컬렉션으로 반환했다.
이번에는 DTO를 그대로 반환해서 JPA가 DTO를 직접 조회하게 만들어 보겠다.
JPA에 DTO를 직접 반환 하는것의 장단점은 이미 정리를 해봤었다.
장점
- 쿼리가 한번만 나간다.
- 원하는 데이터만 직접 선택해 반환하므로 네트워크 용량을 최적화할 수 있다. (fetch join보다도)
단점
- 코드가 지저분하다.
- 생각보다 네트워크 용량 최적화가 적다.
- API 스펙에 너무 의존한다. (재사용X, 스펙 바뀌면 바꿔줘야함)
repository에서는 순수한 엔티티 조회 로직만 들어가야 하는데 view와 관련된 로직이 들어가게 된다.
-> 이럴경우 별도의 order.simpleQueryRepository 패키지를 생성해서 레벨을 분리해야한다.
2021.04.06 - [Web/JPA] - EP2. 지연 로딩과 조회 성능 최적화
OrderQueryRepository에서 DTO로 반환한다.
OrderQueryRepository
package jpabook.jpashop.repository.order.query;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import javax.persistence.EntityManager;
import java.util.List;
@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {
private final EntityManager em;
public List<OrderQueryDto> findOrderQueryDtos() {
List<OrderQueryDto> result = findOrders();
result.forEach(o -> {
List<OrderItemQueryDto> orderItems = findOrderItems(o.getOrderId());
o.setOrderItems(orderItems);
});
return result;
}
private List<OrderItemQueryDto> findOrderItems(Long orderId) {
return em.createQuery(
"select new jpabook.jpashop.repository.order.query.OrderItemQueryDto(oi.order.id, oi.item.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();
}
}
findOrderQueryDtos 에서는 Order를 조회하고 DTO로 바꿔주고 Order의 수만큼 OrderItem도 DTO로 변환해준다.
findOrderQueryDtos
public List<OrderQueryDto> findOrderQueryDtos() {
List<OrderQueryDto> result = findOrders();
result.forEach(o -> {
List<OrderItemQueryDto> orderItems = findOrderItems(o.getOrderId());
o.setOrderItems(orderItems);
});
return result;
}
findOrders에서는 ToOne 관계인 member, delivery를 fetch join으로 한번에 가져온다.
findOrders
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();
}
findOrderItems 에서는 orderItem에서 item을 fetch 조인으로 가져온다.
item은 Order와는 ToMany 관계이지만, orderItem과는 ToOne 관계이므로 페치조인이 가능하다.
findOrderItems
private List<OrderItemQueryDto> findOrderItems(Long orderId) {
return em.createQuery(
"select new jpabook.jpashop.repository.order.query.OrderItemQueryDto(oi.order.id, oi.item.name, oi.orderPrice, oi.count)" +
" from OrderItem oi" +
" join oi.item i" +
" where oi.order.id = :orderId", OrderItemQueryDto.class)
.setParameter("orderId", orderId)
.getResultList();
}
이제 Controller에서 Repository를 통해 DTO를 바로 반환해주면 된다.
@GetMapping("/api/v4/orders")
public List<OrderQueryDto> ordersV4() {
return orderQueryRepository.findOrderQueryDtos();
}
실행하는 쿼리의 수는
Order를 가져올때 한번, (Member, Delivery를 join으로 한번에 가져온다.)
List<OrderQueryDto> result = findOrders();
Order의 수 만큼 2번
result.forEach(o -> {
List<OrderItemQueryDto> orderItems = findOrderItems(o.getOrderId());
o.setOrderItems(orderItems);
});
총 3번의 쿼리가 나가게 된다.
Order 1번 -> N개의 주문 -> N번의 쿼리
N+1 문제가 발생하게 된다.
해결방법
N개의 주문을 where 절에 in 을 사용해서 한번에 조회해온다.
- result에 order들을 담는다.
- orderId를 담은 리스트를 만든다.
- 이 리스트를 JPQL의 in을 통해서 한번에 조회해온다.
- 조회해온 order에서 orderIem을 Map<orderId, ItemDto>형식으로 Map에 담는다.
- Map에 있는 itemDto 를 result에 담아서 반환한다.
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, oi.item.name, oi.orderPrice, oi.count)" +
" from OrderItem oi" +
" join oi.item i" +
" where oi.order.id in :orderIds", OrderItemQueryDto.class)
.setParameter("orderIds", orderIds)
.getResultList();
Map<Long, List<OrderItemQueryDto>> orderItemMap = orderItems.stream()
.collect(Collectors.groupingBy(OrderItemQueryDto::getOrderId));
result.forEach(o -> o.setOrderItems(orderItemMap.get(o.getOrderId())));
return result;
}
그러면 Order를 가져오는 쿼리 1번, 가져온 Order들을 한번에 가져오는 쿼리 1번
총 2번의 쿼리만 나가게 되어서 N + 1 문제를 해결할 수 있다.
JPA에 DTO를 직접 반환하는 것이 마냥 편하지만은 않다.
하지만 저번과 마찬가지로 원하는 데이터만 골라 select하므로 select 하는 양이 적어진다는 이점이 있다.
쿼리를 한번으로 줄일수도 있다.
Controller
@GetMapping("/api/v6/orders")
public List<OrderQueryDto> ordersV6() {
List<OrderFlatDto> flats = orderQueryRepository.findAllByDto_flat();
return flats.stream()
.collect(groupingBy(o -> new OrderQueryDto(o.getOrderId(), o.getName(), o.getOrderDate(), o.getOrderStatus(), o.getAddress()),
mapping(o -> new OrderItemQueryDto(o.getOrderId(), o.getItemName(), o.getOrderPrice(), o.getCount()), toList())
)).entrySet().stream()
.map(e -> new OrderQueryDto(e.getKey().getOrderId(), e.getKey().getName(), e.getKey().getOrderDate(), e.getKey().getOrderStatus(), e.getKey().getAddress(), e.getValue()))
.collect(toList());
}
findAllByDto_flat()
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();
}
OrderFlatDto
package jpabook.jpashop.repository.order.query;
import jpabook.jpashop.domain.Address;
import jpabook.jpashop.domain.OrderStatus;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
@Data
public class OrderFlatDto {
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
private String itemName;
private int orderPrice;
private int count;
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;
}
}
OrderFlatDto에는 한번에 가져올 데이터들이 다 들어가있고,
findAllByDto_flat()에서 fetch join으로 orderItem의 Item까지 가져온다.
컬렉션을 fetch join 했기 때문에 중복데이터가 발생하는데,
Controller에서 이 중복데이터를 원래의 API 스펙에 맞게 바꿔준다.
그러면 쿼리 한번에 데이터들을 중복없이 보내줄 수 있다.
하지만 이 방법은 애플리케이션에서 추가 작업이 있고 페이징도 불가능하다.
'Web > JPA' 카테고리의 다른 글
EP5. OSIV와 성능 최적화 (0) | 2021.04.09 |
---|---|
EP4. API 개발 순서 (0) | 2021.04.08 |
EP3. 컬렉션 조회 최적화 - 1 (0) | 2021.04.07 |
EP2. 지연 로딩과 조회 성능 최적화 (0) | 2021.04.06 |
EP1. 회원관리 API 만들기 (0) | 2021.04.05 |