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

최진영·2021년 11월 22일
93

JPA에 따라오는 꼬리표, N+1

최근들어 가장 많이 사용하는 API 중 하나인 JPA, 항상 JPA를 쓰면 따라오는 이야기가 있습니다.

JPA를 쓰면 N+1 문제가 발생하는데 해결해보신 경험이 있나요?

면접에 들어갔을 때 JPA를 사용한다라고 하면 항상 받던 질문이었습니다. 그때마다 머릿속에 정리가 잘 되지 않아 당황했던 기억이 있는데, 이 기회에 JPA에서 N+1이 발생하는 일반적인 케이스들을 모두 정리해보고자 글을 쓰게 되었어요 :)

흔히 알고있는 즉시로딩, 이 해결책으로 지연로딩 + fetch join을 해도 N+1이 생기는데요. 같이 모든 케이스를 확인해보면서 이야기를 이어나갈까 합니다 😀


모든 예제는 spring boot + spring data jpa환경으로 구성되어있습니다. 가장 많이 사용하는 조합이고, 많은 사람들이 익숙한 코드를 작성하는 것이 좋다고 생각해서 hibernate보다는 위 환경으로 진행하였습니다 :)

현재 글은 기본적으로 JPA에서의 즉시로딩과 지연로딩의 개념을 알고 있다는 전제하에 작성하였습니다.


N+1이 근데 뭐죠...?

JPA를 마냥 처음 공부하던 시기에는 N+1이라는 이야기만 계속 듣지 정작 어떤 건지도, 왜 해결해야하는지도 사실 와닿지 않았어요. 사실 거창하게 N+1이지 간단하게 이야기하면 다음과 같습니다.

조회 시 1개의 쿼리를 생각하고 설계를 했으나 나오지 않아도 되는 조회의 쿼리가 N개가 더 발생하는 문제.

DBMS 툴을 이용해 직접 쿼리문을 만들어 조회할 때는 물론 하나의 쿼리가 발생하겠지만 mybatis, 넘어서는 JPA가 등장함에 따라 자동화된 쿼리문들이 생겨나면서 어쩔 수 없이 발생하는 문제입니다. JPA의 경우에는 객체에 대해서 조회한다고 해도 다양한 연관관계들의 매핑에 의해서 관계가 맺어진 다른 객체가 함께 조회되는 경우에 N+1이 발생하게 됩니다.

케이스들에 대해서 아래에서 이야기하겠지만 지금 간단하게 예를 들면, 유저 한명이 쓴 게시글들을 조회할 때 유저-게시글을 join한 형태의 쿼리문을 원했지만 N개의 게시글을 또 조회하는 쿼리가 날아가는 경우가 있을 수 있겠네요.

그럼 지금부터 그 예들과 대처방안을 순서대로 알아가볼까 합니다.

예제로 사용할 연관관계

가장 흔하게 볼 수 있는 다대일관계 입니다. 한명의 User 여러개의 Article을 가질 수 있는 구조(User : 1, Article : N)이죠.

@Entity
public class User {

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

    @Column(length = 10, nullable = false)
    private String name;

    @OneToMany(mappedBy = "user")
    private Set<Article> articles = emptySet();
@Entity
public class Article {

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

    @Column(length = 50, nullable = false)
    private String title;

    @Lob
    private String content;

