이론 정리/Spring boot

컬렉션 페치조인과 페이징 하기(feat. batch size)

철매존 2022. 6. 18. 01:25
728x90

디프만 프로젝트에서 댓글 관련 API중 하나를 담당했는데, 해당 내용은

어떤 맥주를 통해 이곳에 작성된 모든 기록들을 받아오고, 동시에 저장된 맛(최대 3개)를 가져와 보여준다. 그리고 여기 페이징을 적용한다.

였다.

즉 이 경우 기록 -> 맛을 가져올 때 일대다 조인이 생기게 된다.

  1. 맥주를 통해 기록을 가져옴(여기서 N개의 기록을 가져옴)
  2. 해당 기록에 있는 맛태그를 가져옴(최대 3개의 맛태그)
  3. 맛태그를 통해 맛을 가져옴(다대일)

2, 3번의 로직을 수행할 때에 문제가 여러개 생기게 된다.

  • 먼저 일대다 조인의 경우 페치 조인만으로 페이징을 할 수 없다.
    • 알다시피 일대다 조인을 페치조인하면 동일한 데이터가 여러 번 출력되어 데이터가 뻥튀기된다!!
  • 그리고 비효율적인 쿼리가 생성되게 된다.
    • 하나의 기록마다 맛을 가져오기 위해 최대 3번까지 select를 진행한다.

이 문제를 해결하기 위해 hibernate.default_batch_fetch_size를 사용했다.

사용 방법

먼저 xToOne은 기본 fetch Type이 EAGER인데, 이를 LAZY로 설정해 준다.

다음으로 yml에 hibernate.default_batch_fetch_size를 1000으로 설정했다.

  • size의 경우 100, 1000 등등 알아서 설정하면 되는데, 1000이 가장 자주 쓰이고 이것이 문제가 적을 것 같다고 판단했다.
    • record를 통해 한번에 여러 개의 값들을 가져올 때에 100이면 혹시나 중간에 데이터가 걸릴 수 있어서였다.

그리고 데이터를 실제로 사용할 때에 페이징의 기준이 되는 엔티티만 조회해 준다.

동작 확인해보기

먼저 기록의 데이터들을 가져오게 된다.

다음으로 해당 기록에 있는 맛태그를 가져오는데, 많은 맛태그가 있다고 해도 이를 한꺼번에 in으로 가져온다.

이제 그곳에서 다시 연결되어 있는 맛을 in으로 가져오게 된다.

페이징 문제 해결하기

먼저 일대다 조인의 페이징 문제 해결 방법이다.

기록A - 맛태그1 - 쓴맛
기록A - 맛태그2 - 단맛
기록A - 맛태그4 - 달콤한맛

이 경우 나는 기록A를 기준으로 페이징해야 하는데 실제로는 3개의 데이터가 존재한다.

이렇게 동일한 데이터에 관해 여러 값들이 나오는 경우 해결해야 하는데, DISTINCT를 통해서 엔티티 중복을 해결할 수도 있기는 하다.

나는 DISTINCT가 아니라 위의 batch_fetch_size를 설정해 줌으로서 해결하였다.

다시 위의 동작 확인으로 돌아가 보면, 처음의 select문에서 record를 가져온 것을 확인할 수 있다.

그리고 이 결과를 List에 저장하고, 관련된 엔티티들은 프록시로 존재하기 때문에(LAZY설정으로 인하여) 이를 조회해주면 알아서 초기화하여 가져올 것이다. -> 그것에 2번째와 3번째 쿼리이다.

이를 그리고 이제 페이징의 경우 처음에 가져온, 페이징의 기준이 되는 기록을 통해 진행하면 된다. 왜냐면 최초 실행되는 쿼리는 이 기준이 되는 녀석만 사용하기 때문이다!!!

비효율적인 쿼리 해결하기

다음으로 일대다 조인에서 발생하는 비효율적인 쿼리 해결 방안이다.

DISTINCT를 사용하지 않는 이유

내가 여기서 DISTINCT를 사용하지 않은 이유가 바로 이 비효율적인 쿼리 때문이다.

페치 조인에서의 DISTINCT의 사용은 다음과 같은 기능을 제공한다.

  • SQL에 DISTINCT추가
  • 애플리케이션 내에서 엔티티 중복 제거

여기서 저 SQL에 DISTINCT를 추가하는 것으로 과연

기록A - 맛태그1 - 쓴맛
기록A - 맛태그2 - 단맛
기록A - 맛태그4 - 달콤한맛

해당 내용이 삭제될까?
그렇지 않을 것이다. 왜냐하면 기록A는 겹치지만 뒤의 내용들은 동일하지 않기 때문이다.

그렇기 때문에 애플리케이션 내에서 엔티티의 중복을 제거시키는 프로세스가 이루어지게 되는데, 이는 실제로 DB의 양을 줄이는것도 아니고 애플리케이션에 추가적인 부하를 주게된다.

위의 내용을 검색하려면

  1. record를 가져온다
  2. 해당 record에 있는 맛 태그를 하나하나 검색한다. 이는 LAZY로 되어있기 때문에 필요할 때 마다 하나씩 맛 태그들을 초기화하기 때문이다.
  3. 이 맛 태그에 해당하는 맛들을 다시 하나하나 검색한다.

보면 1개의 record(기록)을 기준으로 볼 때

1 + N(3) + 1

이게 만약 N개의 기록을 가져오게 되면 위의 과정이 전체의 N에 대해 수행되어 굉장히 시간을 많이 소요하게 될 것이다.


이 문제도 마찬가지로 해결 가능하다.

다시 위의 batch_fetch_size 적용의 동작 결과로 돌아가 보면

  1. 기록의 데이터들을 가져와준다.
  2. 그 기록의 데이터들에 대해 해당하는 맛태그를 in을 사용해 다 가져온다.
  3. 그 맛태그의 맛을 다시 in을 사용해 가져와준다.

이렇게 되면 1개의 record(기록)을 기준으로 볼 때

1 + 1 + 1

의 쿼리가 실행되게 된다.
그런데 위의 과정과 비교했을때 그리 크게 차이가 나지 않는것 같은데? 라고 여길수 있는데 N개의 기록을 가져올 때에 큰 차이가 발생한다.

왜냐면 N개의 기록을 가져올 때에 여기서는 어차피 in으로 들고오면 되므로 1000개의 설정 내용에 대해서는 N의 크기와 관계없이 1+1+1 쿼리로 해결되기 때문이다!!

결론

이렇게 batch_fetch_size를 통해서 컬렉션 페치 조회의 페이징 + 성능 개선을 진행하였다.

@BatchSize를 통해 하나씩 엔티티에 적용할까 생각해 보기도 하였지만, 확장성을 고려해 전체 파일에 적용하였다.

이 장점을 나열하면 이와 같다.

  • 쿼리 호출 수를 1+N에서 1+1로 최적화 해줄 수 있다.
  • join에 비해 DB데이터 전송량이 최적화된다.
    • 이는 in을 통해 하나씩 조회하기 때문이다.
      • 따라서 기존의 페치 조인 방식과 비교하면 쿼리의 호출 수 자체는 증가하지만 DB데이터 전송량이 감소한다는 장점이 있다.
        • 여기서는 쿼리의 호출 수 또한 감소시킬 수 있었다.
  • 컬렉션 조회에서도 페이징이 가능해졌다.

그리고 주의해야 할 점은 너무 적은 batch size를 설정하면 이걸 해주는 의미가 없고, 또 너무 커지면 DB가 오류가 날수도 있다고 한다.
따라서 부하가 없는 선에서 100 ~ 1000 사이에서 진행해주면 될것같다.