JPA에서의 N+1 문제란?

프로젝트를 진행하던 중 N+1 문제를 만난 적이 있었습니다. 그당시는 짧은 검색을 통해 join fetch를 하면 해결된다로 넘어갔었는데, 이번 기회에 여러 해결 방법과 함께 알아보면 좋을 것 같았습니다. N+1 문제는 일반적으로 지연 로딩으로 인해 발생합니다. 연관된 데이터를 가져오기 위해 N개의 추가 쿼리문이 실행되는 문제입니다. 말로만 설명하기엔 너무 어려운거 같군요. 예시를 들어보겠습니다.

 

 

배경

데이터베이스는 H2 데이터베이스를 사용했으며, 쿼리 조회와 DB 설정은 다음 사이트를 참고했습니다.

https://www.baeldung.com/spring-boot-h2-database

 

Spring Boot With H2 Database | Baeldung

Learn how to configure and how to use the H2 database with Spring Boot.

www.baeldung.com

https://lannstark.tistory.com/14

 

Spring Boot SQL 보기 옵션 총 정리

Spring Boot에서 query DSL을 사용하건 spring boot JPA를 사용하건, 쿼리를 튜닝할 때 SQL을 봐야할 때가 있다. 그럴 때 사용할 수 있는 몇 가지 옵션을 조사해 보았다. 환경 : Spring boot 2.* + hibernate 5.3.10 이

lannstark.tistory.com

 

 

예시로는 라쿤과 라쿤 그룹이 있습니다. 각각의 라쿤 집단에는 라쿤이 있습니다. 각각의 라쿤 그룹에 라쿤이 몇마리가 있는지 확인해 보겠습니다. 예제에서는 count() 쿼리가 아닌, 리스트를 가져와서 size() 메서드로 마리수를 측정한다고 가정하겠습니다. 전체 라쿤 그룹과 그룹에 속해있는 라쿤들을 모두 조회하는 쿼리의 구조는 다음과 같을 것입니다.

SELECT {라쿤 그룹의 속성들}, {라쿤의 속성들}
FROM raccoon_group g inner join raccoon r on g.id = r.group_id;

 

위 쿼리의 실행 결과를 엔티티에 맞추어서 넣어주면 한번만 쿼리를 날리면 될 것 같습니다. 하지만 Lazy Loading 으로 연관관계를 설정해준다면 생각한 대로 동작하지 않습니다. 다음은 예제 코드와 실행 결과입니다.

@Test
void list() {
    final List<RaccoonGroup> raccoonGroups = groupRepository.findAll();

    for (final RaccoonGroup raccoonGroup : raccoonGroups) {
        System.out.println(raccoonGroup.getName() + ":" + raccoonGroup.getRaccoons().size() + "마리");
    }

    entityManager.flush();
    entityManager.clear();
}

쿼리가 총 3개, 맨 위에서부터 전체 그룹 조회, A 그룹 조회, B 그룹 조회 쿼리가 날라가는 것을 볼 수 있습니다. 그렇다면 왜 이렇게 쿼리가 날라갈까요? 그 이유는 연관관계에 걸어준 Lazy Loading 때문입니다.

 

 

Lazy Loading

연관관계 설정된 객체를 프록시 객체로 가지고 있는 방식입니다. 객체를 실제로 사용하는 시점에서야 데이터베이스에서 해당 객체를 조회합니다. 앞서 raccoonGroup 내부에 있는 raccoon들은 실제 그룹을 조회하는 시점(getRaccoons)이 되서야 데이터베이스에서 조회가 된 것이었습니다. 실제로 일반적인 Collection이 아닌 프록시 객체인 PersistentBag으로 변환되어 저장되는 것을 볼 수 있습니다.

프록시 객체인 PersistentBag으로 저장된 모습

 

실제로 PersistentBag의 get() 메서드를 들어가 보면 컬렉션에서 데이터를 조회하기 전에 read() 메서드로 데이터를 조회해 오는 것을 볼 수 있습니다. 참고로 bag은 저희가 Entity의 필드를 선언할 때 써준 List라고 보시면 됩니다.

