[트러블 슈팅]fetch join 2번 이상 사용시 `MultipleBagFetchException`발생
문제 상황
GrammarBook 엔티티 한 개를 조회할때 Grammar를 fetch join하고 Grammar안에서 GrammarExample도 fetch join하는 한 방 쿼리를 JPQL로 작성했습니다.
//GrammarBookRepository.java
@Query("select gb from GrammarBook gb join fetch gb.grammars g join fetch g.examples where gb.id = :id")
Optional<GrammarBook> findGrammarBookById(@Param("id") Long id);
위 JPQL을 사용한 서비스 메서드를 테스트했더니 org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags: [com.wordwave.grammar.Grammar.examples, com.wordwave.grammarbook.GrammarBook.grammars]
가 발생했습니다.
원인
- hibernate는
@OneToMany
로 컬렉션에 대한 fetch join을 수행할때, List Collection 을unordered List 로 간주하고 Bag 타입으로 인식합니다. - hibernate 는 1개의 Bag 타입에 대한 fetch join 은 허용하지만 여러 개의 Bag 타입에 대한 fetch join 은 허용하지 않습니다.
현재 GrammarBook에서 Grammar는 @OneToMany
로 지정되있고, Grammar에서 GrammarExample도 @OneToMany
로 지정되있습니다.
//GrammarBook.java
...
@OneToMany(mappedBy = "grammarBook", cascade = CascadeType.ALL)
private List<Grammar> grammars = new ArrayList<>();
...
//Grammar.java
...
@OneToMany(mappedBy = "grammar", cascade = CascadeType.ALL)
private List<GrammarExample> examples = new ArrayList<>();
...
@OneToMany
가 붙은 필드가 List Collection이여서 오류가 발생한 것입니다.
+ 좀더 큰 범위에서 보면, OneToMany 필드를 2개 이상 fetch join하려고 해서 발생한 문제입니다.
해결
단순히 List를 Set으로 바꾸면 정렬이 보장되기 때문에 오류가 해결됩니다. 하지만 이는 영속성 처리가 되지 않은 상태의 엔티티를 Set에 넣으면 자동 중복 제거로 인해 이미 있던 동일 필드 데이터가 사라질 수 있다고 합니다.
또, 모든 @OneToMany
필드의 타입을 Set으로 바꾸지 않으면 하이버네이트에서 Set이 아닌 필드의 엔티티를 기준으로 cartesian 곱이 발생해 중복된 데이터를 반환합니다.
중복 데이터는 엔티티에 @OneToMany
필드가 많을수록 가파르게 늘어납니다.
참고로 N:1의 1 쪽에서 fetch join을 사용할때 1쪽의 데이터가 중복되기 때문에 sql에
distinct가 반드시 들어가야 합니다.
💡
cartesian 곱이란?
유효 join 조건을 적지 않았을때 해당 테이블에 대한 모든 데이터를 전부 결합하여 테이블에 존재하는 행 갯수를 곱한 만큼의 결과값이 반환되는 것입니다.
대신 2가지 해결 방법이 있습니다.
1. @OrderColumn
붙이기 → 추천 안함
@OneToMany
필드에@OrderColumn
을 붙여 정렬해주면 한 방 쿼리를 그대로 사용할 수 있습니다.name
속성으로 정렬 기준을 정할 수 있고, 정렬 기준 값은 반드시 정수여야 합니다.@OrderBy
와 함께 사용할수 없습니다.
@OneToMany(mappedBy = "grammarBook", cascade = CascadeType.ALL) @OrderColumn(name = "id")
private List<Grammar> grammars = new ArrayList<>();
@OneToMany(mappedBy = "grammar", cascade = CascadeType.ALL) @OrderColumn(name = "id")
private List<GrammarExample> examples = new ArrayList<>();
2. fetch join 나누기
- 한 방 쿼리를 여러 쿼리로 나눕니다.
- 이후 연관관계가 늘어날수록 한 방 쿼리는 복잡해질 수 있지만, 쿼리를 나누면 가독성이 좋아집니다. 또 처리 속도가 느린 한 방 쿼리를 개선시킬 수 있습니다.
- 쿼리를 2번 이상 날리기 때문에 데이터베이스에 부담이 갈 수 있습니다. 따라서 자주 사용되는 기능이고, 거의 변경되지 않는지 고려해야 합니다.
제 한 방 쿼리는 퀴즈 로직에서 사용하기 위해 GrammarBook에 연관된 Grammar와 GrammarExample들만 불러옵니다. 이는 앞으로 변경될 여지가 없다고 판단됩니다. 아래는 최종 수정 코드입니다.
+ inner join을 사용한 쿼리는 Grammar와 GrammarExample이 null이 아니어야 GrammarBook을 가져올 수 있습니다. Grammar가 null이면 GrammarExample을 join하지 못하고, GrammarExample이 null이어도 마찬가지입니다. left join을 사용하면 연관된 엔티티의 데이터가 없는 경우에도 엔티티를 가져올 수 있습니다.
+ 알고보니 @OrderColumn
**은 단점이 많아 fetch join을 사용하지 않기로 했습니다. ****
fetch join대신 left join을 적용하면 다음과 같습니다.
//GrammarBookRepository.java
@Query("select distinct gb from GrammarBook gb left join gb.grammars g left join g.examples ex where gb.id = :id")
Optional<GrammarBook> findGrammarBookById(@Param("id") Long id);
//GrammarBookService.java
public GrammarBookResponseDto getGrammarBook(Long id) {
GrammarBook grammarBook = this.grammarBookRepository.findGrammarBookById(id)
.orElseThrow(() -> new DataNotFoundException("Grammar book not found"));
List<GrammarDto> grammarDtos = new ArrayList<>();
for (Grammar grammar : grammarBook.getGrammars()) {
grammarDtos.add(GrammarDto.builder()
.id(grammar.getId())
.sentence(grammar.getSentence())
.grammarBookName(grammarBook.getName())
.grammarExamples(convertGrammarExampleDto(grammar.getExamples()))
.build());
}
return GrammarBookResponseDto.builder()
.id(id)
.name(grammarBook.getName())
.grammars(grammarDtos)
.build();
}
테스트하면 다음과 같은 쿼리가 발생합니다.
Hibernate:
select
distinct gb1_0.id,
gb1_0.name
from
grammar_book gb1_0
left join
grammar g1_0
on gb1_0.id=g1_0.grammar_book_id
left join
grammar_example e1_0
on g1_0.id=e1_0.grammar_id
where
gb1_0.id=?
Hibernate:
select
g1_0.grammar_book_id,
g1_0.id,
g1_0.sentence
from
grammar g1_0
where
g1_0.grammar_book_id=?
--grammar_example을 조회하는 쿼리는 총 10번 실행됩니다.
Hibernate:
select
e1_0.grammar_id,
e1_0.id,
e1_0.example,
e1_0.is_answer
from
grammar_example e1_0
where
e1_0.grammar_id=?
다음은 기존의 fetch join시 발생한 쿼리입니다.
Hibernate:
select
distinct gb1_0.id,
g1_0.grammar_book_id,
g1_0.id,
e1_0.grammar_id,
e1_0.id,
e1_0.example,
e1_0.is_answer,
g1_0.sentence,
gb1_0.name
from
grammar_book gb1_0
left join
grammar g1_0
on gb1_0.id=g1_0.grammar_book_id
left join
grammar_example e1_0
on g1_0.id=e1_0.grammar_id
where
gb1_0.id=?
left join은 조회 쿼리가 추가로 발생합니다.(N+1 문제) 그래서 이 한 방 쿼리를 폐기했고, 조회 로직을 수정했습니다.
우선 다른 곳에서 사용하던 다음 쿼리를 활용해 봤습니다. 나머지 코드는 같습니다.
@Override @Nonnull @Query("select gb from GrammarBook gb join fetch gb.grammars where gb.id = :id")
Optional<GrammarBook> findById(@Nonnull @Param("id") Long id);
다시 테스트해본 결과,
Hibernate:
select
gb1_0.id,
g1_0.grammar_book_id,
g1_0.id,
g1_0.sentence,
gb1_0.name
from
grammar_book gb1_0
join
grammar g1_0
on gb1_0.id=g1_0.grammar_book_id
where
gb1_0.id=?
--grammar_example을 조회하는 쿼리는 총 10번 실행됩니다.
Hibernate:
select
e1_0.grammar_id,
e1_0.id,
e1_0.example,
e1_0.is_answer
from
grammar_example e1_0
where
e1_0.grammar_id=?
grammar는 fetch join으로 즉시 불러와집니다. 하지만 앞서 JPQL쿼리는 grammarExample까지 불러오진 않습니다. 그래서 grammarExample을 추가적으로 조회하는 N+1문제가 발생합니다.
이를 해결하기 위해 grammar id를 모아서 한번에 모든 grammarExample을 가져온 다음 각 grammar에 매핑해줍니다.
다음은 GrammarExampleRepository에 작성한 JPA메서드입니다.
grammar id 리스트를 받아 이 리스트에 포함되는 grammar id를 가진 grammarExample들을 반환합니다.
List<GrammarExample> findAllByGrammarIdIn(List<Long> grammarIds);
GrammarDto에 넣기 위해 grammar id에 해당하는 grammarExample을 다시 매핑해줍니다. 이때 Collectors.groupingBy()
를 활용했습니다. 요녀석은 컬렉션의 요소들을 그룹핑해서 Map객체를 생성해줍니다.
public GrammarBookResponseDto getGrammarBook(Long id) {
GrammarBook grammarBook = this.grammarBookRepository.findById(id)
.orElseThrow(() -> new DataNotFoundException("Grammar book not found"));
List<Long> grammarIds = grammarBook.getGrammars().stream()
.map(Grammar::getId)
.toList();
List<GrammarExample> grammarExamples = this.grammarExampleRepository.findAllByGrammarIdIn(grammarIds);
Map<Long, List<GrammarExample>> grammarExamplesMappedGrammarId = grammarExamples.stream()
.collect(Collectors.groupingBy(example -> example.getGrammar().getId()));
List<GrammarDto> grammarDtos = new ArrayList<>();
for (Grammar grammar : grammarBook.getGrammars()) {
List<GrammarExampleDto> grammarExampleDtos = GrammarExampleToGrammarExampleDtoConverter.convert(grammarExamplesMappedGrammarId.get(grammar.getId()));
grammarDtos.add(GrammarDto.builder()
.id(grammar.getId())
.sentence(grammar.getSentence())
.grammarBookName(grammarBook.getName())
.grammarExamples(grammarExampleDtos)
.build());
}
테스트시 발생하는 쿼리는 다음과 같습니다.
Hibernate:
select
gb1_0.id,
g1_0.grammar_book_id,
g1_0.id,
g1_0.sentence,
gb1_0.name
from
grammar_book gb1_0
join
grammar g1_0
on gb1_0.id=g1_0.grammar_book_id
where
gb1_0.id=?
Hibernate:
select
ge1_0.id,
ge1_0.example,
ge1_0.grammar_id,
ge1_0.is_answer
from
grammar_example ge1_0
where
ge1_0.grammar_id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
이제 제일 처음 의도한대로 쿼리가 2개로 나눠지고, N+1이 발생하지 않습니다.