[작성일: 2023. 10. 05]
컬렉션 조회 최적화(2)
주문 조회 V4: JPA에서 DTO 직접 조회
@RestController
@RequiredArgsConstructor
public class OrderApiController {
private final OrderRepository orderRepository;
private final OrderQueryRepository orderQueryRepository;
// ... 코드생략
@GetMapping("/api/v4/orders")
public List<OrderQueryDto> ordersV4(){
return orderQueryRepository.findOrderQueryDtos();
}
}
@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;
}
}
@Data
public class OrderItemQueryDto {
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;
}
}
@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, 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();
}
}
Query는 루트 1번, 컬렉션 N번 실행된다.
ToOne(N:1, 1:1) 관계들을 먼저 조회하고 Tomany(1:N) 관계는 각각 별도로 처리한다. 이런 방식을 선택한 이유는 ToOne 관계는 조인해도 데이터 row 수가 증가하지 않지만 ToMany(1:N) 관계는 조인하면 row 수가 증가한다.
row 수가 증가하지 않는 ToOne 관계는 조인으로 최적화 하기 쉬우므로 한 번에 조회하고, ToMany 관계는 최적화 하기 어려우므로 findOrderItems() 같은 별도의 메서드로 조회한다.
주문 조회 V5: JPA에서 DTO 직접 조회 - 컬렉션 조회 최적화
@RestController
@RequiredArgsConstructor
public class OrderApiController {
private final OrderRepository orderRepository;
private final OrderQueryRepository orderQueryRepository;
// ... 코드 생략
@GetMapping("/api/v5/orders")
public List<OrderQueryDto> ordersV5(){
return orderQueryRepository.findAllByDto_optimization();
}
}
@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {
private final EntityManager em;
// ... 코드 생략
private Map<Long, List<OrderItemQueryDto>> findOrderItemMap(List<Long> orderIds) {
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 :orderIds", OrderItemQueryDto.class)
.setParameter("orderIds", orderIds)
.getResultList();
Map<Long, List<OrderItemQueryDto>> orderItemMap = orderItems.stream()
.collect(Collectors.groupingBy(orderItemQueryDto -> orderItemQueryDto.getOrderId()));
return orderItemMap;
}
private static List<Long> toOrderIds(List<OrderQueryDto> result) {
List<Long> orderIds = result.stream().map(o -> o.getOrderId())
.collect(Collectors.toList());
return orderIds;
}
}
Query는 루트 1번, 컬렉션 1번 싫애된다.
ToOne 관계들을 먼저 조회하고, 여기서 얻은 식별자 orderId로 ToMany 관계인 OrderItem을 한꺼번에 조회한다.
MAP을 사용하면 매칭 성능이 향상된다.(O1)
주문 조회 V6: JPA에서 DTO 직접 조회 - 플랫 데이터 최적화
@RestController
@RequiredArgsConstructor
public class OrderApiController {
private final OrderRepository orderRepository;
private final OrderQueryRepository orderQueryRepository;
// ... 코드 생략
@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());
}
}
@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;
}
}
@Data
@EqualsAndHashCode(of = "orderId")
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;
}
public OrderQueryDto(Long orderId, String name, LocalDateTime orderDate, OrderStatus orderStatus, Address address, List<OrderItemQueryDto> orderItems) {
this.orderId = orderId;
this.name = name;
this.orderDate = orderDate;
this.orderStatus = orderStatus;
this.address = address;
this.orderItems = orderItems;
}
}
@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {
private final EntityManager em;
// ... 코드 생략
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();
}
}
이 방법은Query가 한 번만 실행된다는 장점이 있지만 단점도 있다.
조인으로 인해 DB에서 애플리케이션에 전달하는 데이터에 중복 데이터가 추가되므로 상황에 따라 V5 방법보다 더 느릴 수도 있다.
또한 애플리케이션에서 추가 작업이 크며, 페이징 처리가 불가능하다.
API 개발 고급 정리
엔티티 조회
- 엔티티를 조회해서 그대로 반환(V1)
- 엔티티 조회 후 DTO로 반환(V2)
- 페치 조인으로 쿼리 수 최적화(V3)
- 컬렉션 페이징과 한계 돌파(V3.1)
- 컬렉션은 페치 조인 시 페이징이 불가능하다.
- ToOne 관계는 페치 조인으로 쿼리 수를 최적화한다.
- 컬렉션은 페치 조인 대신에 지연 로딩을 유지하고, hibername.default_batch_fetch_size나 @BatchSize로 최적화한다.
DTO 직접 조회
- JPA에서 DTO 직접 조회(V4)
- 컬렉션 조회 최적화: 일대다 관계인 컬렉션은 IN 절을 활용해서 메모리에 미리 조회해서 최적화(V5)
- 플랫 데이테 최적화: JOIN 결과를 그대로 조회 후 애플리케이션에서 원하는 모양으로 직접 변환(V6)
권장 순서
- 엔티티 조회 방식으로 우선 접근한다.
- 페치 조인으로 쿼리 수 최적화
- 컬렉션 최적화
- 페이징 필요 시 hibernate.default_batch_fetch_size, @BatchSize로 최적화
- 엔티티 조회 방식으로 해결이 안 되면 DTO 조회 방식을 사용한다.
- DTO 조회 방식으로 해결이 안 되면 NativeSQL이나 스프링 JdbcTemplate를 사용한다.
참고:
엔티티 조회 방식은 페치 조인이나 hibernate.default_batch_fetch_size, @BatchSize 같이 코드를 거의 수정하지 않고 옵션만 약간 변경해서 다양한 성능 최적화를 시도할 수 있다. 반면에 DTO를 직접 조회하는 방식은 성능을 최적화하거나 성능 최적화 방식을 변경할 때 많은 코드를 변경해야 한다.
엔티티 조회 방식은 JPA가 많은 부분을 최적화해주기 때문에 단순한 코드를 유지하면서 성능을 최적화 할 수 있다. 반면에 DTO 조회 방식은 SQL을 직접 다루는 것과 유사하다.
🐣 출처: 인프런 김영한님 강의
이 글은 인프런의 김영한님 JPA 강의를 보고 작성한 글입니다.
강의를 들으면서 정리한 글이므로 틀린 내용이나 오타가 있을 수 있습니다.