그렇다면 왜 데이터 조회 시 프록시 객체를 사용할까요? 이유는 필요하지 않은 많은 데이터를 가져올 수 있기 때문입니다. 예시로 저희는 라쿤이 몇마리인지에만 관심이 있지 각각의 라쿤이 무슨 먹이를 좋아하는지는 관심이 없습니다. 이러한 상황에서 먹이 데이터까지 불러오면 불필요한 데이터까지 DB에서 불러오기 때문에 메모리 낭비가 있을 수 있습니다.

 

https://www.baeldung.com/hibernate-lazy-eager-loading

 

Eager/Lazy Loading In Hibernate | Baeldung

Quick and practical introduction to different data loading approaches - lazy and eager - in Hibernate.

www.baeldung.com

앞서서 N+1 문제가 무엇인지와 왜 발생하는지에 대해서 살펴보았습니다. 이제 N+1을 해결하는 방법에 대해서 알아보겠습니다.

 

Join Fetch

이전에는 전체 객체를 조회하고, 객체와 연관관계로 묶여있는 다른 객체를 하나씩 조회하기 때문에 N+1 문제가 발생했었습니다.

Join Fetch 방법은 JPQL을 사용하여 명시적으로 Join으로 연관된 데이터까지 모두 영속화시키는 방법입니다. 추후 연관관계로 묶여 있는 엔티티를 사용할 때, 이미 영속화되어 있으므로 추가 쿼리가 발생하지 않습니다.

참고로 OneToMany 관계이므로 조인으로 인해 그룹이 중복되어 조회될 수도 있으므로 distinct를 사용해서 중복되지 않도록 처리하였습니다.

public interface GroupRepository extends JpaRepository<RaccoonGroup, Long> {

    @Query("select distinct g from RaccoonGroup g join fetch g.raccoons")
    List<RaccoonGroup> findAllByFetch();
}

 

Join Fetch 방법의 단점으로는 페이지네이션과 함께 사용할 경우 limit이 정상적으로 걸리지 않아 원하는 결과가 나오지 않을 수 있으며 쿼리 결과를 모두 메모리에 적재한 다음 페이지네이션을 하기 때문에 성능 상 치명적으로 좋지 않다는 문제점이 있습니다.

https://tecoble.techcourse.co.kr/post/2020-10-21-jpa-fetch-join-paging/

 

BatchSize 설정으로 해결하기

N+1 문제가 조회해온 전체 엔티티의 각각의 연관관계를 하나씩 조회해오기 때문에 생기는 문제이므로, select의 in절을 이용하여 한번에 여러 연관관계 엔티티를 조회해오는 방식입니다. 이번 예제에서는 한번에 두개의 라쿤 그룹에 속해있는 라쿤들을 조회해오도록 작성해 보겠습니다.

@OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.PERSIST, orphanRemoval = true)
@BatchSize(size = 2)
private List<Raccoon> raccoons;

 

 

Entity Graph

attributePaths에 가져올 엔티티를 직접적으로 명시해 줍니다. 연관관계가 있는 필드명을 작성해 주면, 해당 엔티티를 left outer join으로 가져옵니다.

@EntityGraph(attributePaths = {"raccoons"})
@Query("select g from RaccoonGroup g")
List<RaccoonGroup> findAllByEntityGraph();

 

결론

지금까지 N+1 문제를 해결하기 위한 세가지 방법을 살펴보았습니다. 어떤 방법이 가장 좋다라고 정의하기는 어렵고 상황에 맞추어서 사용하면 될 것 같습니다. 각 방법에 대해 간단하게 정리하겠습니다.

FetchJoin

  • 많이 사용되는 방법, 한번에 연관관계에 있는 데이터를 모두 불러온다.
  • Pagination과 함께 사용되는 경우 전체 데이터를 메모리에 불러와서 페이지네이션을 한다는 큰 문제점이 있음

EntityGraph

  • 명시해둔 AttributePath에 해당하는 연관관계 데이터를 불러올 수 있다.
  • 사용방법을 더 자세히 알아보기 위해서는 NamedEntityGraph를 찾아본다.
  • left outer join을 사용하기 때문에 null값이 존재할 수 있다.
  • AttributePath를 잘못 입력하는 경우 런타임에서 예외가 발생한다.

BatchSize

  • in절을 사용하여 데이터를 BatchSize만큼 한번에 가져올 수 있다.
  • 적절한 BatchSize를 찾아야 한다.

+ Recent posts