[JPA] 조회API 성능최적화하기( XToMany ) (3) - DTO 직접조회
이번 포스팅은 xToMany 연관관계에서 조회API의 성능을 최적화하는 세번째 포스팅이다.
5. 필드 최적화 ( DTO 직접 조회 )
지난 포스팅에는 JOIN FETCH로 성능을 최적화 해보았다. 그런데 JOIN FETCH는 SELECT되는 필드가 너무 많다.
select
distinct o1_0.order_id,
d1_0.deliver_id,
d1_0.city,
d1_0.street,
d1_0.zipcode,
d1_0.status,
m1_0.member_id,
m1_0.city,
m1_0.street,
m1_0.zipcode,
m1_0.name,
o1_0.order_date,
o2_0.order_id,
o2_0.id,
o2_0.count,
i1_0.item_id,
i1_0.dtype,
i1_0.name,
i1_0.price,
i1_0.stock_quantity,
i1_0.artist,
i1_0.etc,
i1_0.author,
i1_0.isbn,
i1_0.actor,
i1_0.director,
o2_0.order_price,
o1_0.status
from
orders o1_0
join
member m1_0
on m1_0.member_id=o1_0.member_id
join
delivery d1_0
on d1_0.deliver_id=o1_0.delivery_id
join
order_item o2_0
on o1_0.order_id=o2_0.order_id
join
item i1_0
on i1_0.item_id=o2_0.item_id
위 쿼리는 JOIN FETCH 연산 사용시, DB에 실제로 수행되는 쿼리이다. 하나의 쿼리로 연관된 엔티티 모두를 메모리에 올리려다 보니 필드가 방대해진다. 필요한 필드만 가져오도록 쿼리를 최적화 할 수는 없을까?
JPQL은 엔티티뿐만 아니라 특정객체(DTO)를 SELECT문으로 조회하여 생성할 수 있다.
xToOne 용 SELECT문
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();
}
SELECT 되는 필드는 DTO의 필드이다.
DTO는 화면에 필요한 데이터만 가지고 있으므로, 필요한 데이터만 SELECT 할 수 있다.
이러면 JOIN FETCH 연산은 필요 없어진다. JOIN FETCH는 엔티티를 DTO로 변환하는 과정에서 연관엔티티를 참조하여 추가로 쿼리가 실행되는 것을 방지하려고 존재했다. 그런데 위 코드는 JPQL에서 이미 DTO가 생성되어 반환된다. DTO에 필요한 데이터만 일반JOIN 연산으로 전달하면 된다. 이렇게 하면 방대한 필드가 SELECT 되는 것을 막을 수 있다.
xToMany 용 SELECT문
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();
}
지금까지는 xToOne 연관관계일 때 SELECT문을 보았다. xToMany 연관관계는 DTO가 따로 존재한다. 그러므로 xToMany 연관관계용 SELECT문도 필요하다. xToOne DTO는 xToMany DTO와 일대다 연관관계이다. 그러므로 두 JPQL 모두 실행하여 xToOne DTO에 xToMany DTO 리스트를 세팅해야 한다.
Controller 조회 API
@GetMapping("api/v4/orders")
public List<OrderQueryDto> ordersV4(){
return orderQueryRepository.findOrderQueryDto();
}
Repository 조회 로직
public List<OrderQueryDto> findOrderQueryDto() {
List<OrderQueryDto> result = findOrders(); // xToOne 연관관계 조회
result.forEach((o->{
List<OrderItemQueryDto> orderItems = findOrderItems(o.getOrderId()); //xToMany 연관관계 조회
o.setOrderItems(orderItems); //xToOne DTO에 xToMany DTO 리스트 세팅
}));
return result;
}
xToOne 연관관계의 DTO는 findOrders 메소드로, xToMany 연관관계의 DTO는 findOrderItems 메소드로 조회한다. 실행되는 SELECT 쿼리의 개수는 N+1개이다.
1) xToOne용 쿼리 : 1개
2) xToMany용 쿼리 : N개
N+1개의 쿼리 수행은 성능에 좋지 못하다.
findOrderItems 메소드
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();
}
xToMany DTO를 조회할 때, WHERE 조건문으로 id를 하나씩 받기 때문에 N개의 쿼리가 실행된다. 만약 하나씩 받지 않고 IN절로 다량으로 받도록 변환한다면 쿼리의 개수를 하나로 줄일 수 있다.
6. 필드 최적화 ( DTO 직접 조회 + IN절 사용하기 )
개선된 findOrderItems 메소드
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) // IN절 사용
.setParameter("orderIds", orderIds)
.getResultList();
// 주문ID가 서로다른 DTO들을 주문ID를 기준으로 그룹화하기
Map<Long, List<OrderItemQueryDto>> orderItemMap = orderItems.stream()
.collect(groupingBy(orderItemQueryDto -> orderItemQueryDto.getId()));
return orderItemMap;
}
IN절을 사용하는 방법은 간단하다. WHERE 절에 IN절을 넣고 파라미터는 List로 받으면 된다. 이렇게 여러 번 쿼리를 수행하지 않고 한번에 필요한 리스트를 가져올 수 있게 되었다.
IN절을 사용하기 전에는 List에 주문ID(orderId)가 동일한 DTO만 존재했다. 그래서 xToOne DTO에 반환된 List를 바로 세팅하면 되었다. 그런데 이번에는 주문ID를 IN절로 한번에 조회했기 때문에 반환된 List에는 주문ID가 서로 다른 DTO들이 섞여있다. 그래서 주문ID를 기준으로 그룹화 필요성이 있다.
JAVA Stream은 리스트를 특정키를 기준으로 그룹화하는 API를 제공한다. DTO리스트를 Long 타입의 id를 기준으로 그룹화하여 Map으로 반환한다. Map의 key는 id이고 value는 List이다. id가 동일한 DTO는 같은 List로 묶여 있다. 이런 Map구조는 xToOne DTO에 세팅을 편리하게 만든다.
public List<OrderQueryDto> findAllByDto_optimization() {
List<OrderQueryDto> result = findOrders(); // xToOne DTO 리스트 조회
Map<Long, List<OrderItemQueryDto>> orderItemMap = findOrderItemMap(toOrderIds(result));//xToMany DTO 리스트 조회
result.forEach(o->o.setOrderItems(orderItemMap.get(o.getOrderId()))); // xToOne DTO에 xToMany DTO 세팅하기
return result;
}
xToOne DTO 리스트를 먼저 조회하고
xToMany DTO 리스트를 id를 key로 한 Map으로 조회하고
xToOneDTO에 xToMany DTO 리스트를 세팅하면 일대다 관계가 완성된다.
이로써, 쿼리는 N+1번에서 2번으로 줄어들었다. 성능이 크게 향상되었다.
정리하면, DTO를 JPQL에 직접 사용하면 화면에 최적화된 SELECT문을 구성할 수 있다.
그러나 여기에도 당연히 문제가 존재한다. 화면과 관련된 DTO가 데이터엑세스계층(Repository)까지 결합된다는 점이다. DTO는 특정화면의 스펙에 맞는 데이터를 전송하기 위한 객체이다. 특정화면에만 맞는 DTO가 데이터 엑세스 계층과 결합된다면 재사용성이 떨어진다. 다른 화면과 관련된 모듈은 해당 모듈를 사용할 수 없다.
사실, SELECT문의 성능을 결정짓는 요소는 JOIN연산이지 조회되는 필드의 수가 아니다. 필드가 20-30개가 넘어간다면 필드 최적화를 고려해야 하지만, 그렇지 않다면 JOIN-FETCH로 구현하여 코드의 재사용성을 높히는 것이 좋다. 만약 특정화면에 종속된 데이터엑세스계층의 모듈이 필요하다면 따로 디렉토리를 분리해야 한다. 공동으로 사용하는 Repository와 특정화면에 종속된 Repository를 구분해야 헷갈리지 않는다.
참고자료