컬렉션 FetchJoin의 문제점과 페이징 한계 돌파(@BetchSize)

김건우·2022년 12월 4일
1

Spring Data JPA

목록 보기
6/11
post-thumbnail

다양한 연관관계의 Fetch join

우선 앞에서 다양한 연관관계에 맞게 쿼리의 N+1문제등을 해결하기위해 Fetch Join을 학습하였다. 하지만 주문(Order) 엔티티를 조회할 때 문제가 발생한다.우선 Order는 밑의 그림과 보는바와 같이 연관관계를 갖고있다

모든 관계는 지연로딩(LAZY)

  • 회원(Member)와 다 : 1 관계
  • 배송(Delivery)와 다 : 1 관계
  • 주문상품(OrderItems)와 1 : 다 관계 <- 이 관계에서 Feth join의 문제가 발생

순수 JPA의 Fetch Join을 사용하면 다음과 같은 코드가 나온다.
아래의 코드는 모든 관계를 Fetch Join으로 조회한다.(컬렉션 관계마저 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" + // <- 1 : 다 관계
 " join fetch oi.item i", // 주문상품과 연관된 상품 엔티티 조회
 Order.class)
 .getResultList();
}

keypoint (distinct)

DB의 SQL의 문법에서 distinct는 전체 row가 모두 똑같아야 중복이 제거가 되지만 JPA의 distinct는 pk(식별자 , id값)가 같다면 중복을제거해준다.

위의 코드에서 distinct를 해준 이유는 만약 Order가 2개가 존재하고 1 : 다 관계인 OrderItems가 4개가 존재한다면 1:다 조인이 사용됨으로써 데이터베이스의 row가 증가하고, 그 결과 같은 Order의 조회 수도 증가하게된다. Order가 2개이지만 OrderItems의 개수만큼 Order가 select된다는 말이다(이해가 안되면 데이터 뻥튀기💥 라고 생각하자). 그래서 JPA의 distinct는 SQL에 distinct를 추가해주고 거기에 더해서 같은 엔티티가 조회가 된다면 중복을 걸러주는 강력한 기능을 갖고있다. 하지만 이 코드는 데이터 뻥튀기를 distinct로 해결해 주기는하지만 아주 큰 문제점이 존재한다. 위의 코드와 같이 1:다 관계에 페치 조인을 걸어준다면, 단점으로는 페이징이 불가능하다. 1:다 관계를 Fetch join하는 순간 페이징 자체가 불가능이다. 그 이유는 하이버네이트가 모든 데이터를 DB에서 읽어오고, 메모리에서 페이징을 해버리기 때문에 매우 위험하기 때문이다.
또한 컬렉션 페치 조인은 단 1개만 사용할 수 있다. 그 이유는 컬렉션을 둘 이상 페치 조인을 한다면 데이터가 부정합하게 조회될 수 있기 때문이다.

keypoint (1:다 관계의 FetchJoin은 1개만)

1:다 관계의 fetch Join을 불가피하게 해야할때는 1개만 하자!(컬렉션 FetchJoin은 1개만) 그 이유는 1:다 관계를 2개 이상 사용한다면 데이터가 1:다의 다가 되는것이다 -> 1: N*N 이 되어서 데이터가 뻥튀기된다.

컬렉션 페치 조인의 페이징과 한계돌파

  • 컬렉션을 페치 조인하면 일대다 조인이 발생하므로 데이터가 예측할 수 없이 증가한다
  • 일다대에서 일(1)을 기준으로 페이징을 하는 것이 목적이다. 그런데 데이터는 다(N)를 기준으로 row가 생성된다
  • Order를 기준으로 페이징 하고 싶은데, 다(N)인 OrderItem을 조인하면 OrderItem이 기준이되어버린다

이러한 앞에서 말한 단점 2개 컬렉션을 Fetch join할 때 아래와 같은 단점이 존재한다 🔽

단점

  1. 페이징 처리
  2. 컬렉션 엔티티의 데이터 중복 조회
    문제를 해결하려면 어떻게 해야할까? 물론 방법이 존재한다. 코드도 단순하며 성능최적화도 보장하는 매우 강력한 방법이 존재한다. 먼저 과정은 다음과 같다.

