프로젝트/팀프로젝트 qp편

JPA N+1 발생 케이스과 MultipleBagFetchException 해결책

Thumper 2024. 3. 20. 17:38

Fetch Join을 사용하다 만나는 MultipleBagFetchException 문제에 대해 알아보고, 해결책에 대해 정리 해보자.


목차는 다음과 같다.

  • 프로젝트 요구사항
  • 문제 상황
    • 연관객체 조회
    • N+1 문제를 해결하기 위해 Fetch Join
  • 해결방안
    • 첫 번째 방안 : Set으로 선언한다.
    • 두번째 방안 : Hibernate default_batch_fetch_size
  • 내가 선택한 방안 : Set 타입으로 변경하고 조회결과를 DTO로 반환
  • 참고자료
  • 회고

프로젝트 요구사항

(문제 상황을 말하기 전에, 나의 프로젝트에 Join 쿼리가 왜 필요한지 설명하고자 한다.)
스터디 방에 참여한 유저 정보(프로필 사진, 스터디 시간, 자신의 공부태그)를 조회해야 한다.
image

아이디어 구상단계에서 '크아' 게임과 같이 조회하면 좋겠다는 의견이 있었다.


그리고 스터디 방에 태그를 붙일 수 있는 요구사항이 있었다.

tag tag

아이디어 구상한 피그마 파일 첨부


프로젝트 요구사항에 맞추어 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을 사용하면 아래와 같이 에러가 발생한다.
tempsnip

찾아보니, 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을 Page DTO 타입으로 반환하도록 작성했다.

    @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

"모든 데이터를 조회 하여 메모리에 저장 중 이라고 한다, 데이터가 많아진다면 성능 이슈가 발생할 수 있을거라 예상된다" 라는 경고 이슈가 생겼다.

참고자료

회고

다운로드 (2)

여러 개의 OneToMany 컬렉션을 함께 fetch join 하려다 보니 MultipleBagFetchException 이슈가 발생했었다. 이 문제를 해결하기 위해 List 대신 Set으로 변경하였지만, 새로운 경고인 HHH90003004가 나타났다.

(더 깊은 삽질을 하고 나서) 여러 개의 List를 함께 조회할 때 조회 쿼리 최적화를 다룬 포스팅 주제로 정리하고자 한다.