Posts [JPA] MultipleBagFetchException
Post
Cancel

[JPA] MultipleBagFetchException

MultipleBagFetchException

  • JPA의 N+1문제에 대한 해결책으로 Fetch Join을 사용하다보면 자주 만나는 문제가 있다. 바로 MultipleBagFetchException이다.
  • 이 문제는 2개 이상의 OneToMany 자식 테이블에 Fetch Join을 선언햇을때 발생한다.
    • OneToOne, ManyToOne과 같이 단일 관계의 자식 테이블에는 Fetch Join을 써도 된다.
  • 이 문제에 대한 해결책으로는 보통 2가지를 언급한다.
    • 자식 테이블 하나에만 Fetch Join을 걸고 나머진 Lazy Loading
    • 모든 자식 테이블을 다 Lazy Loading으로
  • 이럴 경우 성능상 이슈가 아무래도 해결되는게 아니므로 아래와 같은 방법을 사용하는게 좋다.

문제 상항

  • OneToMany 관계의 엔티티들이 있다.
  • Store.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
package com.example.domain;

import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@NoArgsConstructor
@Getter
@Entity
public class Store {

    @Id@GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private String address;

    @OneToMany(mappedBy = "store", cascade = CascadeType.ALL)
    private List<Product> products = new ArrayList<>();

    @OneToMany(mappedBy = "store", cascade = CascadeType.ALL)
    private List<Employee> employees = new ArrayList<>();

    public Store(String name, String address) {
        this.name = name;
        this.address = address;
    }

    public void addProduct(Product product) {
        this.products.add(product);
        product.updateStore(this);
    }

    public void addEmployee(Employee employee) {
        this.employees.add(employee);
        employee.updateStore(this);
    }

}
  • Product.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package com.example.domain;

import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;

@NoArgsConstructor
@Getter
@Entity
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private long price;

    @ManyToOne
    @JoinColumn(name = "store_id", foreignKey = @ForeignKey(name = "FK_PRODUCT_STORE"))
    private Store store;

    public Product(String name, long price) {
        this.name = name;
        this.price = price;
    }

    public void updateStore(Store store) {
        this.store = store;
    }
}

  • Employee.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package com.example.domain;

import lombok.Getter;
import lombok.NoArgsConstructor;
import org.apache.tomcat.jni.Local;

import javax.persistence.*;
import java.time.LocalDate;

@NoArgsConstructor
@Getter
@Entity
public class Employee {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private LocalDate hireDate;

    @ManyToOne
    @JoinColumn(name = "store_id", foreignKey = @ForeignKey(name = "FK_EMPLOYEE_STORE"))
    private Store store;

    public Employee(String name, LocalDate hireDate) {
        this.name = name;
        this.hireDate = hireDate;
    }

    public void updateStore(Store store) {
        this.store = store;
    }

}
  • StoreRepository.java
1
2
3
4
5
6
package com.example.domain;

import org.springframework.data.jpa.repository.JpaRepository;

public interface StoreRepository extends JpaRepository<Store, Long> {
}
  • StoreService.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package com.example.domain;

import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
public class StoreService {

    @Autowired
    private StoreRepository storeRepository;

    // Lazy Loading 발동을 위해 get필드 사용
    @Transactional(readOnly = true)
    public long find() {
        System.out.println("========================");
        List<Store> stores = storeRepository.findAll();

        long totalPrice = 0;
        for(Store store:stores) {
            List<Product> products = store.getProducts();
            for(Product product:products) {
                totalPrice += product.getPrice();
            }

            List<Employee> employees = store.getEmployees();
            for(Employee employee:employees) {
                String name = employee.getName();
                System.out.println("employee: " + name);
            }
        }

        System.out.println("========================");
        return totalPrice;
    }

}
  • find메소드에서 Store 엔티티의 자식들(Product/Employee)을 모두 가져와야한다.
  • 기능은 단순하다.
    • 1)전체 Store를 가져온다
    • 2)각 Store의 Product와 Employee를 가져와 전체 상품의 가격을 계산하고 직원들의 이름을 모두 출력한다
  • 테스트 코드는 아래와 같다.(StoreServiceTest.java)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package com.example.domain;