1. xxxToOne(OneToOne, ManyToOne) 관계를 모두 페치조인 한다.

xxxToOne관계는 모두 Fetch Join하는 이유는 ToOne관계는 row수를 증가시키지 않기 때문이다. 그리고 컬렉션은 지금 단계에서는 지연로딩으로 조회하는 것이다.
다음의 코드는 페이징 처리가 들어가는 순수 jpa를 사용한 repository의 Order 조회 메소드이다.

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();
}

한계돌파를 하기 전의 코드와의 차이점은 컬렉션 fetchJoin을 모두 빼버리고 xxxToOne관계만 fetchJoin을 걸어주었다. 그리고 offset과 limit을 사용하여 페이징 처리가 들어갔다.

2. 컬렉션은 지연로딩으로 설정 -> Betch_fetch_size를 사용하자

컬렉션 페치조인에서 한계돌파에서 가장중요한 hibernate.default_batch_fetch_size: (글로벌 설정) / @BatchSize (디테일 설정)

앞에서 컬렉션은 지연로딩으로 조회한다고 했었다. 그 이유는 지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size(글로벌 최적화) 나 @BatchSize(개별 최적화) 를 적용한다.

글로벌 최적화 설정 (batch_fetch_size)

application.yml에 hibernate.default_batch_fetch_size 를 넣어주자!

jpa: 
    hibernate: 
      default_batch_fetch_size: 100

이 위의 코드가 의미하는 바는 컬렉션이나 프록시 객체를 설정한 size만큼 in 쿼리로 조회한다 우선 default_batch_fetch_size: n 는 IN쿼리의 숫자를 몇 개로 할것이냐 라는 뜻이다. 그래서 100으로 지정하면 IN쿼리가 100개가 나가는 것이다.

개별 최적화 설정(@BatchSize(size=100))

 @BatchSize(size = 100)
    @OneToMany(mappedBy = "order",cascade = ALL)
    private List<OrderItem> orderItems = new ArrayList<>();

성능최적화 이후 동작하는 쿼리

  • 컬렉션 관계는 Batch_size로 IN절로 SIZE만큼 한꺼번에 몰아서 가져온다
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 (
                         ?, ?
    )

이전에 컬렉션까지 fetchJoin해서 하면 쿼리가 1번만 나가지만 그것보다 select쿼리가 3번이 나간다. 하지만 정말 성능이 떨어진것일까? 모두 fetchJoin을 하면 어떤 문제가 발생하냐면,
1. 데이터 뻥튀기(데이터 중복)
2. 쿼리는 1번에 나가지만 데이터 전송 자체가 굉장히 많아진다.(용량 증가)

✅ 페이징 한계 돌파의 장점

  • 쿼리 호출 수가 1+N에서 1+1로 최적화된다.(데이터 중복이 없어진다.)
  • fetch join과 비교해 쿼리 호출 수가 약간 증가하지만 중복이 제거되어 DB 데이터 전송량이 감소한다.
  • 컬렉션 fetch join은 페이징이 불가능하지만 이 방법은 페이징이 가능하다.
  • ToOne 관계는 fetch join 해도 페이징에 영향을 주지 않는다.
  • 따라서 ToOne 관계는 fetch join으로 쿼리 수를 줄이고, 컬렉션(ToMany)는 default_batch_fetch_size로 최적화 한다.

SIZE를 정하는 TIP

default_batch_fetch_size 의 크기는 적당한 사이즈를 골라야 하는데, 100~1000 사이를선택하는 것을 권장한다. 이 전략을 SQL IN 절을 사용하는데, 데이터베이스에 따라 IN 절 파라미터를1000으로 제한하기도 한다. 1000으로 잡으면 한번에 1000개를 DB에서 애플리케이션에 불러오므로 DB에 순간 부하가 증가할 수 있다. 하지만 애플리케이션은 100이든 1000이든 결국 전체 데이터를 로딩해야하므로 메모리 사용량이 같다. 1000으로 설정하는 것이 성능상 가장 좋지만, 결국 DB든 애플리케이션이든 순간 부하를 어디까지 견딜 수 있는지로 결정하면 된다.

profile
Live the moment for the moment.

0개의 댓글