1:N에서 1을 기준으로 페이징 하는게 목적인데 데이터는 N을 기준으로 row가 생성된다.
즉, order를 페이징하고 싶은데 orderItem이 기준이 되어버린다.
하이버네이트는 모든 DB 데이터를 읽어온 뒤 메모리에서 페이징하는 위험한 상황이 벌어질 수 있다.
해결 방법
OneToOne, ManyToOne 관계를 모두 fetch join 한다.
ToOne 관계는 row 수를 증가시키지 않으므로 페이징 쿼리에 영향을 주지 않는다.
ex) order의 member, delivery
컬렉션은 지연 로딩으로 조회한다.
fetch join은 사용하지 않는다.
ex) order의 orderItem
지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size와 @BatchSize를 적용한다.
컬렉션이나 프록시 객체를 설정한 size만큼 in 쿼리로 조회한다.
hibernate.default_batch_fetch_size
글로벌로 설정할 때 사용
@BatchSize
개별로 최적화 할 때 사용
before
@Repository
@RequiredArgsConstructor
public class OrderRepository {
public List<Order> findAllWithMemberDelivery(int offset, int limit) {
// ToOne 관계는 fetch join으로 가져온다.
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();
}
}
@RestController
@RequiredArgsConstructor
public class OrderApiController {
@GetMapping("/api/v3.1/orders")
public List<OrderDto> ordersV3_page() {
List<Order> orders = orderRepository.findAllWithMemberDelivery();
return orders.stream().map(OrderDto::new).collect(Collectors.toList());
}
}
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
select orderitems0_.order_id as order_id5_5_0_,
orderitems0_.order_item_id as order_it1_5_0_,
orderitems0_.order_item_id as order_it1_5_1_,
orderitems0_.count as count2_5_1_,
orderitems0_.item_id as item_id4_5_1_,
orderitems0_.order_id as order_id5_5_1_,
orderitems0_.order_price as order_pr3_5_1_
from order_item orderitems0_
where orderitems0_.order_id = ?
select item0_.item_id as item_id2_3_0_,
item0_.name as name3_3_0_,
item0_.price as price4_3_0_,
item0_.stock_quantity as stock_qu5_3_0_,
item0_.artist as artist6_3_0_,
item0_.etc as etc7_3_0_,
item0_.author as author8_3_0_,
item0_.isbn as isbn9_3_0_,
item0_.actor as actor10_3_0_,
item0_.director as directo11_3_0_,
item0_.dtype as dtype1_3_0_
from item item0_
where item0_.item_id = ?
select item0_.item_id as item_id2_3_0_,
item0_.name as name3_3_0_,
item0_.price as price4_3_0_,
item0_.stock_quantity as stock_qu5_3_0_,
item0_.artist as artist6_3_0_,
item0_.etc as etc7_3_0_,
item0_.author as author8_3_0_,
item0_.isbn as isbn9_3_0_,
item0_.actor as actor10_3_0_,
item0_.director as directo11_3_0_,
item0_.dtype as dtype1_3_0_
from item item0_
where item0_.item_id = ?
...반복
위 로그는 order 결과 하나 당 나가는 쿼리다.
order 조회 후 order_item을 쿼리한다.
order_item에 item이 2개 있으므로 다시 2번 쿼리 한다.
다른 order에 대해서도 똑같이 order_item 1번, item 2번 쿼리한다.
order가 100개라면 더 많은 쿼리가 나가게 될 것이다.
after
@RestController
@RequiredArgsConstructor
public class OrderApiController {
@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);
return orders.stream().map(OrderDto::new).collect(Collectors.toList());
}
}
@Repository
public class 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();
}
}
spring:
jpa:
properties:
hibernate:
# 미리 in 절로 땡겨 올 데이터 개수
default_batch_fetch_size: 1000
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 ?
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_
-- in 절로 땡겨온다.
where orderitems0_.order_id in (
?, ?
)
select item0_.item_id as item_id2_3_0_,
item0_.name as name3_3_0_,
item0_.price as price4_3_0_,
item0_.stock_quantity as stock_qu5_3_0_,
item0_.artist as artist6_3_0_,
item0_.etc as etc7_3_0_,
item0_.author as author8_3_0_,
item0_.isbn as isbn9_3_0_,
item0_.actor as actor10_3_0_,
item0_.director as directo11_3_0_,
item0_.dtype as dtype1_3_0_
from item item0_
-- in 절로 땡겨온다.
where item0_.item_id in (
?, ?
)
페이징이 잘 적용되었다.
default_batch_fetch_size
order 2개와 그 아래의 데이터를 가져오는 데 쿼리가 3개만 나갔다.
이전에는 order 마다 item 쿼리 2개씩 총 4개가 나갔는데 확 줄었다.
pk 기준으로 in 절을 날리기 때문에 쿼리 최적화로 빠르게 가져온다.
fetch_size를 100으로 정했는데 데이터가 1000개면 쿼리는 10개가 나간다.
비교
V3
진짜 한 방 쿼리로 모든 걸 가져온다.
컬렉션 때문에 중복 데이터가 많아져 부하 이슈가 있다.
V3.1
중복 없이 최적화 되어서 나온다.
데이터를 몇 천개씩 퍼올릴 때 사용하면 좋다.
장점
쿼리 호출 수가 1+N에서 1+1로 최적화된다.
fetch join과 비교해 쿼리 호출 수가 약간 증가하지만 중복이 제거되어 DB 데이터 전송량이 감소한다.
컬렉션 fetch join은 페이징이 불가능하지만 이 방법은 페이징이 가능하다.
ToOne 관계는 fetch join 해도 페이징에 영향을 주지 않는다.
따라서 ToOne 관계는 fetch join으로 쿼리 수를 줄이고, 나머지는 default_batch_fetch_size로 최적화 한다.