[JPA] cannot simultaneously fetch multiple bags 해결 방법

문제상황

해당 문제는 영속성 전이 기능을 이용하고자 코드를 수정하면서 발생하게 되었다. 초기의 UserAccount는 다음과 같이 구성되어있고 회원가입시 UserAccount 정보, Interests, Questions 총 3번을 저장하는 코드를 작성했었다. 이 부분을 UserAccount 엔티티를 저장함으로써 한번에 저장되도록 수정하면서 발생하게 되었다.

public class UserAccount extends BaseEntity{

    ....

    @OneToMany(mappedBy = "userAccount", fetch = FetchType.EAGER)
    @ToString.Exclude @Setter
    private Set<Interest> interests;

    @OneToMany(mappedBy = "userAccount",fetch = FetchType.EAGER)
    @ToString.Exclude @Setter
    private Set<Question> questions;

    ...
------------- 변경 전 ----------------
// user 저장
UserAccount user =  userAccountRepository.save(user);
// questions 저장
questionService.saveQuestions(user,dto.getQuestions());
// interests 저장
interestService.saveInterest(user, dto.getInterests());

------------- 변경 후 ----------------
user.setQuestions(dto.getQuestions());
user.setInterests(dto.getInterests());
UserAccount user = userAccountRepository.save(user);

동일성 문제 발생

코드를 수정하고 테스트한 결과 데이터의 손실이 발생했고, 이는 Set 자료구조로 인해 발생했습니다. Interest, Question은 Set에 저장되고 두 엔티티의 동일성 비교는 PK를 이용해 비교합니다. 하지만 DB에 저장되기 전에 엔티티의 PK는 Long의 기본값 0이기 때문에 데이터 중복으로 인한 데이터 손실이 발생했고 이를 해결하기 위해 Set -> List로 변경하여 동일성 문제를 해결했습니다.

 @OneToMany(mappedBy = "userAccount", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
 @ToString.Exclude @Setter
 private List<Interest> interests;

@OneToMany(mappedBy = "userAccount",fetch = FetchType.LAZY, cascade = CascadeType.ALL)
@ToString.Exclude @Setter
private List<Question> questions;

다중 패치 조인 오류 발생 With N+1

로그인시 interests와 questions의 정보는 지연 로딩을 통해 가져와도 괜찮지만 추천 유저 리스트를 조회할때는 여러명의 유저를 조회하고 응답할때 조회한 유저들의 기본정보와 interests, questions 정보까지 응답해야했습니다. 여러명의 유저 조회 쿼리를 실행하면 N+1 즉 하나의 유저 조회 쿼리에 유저의 수만큼 interests, questions를 조회하는 쿼리가 추가적으로 발생합니다. 이 문제를 해결하기 위해 다중 패치 조인을 사용했지만 MultipleBagFetchException이 발생했고 해당 부분을 해결하기 위해 BatchSize를 이용해 N+1을 완화시키는 방법으로 데이터를 조회했습니다.

BatchSize란 무엇인가?

N+1은 하나의 쿼리로 유저를 10명 조회한다고 했을때 유저와 OneToMany 관계를 맺은 다른 엔티티를 로딩할 때 userId= :userId를 통해 각각의 연관 엔티티를 조회해 총 10개의 추가 쿼리가 발생합니다.
이런 N+1을 완화시키는 방법으로 BatchSize가 있고 이는 정해진 사이즈만큼 연관된 엔티티를 in(1,2,3,4,5,...)절을 통해 한번에 여러개의 데이터를 가져옴으로써 N+1을 완화시켜줍니다.

BatchSize 적용방법

  1. application.yml
    jpa.properties.hibernate.default_batch_fetch_size: 20
  2. 엔티티 내부
    @BatchSize(size = 20) @OneToMany(mappedBy = "userAccount", fetch = FetchType.LAZY, cascade = CascadeType.ALL) @ToString.Exclude @Setter private List<Interest> interests;

번외) JPA의 연관관계에서의 Set, List의 차이

둘의 가장큰 차이는 중복을 허용하는 것에있습니다. 예를 들어SELECT u FROM UserAccount u LEFT JOIN FETCH u.interests 해당 JPQL을 실행하면 interests와의 패치 조인으로 인해 UserAccount당 가지고 있는 interests의 개수만큼 중복된 유저정보가 생성이 됩니다. 이때 Set은 중복을 허용하지 않기에 중복데이터를 처리할 수 있는데, List는 중복을 허용해서 중복된 데이터를 처리해줄 수 없습니다. 이러한 이유로 다중 패치 조인을 Set은 가능하고 List는 불가능합니다.
이러면 Set이 좋을거라고 생각하지만 Set은 중복처리를 위한 추가적인 처리가 필요하고 또한 중복된 데이터를 모두 메모리에 저장한 다음에 처리하기 때문에 성능적으로는 List보다 떨어지게 됩니다.
성능 또는 편리함에 따라 선택하면 되고 Set은 주로 엔티티를 조회해도 연관된 엔티티가 고정된 개수를 갖거나 적은 개수를 가지고 있을때 사용하면 좋습니다.