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
는 한개만 사용이 가능하다.
이제는 해결책을 알아보자.
이를 해결할 수 있는 방법은 여러개가 존재한다.
- Hibernate default_batch_fetch_size
- 데이터 타입을 Set으로 변경
- fetch join 쿼리를 2회로 나누어 실행
- mainQuery와 subQuery를 별도 서비스에서 조회한 뒤 key를 기준으로 조립
대충 알겠지만 1, 2번은 우리가 원하는 방식이고 3, 4번은 최적화와 관계없는 방식이다. 그렇다면 1, 2번을 알아보자.
해결책은 하이버네이트의 default_batch_fetch_size
옵션에 있다.
N+1 문제란 결국 부모 엔티티와 연관관계가 있는 자식 엔티티들의 조회 쿼리가 문제이다. -> 부모 엔티티의 Key 하나하나를 자식 엔티티 조회로 사용하기 때문인데
그렇다면 1개씩 사용되는 조건문을 in절로 묶어서 조회하면 어떨까?
바로 hibernate.default_batch_fetch_size 옵션이 이 동작을 하게끔 해준다.
이 옵션은 지정된 수만큼 in절에 부모 Key를 사용하게 해준다.
~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을 사용하면 될 것이다.