Mini

[spring-jpa] 1:N 컬렉션 조회 최적화 본문

Java/JPA

[spring-jpa] 1:N 컬렉션 조회 최적화

Mini_96 2025. 3. 9. 20:02

* 문제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

  • 실행되는 쿼리 관찰

ToOne관계들 join 쿼리 1회
첫번째 order_item이 조회 1회
연관된 item이 2개여서 쿼리 2회

  • 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