    @ManyToOne
    private User user;

Fetch type 같은 경우 현재 코드에서는 적용해두지 않았지만 즉시로딩, 지연로딩 각각에 들어가서는 명시하여 사용합니다.

(여담으로 Fetch type은 default로 ~ToMany에서는 Lazy, ~ToOne에서는 Eager로 지정되어있는데 이런 부분들은 default옵션을 사용한다고 하더라도 명시해주는 것이 협업하는 다른 개발자가 보기에도 좋습니다.)

즉시로딩

실무에서 가장 쓰지말아야할, 모든 문제의 첫번째 원인이 되는 즉시로딩입니다.

// User.java
@OneToMany(mappedBy = "user", fetch = FetchType.EAGER)
private Set<Article> articles = emptySet();

// Article.java
@ManyToOne(fetch = FetchType.EAGER)
private User user;

User의 입장에서 즉시로딩을 사용한다고 했을 때, Article의 모든 List를 다 같이 조회하고 싶을 상황이 생길 수도 있는데 왜 즉시로딩이 문제가 될까요?

사실 일반적으로 findById에 대한 메소드는 EntityManager에서 PK 값을 찍어서 사용하기 때문에 JPA가 내부적으로 join문을 사용해서 최적화를 다음처럼 진행해줍니다.

@Test
@DisplayName("Eager type은 User를 단일 조회할 때 join문이 날아간다.")
void userSingleFindTest() {
	System.out.println("== start ==");
	User user = userRepository.findById(1L)
		.orElseThrow(RuntimeException::new);
	System.out.println("== end ==");
	System.out.println(user.name());
}

내부적으로 inner join문 하나가 날아가서 User가 조회됨과 동시에 Article까지 즉시로딩되는 것을 확인할 수 있습니다. findById, 즉 EntityManager에서 entityManager.find(); 같은 경우 jpa가 내부적으로 join문에 대한 쿼리를 만들어서 반환을 하기 때문에 즉시로딩으로는 문제가 없어보이기도 합니다.

다만 문제는 jpql에 있습니다. 우리는 findById만 사용하는 것이 아니라 직접 jpql문을 짜서 전달하기도하고, data jpa에서 findBy~의 쿼리메소드 같은 경우에도 data jpa 내부에서 jpql이 만들어져서 나갑니다.

아니 그럼 jpql은 뭐가 문제일까요?🤔

일단 jpql이 sql로 그대로 번역이 됩니다. 만약 User의 findAll()을 요청하는 것이라면 select u from User u ;라는 쿼리가 발생하게 되는 것이겠죠. User를 찾는거는 문제가 없었지만 여기서 우리는 "즉시로딩"을 Article column에 걸어두었던 것을 잊으면 안됩니다.

User를 select 해왔지만 JPA는 Article에 대해서 EAGER가 걸려있는 것을 보고 select한 모든 User에 대해서 article이 있는지를 검색하게 됩니다.

즉, 모든 User에 대해서 검색하고 싶어서 select 쿼리를 하나 날렸지만(1), 즉시로딩이 걸려있기 때문에 각각의 User가 가진 Article을 모두 검색한다(N)라는 N+1 문제가 발생하는 것입니다.

@Test
@DisplayName("Eager type은 User를 전체 검색할 때 N+1문제가 발생한다.")
void userFindTest() {
    System.out.println("== start ==");
    List<User> users = userRepository.findAll();
    System.out.println("== find all ==");
}

지금이야 User 수가 적으니 N+1문제가 크게 와닿지 않지만 유저가 몇백만되는 서비스였다면 모든 유저를 검색하기 위해 1개의 쿼리만 날려도 되는데 Eager가 감지되어 몇백만의 추가 쿼리가 발생할 수 있는 것이죠.

정리하면 다음과 같습니다.

즉시로딩은 Jpql로 전달되는 과정에서 Jpql 후 Eager 감지로 인한 N쿼리가 추가로 발생하는 경우가 있기 때문에 사용해서는 안된다.

지연로딩

잘 아시다시피 프록시 객체를 로딩하는 과정에서 즉시로딩 말고 지연로딩이 있습니다. 연관된 객체를 "사용"하는 시점에 로딩을 해주는 방법이죠.

그럼 이제 문제였던 즉시 로딩을 바꿨으니 N+1은 더이상 발생하지 않을까요?🤔

네 아닙니다. 지연 로딩은 해당 연결 entity에 대해서 프록시로 걸어두고, 사용할 때 쿼리문을 결국 날리기 때문에 처음 find할 때는 N+1이 발생하지 않지만 추가로 User 검색 후 User의 Article을 사용해야한다면 이미 캐싱된 User의 Article 프록시에 대한 쿼리가 또 발생하게 됩니다.

@Test
@DisplayName("Lazy type은 User 검색 후 필드 검색을 할 때 N+1문제가 발생한다.")
void userFindTest() {
    System.out.println("== start ==");
    List<User> users = userRepository.findAll();
    System.out.println("== find all ==");
    for (User user : users) {
        System.out.println(user.articles().size());
    }
}

지연로딩을 했으니 지연로딩 대상인 Article을 나중에 조회해서 쿼리가 또 날아가는 것조차도 N+1 문제의 예 중 하나인 것이죠.

지연로딩에서의 해결책 - fetch join

즉시로딩, 지연로딩으로 넘어오면서 우리는 근본적으로 N+1이 JPA에서 생기는 원인을 알 수 있었습니다.

JPA가 자동으로 먼저 생성해주는 Jpql을 통해서 우선적으로 쿼리를 만들다보니 연관관계가 걸려있어도 join이 바로 걸리지 않는다.

일단 즉시로딩에서는 우리가 커스텀할 수 있는 부분이 존재하지 않기 때문에 지연로딩 과정에서 우리는 바로 사용을 할 객체에 대해서는 join을 걸 수 있도록 조정해주어야 합니다. 그것이 fetch join인거죠.

먼저 fetch join을 고려하지 않았을 때 Jpql을 임의로 만들어 보도록 하겠습니다.

@Query("select distinct u from User u left join u.articles")
List<User> findAllJPQL();

join이 들어가고 distinct가 들어가긴 했지만 사실 그렇게 복잡한 Jpql 구문은 아닙니다. article에 대해서 outer join을 했고, article이 list로 들어가기 때문에 User가 여러 개 분리되어 생성되는 것을 distinct로 막은 구문입니다.

그럼 이 쿼리를 실제로 돌려볼까요?

@Test
@DisplayName("일반 jpql 쿼리문은 N+1문제가 발생한다.")
void normalJpqlTest() {
    System.out.println("== start ==");
    User user = userRepository.findByNameJPQL("username1")
        .orElseThrow(RuntimeException::new);
    System.out.println("== end ==");
    System.out.println(user.articles().size());
}

위에서 findAll에서 사실 join만 없다일 뿐이지 당연히 N+1문제는 동일하게 발생합니다. 아직까지는 지연로딩이 걸려있고, join을 했어도 프록시로 가져오는건 변함이 없으니까요.

일반적인 fetch join

여기서 해결책은 fetch join을 걸어버리면 됩니다.

@Query("select distinct u from User u left join fetch u.articles")
List<User> findAllJPQLFetch();

join문에 fetch를 걸어주면 되는데요. fetch는 지연 로딩이 걸려있는 연관관계에 대해서 한번에 같이 즉시로딩해주는 구문입니다. 쿼리를 한번 볼까요?

@Test
@DisplayName("fetch join을 하면 N+1문제가 발생하지 않는다.")
void fetchJoinTest() {
    System.out.println("== start ==");
    List<User> users = userRepository.findAllJPQLFetch();
    System.out.println("== find all ==");
    for (User user : users) {
        System.out.println(user.articles().size());
    }
}

구분선을 그릴 필요도없이 쿼리를 날릴 때 article을 한번에 모두 가져옴을 알 수 있습니다.

fetch join을 하는 다른 방법 한가지 - @EntityGraph

jpql에서 fetch join을 하게 된다면 하드코딩을 하게 된다는 단점이 있습니다. 이를 최소화하고싶다면 @EntityGraph를 사용하면 되는데요. 바로 예제 쿼리를 보여드리겠습니다.

@EntityGraph(attributePaths = {"articles"}, type = EntityGraphType.FETCH)
@Query("select distinct u from User u left join u.articles")
List<User> findAllEntityGraph();

Hibernate의 Jpql 구문에서의 fetch는 존재하지는 않지만 기존과 마찬가지로 fetch join을 통해 바로 조회할 수 있음을 확인할 수 있습니다.

fetch join 하면 만능... 은 아닙니다.

fetch join을 사용하면 뭐든 다 이룰수 있는 것처럼 보이긴하지만 사실은 사용시 유의점이 있는 케이스들이 있습니다. 그 두가지를 지금 알아보겠습니다.

1. Pagination

Paging처리를 JPA에서 할 때 가장 많이 겪는 이슈입니다. fetch join을 통해서 N+1을 개선한다고는 하지만 막상 Page를 반환하는 쿼리를 작성해보면 다음과 같은 에러가 발생하거든요.

Fetch join을 Paging처리해서 반환해볼까요?

@EntityGraph(attributePaths = {"articles"}, type = EntityGraphType.FETCH)
@Query("select distinct u from User u left join u.articles")
Page<User> findAllPage(Pageable pageable);
@Test
@DisplayName("fetch join을 paging처리에서 사용해도 N+1문제가 발생한다.")
void pagingFetchJoinTest() {
    System.out.println("== start ==");
    PageRequest pageRequest = PageRequest.of(0, 2);
    Page<User> users = userRepository.findAllPage(pageRequest);
    System.out.println("== find all ==");
    for (User user : users) {
        System.out.println(user.articles().size());
    }
}

0페이지의 총 2명의 유저를 반환하는 PageRequest 객체를 파라미터로 입력받았습니다.

과연 정상적으로 쿼리가 하나만 나갈까요?

하나만 나가기는 했습니다. (Count 쿼리는 Page 반환시 무조건 발생하는 쿼리이므로 제외하도록 하겠습니다.)

근데 쿼리를 자세히보면 Mysql에서 페이징 처리를 할 때 사용을 하는 Limit, Offset이 없습니다. 분명 limit은 size 2로, offset은 page 0으로 지정해줬는데 말이죠. 근데 또 반환 값은 2명의 유저 article size가 나왔는데 뭐가 어떻게 된걸까요?

사실 이미지에서 일부러 잘라서 보여드렸지만 == start == 밑에 어떠한 WARN 구문이 있습니다.

2021-11-18 22:25:56.284  WARN 79170 --- [    Test worker] o.h.h.internal.ast.QueryTranslatorImpl   : HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!

해석해보면 collection fetch에 대해서 paging처리가 나왔긴한데 applying in memory, 즉 인메모리를 적용해서 조인을 했다고 합니다.

실제 날아간 쿼리와 이 문구를 통합해서 이해를 해보면 일단 List의 모든 값을 select해서 인메모리에 저장하고, application 단에서 필요한 페이지만큼 반환을 알아서 해주었다는 이야기가 됩니다.

이러면 우리는 사실상 Paging을 한 이유가 없어지는 것과 마찬가지입니다. 100만건의 데이터가 있을 때 그 중 10건의 데이터만 paging하고 싶었으나 100만건을 다 가져온다? 그것도 메모리에? OOM(Out of Memory)이 발생할 확률이 매우 높습니다.

따라서 Pagination에서는 fetch join을 하고싶어서 한다고 하더라도 해결을 할 수 없습니다.

짧게 이야기를 하자면 fetch join에서 distinct를 쓰는 것과 연관이 있습니다. distinct를 쓰는 이유는 하나의 연관관계에 대해서 fetch join으로 가져온다고 했을 때 중복된 데이터가 많기 때문에 실제로 원하는 데이터의 양보다 중복되어 많이 들어오게 됩니다.

그 이유때문에 개발자가 직접 distinct를 통해서 jpa에게 중복 처리를 지시하게 되는 것이고, Paging처리는 쿼리를 날릴 때 진행되기 때문에 jpa에게 pagination 요청을 하여도 jpa는 distinct때와 마찬가지로 중복된 데이터가 있을 수 있으니 limit offset을 걸지 않고 일단 인메모리에 다 가져와서 application에서 처리하는 것이죠.

눈치가 빠르신 분이라면 눈치를 채셨을 것 같은데 한가지는 위에서 말한 distinct가 생기는 상황 자체를 없애는 것 입니다.

Pagination 해결책(?) 1 : ToOne 관계에서 페이징 처리

사실 해결책이라기보다는 ~ToOne 관계라면 페이징 처리를 진행해도 괜찮다라는 내용입니다.

@EntityGraph(attributePaths = {"user"}, type = EntityGraphType.FETCH)
@Query("select a from Article a left join a.user")
Page<Article> findAllPage(Pageable pageable);

Article은 User에 대해서 ManyToOne 연관관계이기 때문에 지금처럼 Pagination을 진행한다고 해도 인메모리에서 모든 Article을 조회하는 것이 아닌 limit을 걸어 필요한 데이터만 가져올 수 있습니다.

따라서 사실 Pagination의 해결책이라는 부제목이 달려있지만 ~ToOne 관계에 있는 경우 fetch join을 걸어도 Pagination이 원하는대로 제공된다라는 것을 알면 좋을 것 같습니다.

Pagination 해결책 2 : Batch Size

다만 ~ToMany 관계, 즉 컬랙션 조인을 했을 경우 Many인 다객체들이 One에 매핑되어 fetch join된다면 Pagination에서 갯수를 판단히기 힘들기 때문에 fetch join을 사용할 경우 임의로 인메모리에서 조정한다고 이야기했었습니다.

따라서 컬랙션 조인을 하는 경우에는 fetch join을 아예 사용하지 않고 조회할 컬랙션 필드에 대해서 @BatchSize 를 걸어 해결합니다.

Page<User> findAll(Pageable pageable);

findAll() default메소드를 사용하겠습니다. 다만 달라지는 것은 필드에 @BatchSize를 걸게 될 것입니다.

이렇게 되었을 때 동일하게 테스트 코드를 날리면 articles만 따로 한번에 select하게 됩니다. 처음 지연로딩 default한 설정 + pagination은 어떻게 처리되는지 아래를 확인해보겠습니다.

어.... 분명 그냥 지연로딩했을 때랑은 다른거 같기는 합니다. 분명 User에 대해서 limit 쿼리가 나갔기 때문에 인메모리가 아닌 정상적인 pagination이 작동되었는데 밑에 article을 select하는 쿼리가 하나 등장했습니다.

어떻게 된거냐면 지연로딩하는 객체에 대해서 Batch성 loading을 하는 것이라고 생각하면 됩니다.

기존의 지연로딩에 대해서는 객체를 조회할 때 그때그때 쿼리문을 날려서 N+1 문제가 발생한 반면 객체를 조회하는 시점에 쿼리를 하나만 날리는게 아니라 해당하는 Article에 대해서 쿼리를 batch size개를 날리는 것입니다. batch 쿼리에서 where부분만 확대해서 보겠습니다.

where
	articles0_.user_id in (
		?, ?
	)

in (?, ?)가 결국 user id를 100개를 가져오는 쿼리문으로써 그때그때 조회하는 것이 아닌 조회할 때 그냥 batch size만큼 한번에 가져와서 뒤에 생길 지연로딩에 대해서 미연에 방지하는 것이라고 보면 좋을 것 같아요.

(batch size는 Article이 아닌 User의 갯수가 기준입니다.)

다만, Batch Size는 연관관계에서의 데이터 사이즈를 확실하게 알 수 있다면 최적회된 size를 구할 수 있겠지만, 사실 일반적인 케이스에서 최적화된 데이터 사이즈를 알기 힘들고 일반적으로 100~1000을 쓴다이지 확실하게 알지 못한다면 안좋은 방법이 될 수 있습니다.

Pagination 해결책 3 : @Fetch(FetchMode.SUBSELECT)

@BatchSize와 비슷하지만 다른 어노테이션입니다.

@BatchSize의 경우 사이즈 갯수 제한을 임의로 두어서 사용자가 최적화된 데이터 사이즈를 적용하게끔 도와준다면 이 어노테이션은 그냥 전부다 합니다..

코드 결과를 바로 보겠습니다.

다른 부분은 BatchSize와 동일하니 Collection을 따로 조회하는 쿼리만 가져왔습니다. 보면 where절 안에 in 문에서 현재 select할 User의 아이디가 들어가있어야하는데.... User를 그냥 싹다 조회하는 select all 쿼리가 들어있음을 알 수 있습니다.

즉, Batch Size의 경우 주어진 size만큼 User Id를 입력하여 그때그때 프록시 상태에 따라 지연로딩을 했다면, 지금은 그런거 없이 User Id를 싹다 조회하겠다는 것이죠. 마치 @BatchSize(size = 무한대)처럼 말이죠.

다만, 과연 한번에 모든 batch를 가져오는 것이 과연 좋은 판단인가에 대한 것은 의문이 있습니다. 🤔 Batch Size의 경우 size가 100일때 100만명의 유저에서 100명의 user id에 대한 검색을 하는 반면, SUBSELECT는 100만명 모든 유저를 일단 select하게되거든요.

사실 이 부분은 성능적인 테스트가 필요하여 차후에 관련해서 테스트를 하게 된다면 포스팅하여 링크를 남기도록 하겠습니다 :)

현재로써는 Batch Size 이상으로 이 방법을 사용할 필요는 없다는 것이 개인적인 생각입니다.

2. 둘 이상의 Collection fetch join(~ToMany) 불가능

fetch join은 앞서 batch size에서 이야기한대로 일단 하나의 collection fetch join에 대해서 인메모리에서 모든 값을 다 가져오기 때문에 pagination이 불가능했었습니다.

fetch join을 할 때 ToMany의 경우 한번에 fetch join을 가져오기 때문에 collection join이 2개이상이 될 경우 너무 많은 값이 메모리로 들어와 exception이 추가로 걸립니다. 그 exception이 MultipleBagFetchException인데요, 아래 사진에서 알 수 있다시피 2개 이상의 bags, 즉 collection join이 두개이상일 때 exception이 발생합니다.

음... 말만봐선 이해가 바로 가지 않을 수 있으니 바로 예를 들면 더 이해가 가기 쉬울 것 같아 바로 exception이 발생할 수 있는 entity를 가져와보겠습니다.

@Entity
@Table(name = "users")
public class User {

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

