Spring Data JPA에서 FetchJoin 주의 사항

박준수·2024년 2월 14일

Archive

목록 보기
4/7

1. FetchJoin과 페이징

Pagination을 이용하여 Team을 조회하는 경우

  • 모든 Team의 이름과, 각 Team의 Member들의 이름까지 알아야 하는 경우 Pagination을 이용하여 조회를 한다고 가정하자.

  • N+1문제를 방지하기 위해 Fetch Join을 사용하였고, Pagealbe을 이용해 Page을 반환하게 하는 JPQL을 작성하였다.
  • Pageable객체를 Spring Data JPA Repository에 인자로 전달하면, 사용자가 페이징 쿼리(JPQL)을 만들지 않아도 Spring Data JPA가 파라미터로 받은 Pageble 객체로 페이징, 정렬 쿼리를 만들어 준다. → 그렇다면 Paging 처리를 위해 mysql에서 limit, offset 쿼리가 포함될 것을 예상할 수 있다.

  • [WARN] : firstResult/maxResults specified with collection fetch; applying in memory
  • 하지만 실제로는 페이징할 때 사용하는 limit 이 SQL에서 포함이 되어 있지 않는다. 또한 쿼리 결과를 전부 메모리에 적재한 뒤 어플리케이션 단에서 Pagination 작업을 수행한다는 경고 로그가 발생한다.
  • Team 엔티티가 3개가 있고 각각의 Team 엔티티와 연관된 Member가 7명 씩 존재한다고 가정을 해보면 1:N관계를 Join하면 총 21개의 DB Row가 조회된다. 조회되는 데이터의 수가 변경되기 때문에 단순하게 LIMIT 구문을 사용하는 쿼리로 페이지네이션을 적용하기 어렵다.
  • 쿼리 결과를 전부 메모리에 적재한 뒤 Pagination 작업을 어플리케이션 레벨에서 하기 때문에 위험하다는 로그이다. → 만약 Team이 10만 이고 각 Team당 연관된 Member도 10만이면 100만이 메모리에 적재된 뒤 Pagination 작업을 또 해야한다!

Pagination을 이용하여 Member을 조회하는 경우

  • MemberRepository에도 똑같은 상황으로 조회를 해보자

  • 이번에는 경고 문자도 없고, limit 도 sql 문에 포함이 되어있다.
  • Member와 Team는 N:1 관계이기 때문에 Join해도 조회되는 DB ROW의 수가 변경되지 않기 때문이다.

정리

  • 김영한 님이 말쓸하신바에 정리를 해보자면
  • ToOne 애너테이션을 통해 형성된 관계인 경우 테이블 조인에 따라 데이터 수가 변경되지 않으므로 페이징 처리가 잘된다.
  • ToMany 애너테이션을 통해 형성된 관계인 경우 테이블 조인에 따라 데이터가 변경되어 페이징 처리와 페치 조인이 동시에 불가능하다. → 되더라도 모든 데이트를 메모리에 불럿와서 페이징을 적용하기 때문에 OutOfMemoryError가 발생할 수 있어 매우 위험하다!

그렇다면 OneToMany인 Team에서 Pagination을 어떻게 적용할까?

  • 이전 글인 N+1 게시글에서 N+1문제를 해결하는 방법은 FetchJoin말고도 BatchSIze가 있다.
  • application.yml에 default_batch_fetch_size=10 으로 설정을 하고, Team의 개수가 100이라고 가정하자. → 원래 아무런 설정이 없었을 때는 1 + 100번의 조회 쿼리가 발생하지만 batchSize의 설정으로 인해 1 + 10으로 줄어든다.

  • application.yml에서 batch_size를 설정한 것은 전역적으로 한 것이고 일부반 적용하고 싶다면 @BatchSize를 사용하면 된다. team 엔티티에서 연관된 데이터를 가져올 List에 FetchType도 EAGER이어야 한다.

  • 쿼리에서는 join fetch를 삭제한다.

  • 다시 테스트를 돌려보면 먼저 teamId를 조회하고 그 팀 Id가 포함되어있는지 조회를 한다.

2. 연관관계에서 ‘1’인 엔티티에 FetchJoin을 2번 사용하는 경우

  • 팀 엔티티와 1대N 관계를 하나 더 만들기 위해 Sponsor 엔티티를 하나 더 만들어 보았다. 팀 엔티티에는 OneToMany 어노테이션을 이용해 양방향으로 맺어 주었다.

  • Sponsor 테이블에는 다음과 같이 데이터를 삽입해주었다.

Team을 조회하는데 팀의 Member와 Sponsor를 출력해야한다. 라고 가정을 해보면 Team 엔티티를 조회할 때 Fetch Join을 Member와 Sponsor 각각 하여 조회를 하면 될 것이라고 생각을 했다.

  • 그러나 다음 테스트를 실행해보면 MultipleBagFetchException 이 발생한다다.
  • ToMany에서 FetchJoin을 2번 사용해서 그렇습니다.
    • ToOne은 몇개든 사용 가능합니다
    • ToMany는 1개만 가능합니다.

그럼 ‘1’인 엔티티에서 ‘N’인 엔티티의 데이터를 어떻게 가져와야 할까요?

  • 저는 처음에 TeamsDto 레코드를 생성하고 Left Join을 두 번 사용하면 문제가 해결이 될 줄 알았다.

  • 그러나 테스트를 실행시켜 보면 다음과 같이 예외가 발생한다.
  • 쿼리는 한방으로 나가는데 문제는 Member의 개수와 Sponsor의 개수가 곱해져서 출력이 되는 것이다.
  • left join을 두 번 사용한다면, 첫 번째 left join 결과에 대해 두 번째 left join을 수행하게 됩니다. 이때 각각의 결과 행은 첫 번째 left join 결과 행과 두 번째 테이블 간의 카테시안 곱이 발생하게 되어서 전체 결과 행의 수가 다음과 같이 발생하게 되었다.

QueryDSL을 이용한 group_concat

  • 이번에는 QueryDSL을 이용하여 mysql의 group_concat을 사용하였다.
  • group_concat은 조건을 만족하는 결과값으로 합쳐져 있는 문자열로 받을 수가 있다.

  • 다음과 같이 쿼리가 발생하고 결과를 얻을 수 있다.
  • 이 방식 말고 BatchSize를 이용하여 쿼리를 나눠서 문제를 해결할 수 도 있다.

정리

  • 연관관계에서 ‘1’인 엔티티에 FetchJoin을 2번 사용하는 경우 예외가 발생하기에 QueryDSL과 Mysql의 group_concat 함수를 이용하여 하나의 쿼리로 문제를 해결 할 수 있다. 잘못된 정보는 알려주시면 감사하겠습니다^^

참고

JPA Pagination, 그리고 N + 1 문제

JPA에서 Fetch Join과 Pagination을 함께 사용할때 주의하자

JPA Fetch 조인(join)과 페이징(paging) 처리

fetch join + Paging (limit) 처리 시 발생하는 문제 및 해결

MultipleBagFetchException 발생시 해결 방법

profile
방구석개발자

0개의 댓글