Optimized-Collection-Inquiry
1 컬 렉션 조회 최적화
- 지연로딩과 조회 성능 최적화
- 위 문서에서는 ToOne 관계에 대해서 조회 성능을 최적화 하는 방법을 알아봤습니다.
- 이번 글에서는 ToMany 관계에 대해서 조회 성능을 최적화 하는 방법을 알아보겠습니다.
1.1 예시 상황
Order와Member일대일 관계입니다.Order와Delivery일대일 관계입니다.Order와OrderItem일대다 관계입니다.OrderItem과Item은 다대일 관계입니다.- 연관 관계는 모두
fetch = FetchType.LAZY로 설정되어 있습니다.
2 엔티티 조회
2.1 엔티티 직접 노출 버전
- 엔티티 직접 노출하는 버전을 구현해보겠습니다.
- 엔티티 직접 노출은 권장하는 방법이 아닙니다.
- 엔티티가 변하면 API 스펙이 변하는 문제가 발생합니다.
- 양방향 연관 관계가 있다면 JSON으로 변환하는 과정에서 문제가 발생합니다.
- 양방향 연관관계면 무한 루프에 걸리지 않게 한곳에 @JsonIgnore 를 추가해야 합니다.
2.1.1 예시
@RestController
@RequiredArgsConstructor
public class OrderApiController {
private final OrderRepository orderRepository;
@GetMapping("/api/v1/orders")
public List<Order> ordersV1() {
List<Order> all = orderRepository.findAll();
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;
}
}
- SQL 실행 횟수
Order조회 1회 -> N개의Order가 조회됨Order->Member지연 로딩 N회Order->Delivery지연 로딩 N회Order->OrderItem지연 로딩 N회 각각 K개의OrderItem이 조회된다.OrderItem->Item지연 로딩 K회- 총
1 + 3N + NK회의 SQL이 실행됩니다.
2.2 엔티티를 DTO로 변환 버전
- 이번엔 엔티티를 DTO로 변환하는 버전을 구현해보겠습니다.
2.2.1 예시
@RestController
@RequiredArgsConstructor
public class OrderApiController {
private final OrderRepository orderRepository;
@GetMapping("/api/v2/orders")
public List<OrderDto> ordersV2() {
List<Order> orders = orderRepository.findAll();
List<OrderDto> result = orders.stream()
.map(o -> new OrderDto(o))
.collect(toList());
return result;
}
}
@Data
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
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();
}
}
- SQL 실행 횟수
Order조회 1회 -> N개의Order가 조회됩니다.Order->Member지연 로딩 N회Order->Delivery지연 로딩 N회Order->OrderItem지연 로딩 N회 각각 K개의OrderItem이 조회됩니다.OrderItem->Item지연 로딩 K회- 총
1 + 3N + NK회의 SQL이 실행됩니다.
- Lazy 로딩으로 인해 N + 1 문제가 발생합니다.
2.3 엔티티를 DTO로 변환 + 페치 조인 최적화 버전
Order와OrderItem일대다 관계입니다.Order와OrderItem을 페치 조인합니다.Order1개에OrderItem이 5개 있다고 가정하면 페치 조인시 로우가 5개가 됩니다.- 즉 같은
Order가 5개가 중복되게 됩니다. - 이 증복을 없어기 위해서
distinct를 사용할 수 있습니다. - JPA의
distinct는 SQL에 distinct를 추가하고, 더해서 같은 엔티티가 조회되면, 애플리케이션에서 중복을 걸러줍니다.
- 결과적으로 SQL 실행 횟수는 1회입니다.
- 단점
- 컬렉션을 페치 조인하게되면 페이징이 불가능합니다.
- 페이징이 불가능한 이유
- 일다대에서 일(1)을 기준으로 페이징을 하는 것이 목적입니다.
- 그런데 데이터는 다(N)를 기준으로 row 가 생성됩니다.
Order를 기준으로 페이징 하고 싶은데, 다(N)인OrderItem을 조인하면 OrderItem이 기준이 되어 버려 페이 징이 불가능해집니다.
컬렉션 페치 조인을 사용하면 페이징이 불가능하다. 하이버네이트는 경고 로그를 남기면서 모든 데이터를 DB에서 읽어오고, 메모리에서 페이징 해버리는데 이는 매우 위험하다.
2.3.1 예시
@RestController
@RequiredArgsConstructor
public class OrderApiController {
private final OrderRepository orderRepository;
@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;
}
}
@Repository
@RequiredArgsConstructor
public class OrderSimpleQueryRepository {
private final EntityManager em;
public List<Order> findAllWithItem() {
// Order와 일대다 관계인 orderItems을 페치 조인함으로써 orderItems을 기준으로 row가 생성되고 이로인해 페이징이 불가능해진다
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();
}
}
2.4 엔티티를 DTO로 변환 + 페치 조인 최적화 + 페이징 버전
- 앞선 버전에서는 컬렉션을 페치 조인하면서 페이징이 불가능합니다.
- 페이징이 필요한 경우 컬렉션 엔티티를 함께 조회하는 방법을 알아보겠습니다.
2.4.1 컬렌션 조회 최적화 방법
- 먼저 ToOne(OneToOne, ManyToOne) 관계를 모두 페치조인 합니다.
- ToOne 관계는 row수를 증가시 키지 않으므로 페이징 쿼리에 영향을 주지 않습니다.
- 컬렉션은 지연 로딩으로 조회합니다.
- 지연 로딩 성능 최적화를 위해
spring.jpa.properties.hibernate.default_batch_fetch_size,@BatchSize를 적용합니다.spring.jpa.properties.hibernate.default_batch_fetch_size: 글로벌 설정@BatchSize: 개별 설정을 위한 애노테이션- 이 옵션을 사용하면 컬렉션이나, 프록시 객체를 한꺼번에 설정한 size 만큼 IN 쿼리로 조회한다.
2.4.2 장점
- 쿼리호출수가
1+N에서1+1로 최적화됩니다. - 컬렉션 페치 조인은 페이징이 불가능 하지만 이 방법은 페이징이 가능합니다.
- 조인보다 DB 데이터 전송량이 최적화 됩니다.
Order와OrderItem을 조인하면Order가OrderItem만큼 중복해서 조회됩니다.
정보
default_batch_fetch_size 의 크기는 적당한 사이즈를 골라야 하는데, 100~1000 사이를 선택 하는 것을 권장한다. 이 전략을 SQL IN 절을 사용하는데, 데이터베이스에 따라 IN 절 파 라미터를 1000으로 제한하기도 한다. 1000으로 잡으면 한번에 1000개를 DB에서 애플리케이션에 불러오므로 DB에 순간 부하가 증가할 수 있다. 하지만 애플리케이션은 100이든 1000이든 결국 전체 데이터를 로딩해야 하므로 메모리 사용량이 같다. 1000으로 설정하는 것이 성능상 가장 좋지만, 결국 DB든 애플리케이션이든 순간 부 하를 어디까지 견딜 수 있는지로 결정하면 된다.
2.4.3 예시
@RestController
@RequiredArgsConstructor
public class OrderApiController {
private final OrderRepository orderRepository;
@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;
}
}
@Repository
@RequiredArgsConstructor
public class OrderRepository {
private final EntityManager em;
public List<Order> findAllWithMemberDelivery() {
// Order와 ToOne 관계인 member와 delivery는 페치 조인한다
return em.createQuery(
"select o from Order o" +
" join fetch o.member m" +
" join fetch o.delivery d", Order.class)
.getResultList();
}
}