용로그
article thumbnail

JPA를 "잘" 사용하려고 Query를 개선하다 보면 한 번쯤은 만날 수밖에 없는 문제가 있습니다. 바로 MultipleBagFetchException인데요. 해당 문제는 2개 이상의 xToMany 관계에 대해서 Fetch Join을 사용할 때 발생합니다.

 

JPA fetch join 특징은 다음과 같습니다.

  • OneToMany, ManyToMany : 하나의 fetch join만 사용 가능(Set을 사용하는 경우 제외)
  • ManyToOne, OneToOne : 여러개의 fetch join 사용 가능

그리고 해결할 방법들도 당연히 존재하는데요, 아래 문제는 당연하게도 fetch type(EAGER, LAZY)로 인한 N+1 문제를 해결하기 위해 발생한 문제이기 때문에 아래와 같이 해결할 수 있습니다.

  • LAZY Loading을 그대로 사용한다.(N+1 발생)
  • List 대신 Set을 사용한다.(n개의 fetch join 사용 가능)
  • 하나의 엔티티에 대해서만 fetch join을 적용한다.(나머지 toMany 관계 엔티티들은 N+1 발생)
  • fetch join 분리 후 결과 조합하기

하지만 저희는 개발자이기 때문에 이대로 사용할 순 없겠죠? 성능상의 이슈도 있고 뭣보다 이대로 넘어갈 수는 없는 노릇이니까요. 우선 MultipleBagFetchException이 발생하는 대중적인 상황을 먼저 알아보고, 제가 만난 상황에서 해당 트러블을 어떻게 해결했는지 소개하겠습니다.

첫 번째 상황 - 하나의 테이블에서 여러개의 xToMany 관계를 가지고 있을 때


아래와 같이 하나의 엔티티가 여러개의 xToMany 관계를 가지고 있습니다.

 

@Builder.Default
@OneToMany(mappedBy = "example", cascade = {PERSIST, REMOVE})
private List<A> as = new ArrayList<>();
    
@Builder.Default
@OneToMany(mappedBy = "example", cascade = {PERSIST, REMOVE})
private List<B> bs = new ArrayList<>();

 

Repository는 조금 더 직관적으로 보기 위해 쿼리문을 직접 작성하였습니다. 실제로 사용할 때 단순 쿼리 메서드로 해결되는 경우라면 영속성 컨텍스트를 활용하기 위해 @Query를 사용하지 않는 것이 좋습니다.

 

public interface BasicRepository extends JpaRepository<Basic, Long> {

    @Query("""
        select basic from Basic basic
    """)
    List<Basic> findAll();

}

 

N+1 문제를 확인하기 위해 간단하게 테스트 코드를 작성해보겠습니다.

 

@DataJpaTest
@Sql("/basic-test.sql")
class BasicRepositoryTest {

    @Autowired
    private BasicRepository basicRepository;
    
    @Test
    void Fetch_Join_Test() {
        List<Basic> basics = basicRepository.findAll();
        System.out.println("========조회========");

        for (Basic basic : basics) {
            for (A a : basic.getAList()) {
                a.getId();
            }

            for (B b : basic.getBList()) {
                b.getId();
            }
        }

    }

}

 

데이터는 A 10개, B 10개씩 넣었습니다.(총 20개) 그렇다면 쿼리는 총 몇 번 실행될까요?? 쿼리 결과는 다음과 같습니다.

  • Basic 조회 쿼리 - 1번
  • A 자식 조회 쿼리 - 10번
  • B 자식 조회 쿼리 - 10번

분명 findAll 쿼리 한 번을 실행시켰는데, 부가적인 쿼리가 20번이 더 실행돼서 총 21개의 쿼리가 실행되었습니다. 참고로 basic row는 1 밖에 되지 않습니다.

 

 

위 상황을 해결하기 위해서 fetch join을 사용해 보겠습니다. 레파지토리의 쿼리를 다음과 같이 수정합니다.

 

@Query("""
        select basic from Basic basic
        left join fetch basic.aList
        left join fetch basic.bList
    """)
List<Basic> findAll();

 

다시 테스트코드를 실행시켜 보면 아래와 같은 에러가 발생합니다.

 

MultipleBagFetchException

batch_fetch_size로 해결하기

가장 쉬우면서도 적정 사이즈만 설정한다면 큰 사이드 이펙트도 없는 방법이 하이버네이트에서 제공하는 batch_fetch_size 옵션입니다. 그림으로 JPA의 N+1을 다시 바라봅시다.

 

 