    @Column(length = 10, nullable = false)
    private String name;
    
    @OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
    private List<Article> articles = new ArrayList<>();
    
    @OneToMany(mappedBy = "question", fetch = FetchType.LAZY)
    private List<Question> questions = new ArrayList<>();

기존에 있었던 코드는 일단 뒤로하고, ArticleQuestion@OneToMany로 받는 User Entity에서 시작해볼게요.

당연하게도 User를 검색할 때 User만 사용한다면 지연로딩으로 인해서 아무런 문제가 발생하지 않겠지만, articles나 questions를 받아와야하는 상황이라면 N+1이 발생할 것입니다. 그럼 지금까지 했던대로 Repository에서 fetch join을 해볼까요?

@EntityGraph(attributePaths = {"articles", "questions"}, type = EntityGraphType.FETCH)
@Query("select distinct u from User u left join u.articles")
List<User> findAllEntityGraph2();
@Test
@DisplayName("collection join 2개일 때 fetch join")
void collectionFetchJoinTest() {
    System.out.println("== start ==");
    List<User> users = userRepository.findAllEntityGraph2();
    System.out.println("== find all ==");
}

이렇게 테스트를 진행하면 Users는 ~ToMany가 두 개, 즉 collection fetch join이 두 개 이상 걸리기 때문에 바로 이야기했던 Exception이 발생합니다.

org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags: [com.example.jpa.domain.User.articles, com.example.jpa.domain.User.questions]; nested exception is java.lang.IllegalArgumentException: org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags: [com.example.jpa.domain.User.articles, com.example.jpa.domain.User.questions]

~ToOne은 얼마만큼 fetch join을 해도 괜찮지만 ~ToMany는 하나일 때는 인메모리에서 처리하고 두 개이상은 Exception으로 제한한다... 그럼 어떻게 해결해야할까요?🤔

MultipleBagFetchException 해결책 1 : 자료형을 Set으로

자료형을 Set으로 변경을 하면 해결되는 아마도 MultipleBag가 List로 되어있을 때 중복 자체를 허용하지 않는다면 복잡한 여러개의 collection fetch 관계를 해결할 수 있음이 아닐까 생각합니다.

전부다 Set자료로 바꾸고 해결되는지 볼까요?

@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private Set<Article> articles = emptySet();

@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private Set<Question> questions = emptySet(); 

MultipleBagFetchException이라는 둘이상의 collection fetch join을 막는 exception없이 정상적으로 모든 데이터를 가져옴을 알 수 있습니다.

Set을 사용하게 된다면 HashSet으로는 순서가 중요한 데이터에는 순서를 보장할 수 없기 때문에 LinkedHashSet을 사용해야합니다. (자료구조 상 List보다 손해가 있을 수 있겠네요.)

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

Pagination은 근본적으로 몇개의 collection join 있던 간에 인메모리에서 가져오기 때문에 OOM을 발생시킬 수 있는 원인이 되어 해당 방법으로는 해결이 불가능합니다.

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

HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!

MultipleBagFetchException 해결책 2 : 돌고돌아 BatchSize

앞서 Pagination의 해결책 중 하나로 나온 Batch Size입니다.

물론 이 방법이 Pagination의 해결책 중 하나로 나온 방법이긴 하지만 Collection join이 두 개 이상일 때 MultipleBagFetchException을 해결할 수 있는 방법이기도 합니다.

List 자료구조를 사용해야하는 상황이거나, Set을 사용한다고 Pagination에서 인메모리 로딩을 막을 수 없기 때문에 2개 이상의 Collection join을 사용하는데 Pagination을 사용해야할 경우도 인메모리를 사용하지 않고 사용할 수 있습니다.

정리하면 두 가지 경우로 간추릴 수 있겠네요.

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

Set, 혹은 List 위에 @BatchSize를 걸게 되면 동일하게 인메모리에 가져오는 것이 아닌 호출하는 당시에 한번에 모든 데이터를 가져오는 동작구조를 가집니다.

@BatchSize(size = 100)
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private Set<Article> articles = emptySet();

@BatchSize(size = 100)
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private Set<Question> questions = emptySet();
@BatchSize(size = 100)
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private List<Article> articles = new ArrayList<>();

@BatchSize(size = 100)
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private List<Question> questions = new ArrayList<>();
@Query("select distinct u from User u left join u.articles left join u.questions")
Page<User> findAllPage2(Pageable pageable);

주의해야할 점은 batch size에 fetch join을 걸면 안됩니다.

fetch join이 우선시되어 적용되기 때문에 batch size가 무시되고 fetch join을 인메모리에서 먼저 진행하여 List가 MultipleBagFetchException가 발생하거나, Set을 사용한 경우에는 Pagination의 인메모리 로딩을 진행합니다.

일단 fetch join이 아닌 지연로딩으로 User만 가져온 모습이며,

<img src="https://images.velog.io/images/jinyoungchoi95/post/98c21469-1d40-4d6d-a89b-a7f75eb519a7/19.png alt="19" style="zoom:33%; margin: 0px auto" />

필요한 article값을 출력하고자할 때 article만 따로 batch 쿼리를 날려 받음을 알 수 있습니다.

결론

너무 많은 이야기를 해온 것 같아 마지막으로 모든 케이스를 항목화할 필요가 있을 것 같아 정리해보면 다음과 같습니다.