import org.aspectj.lang.annotation.After;
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.time.LocalDate;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.jupiter.api.Assertions.*;
import static org.hamcrest.Matchers.is;

@SpringBootTest
class StoreServiceTest {

    @Autowired
    StoreRepository storeRepository;
    @Autowired
    StoreService storeService;

    @BeforeEach
    void setUp() {
        Store store1 = new Store("서점1", "서울시 강남구");
        store1.addProduct(new Product("책1_1", 10000L));
        store1.addProduct(new Product("책1_2", 20000L));
        store1.addEmployee(new Employee("직원1_1", LocalDate.now()));
        store1.addEmployee(new Employee("직원1_2", LocalDate.now()));
        storeRepository.save(store1);

        Store store2 = new Store("서점2", "서울시 강남구");
        store2.addProduct(new Product("책2_1", 10000L));
        store2.addProduct(new Product("책2_2", 20000L));
        store2.addEmployee(new Employee("직원2_1", LocalDate.now()));
        store2.addEmployee(new Employee("직원2_2", LocalDate.now()));
        storeRepository.save(store2);
    }

    @AfterEach
    void tearDown() {
        storeRepository.deleteAll();
    }

    @Test
    public void NO_Repository_의_BatchSize () throws Exception {
        long size = storeService.find();
        assertThat(size, is(60000L));
    }
}
  • 위의 테스트 코드를 실행해서 발생한 쿼리들을 보면 아래의 그림과 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
Hibernate: 
    select
        store0_.id as id1_2_,
        store0_.address as address2_2_,
        store0_.name as name3_2_ 
    from
        store store0_ # 1) Store 전체 쿼리

Hibernate: 
    select
        products0_.store_id as store_id4_1_0_,
        products0_.id as id1_1_0_,
        products0_.id as id1_1_1_,
        products0_.name as name2_1_1_,
        products0_.price as price3_1_1_,
        products0_.store_id as store_id4_1_1_ 
    from
        product products0_ 
    where
        products0_.store_id=? # 2) Store 1번의 Product 자식들 조회 쿼리

Hibernate: 
    select
        employees0_.store_id as store_id4_0_0_,
        employees0_.id as id1_0_0_,
        employees0_.id as id1_0_1_,
        employees0_.hire_date as hire_dat2_0_1_,
        employees0_.name as name3_0_1_,
        employees0_.store_id as store_id4_0_1_ 
    from
        employee employees0_ 
    where
        employees0_.store_id=? # 3) Store 1번의 Employee 자식들 조회 쿼리

employee: 직원1_1
employee: 직원1_2

Hibernate: 
    select
        products0_.store_id as store_id4_1_0_,
        products0_.id as id1_1_0_,
        products0_.id as id1_1_1_,
        products0_.name as name2_1_1_,
        products0_.price as price3_1_1_,
        products0_.store_id as store_id4_1_1_ 
    from
        product products0_ 
    where
        products0_.store_id=? # 4) Store 2번의 Product 자식들 조회 쿼리

