Skip to content

Latest commit

 

History

History
127 lines (85 loc) · 5.28 KB

05.md

File metadata and controls

127 lines (85 loc) · 5.28 KB

JPA - MultipleBagFetchException

JPA의 N+1 문제에 대한 해결책으로 Fetch Join을 사용하다보면 자주 만하는 문제가 있다.

바로 MultipleBagFetchException 이다.

이 문제는 2개 이상의 OneToMany 자식 테이블에 Fetch Join을 했을 때 발생한다.

ex1) 엔티티 A, B, C가 존재하고 A:B=1:N 관계이고 B:C=1:N 관계일때, 테이블 A를 기준으로 B와 B.C를 Fetch Join 할때 발생한다. ex2) 엔티티 A, B, C가 존재하고 A:B=1:N 관계이고 A:C=1:N 관계일때, 테이블 A를 기준으로 B와 C를 Fetch Join 할때 발생한다.

이 예외가 발생하는 이유는 하이버네이트에서 두 번의 패치조인으로 조회할 때 Bag(MultiSet) 자료형을 사용하는데, 자바에서는 Bag(MultiSet) 자료형이 없기 때문에 하이버네이트에서는 List를 Bag로써 사용하고 있는 것이다.

Bag(MultiSet)은 중복된 요소를 포함할 수 있는 순서가 없는 컬렉션이다. 즉, ~ToMany 관계의 연관관계 엔티티를 두 개 이상 패치조인 한다면 중복이 일어날 수 밖에 없으므로 Set 자료형으로 검색할 수 밖에 없는 것이다.

OneToOne, ManyToOne과 같이 단일 관계의 자식 테이블에는 Fetch Join을 여러번 써도 된다.

이 문제에 대한 해결책으로 보통 2가지를 언급하는데 다음과 같다.

  • 자식 테이블 하나에만 Fetch Join을 걸고 나머지는 Lazy Loading으로 조회
  • 모든 자식 테이블을 다 Lazy Loading으로 조회

하지만 이럴 경우 성능은 떨어지게 되어있다. 그럼 성능까지 해결하는 방법은 무엇이 존재할까?

일단 JPA에서 Fetch Join의 조건은 다음과 같다.

  • ~ToOne몇개든 사용이 가능하다.
  • ~ToMany한개만 사용이 가능하다.

이제는 해결책을 알아보자.

이를 해결할 수 있는 방법은 여러개가 존재한다.

  1. Hibernate default_batch_fetch_size
  2. 데이터 타입을 Set으로 변경
  3. fetch join 쿼리를 2회로 나누어 실행
  4. mainQuery와 subQuery를 별도 서비스에서 조회한 뒤 key를 기준으로 조립

대충 알겠지만 1, 2번은 우리가 원하는 방식이고 3, 4번은 최적화와 관계없는 방식이다. 그렇다면 1, 2번을 알아보자.

Hibernate default_batch_fetch_size

해결책은 하이버네이트의 default_batch_fetch_size 옵션에 있다.

N+1 문제란 결국 부모 엔티티와 연관관계가 있는 자식 엔티티들의 조회 쿼리가 문제이다. -> 부모 엔티티의 Key 하나하나를 자식 엔티티 조회로 사용하기 때문인데

그렇다면 1개씩 사용되는 조건문을 in절로 묶어서 조회하면 어떨까?

바로 hibernate.default_batch_fetch_size 옵션이 이 동작을 하게끔 해준다.

이 옵션은 지정된 수만큼 in절에 부모 Key를 사용하게 해준다.

List를 Set 자료형으로 변경

~ToMany 컬렉션을 List에서 Set으로 변경하면 중복을 허용하고 순서가 없는 자료형이기 때문에 하이버네이트 Bag(MultiSet) 자료형이랑 거의 동일해진다. 따라서 Set 자료형으로 변경하고 JPQL에서 중복을 제거하는 distinct 키워드를 사용하면 우리가 원하는 데이터를 얻을 수 있다.

@Entity
class Test private constructor(

    @Column(name = "version", unique = true, updatable = false, nullable = false)
    val version: Int

) : BaseTimeEntity() {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "test_id")
    val id: Long? = null

    @OneToMany(mappedBy = "test", fetch = LAZY, cascade = [ALL], orphanRemoval = true)
    val questions: MutableSet<Question> = mutableSetOf()

    constructor(version: Int, questions: MutableSet<Question>) : this(version) {
        this.questions.addAll(questions)
        questions.forEach { question ->
            question.test = question.test ?: this
        }
    }
}
@Entity
class Question private constructor(

    @Column(name = "question_number", unique = true, nullable = false, updatable = false)
    val number: Int,

    @Column(name = "content", nullable = false)
    val content: String

) : BaseTimeEntity() {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "question_id")
    val id: Long? = null

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "test_id", nullable = false)
    var test: Test? = null

    @OneToMany(mappedBy = "question", fetch = LAZY, cascade = [ALL], orphanRemoval = true)
    @BatchSize(size = 100)
    var answers: MutableSet<Answer> = mutableSetOf()

    constructor(number: Int, content: String, answers: MutableSet<Answer>) : this(number, content) {
        this.answers.addAll(answers)
        answers.forEach { answer ->
            answer.question = answer.question ?: this
        }
    }
}
@Query("select t from Test t " +
        "join fetch t.questions q " +
        "join fetch q.answers a " +
        "where t.version = :version")
fun findWithQuestionsAndAnswersByVersion(version: Int): Test?

정리

그렇다면 둘 중에 어떤 전략을 사용하면 될까? 그야 목적에 따라 사용하면 된다.

순서가 달라도 상관없다면 Set을 사용하면 되는 것이고, 순서가 중요한 속성이라면 Hibernate default_batch_fetch_size을 사용하면 될 것이다.