basic 1에서 a와 b를 각각 한 번씩 조회합니다. basic2에서도 마찬가지로 a와 b를 각각 한 번씩 더 조회하죠. 이렇듯 JPA에서 N+1이 발생하는 이유는 부모의 id를 key값으로 사용하기 때문입니다.

 

만약 findAll에서 조회된 모든 basic에서 id를 꺼내와야 한다면 부가적인 쿼리가 정비례하게 늘어나겠죠? 그래서 위 쿼리가 데이터의 개수만큼 더 늘어난 것입니다.

 

그렇다면 batch_fetch_size는 뭘까요? 간단하게 말하면 조회의 key로 사용하는 부모의 id를 리스트로 만들어줍니다. 말로만 들으면 이해가 잘 안 되니 다시 그림으로 보죠.

 

 

이렇게 부모 테이블의 id를 list 형태로 사용하여 조회하는 방식입니다. 그리고 해당 list의 사이즈를 default_batch_fetch_size로 설정할 수 있습니다. 백문이 불여일견이니 직접 검증해 봅시다. 우선 yml에서 defualt_batch_fetch_size를 설정해 줍니다.

 

spring:
  jpa:
    open-in-view: false
    show-sql: true
    database-platform: org.hibernate.dialect.H2Dialect
    properties:
      hibernate:
        use_sql_comments: true
        highlight_sql: true
	default_batch_fetch_size: 1000

 

보통 옵션값을 1,000 이상 주지는 않는다고 합니다. in절 파라미터로 1,000개 이상을 주었을 때 너무 많은 in절 파라미터로 인해 문제가 발생할 수도 있기 때문입니다.

지금 옵션은 1000으로 두었기 때문에 Basic이 1000개를 넘지 않으면 자식 테이블 한 개당 단 하나씩의 쿼리로 수행된다는 장점도 있습니다. 자세한 내용은 jojoldu님의 블로그를 참고해주세요.

 

findAll 메서드를 원복 시켜놓고, 테스트를 다시 실행해 봅시다.

 

public interface BasicRepository extends JpaRepository<Basic, Long> {

    @Query("""
        select basic from Basic basic
    """)
    List<Basic> findAll();

}

 

똑같은 쿼리를 실행했음에도, 실제로 실행된 추가 쿼리의 개수는 10배나 개선된 모습입니다.(20번 -> 2번) 아래의 where array_contains 구문이 batch_fetch_size를 사용함으로써 생긴 문법입니다.

 

 

지금은 단순 쿼리가 20개에서 2개로 줄어든 모습이지만, 이론적으로 생각해 보면 basic의 데이터가 100만이고, batch_fetch_size를 사용하지 않았을 때 쿼리 개수는 1번(Basic 조회) + 100만 번(A조회) + 100만 번(B조회) = 2,000,001번 실행될 것입니다.

 

하지만 batch_fetch_size를 100으로 설정했을 때 쿼리 개수는 1번(Basic 조회) + 1000번(A조회) + 1000번(B조회) = 2001번이 실행되겠죠? 최대 1000배까지 차이가 날 수 있다는 것입니다. 그렇기 때문에 batch_fetch_size를 적절히 사용해 주면 매우 좋습니다.

 

두 번째 상황 - 체이닝된 xToMany


이제는 필자가 만난 문제 상황에 대해 소개해보려고 합니다. 필자는 bootcamp를 조회하면 그에 관련된 갖가지 데이터를 같이 반환하려고 했습니다. 당연하게도 조회를 하는 과정에서 여러 개의 1:N 관계가 얽혀있었죠. 과연 체이닝 된 xToMany 관계에서도 batch_fetch_size가 동작할까요?

 

예를 들면 아래와 같은 엔티티 구조에서 발생합니다. 굳이 하나의 엔티티에 여러 개의 OneToMany, ManyToMany가 아니어도 내가 한 번에 조회하려고 할 때 toMany 관계가 2개 이상이라면 발생하게 됩니다.

 

 

위 엔티티를 보면 다대일 관계의 Apply(N) : Bootcamp(1)Apply(1) : CardinalNumbers(N)의 관계가 있습니다. 필자는 Bootcamp의 Id를 입력받으면 그에 관련된 모든 정보를 반환하려고 합니다.

 

findById(단건조회)가 아닌 findAll(컬렉션 조회) 같은 쿼리였다면 엄청나게 많은 N 쿼리가 발생할 것입니다. 테스트 데이터는 다음과 같이 삽입(Insert)되어 있다는 가정하에 진행하겠습니다.

 

 

  • 쿼리 1: select bootcamp from Bootcamp bootcamp where bootcamp.id = :bootcampId
  • 쿼리 2: select * from apply a where a.bootcampId = ?
  • 쿼리 3: select * from cardinal_number cn where cn.apply_id = 1
  • 쿼리 4: select * from cardinal_number cn where cn.apply_id = 2