Hibernate: 
    select
        employees0_.store_id as store_id4_0_0_,
        employees0_.id as id1_0_0_,
        employees0_.id as id1_0_1_,
        employees0_.hire_date as hire_dat2_0_1_,
        employees0_.name as name3_0_1_,
        employees0_.store_id as store_id4_0_1_ 
    from
        employee employees0_ # 5) Store 2번의 Employee 자식들 조회 쿼리
    where
        employees0_.store_id=?
  • 5번의 쿼리가 수행되었다.
    • Store 조회 쿼리 실행(id 1,2인 엔티티 반환) - 1번 수행
    • 1번 Store의 Product, Employee 조회 각각 발생 - 2번, 3번 수행
    • 2번 Store의 Product, Employee 조회 각각 발생 - 4번, 5번 수행
  • 조회된 부모의 수만큼 자식 테이블의 쿼리가 추가 발생하는 현상을 JPA의 [N+1](https://github.com/jeonyoungho/TIL/blob/master/Spring/JPA/N%2B1%EB%AC%B8%EC%A0%9C/N%2B1%EB%AC%B8%EC%A0%9C.md)문제라고 한다.

  • 이 문제를 해결하기 위해 Product / Employee 조회에 Fetch Join을 적용한다.
1
2
3
4
5
6
7
8
public interface StoreRepository extends JpaRepository<Store, Long> {

    @Query("SELECT s " +
            "FROM Store s " +
            "JOIN FETCH s.products " +
            "JOIN FETCH s.employees")
    List<Store> findAllByFetchJoin ();
}
  • 하지만 이렇게 1:N 관계의 자식 테이블 여러 곳에 Fetch Join을 사용하면 아래와 같이 에러가 발생한다.
    스크린샷 2021-06-13 오후 9 37 07

  • JPA에서 Fetch Join의 조건은 다음과 같다.
    • To One은 몇개든 사용 가능하다
    • ToMany는 1개만 사용 가능하다.
  • 어떻게 하면 MultipleBagFetchException 에러 없이 N+1문제를 최대한 회피할 수 있을까?

해결책 - Hibernate default_batch_fetch_size

  • 해결책은 hibernate의 default_batch_fetch_size옵션에 있다.
  • 다시 한 번 JPA의 N+1문제를 바라보자.
    1

  • N+1 문제란 결국 부모 엔티티와 연관 관계가 있는 자식 엔티티들의 조회 쿼리가 문제이다. 부모 엔티티의 Key 하나하나를 자식 엔티티 조회로 사용하기 때문이다.
  • 만약 1개씩 사용되는 조건문을 in절로 묶어서 조회하면 어떨까?
    2

  • 바로 이 개념으로 사용되는 것이 바로 hibernate.default_batch_fetch_size 옵션이다.
  • 해당 옵션은 지정된 수만큼 in절에 부모 key를 사용하게 해준다.
  • 즉, 1000개를 옵션값으로 지정하면 1000개 단위로 in절에 부모 key가 넘어가서 자식 엔티티 들이 조회되는 것이다. 단순하게 생각해도 쿼리 수행수가 1/1000이 되는거다.
  • 위의 옵션을 적용하여 다시 한 번 Test코드를 수행해보자
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
package com.example.domain;

import org.aspectj.lang.annotation.After;
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.TestPropertySource;

import java.time.LocalDate;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.jupiter.api.Assertions.*;
import static org.hamcrest.Matchers.is;

@SpringBootTest
@TestPropertySource(properties = "spring.jpa.properties.hibernate.default_batch_fetch_size=1000") // 옵션 적용
class StoreServiceTest {

    @Autowired
    StoreRepository storeRepository;
    @Autowired
    StoreService storeService;

    @BeforeEach
    void setUp() {
        Store store1 = new Store("서점1", "서울시 강남구");
        store1.addProduct(new Product("책1_1", 10000L));
        store1.addProduct(new Product("책1_2", 20000L));
        store1.addEmployee(new Employee("직원1_1", LocalDate.now()));
        store1.addEmployee(new Employee("직원1_2", LocalDate.now()));
        storeRepository.save(store1);

        Store store2 = new Store("서점2", "서울시 강남구");
        store2.addProduct(new Product("책2_1", 10000L));
        store2.addProduct(new Product("책2_2", 20000L));
        store2.addEmployee(new Employee("직원2_1", LocalDate.now()));
        store2.addEmployee(new Employee("직원2_2", LocalDate.now()));
        storeRepository.save(store2);
    }

    @AfterEach
    void tearDown() {
        storeRepository.deleteAll();
    }

    @Test
    public void NO_Repository_의_BatchSize () throws Exception {
        long size = storeService.find();
        assertThat(size, is(60000L));
    }
}

출처

This post is licensed under CC BY 4.0 by the author.