본문 바로가기
IT/Spring boot

[Spring boot] JPA fetch join 2개 이상의 Collection Join 해결법

by kyu-nahc 2024. 9. 23.
반응형

 

 

JPA fetch Join

이전 포스트에서는 JPA의 N+1 문제 및 해결방안에 대해 정리하였다.

 

[Spring boot] JPA N+1 발생 케이스 및 해결 방법

JPA FetchType저번 포스트에서 Jpa의 FetchType인 지연 로딩과 즉시 로딩에 대해 정리하였다. [Spring boot] JPA FetchTypeFetchTypeFetchType이란 JPA에서 엔티티를 조회할 때 연관된 ‘엔티티 조회 방법을 결정하는

kyu-nahc.tistory.com

 

지연로딩의 문제점을 해결하기 위해 fetch Join을 사용하였지만,

fetch Joinpagination이나 2개 이상의 Collection Join시 문제가 되었다.

pagination은 fetch Join을 사용하지 않고 BatchSize를 사용함으로써 해결하였지만,

2개 이상의 Collection Join을 사용 시에 해결책은 명시하지 않았다.

해당 포스트에서는 이에 대한 해결책을 제시한다.

 

fetch join은 이전 글  batchSize에서 이야기한 대로 일단 하나의 collection fetch join에 대해서

인메모리에서 모든 값을 다 가져오기 때문에 pagination이 불가능하다.

fetch join을 할 때 ToMany의 경우 한 번에 fetch join을 가져오기 때문에

Collection join이 2개 이상이 될 경우 너무 많은 값이 메모리로 들어와 exception이 발생한다.

public class MultipleBagFetchException extends HibernateException {
    private final List bagRoles;

    public MultipleBagFetchException(List bagRoles) {
        super("cannot simultaneously fetch multiple bags: " + bagRoles);
        this.bagRoles = bagRoles;
    }

    public List getBagRoles() {
        return this.bagRoles;
    }
}

 

해당 exception이 MultipleBagFetchException이고,

위의 코드에서 알 수 있다시피 2개 이상의 bags,

collection join이 두 개이상일 때 exception이 발생하게 된다.

예시 코드를 통해 해당 예외가 발생하는지 살펴보자.

public class Member extends BaseEntity{

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String userId; // User Id
    private String password; // User password
    private String email; // User Email
    private String name; // User name
    
    @OneToMany(fetch=FetchType.LAZY, mappedBy = "member")
    @JsonManagedReference
    private List<Post> posts = new ArrayList<>();

    @OneToMany(fetch=FetchType.LAZY, mappedBy = "member")
    @JsonManagedReference
    private List<Comment> comments = new ArrayList<>();

}

public class Comment extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String content;

    @ManyToOne(fetch=FetchType.EAGER)
    @JsonBackReference
    private Member member;
}

 

 

그전까지 예시로 들었던 1:N인 Member와 Post의 관계에

추가적으로 1:N 관계인 Member와 Comment 관계가 존재한다고 하자.

Post와 Comment를 @OneToMany로 받는 Member Entity로 테스트를 진행한다.

당연하게도 Member를 검색할 때 Member만 사용한다면

지연로딩으로 인해서 아무런 문제가 발생하지 않겠지만,

Post나 Comment를 받아와야 하는 상황이라면 N+1 문제가 발생할 것이다.

@EntityGraph(attributePaths = {"posts", "comments"}, type = EntityGraph.EntityGraphType.FETCH)
@Query("select distinct m from Member m left join m.posts")
List<Member> findAllFetchJoin();

@Test
@Transactional
void collectionJoinTest(){
    System.out.println("== start ==");
    List<Member> members = memberRepository.findAllFetchJoin();
    System.out.println("== find all ==");
    for(Member member : members){
        System.out.println("Size : "+member.getPosts().size());
    }
}
org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags:

 

위와 같이 JPQL을 생성하고 테스트를 진행한다.

이렇게 테스트를 진행하면 Member는 ~ToMany가 두 개,

