JPA N+1 발생 케이스과 MultipleBagFetchException 해결책
Fetch Join을 사용하다 만나는 MultipleBagFetchException
문제에 대해 알아보고, 해결책에 대해 정리 해보자.
목차는 다음과 같다.
- 프로젝트 요구사항
- 문제 상황
- 연관객체 조회
- N+1 문제를 해결하기 위해 Fetch Join
- 해결방안
- 첫 번째 방안 : Set으로 선언한다.
- 두번째 방안 : Hibernate default_batch_fetch_size
- 내가 선택한 방안 : Set 타입으로 변경하고 조회결과를 DTO로 반환
- 참고자료
- 회고
프로젝트 요구사항
(문제 상황을 말하기 전에, 나의 프로젝트에 Join 쿼리가 왜 필요한지 설명하고자 한다.)
스터디 방에 참여한 유저 정보(프로필 사진, 스터디 시간, 자신의 공부태그)를 조회해야 한다.
아이디어 구상단계에서 '크아' 게임과 같이 조회하면 좋겠다는 의견이 있었다.
그리고 스터디 방에 태그를 붙일 수 있는 요구사항이 있었다.
아이디어 구상한 피그마 파일 첨부
프로젝트 요구사항에 맞추어 Room 엔티티에 RoomHashTag(방 태그), User(참여한 유저)를 함께 조회하는 쿼리를 구현해야 한다.
스터디 방 정보와 함께 해당 방에 참여한 유저 정보를 조회하는 쿼리를 구현하는 과정에서 겪은 이슈에 대해 정리해보자.
문제 상황
Room 엔티티 안에 OneToMany 관계가 2개 이상
선언 되어있다.
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "room")
public class Room {
@Id
@GeneratedValue(strategy = IDENTITY)
@Column(name = "room_id")
private Long id;
@OneToMany(mappedBy = "room", cascade = CascadeType.ALL)
private List<RoomHashTag> roomHashTags = new ArrayList<>(); // 방에 여러 태그를 붙일 수 있다.
@JsonIgnore
@OneToMany(mappedBy = "room", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<User> participants = new ArrayList<>(); // 방 참여자들 (연관 관계)
...
연관객체 조회
Room 엔티티와 관련된 모든 자식 엔티티(RoomHashTag, User)를 함께 조회하는 쿼리를 생각하면 다음과 같다. 테스트를 실행해보자.
@Test
@Transactional(readOnly = true)
void test_query() {
List<Room> rooms = roomRepository.findAll();
List<String> tag_list = rooms.stream()
.map(Room::getRoomHashTags)
.flatMap(Collection::stream)
.map(RoomHashTag::getTagName)
.collect(Collectors.toList());
for (String s : tag_list) {
log.info("tag name={}", s);
}
List<String> user_list = rooms.stream()
.map(Room::getParticipants)
.flatMap(Collection::stream)
.map(User::getNickname)
.collect(Collectors.toList());
for (String s : user_list) {
log.info("user name={}", s);
}
}
이 테스트 코드를 실행해서 발생한 쿼리는 다음과 같다.
Hibernate: # 1) Room(스터디 방) 전체 쿼리
select
r1_0.room_id,
r1_0.current_user_num,
r1_0.expected_users,
r1_0.host_email,
r1_0.room_status,
r1_0.study_chat_box_id,
r1_0.study_timer,
r1_0.thumbnail,
r1_0.title
from
room r1_0
Hibernate: # 2) Room의 RoomHashTag(방 태그) 조회 쿼리
select
r1_0.room_id,
r1_0.room_hash_tag_id,
r1_0.tag_option
from
room_hash_tag r1_0
where
r1_0.room_id=?
Hibernate: # 3) Room의 User(참여한 유저) 조회 쿼리
select
p1_0.room_id,
p1_0.user_id,
p1_1.create_date,
p1_1.email,
p1_1.modified_date,
p1_1.password,
p1_1.user_level,
p1_0.nickname,
p1_0.profile_img,
p1_0.study_time,
p1_0.study_tree_id,
p1_0.user_status
from
users p1_0
join
user_base p1_1 on p1_0.user_id=p1_1.user_id
where
p1_0.room_id=?
(저의 입력된 데이터 기준으로) 총 23번의 쿼리가 수행되었다.
- Room 조회 쿼리 실행 (id가 1부터 11인 엔티티 반환) - 1번 수행
- Room의 RoomHashTag 조회 / User 조회 각각 발생 - 각각 11번씩 수행
조회된 부모의 수만큼 자식 테이블의 쿼리가 추가 발생하는 JPA의 N+1 문제
가 발생한다.
N+1 문제를 해결하기 위해 Fetch Join
이 문제를 해결하기 위해 RoomHashTag, User 조회에 Fetch Join을 적용했다.
@Test
@DisplayName("1:N 매핑 2개이상 조인시 오류확인")
void fetch_err() {
List<Room> result = queryFactory.selectFrom(room)
.leftJoin(room.participants, user).fetchJoin() // room - user fetch
.leftJoin(room.roomHashTags, roomHashTag).fetchJoin() // room - roomHashTags fetch
.fetch();
}
하지만 이렇게 1:N 관계의 자식 테이블 여러 곳에
Fetch Join을 사용하면 아래와 같이 에러가 발생한다.
찾아보니, JPA Fetch Join의 조건이 있다고 한다.
- ToOne은 몇개든 사용 가능하다.
- ToMany는 1개만 가능하다.
나의 경우는, OneToMany 관계가 2개 이상이기 때문에 MultipleBagFetchException 에러가 발생한 것이다. 어떻게 문제를 해야 할까?
해결방안
해결 방안으로 2가지 방법이 있다.
- 1:N 매핑된 필드의 타입을
Set
으로 선언한다. - 배치 사이즈 : Hibernate default_batch_fetch_size
첫 번째 방안 : Set으로 선언한다.
Set은 중복을 허용하지 않는 자료구조이기 때문에 중복등록이 되지 않는다.
Set으로 변경한 코드는 아래와 같다.
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "room")
public class Room {
@Id
@GeneratedValue(strategy = IDENTITY)
@Column(name = "room_id")
private Long id;
@OneToMany(mappedBy = "room", cascade = CascadeType.ALL)
private Set<RoomHashTag> roomHashTags = new HashSet<>(); // 방에 여러 태그를 붙일 수 있다.
@JsonIgnore
@OneToMany(mappedBy = "room", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private Set<User> participants = new HashSet<>(); // 방 참여자들 (연관 관계)
...
Set은 순서가 보장되지 않기 때문에 LinkedHashSet을 사용하여 순서를 보장해야 한다.
이렇게 했을 때는MultipleBagFetchException이 발생하지 않는다.
두번째 방안 : Hibernate default_batch_fetch_size
hibernate.default_batch_fetch_size 옵션이다. 해당 옵션은 지정된 수만큼 in절에 부모 Key를 사용하게 해준다. 1000개를 옵션값으로 지정하면 1000개 단위로 in절에 부모 Key가 넘어가서 자식 엔티티들이 조회된다.
@TestPropertySource(properties = "spring.jpa.properties.hibernate.default_batch_fetch_size=15") // 옵션 적용
@Slf4j
@SpringBootTest
@Transactional
class RoomServiceTest {
...
@Test
@DisplayName("n+1 문제가 발생하는 코드")
@Transactional(readOnly = true)
void test_query() {
List<Room> rooms = roomRepository.findAll();
List<String> tag_list = rooms.stream()
.map(Room::getRoomHashTags)
.flatMap(Collection::stream)
.map(RoomHashTag::getTagName)
.collect(Collectors.toList());
for (String s : tag_list) {
log.info("tag name={}", s);
}
List<String> user_list = rooms.stream()
.map(Room::getParticipants)
.flatMap(Collection::stream)
.map(User::getNickname)
.collect(Collectors.toList());
for (String s : user_list) {
log.info("user name={}", s);
}
}
@TestPropertySource를 이용해 hibernate.default_batch_fetch_size을 이 테스트에만 적용되도록 하였다. 테스트를 수행했을 때 실행되는 쿼리는 다음과 같다.
Hibernate: # 1) Room 전체 쿼리
select
r1_0.room_id,
r1_0.current_user_num,
r1_0.expected_users,
r1_0.host_email,
r1_0.room_status,
r1_0.study_chat_box_id,
r1_0.study_timer,
r1_0.thumbnail,
r1_0.title
from
room r1_0;
Hibernate: # 2) Room 1~11번의 RoomHashTag 자식 조회 쿼리
select
r1_0.room_id,
r1_0.room_hash_tag_id,
r1_0.tag_option
from
room_hash_tag r1_0
where
r1_0.room_id in (1,2,3,4,5,6,7,8,9,10,11,NULL,NULL,NULL,NULL);
Hibernate: # 3) Room 1~11번의 User 자식 조회 쿼리
select p1_0.room_id,
p1_0.user_id,
p1_1.create_date,
p1_1.email,
p1_1.modified_date,
p1_1.password,
p1_1.user_level,
p1_0.nickname,
p1_0.profile_img,
p1_0.study_time,
p1_0.study_tree_id,
p1_0.user_status
from
users p1_0 join user_base p1_1 on p1_0.user_id=p1_1.user_id
where
p1_0.room_id in (1,2,3,4,5,6,7,8,9,10,11,NULL,NULL,NULL,NULL);
쿼리를 보면, where room_id = ?
였던 조회 쿼리가 where room_id in (..)
으로 변경되었다.
총 23번 수행되던 쿼리가 총 3번의 쿼리 수행으로 개선되었다.
(부모 엔티티가 많을수록 쿼리 수행 개선에 도움이 될듯..)
내가 선택한 방안 : Set 타입으로 변경하고 조회결과를 DTO로 반환
위와 같이 Room에 있는 OneToMany 매핑된 필드를 Set으로 변경하고, 다음과 같이 Querydsl로 조회쿼리를 구현했다.
package inf.questpartner.repository.room;
import com.querydsl.core.QueryResults;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.jpa.impl.JPAQueryFactory;
import inf.questpartner.controller.dto.RoomSearchCondition;
import inf.questpartner.domain.room.Room;
import inf.questpartner.domain.room.common.tag.TagOption;
import jakarta.persistence.EntityManager;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import static inf.questpartner.domain.room.QRoom.room;
import static inf.questpartner.domain.room.QRoomHashTag.roomHashTag;
import static inf.questpartner.domain.users.user.QUser.*;
import static org.springframework.util.StringUtils.hasText;
public class RoomRepositoryCustomImpl implements RoomRepositoryCustom {
private final JPAQueryFactory queryFactory;
public RoomRepositoryCustomImpl(EntityManager em) {
this.queryFactory = new JPAQueryFactory(em);
}
// Room을 모두 조회한다.
@Override
public Page<Room> findAllWithTagAndUser(Pageable pageable) {
QueryResults<Room> result = queryFactory.selectFrom(room)
.leftJoin(room.roomHashTags, roomHashTag).fetchJoin()
.leftJoin(room.participants, user).fetchJoin()
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetchResults();
return new PageImpl<>(result.getResults(),pageable,result.getTotal());
}
// 특정 태그가 있는 Room을 모두 조회한다.
@Override
public Page<Room> findByTagOption(RoomSearchCondition condition, Pageable pageable) {
QueryResults<Room> result = queryFactory.selectFrom(room)
.leftJoin(room.roomHashTags, roomHashTag).fetchJoin()
.leftJoin(room.participants, user).fetchJoin()
.where(tagOptionContains(condition.getTagOption()))
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetchResults();
return new PageImpl<>(result.getResults(),pageable,result.getTotal());
}
private static BooleanExpression tagOptionContains(TagOption tagOption) {
return tagOption != null ? roomHashTag.tagOption.eq(tagOption) : null;
}
}
그리고 아래와 같이 Page
@Transactional(readOnly = true)
public Page<ResRoomPreview> findAll(Pageable pageable) {
Page<Room> rooms = roomRepository.findAllWithTagAndUser(pageable);
return ResRoomPreview.convert(rooms);
}
@Transactional(readOnly = true)
public Page<ResRoomPreview> sort(RoomSearchCondition condition, Pageable pageable) {
Page<Room> rooms = roomRepository.findByTagOption(condition, pageable);
return ResRoomPreview.convert(rooms);
}
POSTMAN으로 확인해보기
postman으로 아래 GET 요청으로 테스트해서 발생한 쿼리를 확인해보자.
http://localhost:8080/rooms/list
http://localhost:8080/rooms/search?tagOption=CODING_TEST
# 1) room, room_hash_tag, users 테이블을 조인하여 room 테이블의 레코드 수를 카운트한다.
select
count(r1_0.room_id)
from
room r1_0
left join
room_hash_tag r2_0 on r1_0.room_id=r2_0.room_id
left join
users p1_0 on r1_0.room_id=p1_0.room_id;
# 2) room, room_hash_tag, users, user_base 테이블을 조인하여 모두 조회한다.
select
r1_0.room_id,
r1_0.current_user_num,
r1_0.expected_users,
r1_0.host_email,
p1_0.room_id,
p1_0.user_id,
p1_1.create_date,
p1_1.email,
p1_1.modified_date,
p1_1.password,
p1_1.user_level,
p1_0.nickname,
p1_0.profile_img,
p1_0.study_time,
p1_0.study_tree_id,
p1_0.user_status,
r2_0.room_id,
r2_0.room_hash_tag_id,
r2_0.tag_option,
r1_0.room_status,
r1_0.study_chat_box_id,
r1_0.study_timer,
r1_0.thumbnail,
r1_0.title
from
room r1_0
left join room_hash_tag r2_0 on r1_0.room_id=r2_0.room_id
left join (users p1_0 join user_base p1_1 on p1_0.user_id=p1_1.user_id) on r1_0.room_id=p1_0.room_id;
WARN 13744 --- \[nio-8080-exec-5\] org.hibernate.orm.query
: HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
# room, room_hash_tag, users, user_base 테이블을 조인하여 조회한다.
(이때 room_hash_tag 테이블에서 'CODING_TEST' 태그 옵션을 가진 Room 조회한다.)
select
count(r1_0.room_id)
from
room r1_0
left join room_hash_tag r2_0 on r1_0.room_id=r2_0.room_id
left join users p1_0 on r1_0.room_id=p1_0.room_id where r2_0.tag_option='CODING_TEST';
select
r1_0.room_id,
r1_0.current_user_num,
r1_0.expected_users,
r1_0.host_email,
p1_0.room_id,
p1_0.user_id,
p1_1.create_date,
p1_1.email,
p1_1.modified_date,
p1_1.password,
p1_1.user_level,
p1_0.nickname,
p1_0.profile_img,
p1_0.study_time,
p1_0.study_tree_id,
p1_0.user_status,
r2_0.room_id,
r2_0.room_hash_tag_id,
r2_0.tag_option,
r1_0.room_status,
r1_0.study_chat_box_id,
r1_0.study_timer,
r1_0.thumbnail,
r1_0.title
from
room r1_0
left join room_hash_tag r2_0 on r1_0.room_id=r2_0.room_id
left join (users p1_0 join user_base p1_1 on p1_0.user_id=p1_1.user_id) on r1_0.room_id=p1_0.room_id
where
r2_0.tag_option='CODING_TEST';
WARN 13744 --- [nio-8080-exec-5] org.hibernate.orm.query
: HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
MultipleBagFetchException 오류는 해결되었지만 다음과 같은 경고가 발생했다.
WARN 13744 --- [nio-8080-exec-5] org.hibernate.orm.query
: HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
"모든 데이터를 조회 하여 메모리에 저장 중 이라고 한다, 데이터가 많아진다면 성능 이슈가 발생할 수 있을거라 예상된다" 라는 경고 이슈가 생겼다.
참고자료
회고
여러 개의 OneToMany 컬렉션을 함께 fetch join 하려다 보니 MultipleBagFetchException 이슈가 발생했었다. 이 문제를 해결하기 위해 List 대신 Set으로 변경하였지만, 새로운 경고인 HHH90003004가 나타났다.
(더 깊은 삽질을 하고 나서) 여러 개의 List를 함께 조회할 때 조회 쿼리 최적화
를 다룬 포스팅 주제로 정리하고자 한다.