  • 즉시로딩
    • jpql을 우선적으로 select하기 때문에 즉시로딩을 이후에 보고 또다른 쿼리가 날아가 N+1
  • 지연로딩
    • 지연로딩된 값을 select할 때 따로 쿼리가 날아가 N+1
  • fetch join
    • 지연로딩의 해결책
    • 사용될 때 확정된 값을 한번에 join에서 select해서 가져옴
    • Pagination이나 2개 이상의 collection join에서 문제가 발생
  • Pagination
    • fetch join 시 limit, offset을 통한 쿼리가 아닌 인메모리에 모두 가져와 application단에서 처리하여 OOM 발생
    • BatchSize를 통해 필요 시 배치쿼리로 원하는 만큼 쿼리를 날림 > 쿼리는 날아가지만 N번 만큼의 무수한 쿼리는 발생되지 않음
  • 2개 이상의 Collection join
    • List 자료구조의 2개 이상의 Collection join(~ToMany관계)에서 fetch join 할 경우 MultipleBagFetchException 예외 발생
    • Set자료구조를 사용한다면 해결가능 (Pagination은 여전히 발생)
    • BatchSize를 사용한다면 해결가능 (Pagination 해결)

정리를 해도 많은게 눈에 보이네요. 즉시로딩부터 fetch join까지 이야기하면서 fetch join의 문제점 역시 함께 알아가야 N+1문제에 대한 모든 해결방안을 알 수 있다고 생각해서 어쩔 수 없는 선택이었습니다. 😥

해결하고자하는 상황, 그리고 주어진 정책에 따라 여러가지의 판단을 할 수 있겠지만, 제가 생각하기에는 지연로딩은 항상 기본으로 깔고 들어가며 Pagination 상황이 가정되지 않는다면 Set 자료구조를 사용해서 MultipleBagFetchException를 예방하고 Paginatioin이 필수적으로 들어가는 상황이라면 Batch Size를 사용할 것 같습니다.

참고 링크

JPA N+1 발생원인과 해결 방법

MultipleBagFetchException 발생시 해결 방법

JPA N+1 문제 및 해결방안

자바 ORM 표준 JPA 프로그래밍 - 기본편

profile
오늘 짠 코드가 5년뒤에 봐도 좋은 코드가 되길 바라는 개발자입니다.

4개의 댓글

comment-user-thumbnail
2022년 3월 22일

대단해요!!!!!

답글 달기
comment-user-thumbnail
2022년 8월 12일

정성스러운 글 잘봤습니다.
제가 겪은 N+1 로 겪은 모든 문제들과 해결책이 있네요

답글 달기
comment-user-thumbnail
2022년 11월 17일

보통 지연로딩을 기본값으로 해놓고 쓰는데, 즉시로딩에 대한 장점, 쓰임새도 있으면 좋을 것 같았네요. 실무에 절대 쓰지말아야 할 즉시로딩이라 되어있어서 좀 놀랐습니다. 찾아보면 나오겠지만 연관되어 있는 엔티티간에 쓰임새가 거의 같이 일어나는 관계라면 지연로딩보다 즉시로딩이 성능에 더 좋습니다.

  • batch size 성능이슈까지 다룬거면 정말.. 대단하네요! 글 잘보고갑니다~!
답글 달기
comment-user-thumbnail
2024년 3월 4일

N+1 문제를 다룰 때마다 참고하고 있습니다. 좋은 글 감사합니다!

답글 달기