오봉이와 함께하는 개발 블로그
JPA 2 - 주문 조회V3.1 : 엔티티를 DTO로 변환(페이징과 한계 돌파) 본문
주문 조회 V3.1: 엔티티를 DTO로 변환 - 페이징과 한계 돌파
- 컬렉션을 페치 조인하면 페이징이 불가능하다.
- 컬렉션을 페치 조인하면 일대다 조인이 발생하므로 데이터가 예측할 수 없이 증가한다.
- 일대다에서 일(1)을 기준으로 페이징을 하는 것이 목적인데, 데이터는 다(N)를 기준으로 row가 생성된다.
- Order를 기준으로 페이징을 하고 싶은데, 다(N)인 OrderItem을 조인하면 OrderItem이 기준이 된다.
- 이 경우 하이버네이트는 경고 로그를 남기고 모든 DB 데이터를 읽어서 메모리에서 페이징을 시도한다.
- 이는 최악의 경우 장애로 이어질 수 있다.
한계 돌파
그러면 페이징 + 컬렉션 엔티티를 함께 조회하려면 어떻게 해야할까?
지금부터 코드도 단순하고, 성능 최적화도 보장하는 매우 강력한 방법을 알아보자.
대부분의 페이징 + 컬렉션 엔티티 조회 문제는 이 방법으로 해결할 수 있다.
- 먼저 XToOne(OneToOne, ManyToOne) 관계를 모두 페치조인 한다.
- XToOne 관계는 row수를 증가시키지 않으므로 페이징 쿼리에 영향을 주지 않는다.
- 그러므로 필요하다면 페치 조인을 계속 걸어도 무방하다.
- XToOne 관계는 row수를 증가시키지 않으므로 페이징 쿼리에 영향을 주지 않는다.
- 컬렉션(OneToMany)은 지연 로딩으로 조회한다.
- 지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size, @BatchSize를 적용한다.
- hibernate.default_batch_fetch_size: 글로벌 설정
- @BatchSize: 개별 최적화
- 이 옵션을 사용하면 컬렉션이나, 프록시 객체를 한꺼번에 설정한 size 만큼 IN 쿼리로 조회한다.
OrderRepository에 추가
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();
}
OrderApiController에 추가
/**
* V3.1 엔티티를 조회해서 DTO로 변환 페이징 고려
*- XToOne 관계만 우선 모두 페치 조인으로 최적화
* - 컬렉션 관계는 hibernate.default_batch_fetch_size, @BatchSize로 최적화
* */
@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;
}
실행결과
- http://localhost:8080/api/v3.1/orders?offset=1&limit=100
위 쿼리를 보면 limit과 offset이 잘 적용되어 있는 것을 볼 수 있다.select order0_.order_id as order_id1_6_0_, member1_.member_id as member_i1_4_1_, delivery2_.delivery_id as delivery1_2_2_, order0_.delivery_id as delivery4_6_0_, order0_.member_id as member_i5_6_0_, order0_.order_date as order_da2_6_0_, order0_.status as status3_6_0_, member1_.city as city2_4_1_, member1_.street as street3_4_1_, member1_.zipcode as zipcode4_4_1_, member1_.name as name5_4_1_, delivery2_.city as city2_2_2_, delivery2_.street as street3_2_2_, delivery2_.zipcode as zipcode4_2_2_, delivery2_.status as status5_2_2_ from orders order0_ inner join member member1_ on order0_.member_id=member1_.member_id inner join delivery delivery2_ on order0_.delivery_id=delivery2_.delivery_id limit ? offset ?
[
{
"orderId": 11,
"name": "userB",
"orderDate": "2022-09-05T23:46:08.521757",
"orderStatus": "ORDER",
"address": {
"city": "진주",
"street": "2",
"zipcode": "2222"
},
"orderItems": [
{
"itemName": "SPRING1 BOOK",
"orderPrice": 20000,
"count": 3
},
{
"itemName": "SPRING2 BOOK",
"orderPrice": 40000,
"count": 4
}
]
}
]
결과도 마찬가지로 우리가 원하는 결과만 가져온다.
- 쿼리문 갯수(offset, limit 미 적용시)
- Order(Member, Delivery 페치 조인 포함) : 1개
- OrderItem : 2개 (Order에 OrderItem이 2개)
- Item : 4개 (OrderItem에 같은 PK로 ItemA, ItemB 주문으로 OrderItem의 row 1개당 Item row 2개)
- 쿼리문 갯수(offset=1, limit=100 적용시)
- Order(Member, Delivery 페치 조인 포함) : 1개
- OrderItem : 1개
- Item : 2개
최적화 옵션
application.yml
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
select
orderitems0_.order_id as order_id5_5_1_,
orderitems0_.order_item_id as order_it1_5_1_,
orderitems0_.order_item_id as order_it1_5_0_,
orderitems0_.count as count2_5_0_,
orderitems0_.item_id as item_id4_5_0_,
orderitems0_.order_id as order_id5_5_0_,
orderitems0_.order_price as order_pr3_5_0_
from
order_item orderitems0_
where
orderitems0_.order_id in (
?, ?
)
orderitems0_.order_id in
이라는 새로운 것이 생겼다.orderitems0_ where orderitems0_.order_id in (4, 11);
in 쿼리로 한 쿼리에 모든 데이터를 가져왔다. (Item도 마찬가지로 in 쿼리 사용)
default_batch_fetch_size: 100
에서 100
은 in 쿼리의 갯수를 최대 몇 개로 설정할 것인가에 대한 설정이다.
개별로 설정하려면 @BatchSize
를 적용하면 된다.(컬렉션은 컬렉션 필드에, 엔티티는 엔티티 클래스에 적용)
V3
은 쿼리 하나로 모두 조회가 되지만 V3.1
은 쿼리가 3개로 조회가 된다.
그럼 V3
의 방식으로 사용하는 것이 맞을까?
V3
는 쿼리 하나로 가져오는 장점이 있지만, 단점으로는 중복 데이터가 너무 많다는 점이다.
그래서 쿼리 자체는 하나지만 데이터를 전송하는 용량 자체는 많아지는 결과가 생긴다.
V3.1
는 쿼리를 V3
에 비해서 많이 날린다는 단점이 있지만, 최적화된 데이터만 나오기 때문에 데이터를 전송하는 용량 자체가 줄어드는 효과가 있다.
- 장점
- 쿼리 호출 수가 1+N -> 1+1로 최적화 된다.
- 조인보다 DB 데이터 전송량이 최적화 된다.
- Order와 OrderItem을 조인하면 Order가 OrderItem 만큼 중복해서 조회된다. 이 방법은 각각 조회하므로 전송해야할 중복 데이터가 없다.
- 페치 조인 방식과 비교해서 쿼리 호출 수가 약간 증가하지만, DB 데이터 전송량이 감소한다.
- 컬렉션 페치 조인은 페이징이 불가능 하지만 이 방법은 페이징이 가능하다.
- 결론
- XToOne 관계는 페치 조인해도 페이징에 영향을 주지 않는다.
- 따라서 XToOne 관계는 페치조인으로 쿼리 수를 줄이고 해결하고, 컬렉션 패치 조인은 hibernate.default_batch_fetch_size로 최적화 하자.
default_batch_fetch_size
의 크기는 적당한 사이즈를 골라야 하는데, 100 ~ 1000 사이를 선택하는 것을 권장한다. 이 전략을 SQL IN 절을 사용하는데, 데이터베이스에 따라 IN 절 파라미터를 1000으로 제한하기도 한다. 1000으로 잡으면 한번에 1000개를 DB에서 애플리케이션에 불러오므로 DB 에 순간 부하가 증가할 수 있다. 하지만 애플리케이션은 100이든 1000이든 결국 전체 데이터를 로딩해야 하므로 메모리 사용량이 같다. 1000으로 설정하는 것이 성능상 가장 좋지만, 결국 DB든 애플리케이션이든 순간 부하를 어디까지 견딜 수 있는지로 결정하면 된다. 잘 모르겠으면 100 ~ 500 사이로 두고 사용하자...
인프런 김영한 지식공유자님 강의 - 실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화
'BE > JPA' 카테고리의 다른 글
JPA 2 - 주문 조회V5 : JPA에서 DTO 직접 조회(컬렉션 조회 최적화) (0) | 2022.09.06 |
---|---|
JPA 2 - 주문 조회V4 : JPA에서 DTO 직접 조회 (0) | 2022.09.06 |
JPA 2 - 주문 조회V3 : 엔티티를 DTO로 변환(페치 조인 최적화) (0) | 2022.09.05 |
JPA 2 - 주문 조회V2 : 엔티티를 DTO로 변환 (0) | 2022.09.05 |
JPA 2 - 주문 조회 V1 : 엔티티 직접 노출 (0) | 2022.09.05 |