[Spring Boot/JPA] JPQL : ToMany 관계의 컬렉션 조회 최적화
앞선 포스팅에 이어서 이번 포스팅에서는 JPA를 이용하여 To Many 관계의 컬렉션을 조회할 때의 최적화 방법에 대해서 다루어 보려고 한다.
컬렉션 조회
{
"orderId": 4,
"name": "userA",
"orderItems": [
{
"orderId": 4,
"itemName": "JPA1 BOOK",
"orderPrice": 10000,
"count": 1
},
{
"orderId": 4,
"itemName": "JPA2 BOOK",
"orderPrice": 20000,
"count": 2
}
]
}
컬렉션을 조회한다는 것은 다음 JSON과 같이 엔티티가 1:N 관계를 가질 때, 연관된 N (Collection) 을 함께 가져오는 것을 의미한다.
컬렉션에서의 Fetch Join
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();
}
다음 쿼리문은 Order와 1:N으로 연관된 OrderItems 컬렉션을 Fetch Join으로 함께 가져오는 방식을 사용하고 있다.
distinct를 붙인 이유는 하기에서 언급할 데이터 뻥튀기를 방지하기 위함인데, Hibernate 6.0부터는 자동 적용되어 스프링 3점대 버전을 사용한다면 굳이 넣어주지 않아도 된다.
컬렉션 Fetch Join을 사용할 때에는 특징과 주의할 점이 있다.
1. 데이터 뻥튀기(Data Explosion)
- 컬렉션 Fetch Join은 연관된 엔터티의 컬렉션을 함께 로드하기 때문에, 데이터 뻥튀기 현상이 발생할 수 있다.
- 컬렉션을 페치 조인하면 일대다 조인이 발생하므로 데이터가 예측할 수 없이 증가한다.
- 예를 들어, 부모 엔터티가 N개의 자식 엔터티를 가지고 있다면, 결과 집합에는 N번 중복된 데이터가 포함될 수 있다.
2. 페이징 불가능
- 컬렉션 Fetch Join을 사용할 경우 페이징이 불가능하다.
- Hibernate는 모든 데이터를 DB에서 읽어오고 중복된 데이터를 포함시켜서 메모리에서 페이징 처리한다(!!) -> 장애로 이어질수도
- 1:N 관계에서 "1"을 기준으로 페이징을 하는 것이 목적이다. 그런데 데이터는 "N"를 기준으로 row가 생성된다.
- 즉, Order를 기준으로 페이징 처리하고 싶어도, OrderItem에 따라 페이징되는 현상이 일어날 수 있다.
3. 하나의 컬렉션 Fetch Join만 가능
- JPA에서는 하나의 컬렉션 Fetch Join만 사용할 수 있다. 즉, 여러 개의 컬렉션에 대해 Fetch Join을 사용하는 것은 지원되지 않는다
- 여러 개의 컬렉션에 대해 Fetch Join을 사용하면 예측할 수 없는 결과가 발생할 수 있다.
- 이는 JPA의 설계 전략에 의한 것으로 사용자에게 예측 가능하고 일관된 동작을 제공하려는 목적이다.
컬렉션 Fetch Join은 특히 작은 규모의 데이터셋이거나 페이징이 필요 없는 경우에 유용할 수 있지만, 대용량 데이터셋이나 페이징이 필요한 경우에는 다른 전략을 고려하는 것이 좋다.
엔티티 조회 방식 최적화 방법
ToOne 관계는 Fetch Join / ToMany 관계는 Lazy Loading + BatchSize
@GetMapping("/api/v3.1/orders")
public List<OrderDto> ordersV3_Paging(
@RequestParam(value = "offset", defaultValue = "0") int offset,
@RequestParam(value = "limit", defaultValue = "100") int limit
){
List<Order> orders = orderRepository.findAllWithItem(offset, limit);
List<OrderDto> result = orders.stream()
.map(o -> new OrderDto(o))
.collect(Collectors.toList());
return result;
}
public List<Order> findAllWithItem(int offset, int limit) {
return em.createQuery("select distinct o from Order o " +
" join fetch o.member m" +
" join fetch o.delivery d", Order.class) //Lazy Loading으로 필요 시 OrderItem도 조회됨
.setFirstResult(offset)
.setMaxResults(limit)
.getResultList();
}
- ToOne 관계는 모두 Fetch Join : ToOne 관계는 Row를 증가시키지 않으므로 페이징 쿼리에 영향을 주지 않는다.
- 컬렉션은 지연 로딩으로 조회 : 컬렉션을 나중에 필요할 때 불러오므로 페이징 처리가 가능해진다!
- 지연 로딩 최적화 : Hibernate의 BetchSize를 설정하여 지연 로딩을 최적화시킨다.
BetchSize 조절
Batch Size는 한 번의 쿼리에서 가져오는 엔티티의 수이다.
예를 들어, Batch Size가 10으로 설정되어 있다면, Hibernate는 데이터베이스에서 10개의 엔티티를 한 번에 가져오려고 시도한다.
JPA는 컬렉션이나, 프록시 객체를 한꺼번에 설정한 size 만큼 IN 쿼리로 조회하는 과정을 수행하게 된다.
따라서 지연 로딩 시 쿼리의 개수가 1+N -> 1+1가 된다. 다수의 엔티티를 단일 쿼리로 가져오기 때문에 데이터베이스와의 통신을 줄이고 성능을 최적화할 수 있다.
jpa:
hibernate:
ddl-auto: update
properties:
hibernate:
#...
default_batch_fetch_size: 100
글로벌 설정 : jpa.properties.hibernate.default_batch_fetch_size 프로퍼티를 조절한다.
개별 설정 : @BatchSize 어노테이션을 사용한다.
DTO로 직접 조회 방식
@Data
public class OrderQueryDto {
// Constructor
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
private List<OrderItemQueryDto> orderItems;
}
@Data
public class OrderItemQueryDto {
// Constructor
private Long orderId;
private String itemName;
private int orderPrice;
private int count;
}
public List<OrderQueryDto> findOrderQueryDtos(){
List<OrderQueryDto> result = findOrders();
result.forEach(o -> {
List<OrderItemQueryDto> orderItems = findOrderItems(o.getOrderId());
o.setOrderItems(orderItems);
});
return result;
}
private List<OrderQueryDto> findOrders(){
return em.createQuery(
"select new jpabook.jpashop.dto.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();
}
private List<OrderItemQueryDto> findOrderItems(Long orderId) {
return em.createQuery(
"select new jpabook.jpashop.dto.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();
}
Query : 루트 1번, 컬렉션 N 번 실행 ToOne(N:1, 1:1) 관계들을 먼저 조회하고, ToMany(1:N) 관계는 각각 별도로 처리한다.
ToOne 관계는 Join해도 데이터 row 수가 증가하지 않는다. Join 최적화를 하기 쉬우므로 한번에 조회한다.
ToMany 관계는 Join 시 Row 수가 증가한다. 따라서 별도로 각각 조회한다.
해당 방식은 한 개의 엔티티 조회 시 1 + N(컬렉션의 길이) 만큼의 쿼리가 수행되게 된다.
DTO 조회 방식 성능향상
public List<OrderQueryDto> findAllByDtoOptimization() {
List<OrderQueryDto> result = findOrders();
List<Long> orderIds = result.stream()
.map(o -> o.getOrderId())
.collect(Collectors.toList());
List<OrderItemQueryDto> orderItems = em.createQuery(
"select new jpabook.jpashop.dto.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();
// OrderId와 Item List를 그룹으로 매칭시킴
Map<Long, List<OrderItemQueryDto>> orderItemMap = orderItems.stream()
.collect(Collectors.groupingBy(OrderItemQueryDto -> OrderItemQueryDto.getOrderId()));
//Order 마다 Map의 Item을 추가시킴
result.forEach(o -> o.setOrderItems(orderItemMap.get(o.getOrderId())));
return result;
}
In Query의 사용 : 쿼리 수를 줄이기 위함이다. 부모 컬렉션 1번의 쿼리와, 자식(N) 컬렉션 쿼리, 총 두번의 쿼리가 수행된다.
- 먼저 Order를 조회한 후, OrderID List를 만들어둔다.
- OrderItem을 조회할 때, IN 키워드를 사용하여 OrderID List에 해당하는 데이터들을 조회한다.
- 코드단에서 스트림을 이용하여 Order와 OrderItem을 그룹 매칭시킨다.
여러개의 Order를 한꺼번에 조회하는 경우에는 해당 방법이 유효할 수 있다.
예를 들어서 조회한 Order 데이터가 1000건인데 쿼리가 총 1 + 1000번 실행된다.
해당 방식으로 최적화 하면 쿼리가 총 1 + 1번만 실행된다.
그렇지만 해당 방식은 코드의 복잡도를 증가시키게 되므로 단일 조회 시에는 이전 DTO 조회 방식을 사용해보고, 리스트 조회 시 성능향상이 필요하다면 해당 방식을 사용하면 좋을 것 같다.
Flat한 형식의 DTO 조회 후 코드단에서 처리
@Data
public class OrderFlatDto {
//Constructor
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 List<OrderFlatDto> findAllByDtoFlat() {
return em.createQuery(
"select new jpabook.jpashop.dto.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();
}
해당 방식은 아래와 같은 Flat한 포멧의 형식으로 JPA에서 데이터를 가져온 후에 코드 메모리 단에서 후가공하는 방법이다.
Join을 사용하여 컬렉션까지 함께 조회한다.
[
{
"orderId": 4,
"name": "userA",
"orderDate": "2024-01-07T09:51:47.82874",
"orderStatus": "ORDER",
"itemName": "JPA1 BOOK",
"orderPrice": 10000,
"count": 1
},
{
"orderId": 4,
"name": "userA",
"orderDate": "2024-01-07T09:51:47.82874",
"orderStatus": "ORDER",
"itemName": "JPA2 BOOK",
"orderPrice": 20000,
"count": 2
},
]
코드 메모리 단에서 처리
@GetMapping("/api/v6/orders")
public List<OrderQueryDto> OrdersV6(){
List<OrderFlatDto> flats = orderQueryRepository.findAllByDtoFlat();
//애플리케이션 메모리단에서 중복을 제거
Map<Long, List<OrderItemQueryDto>> orderItemMap = new HashMap<>();
Map<Long, OrderQueryDto> orderMap = new HashMap<>();
flats.forEach(flat -> {
Long orderId = flat.getOrderId();
if(orderMap.get(orderId) == null){
orderMap.put(orderId,new OrderQueryDto(orderId, flat.getName(), flat.getOrderDate(), flat.getOrderStatus(), flat.getAddress()));
}
if(orderItemMap.get(orderId) == null){
orderItemMap.put(orderId, new ArrayList<OrderItemQueryDto>());
}
orderItemMap.get(orderId).add(new OrderItemQueryDto(orderId, flat.getItemName(), flat.getOrderPrice(), flat.getCount()));
});
orderItemMap.forEach((orderId, orderItem)->{
orderMap.get(orderId).setOrderItems(orderItem);
});
List<OrderQueryDto> result = new ArrayList<>(orderMap.values());
return result;
}
Flat한 데이터 포멧을 메모리 단에서 처리한다.
장점은 쿼리가 1번만 수행된다는 점이다.
그렇지만 단점은 DB에서 애플리케이션에 전달하는 데이터에 중복 데이터가 추가되므로 애플리케이션의 부담이 증가한다.
상황에 따라 성능을 향상시킬 수도 있지만 오히려 성능이 안좋아 질 수도 있다.
따라서 해당 방식은 DB 부하 vs 애플리케이션의 부하 사이에서의 고려를 했을 때 사용할 수 있는 방법이다.
또한 페이징 처리도 불가능하다. (데이터 뻥튀기)
정리
일반적인 경우 성능 최적화에 따라 코드의 복잡성이 증가하게 된다.
엔티티 조회 방식은 JPA가 많은 부분을 최적화 해주기 때문에, 단순한 코드를 유지하면서, 성능을 최적화 할 수 있다.
DTO 조회 방식은 엔터티 조회 방식 이상으로 성능을 최적화할 수 있지만 코드의 복잡성을 증가시킨다.
두 방식에는 Trade-off가 있으며, 최적화와 코드 복잡성 사이에서의 선택을 해야 한다.
JPA를 사용하여 데이터베이스를 조회할 때 다음의 의사결정 과정을 따라보자.
1. 엔티티 조회 방식 : 우선적으로는 엔티티 조회 방식으로 접근
2. To One 관계 : Fetch Join으로 쿼리 수 최적화
3. To Many 관계 : 컬렉션 최적화
- 페이징이 필요 없는 경우 : Fetch Join (+ distinct)
- 페이징이 필요한 경우 : Lazy Loading + BetchSize 최적화
4. DTO 조회 방식 : 엔티티 조회 방식이 느릴 경우 사용
- 단일 부모 + 연관 컬렉션 조회 시 : ToOne(N:1, 1:1) 관계들을 먼저 조회하고, ToMany(1:N) 관계는 각각 별도로 처리
- 부모 + 연관 컬렉션 리스트 조회 : 필요 시 In Query 사용을 통한 최적화
5. DTO 조회 방식으로 해결이 안되면 Native 방식 사용
Reference : 인프런 김영한님 : API 개발 고급 - 컬렉션 조회 최적화
'Backend > Spring' 카테고리의 다른 글
[Spring Boot] 사용자 정의 예외처리 : Exception Handler (0) | 2024.01.15 |
---|---|
[Spring Boot/JPA] Spring data JPA : JpaRepository 사용 (1) | 2024.01.15 |
[Spring Boot/JPA] JPQL : 지연 로딩과 N+1 문제 해결 (1) | 2024.01.08 |
[Spring Boot] DI/IoC의 개념, Bean 등록 및 의존성 주입 방법들 (3) | 2023.12.02 |
[SpringBoot] Multipart 파일 Upload & Download (0) | 2023.09.04 |