실제로 N+1을 해결하지 않은 상황에서 쿼리는 다음과 같습니다. 

 

apply에 2개의 cardinal_number 데이터가 있는 상황

1(bootcamp) + N(apply(1), cardinal_number(2)) = 총 4번의 쿼리가 발생합니다. 위 문제를 해결하기 위해서 fetch join을 걸어보겠습니다.

 

@Query("""
    SELECT DISTINCT bootcamp FROM Bootcamp bootcamp
    LEFT JOIN FETCH bootcamp.applies applies
    LEFT JOIN FETCH applies.cardinalNumbers cardinalNumber
    LEFT JOIN FETCH cardinalNumber.curriculum curriculum
    WHERE bootcamp.id = :bootcampId
    """)
Optional<Bootcamp> findBootcampWithAllRelation(Long bootcampId);

 

당연하게도 아래와 같은 에러 메시지가 뜨면서 실행되지 않습니다. 원인은 여러 개의 xToMany 관계에 대해서 fetch join을 실행해서 그렇겠죠. 그렇다면 체이닝 된 xToMany 관계의 N+1은 해결할 수 없는 걸까요?

 

 

batch_fetch_size로 해결될까?

이 상황에서도 batch_fetch_size를 적용할 수 있습니다. 만약 체이닝 xToMany 관계에서도 적용된다는 가설을 세운다면, 이번에 쿼리를 실행했을 때 2번 검증했던 cardinal_number의 아이디를 배열로 처리함으로써 아래의 쿼리 횟수가 되어야 합니다.

  • bootcamp 쿼리 1번
  • apply 쿼리 1번
  • cardinal_number 쿼리 1번

쿼리 총 3번 실행

더 확실한 검증을 위해 데이터를 더 추가해 보았습니다. 그림으로 본다면 아래와 같습니다.

 

 

우선 batch_fetch_size를 적용하지 않은 상태로 실행해 보겠습니다. 데이터를 추가한 대로 꽤나 많은 양의 쿼리가 발생합니다.

 

 

이제 batch_fetch_size를 다시 적용하여 실행시켜 보겠습니다. 저희가 생각한 대로라면 쿼리 2와 3이 빨간 박스 부분에서 단 한 번씩 실행되어야 합니다. 이제 실행해 보면 아래와 같은 쿼리가 발생합니다.

 

 

위와 같이 batch_fetch_size만으로도 해결할 순 있지만, 차선책이라는 것만 알아두고 데이터가 많은 자식 테이블에 대해서는 fetch join을 적용해주는 것이 좋습니다.

 

@Override
@Query("""
    SELECT bootcamp FROM Bootcamp bootcamp
    JOIN FETCH bootcamp.applies
    WHERE bootcamp.id = :bootcampId
""")
Optional<Bootcamp> findBootcampWithAllRelation(Long bootcampId);

 

정리 (Feat. Fetch Join 안 써도 되는 거 아닌가요?)


이 글을 읽으면서 들 수 있는 생각이, batch_fetch_size로 다 해결이 되는데, 복잡하게 fetch join 굳이 써야 하나?라고 생각이 들 수 있습니다. 결론부터 말하자면 절대 그렇지 않습니다. 우선 아래와 같은 결론에 도달할 수 있습니다.

  • 다중 fetch join은 하이버네이트에서 제공하는 default_batch_fetch_size로 해결할 수 있다.
  • xToOne 관계의 엔티티들은 모두 fetch join을 사용하여 해결할 수 있다.
  • xToMany의 다중 연관 관계들은 가장 많은 데이터를 가지고 있는 자식 엔티티에만 fetch join을 사용한다.
    • fetch join을 걸지 않은 엔티티 쪽은 default_batch_fetch_size를 적용하여 최소한의 성능을 보장한다.

size를 무한대로 늘릴 순 없기 때문이죠, 하지만 정석대로 fetch join을 사용한다면 default_batch_fetch_size에 구애받지 않고 쿼리 튜닝을 진행할 수 있으니, fetch join으로 해결이 안 되는 조회 쿼리에 대해서 사용하는 게 좋겠죠?

 

default_batch_fetch_size 옵션은 다중 fetch join을 사용할 수 없는 상황에서 최소한의 성능을 보장하기 위한 차선책일 뿐, 최선이 아니라는 점을 꼭 기억해주세요. 읽어주셔서 감사합니다!

profile

용로그

@용로그

벨덩보단 용덩 github.com/wonyongChoi05