collection fetch join이 두 개 이상 걸리기 때문에 MultipleBagFetchException이 발생한다.

~ToOnefetch join을 많이 해도 괜찮지만 ~ToMany는 하나일 때는 인메모리에서 처리하고

두 개이상은 Exception으로 제한하는 문제가 존재한다. 따라서 이에 대한 해결책이 필요하다.

 

Set 자료구조 사용

자료형을 Set으로 변경을 하면 해결되는 이유는

MultipleBag가 List로 되어 있을 때 중복 자체를 허용하지 않는다면,

복잡한 여러 개의 collection fetch 관계를 해결할 수 있기 때문이다.

Set 자료 구조는 중복 자체를 허용하지 않기 때문에, 

여러 개의 Collection Fetch 관계를 해결할 수 있는 것이다.

아래 코드를 통해 실제로 적용되는지 살펴보자.

public class Member extends BaseEntity{

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String userId; // User Id
    private String password; // User password
    private String email; // User Email
    private String name; // User name

    @OneToMany(fetch=FetchType.LAZY, mappedBy = "member")
    @JsonManagedReference
    private Set<Post> posts = Collections.emptySet();

    @OneToMany(fetch=FetchType.LAZY, mappedBy = "member")
    @JsonManagedReference
    private Set<Comment> comments = Collections.emptySet();

}
== start ==
Hibernate: 
    select
        distinct m1_0.id,
        c1_0.member_id,
        c1_0.id,
        c1_0.content,
        c1_0.created_date_time,
        c1_0.modified_date_time,
        m1_0.created_date_time,
        m1_0.email,
        m1_0.modified_date_time,
        m1_0.name,
        m1_0.password,
        p1_0.member_id,
        p1_0.id,
        p1_0.content,
        p1_0.created_date_time,
        p1_0.modified_date_time,
        p1_0.title,
        m1_0.user_id 
    from
        member m1_0 
    left join
        post p1_0 
            on m1_0.id=p1_0.member_id 
    left join
        comment c1_0 
            on m1_0.id=c1_0.member_id
== find all ==
Size : 2
Size : 3
Size : 0

 

MultipleBagFetchException이라는 둘 이상의 collection fetch join을

막는 exception 없이 정상적으로 모든 데이터를 가져오는 것을 볼 수 있다.

우선, MultipleBagFetchException이 발생하는 원인중복을 방지하기 위해 발생한다.

즉, join된 두 컬렉션 간의 카테시안 곱이 발생함을 방지하기 위해서이다.

만약, Post가 1개, Comment가 2개인 경우에는 1 * 2 = 2이므로

성능에 커다란 영향을 미치지 않겠지만, Post가 200개, Comment가 300개라면

60,000개의 결과가 나오므로 엄청난 성능 저하가 발생할 것이다.

 

그러나 자료구조 Set은 중복을 허용하지 않는다.

따라서 카테시안 곱이 발생할 일이 없고, MultipleBagFetchException도 발생하지 않는다.

List, List를 fetchJoin 하면 해당 예외가 발생하겠지만,

List, Set 또는 Set, Set을 함께 fetch Join 하는 상황에선 터지지 않는다.

 

다만 Set을 사용한다고 해서, Pagination은 마찬가지로 해결이 불가능하다.

Pagination은 근본적으로 몇 개의 collection join 있든 간에

인메모리에서 가져오기 때문에 OOM을 발생시킬 수 있는 원인이 되기 때문이다.

설령 Collection join이 한 개인 상황에서 Set 자료구조를 사용한다고 해도 인메모리에서 가져온다.

 

BatchSize 사용

이전 포스트에서 Pagination의 해결책 중 하나로 나온 BatchSize이다.

물론 이 방법이 Pagination의 해결책 중 하나로 나온 방법이지만

Collection join이 두 개 이상일 때 MultipleBagFetchException을 해결할 수 있는 방법이다.

List 자료구조를 사용해야 하는 상황이거나,

Set을 사용한다고 Pagination에서 인메모리 로딩을 막을 수 없기 때문에

