Mini
[spring-jpa] 1:N 컬렉션 조회 최적화 본문
* 문제1
- order는 1개의 member를 가진다.
- order는 1개의 delivery를 가진다.
- order는 여러개의 orderItem을 가진다.
- item과 order는 다대다 관계이다. (중간테이블은 orderItem)
- 이 상태에서, order를 조회하는 api를 개발해보자.
public List<Order> findAllWithItem() {
return em.createQuery(
"select 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와 orderItem 만 집중해서 봐보자.
- 4개의 행이 반환된다.
- 로그를 찍어보면 다음과 같이 나온다.
public List<OrderDto> ordersV3() {
List<Order> orders = orderRepository.findAllWithItem();
for (Order order : orders) {
System.out.println("order ref = " + order + " id = " + order.getId());
}
- 우리가 원하는것은 중복이 제거된 order 2개만을 원하는것이다.
* 해결1
- distinct 키워드를 달아주면 어떨까?
- db에 distinct 쿼리가 나가는걸까?
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();
}
- 기대한대로 2개만 반환되었다.
- 쿼리를 찍어보자
- 예상과 달리 db에서는 행이 4개가 만들어 졌다.
- db의 distinct는 한줄한줄의 data가 전부 똑같아야 제거가 된다!
- 비밀은, jpa에서 order의 id가 같으면, 중복을 제거해주었다는 점이다.
- 즉, jpql의 distinct는 아래 2가지 역할을 한다.
- 1) db에 distinct 쿼리 보내기
- 2) 같은 id 같은 엔티티가 조회되면, 애플리케이션에서 중복을 제거
* 문제2
- 페이징 불가능
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)
.setFirstResult(1)
.setMaxResults(100)
.getResultList();
}
- 페이징로직을 추가하고 실제로 실행되는 쿼리를 살펴보자
- limit, offset이 없다 (???)
select
distinct order0_.order_id as order_id1_6_0_,
member1_.member_id as member_i1_4_1_,
delivery2_.delivery_id as delivery1_2_2_,
orderitems3_.order_item_id as order_it1_5_3_,
item4_.item_id as item_id2_3_4_,
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_,
orderitems3_.count as count2_5_3_,
orderitems3_.item_id as item_id4_5_3_,
orderitems3_.order_id as order_id5_5_3_,
orderitems3_.order_price as order_pr3_5_3_,
orderitems3_.order_id as order_id5_5_0__,
orderitems3_.order_item_id as order_it1_5_0__,
item4_.name as name3_3_4_,
item4_.price as price4_3_4_,
item4_.stock_quantity as stock_qu5_3_4_,
item4_.artist as artist6_3_4_,
item4_.etc as etc7_3_4_,
item4_.author as author8_3_4_,
item4_.isbn as isbn9_3_4_,
item4_.actor as actor10_3_4_,
item4_.director as directo11_3_4_,
item4_.dtype as dtype1_3_4_
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
inner join
order_item orderitems3_
on order0_.order_id=orderitems3_.order_id
inner join
item item4_
on orderitems3_.item_id=item4_.item_id
- 이유
- limit offset은 행의 앞을 생략하고 가져오는 것이다
- N인 order_item기준으로 data가 뻥튀기 되버렸기 때문에
- 아래의 행에서 limit, offset 명령어 만으로 중복을 제거하고 유니크한 order만 가져올수 있는 방법이 없다.
- 그래서 jpa는 db의 data를 통째로 애플리케이션으로 가져온후, 경고를 남기고 메모리에서 페이징 해버린다!
- 이는 ,Out of memory 의 원인이 된다.
2025-03-09 19:11:11.061 WARN 9556 --- [nio-8080-exec-1] o.h.h.internal.ast.QueryTranslatorImpl : HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!
- 데이터가 작은경우는 문제가 없지만,
- 주문 1개에 10000개 의 데이터가 있는경우만해도 장애로 이어질수 있다. (사용자가 N명이라면 10000*N개의 data)
* 해결2
- ToOne관계는 마음껏 페치조인
- ToMany 관계는 lazy 로딩
- dto로 변환과정에서 조회발생 -> 캐시에 값이없음 -> db에 쿼리 실행
@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(Collectors.toList());
return result;
}
- 문제 : N+1 문제 발생
* 문제3
- 실행되는 쿼리 관찰
- 2번째 order_item 조회1회, 연관된 item 2개여서 쿼리 2회
- 결과 : order 1번 조회 -> 1 + N(연관된 order_item 갯수) + M(연관된 item 갯수) 문제 발생!
- 페이징은 가능
* 해결 3
- batchSize 적용 (IN절)
jpa:
hibernate:
ddl-auto: create #none : table data 드랍하지마 => 영구사용 #create : 실행할때마다 테이블드랍후 새로만듬
properties:
hibernate:
#show_sql: true
format_sql: true
default_batch_fetch_size: 100
- 쿼리 실행 결과
- 결과
- 1+N+M 쿼리를
- 1+1+1 쿼리로 개선!
- 연관된 컬렉션을 조회시, IN절로 한번에 가져옴
- jpa에서 pk 기반으로 in 쿼리가 나가기때문에, 성능이 매우 좋음
* 한번에 페치조인 vs IN절 (batchSize)
- 페이징이 필요없으면 무조건 페치조인이 유리할까?
- 페치조인의 결과
- 필요없는 (중복) data가 많다.
- N쪽에 맞춰서 1의 data는 뻥튀기 되어있다.
- 두번째 order와 관련된 정보는 사실은 필요없는 값이다.
- IN절의 결과
- 이경우, 쿼리는 더 나가지만, 불필요한 data 가 없다.
- db에서 애플리케이션으로 중복없는 data가 전송된다.
* 결론
- ToOne관계는 페치조인으로, 컬렉션관계는 지연로딩 && batchSize를 적용
- data 전송량이 엄청 많은경우, IN절이 (쿼리횟수는 더 많더라도) 더 효율적일 수 있다.
- 페이징이 필요없는 상황에서도 페치조인이 항상 유리한것은 아니다.
'Java > JPA' 카테고리의 다른 글
[JPA] jpa 쓰는이유 // h2 database not found 해결, javax vs jakarta (0) | 2024.08.24 |
---|