빙응의 공부 블로그
[Spring] 컬렉션 조회 최적화 본문
📝 개요
저번 시간에는 1대1, 다대1에 대해 페치 조인을 통해 최적화를 해보았다.
그렇다면 일대다 관계 조회는 어떻게 최적화해야할까?
📝 컬렉션 조회 최적화
🚩 V1. 엔티티 직접 노출
/**
* V1. 엔티티 직접 노출
* - Hibernate5Module 모듈 등록, LAZY=null 처리
* - 양방향 관계 문제 발생 -> @JsonIgnore
*/
@GetMapping("/api/v1/orders")
public List<Order> ordersV1() {
List<Order> all = orderRepository.findAllByString(new OrderSearch());
for (Order order : all) {
order.getMember().getName(); //Lazy 강제 초기화
order.getDelivery().getAddress(); //Lazy 강제 초기환
List<OrderItem> orderItems = order.getOrderItems();
orderItems.stream().forEach(o -> o.getItem().getName()); //Lazy 강제 초기화
}
return all;
}
저번과 같게 엔티티를 직접 노출하는 방법은 좋은 방법이 아니므로 그냥 눈으로만 보자
🚩 V2 엔티티를 DTO로 변환
@GetMapping("/api/v2/orders")
public List<OrderDto> ordersV2() {
List<Order> orders = orderRepository.findAllByString(new OrderSearch());
List<OrderDto> result = orders.stream()
.map(o -> new OrderDto(o))
.collect(toList());
return result;
}
엔티티를 DTO로 변환하여 반환하자. 이러면 필요한 정보만 보내줄 수 있다.
@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 order) {
orderId = order.getId();
name
= order.getMember().getName();
orderDate = order.getOrderDate();
orderStatus = order.getStatus();
address = order.getDelivery().getAddress();
orderItems = order.getOrderItems().stream()
.map(orderItem -> new OrderItemDto(orderItem))
.collect(toList());
}
}
@Data
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();
}
}
이렇게 DTO롤 만들어서 내부 수행을 해 변환 시키면 된다.
결과
- 지연 로딩으로 너무 많은 SQL이 실행된다.
🚩 V3 페치 조인 최적화
@GetMapping("/api/v3/orders")
public List<OrderDto> ordersV3() {
List<Order> orders = orderRepository.findAllWithItem();
List<OrderDto> result = orders.stream()
.map(o -> new OrderDto(o))
.collect(toList());
return result;
}
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번만 실행할 수 있다.
distinct를 사용하는 이유는 1대다 조인이 있으므로 데이터베이스의 크기가 매우 증가하여 distinct를 추가해 중복을 걸러준다.
결론
- 페치 조인으로 SQL이 1번만 실행되어 편해진다.
- 그러나 페이징이 불가능하다.
🚩 V3.1 페이징하는법
- 컬렉션을 페치 조인하면 페이징이 불가능하다.
- 컬렉션을 페치 조인하면 일대다 조인이 발생하므로 데이터가 예측할 수 없이 증가한다.
- 일대다에서 일을 기준으로 페이징을 하는 것이 목적이다, 그런데 데이터는 다를 기준으로 생성된다.
- 한계돌파
- 그렇다면 페이징 + 컬렉션 엔티티를 함께 조회하려면 어떻게 해야할까?
- 먼저 ToOne 관계를 모두 패치조인한다. ToOne 관계는 row 수를 증가시키지 않기 때문이다.
- 컬렉션은 지연 로딩으로 조회한다.
- 지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size , @BatchSize 를 적용한다.
- hibernate.default_batch_fetch_size: 글로벌 설정
- @BatchSize: 개별 최적화
- 이 옵션을 사용하면 컬렉션이나, 프록시 객체를 한꺼번에 설정한 size 만큼 IN 쿼리로 조회한다.
- 그렇다면 페이징 + 컬렉션 엔티티를 함께 조회하려면 어떻게 해야할까?
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();
}
위처럼 일단 모든 ToOne 관계를 페치 조인하고 컬렉션은 나몰라라 지연 로딩으로 조회하면 된다.
@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> result = orders.stream()
.map(o -> new OrderDto(o))
.collect(toList());
return result;
}
결론
- 장점
- 퀄리 호출 수가 1+1이다.
- 조인보다 DB 데이터 전송량이 최적화 된다.
- 페치 조인 방식과 비교해서 쿼리 호출 수는 약간 증가하지만, DB 데이터 전송량이 감소한다.
- 이 방법은 페이징 가능
🚩 V4 JPA에서 DTO 직접 조회
/**
*
컬렉션은 별도로 조회
* Query: 루트 1번, 컬렉션 N 번
*
단건 조회에서 많이 사용하는 방식
*/
public List<OrderQueryDto> findOrderQueryDtos() {
//루트 조회(toOne 코드를 모두 한번에 조회)
List<OrderQueryDto> result = findOrders();
//루프를 돌면서 컬렉션 추가(추가 쿼리 실행)
result.forEach(o -> {
List<OrderItemQueryDto> orderItems = findOrderItems(o.getOrderId());
o.setOrderItems(orderItems);
});
return result;
}
위처럼 DTO를 조회하고 별도로 컬렉션을 DTO에 추가하는 방식을 사용하면 된다.
/**
* 1:N 관계(컬렉션)를 제외한 나머지를 한번에 조회
*/
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();
}
/**
* 1:N 관계인 orderItems 조회
*/
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();
}
자세한 로직은 지연로딩때와 동일하다. DTO로 직접 참조하는 것
결과
- 쿼리는 루트 1번, 컬렉션 N번을 실행한다.
- ToOne 관계들을 먼저 조회하고, ToMany 관계는 각각 별도로 처리한다.
- 이러한 방식을 선택하는 이유는 각각의 관계는 row 증가 유무가 다르기 때문이다.
🚩 V5 컬렉션 조회 최적화
/**
* 최적화
* Query: 루트 1번, 컬렉션 1번
* 데이터를 한꺼번에 처리할 때 많이 사용하는 방식
*
*/
public List<OrderQueryDto> findAllByDto_optimization() {
//루트 조회(toOne 코드를 모두 한번에 조회)
List<OrderQueryDto> result = findOrders();
//orderItem 컬렉션을 MAP 한방에 조회
Map<Long, List<OrderItemQueryDto>> orderItemMap =
findOrderItemMap(toOrderIds(result));
//루프를 돌면서 컬렉션 추가(추가 쿼리 실행X)
result.forEach(o -> o.setOrderItems(orderItemMap.get(o.getOrderId())));
return result;
}
private List<Long> toOrderIds(List<OrderQueryDto> result) {
return result.stream()
.map(o -> o.getOrderId())
.collect(Collectors.toList());
}
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();
return orderItems.stream()
.collect(Collectors.groupingBy(OrderItemQueryDto::getOrderId));
}
개선사항은 SQL의 IN으로 한번의 쿼리를 날려 데이터를 조회하게 하는 것이다. 전의 V4는 스트림을 이용해 하나씩 쿼리를 날려 조회했지만 한번에 조회해서 1번만 쿼리를 날리게 한다.
결과
- 쿼리는 루트 1번, 컬렉션 1번
📝 정리
- 엔티티를 조회해서 그대로 반환 : 엔티티 직접 노출은 피해야함
- 엔티티 조회 후 DTO 변환 : 지연 로딩으로 인한 쿼리 증가
- 페치 조인으로 쿼리 수 최적화 : 페이징 불가능
- JPA에서 DTO를 직접 조회 : 쿼리 1번에 컬렉션 N번
- 컬렉션 조회 최적화 - SQL의 IN을 통해 쿼리 1번에 컬렉션 1번
'Spring > 인프런_JPA' 카테고리의 다른 글
[Spring]스프링 데이터 JPA - 확장 기능 (1) | 2024.07.23 |
---|---|
[Spring]스프링 데이터 JPA - 쿼리 메소드 기능 (2) | 2024.07.22 |
[Spring]JPA 지연 로딩 성능 최적화 (0) | 2024.07.03 |
[Spring]실전! 스프링 부트와 JPA 활용1 - 변경 감지와 병합 (0) | 2024.03.18 |
[Spring]자바 ORM 표준 JPA 프로그래밍 - 객체지향 쿼리 언어2 (0) | 2024.02.25 |