2개 이상의 Collection join을 사용하는데 Pagination을 사용해야 할 경우도

인메모리를 사용하지 않고 사용할 수 있다. 정리하면 두 가지 경우로 케이스를 구분할 수 있다.

  • List 자료구조를 꼭 사용해야 하는 경우
  • 2개 이상의 Collection join을 사용하는데
    Pagination을 사용해야 해서 인메모리 OOM을 방지하고자 하는 경우

Set, 혹은 List 위에 @BatchSize를 걸게 되면 동일하게 인메모리에 가져오는 것이 아닌

호출하는 당시에 한 번에 모든 데이터를 가져오는 동작구조를 가지게 된다.

아래 코드들과 테스트를 통해 결과를 살펴보면 다음과 같다.

public class Member extends BaseEntity{

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String userId; // User Id
    private String password; // User password
    private String email; // User Email
    private String name; // User name

    @BatchSize(size=100)
    @OneToMany(fetch=FetchType.LAZY, mappedBy = "member")
    @JsonManagedReference
    private List<Post> posts = new ArrayList<>();


    @BatchSize(size=100)
    @OneToMany(fetch=FetchType.LAZY, mappedBy = "member")
    @JsonManagedReference
    private List<Comment> comments =  new ArrayList<>();

}
@Query("select m from Member m left join m.posts left join m.comments")
Page<Member> findAllPageByTwoCollection(Pageable pageable);

@Test
@Transactional
void twoCollectionPageTest(){
    System.out.println("== start ==");
    PageRequest pageRequest = PageRequest.of(0, 3);
    Page<Member> page = memberRepository.findAllPageByTwoCollection(pageRequest);
    System.out.println("== find all ==");
    for (Member member : page) {
        System.out.println("Size : " + member.getPosts().size());
    }
}
== start ==
Hibernate: 
    select
        m1_0.id,
        m1_0.created_date_time,
        m1_0.email,
        m1_0.modified_date_time,
        m1_0.name,
        m1_0.password,
        m1_0.user_id 
    from
        member m1_0 
    left join
        post p1_0 
            on m1_0.id=p1_0.member_id 
    left join
        comment c1_0 
            on m1_0.id=c1_0.member_id 
    limit
        ?
== find all ==
Hibernate: 
    select
        p1_0.member_id,
        p1_0.id,
        p1_0.content,
        p1_0.created_date_time,
        p1_0.modified_date_time,
        p1_0.title 
    from
        post p1_0 
    where
        p1_0.member_id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
Size : 2
Size : 3

 

위의 코드를 보면 Member Entity에서 ~ToMany 관계에 대해 BatchSize를 명시하고,

JPQL에서는 fetch join을 생략한 것을 볼 수 있다.

이 점이 매우 중요한데, batchSize를 사용 시 fetch join을 사용하면 안 된다.

fetch join이 우선시 되어 적용되기 때문에 batch size가 무시되고

fetch join을 인메모리에서 먼저 진행하여 List가 MultipleBagFetchException가 발생하거나,

Set을 사용한 경우에는 Pagination의 인메모리 로딩을 진행하게 된다.

 

이처럼 MultipleBagFetchException를 예방하기 위해서

~ToMany의 관계 Mapping을 몇 개 사용하는지 사전에 미리 파악해야 한다.

Pagination 상황이 가정되지 않는다면,

Set 자료구조를 사용해서 MultipleBagFetchException를 예방해야 한다.

만약 Paginatioin이 필수적으로 들어가는 상황이라면

Batch Size를 사용하는 것이 좋은 방법일 수 있다. 

따라서 현재의 상황을 파악하고 적합한 해결책을 적용할 필요가 있다.

 

 

참고자료

https://ilovepotato.tistory.com/36

https://velog.io/@jinyoungchoi95/JPA-%EB%AA%A8%EB%93%A0-N1-%EB%B0%9C%EC%83%9D-%EC%BC%80%EC%9D%B4%EC%8A%A4%EA%B3%BC-%ED%95%B4%EA%B2%B0%EC%B1%85

반응형

loading