컬렉션 조회 API 성능 최적화 (V4. hibernate.default_batch_fetch_size)
https://praaay.tistory.com/7 컬렉션 조회 API 성능 최적화https://praaay.tistory.com/6 API 조회 성능 최적화추가, 수정 API 보다도 조회 API가 성능에 가장 민감한 만큼 JPA를 사용할 때 조회 API의 성능을 최적화
praaay.tistory.com
위 글의 연장선으로 V5. JPA에서 DTO를 직접 조회하는 방식을 살펴보겠습니다.
이전 V4에서는 컬렉션이 아닌 xToOne 관계의 엔티티들을 페치 조인하는 쿼리 한 번과 컬렉션인 xToMany 관계의 엔티티를 where in 절로 가져오는 쿼리 한 번으로 총 두 번의 쿼리문이 나가도록 개선했습니다.
이번 V5에서는 JPA에서 API 스펙에 완전히 맞춘 형식으로 다시 말해 DTO를 직접 조회하여 한 번의 쿼리문만으로 DTO에 필요한 데이터를 가져올 수 있도록 개선해 보겠습니다.
주문 조회 V5: JPA에서 DTO 직접 조회 - xToOne 관계의 경우
xToOne 관계 조회 API 성능 최적화
추가, 수정 API 보다도 조회 API가 성능에 가장 민감한 만큼 JPA를 사용할 때 조회 API의 성능을 최적화하는 방법을 단계별로 정리하려 합니다. 주문과 배송정보 그리고 회원을 조회하는 API를 만들
praaay.tistory.com
위 글에서 xToOne 관계의 엔티티일 때 JPA에서 DTO를 직접 조회하는 방법을 살펴보았습니다. select 절을 사용해 아래 코드와 같이 DTO가 필요로 하는 데이터를 선택해서 조회했습니다.
@Repository
@RequiredArgsConstructor
public class OrderSimpleQueryRepository {
private final EntityManager em;
public List<OrderSimpleQueryDto> findOrderDtos() {
return em.createQuery(
"select new jpabook.jpashop.repository.order.simplequery.OrderSimpleQueryDto(o.id, m.name, o.orderDate, o.status, d.address)" +
" from Order o" +
" join o.member m" +
" join o.delivery d", OrderSimpleQueryDto.class)
.getResultList();
)
}
}
select 절을 사용하여 DTO를 직접 조회하여 불필요한 SELECT 쿼리의 필드를 줄이고 한 번의 네트워크 통신으로 필요한 데이터를 얻어 성능 측면에서 효과적인 방법이었습니다.
하지만 이 방식은 엔티티가 아닌 DTO에 의존하기 때문에 API 스펙이 변경되어 DTO가 변경되었을 때 select 구문을 수정해야 하는 문제가 있습니다. 또한 특정 DTO를 위한 조회 쿼리이기 때문에 findOrderDtos 메서드를 재사용할 수 없게 되는 문제도 있었습니다.
xToOne 관계에서 드러났던 문제점은 xToMany 관계의 엔티티를 조회할 때도 동일하게 드러납니다.
주문 조회 V5: JPA에서 DTO 직접 조회 - xToMany 관계의 경우
xToMany 관계의 엔티티가 있을 때 JPA에서 DTO를 직접 조회하는 방법을 먼저 살펴봅시다.
// OrderApiController
private final OrderQueryRepository orderQueryRepository;
@GetMapping("/api/v5/orders")
public List<OrderQueryDto> ordersV5() {
return orderQueryRepository.findOrderQueryDtos();
}
// OrderQueryRepository
@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {
private final EntityManager em;
/**
* 컬렉션은 별도로 조회
* Query: 루프 1번, 컬렉션 N번
* 단건 조회에서 많이 사용하는 방식
*/
public List<OrderQueryDto> findOrderQueryDtos(){
//루트 조회(toOne 코드를 모두 한번에 조회)
List<OrderQueryDto> result = findOrders();
//OrderQueryDto 안에 생성자에서 orderItems값을 채워주지 못해서 여기서 넣어줌. (추가 쿼리 실행)
result.forEach(o -> {
List<OrderItemQueryDto> orderItems = findOrderItems(o.getOrderid());
o.setOrderItems(orderItems);
});
return result;
}
/**
* 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();
//orderItems는 1대다관계이므로 데이터가 뻥튀기 되어 넣을 수 없음.
}
/**
* 1:N 관계인 orderItems 조회
* 1대다인 부분은 따로 쿼리를 짜야함.
*/
private List<OrderItemQueryDto> findOrderItems(Long orderId) { //OrderItem안에 Order에 name이 존재
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();
}
}
Command(명령)와 Query(조회)를 분리하는 방향이 유지보수 측면에서 이점이 많기 때문에 핵심 비즈니스를 위한 엔티티를 조회할 때는 OrderRepository 클래스를 참조하고 엔티티가 아니라 특정 API 스펙이나 화면에 맞춰진 DTO를 조회하는 쿼리들은 OrderQueryRepository 클래스에서 관리하도록 둘을 분리했습니다.
위 코드를 보면 컬렉션이 아닌 엔티티를 조회하는 select 절과 컬렉션인 엔티티를 조회하는 select 절을 따로 구현했습니다. findOrders 메서드에서 컬렉션인 OrderItems 엔티티까지 조회하면 join 절에 의해서 데이터가 뻥튀기되기 때문에 같이 조회하지 못하고 xToOne 관계들을 먼저 조회하고, xToMany 관계는 각각 별도로 처리했습니다.
findOrderItems 메서드에서 OrderItem을 기준으로 조회하는데 item을 조인할 수 있는 이유는 둘이 xToOne 관계이기 때문입니다.
그렇다면 findOrderQueryDtos 메서드는 몇 번의 쿼리문이 나갈까요?
findOrderQueryDtos 메서드에서 findOrders 메서드를 호출하며 한 번 쿼리가 나가고, findOrders 메서드에서 반환받은 result를 forEach로 루프를 돌면서 루프 한 번당 OrderItems 컬렉션을 가져오는 쿼리가 한 번 나갑니다.
따라서 총 1 + N(result size)만큼 쿼리가 나가는 것입니다.
select 절로 쿼리를 날리게 되면 데이터를 플랫하게만 얻을 수 있습니다. 따라서 select 절만으로 컬렉션을 대응하기 위해서는 루프를 돌려야 합니다. 컬렉션이 없을 때는 한 번의 쿼리로 JPA에서 DTO를 조회할 수 있었지만, 컬렉션을 조회할 경우 동일한 방법으로는 1 + N 만큼 쿼리가 나가며 성능적으로 개선되지 않습니다.
DTO로 조회하는 방식은 코드 재사용성을 포기하더라도 성능을 개선하려는 방식인데 전혀 성능이 개선되지 않았습니다. 사실 컬렉션이 포함되었을 때 JPA에서 DTO로 조회하는 방식은 in 절을 꼭 사용해야 합니다. in 절을 사용하는 방법을 V6로 살펴봅시다.
주문 조회 V6: JPA에서 DTO 직접 조회 - 컬렉션 조회 최적화
JPA에서 DTO를 직접 조회할 때는 개발자가 직접 select 문을 작성해서 쿼리를 날려야 합니다. 이때 select 문은 플랫한 상태(한 번에 하나의 로우)로 데이터를 조회하기 때문에 컬렉션의 경우 1 + N 문제가 발생하게 됩니다.
따라서 이를 최적화하기 위해서는 in절을 사용해야 합니다. SQL의 in절은 특정 열이나 값이 주어진 목록에 포함되어 있는지를 확인하는 조건절입니다. in절은 여러 개의 값을 비교할 때 사용되며, 주로 where절과 함께 사용됩니다. in절은 여러 개의 값을 비교하는데 유용합니다. SQL에서 in절은 아래와 같이 사용됩니다.
SELECT *
FROM emp
WHERE ename IN ('JONES', 'SCOTT', 'MILLER')
SELECT *
FROM emp
WHERE ename NOT IN ('JONES', 'SCOTT', 'MILLER')
in절을 이해했다면, 이제 아래 코드로 V6에서 in절을 사용하는 방법을 살펴봅시다.
// OrderApiController
@GetMapping("/api/v6/orders")
public List<OrderQueryDto> ordersV6(){
return orderQueryRepository.findAllByDto_optimization();
}
// OrderQueryRepository
/**
* 최적화
* 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;
}
// Order 리스트를 OrderId 리스트로 변환
private List<Long> toOrderIds(List<OrderQueryDto> result) {
return result.stream()
.map(o -> o.getOrderId())
.collect(Collectors.toList());
}
// in절로 한방 쿼리 만듬
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();
// List를 Map으로 변환해서 성능 최적화
return orderItems.stream()
.collect(Collectors.groupingBy(orderItemQueryDto -> orderItemQueryDto.getOrderId()));
}
V6에서는 xToOne 관계들을 먼저 조회하고, 여기서 얻은 식별자 orderId로 xToMany 관계인 OrderItem을 한꺼번에 조회합니다. where절에 in절을 추가하여 한 번의 쿼리만으로 컬렉션을 조회할 수 있게 되었습니다.
V6는 OrderQueryDto를 조회하기 위한 처음 쿼리 한 번과 OberItemQueryDto 컬렉션을 조회하기 위한 쿼리 한 번으로 총 두 번 쿼리문이 나가게 됩니다. 1 + N번 쿼리가 나가던 V5와 비교하면 성능이 많이 개선되었습니다.
마지막으로 V7에서 쿼리문 한 번으로 개선해 보겠습니다.
V7: JPA에서 DTO로 직접 조회, 플랫 데이터 최적화
// OrderApiController
@GetMapping("/api/v7/orders")
public List<OrderQueryDto> ordersV7() {
List<OrderFlatDto> flats = orderQueryRepository.findAllByDto_flat();
// flats의 중복 데이터를 직접 제거, 그리고 orderQueryDto로 변환
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());
}
// 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();
}
V7은 Order를 기준으로 모두 조인해서 한 방에 가져오는 방식입니다. 당연히 결과는 OrderItems 컬렉션의 사이즈만큼 뻥튀기되고 중복된 데이터가 포함될 수밖에 없습니다. DB에서 내려온 중복된 데이터를 애플리케이션에서 제거하는 방식입니다.
쿼리는 한 번 나가지만 조인으로 인해 DB에서 애플리케이션에 전달하는 데이터에 중복 데이터가 추가되므로 상황에 따라 V6 보다 더 느릴 수 있습니다. 심지어 중복된 데이터를 제거하는 애플리케이션의 추가 작업의 비용이 큽니다.
Order를 기준으로 데이터를 조회했지만, DB에서 애플리케이션으로 내려오는 결과는 OrderItem의 사이즈만큼 나오기 때문에 Order를 기준으로 페이징 기능을 추가할 수 없습니다. (OrderItem을 기준으로 페이징은 가능)
지금까지 V1부터 V7를 걸쳐서 컬렉션 데이터를 조회하는 경우의 API 성능을 개선해 보았습니다.
그럼 어떤 방식을 사용해야 할까요? 사실 V1 ~ V7는 각기 다른 장단점을 갖고 있기 때문에 상황에 맞춰 선택해야 합니다.
하지만 권장하는 순서는 있습니다.
V1 ~ V7 권장 순서
- 엔티티 조회 방식으로 우선 접근
- 페치 조인으로 쿼리 수 최적화
- 컬렉션 최적화
- 페이징 필요시 hibernate.default_batch_fetch_size 또는 @BatchSize로 최적화
- 페이징 필요 없다면 페치 조인 사용
- 엔티티 조회 방식으로 해결이 안 되면 DTO 조회 방식 사용
- DTO 조회 방식으로 해결이 안 되면 NativeSQL 또는 스프링 JdbcTemplate 사용
엔티티 조회 방식은 페치 조인이나, hibernate.default_batch_fetch_size 같이 코드를 거의 수정하지 않고 옵션만 약간 변경해서 다양한 성능 최적화를 시도할 수 있습니다.
반면에 DTO를 직접 조회하는 방식은 Native SQL을 사용하는 방식과 비슷하게 성능을 최적화하거나 성능 최적화 방식을 변경할 때 많은 코드를 변경해야 합니다.
사실 엔티티 조회 방식으로 해결이 안 될 정도의 트래픽이라면, Redis 또는 local memory와 같은 캐싱 기능을 고려해야 합니다. 이때 엔티티를 직접 캐싱하면 안 되고 엔티티를 DTO로 변환하여 DTO를 캐시해야 합니다. 엔티티는 영속성 컨텍스트에 의해 관리되고 상태를 유지하기 때문에 절대로 캐시에 올라가면 안 됩니다.
'Spring > API 성능 최적화' 카테고리의 다른 글
OSIV와 성능 최적화 (0) | 2024.10.12 |
---|---|
컬렉션 조회 API 성능 최적화 (V4. hibernate.default_batch_fetch_size) (0) | 2024.10.05 |
컬렉션 조회 API 성능 최적화 (V1 ~ V3. 컬렉션 페치 조인과 데이터 뻥튀기) (1) | 2024.10.01 |
xToOne 관계 조회 API 성능 최적화 (1) | 2024.09.29 |