개발 일지/Spring

[JPA] N+1 문제에 대해서

미숫가루설탕많이 2023. 6. 30. 13:35

위 로직은 이력서 도메인과 1:N 관계의 entity들이다. 1개의 이력서에는 기술 스택, 교육 사항, 프로젝트, 경력 등을 여러 개 입력할 수 있다.

 

여기서 하나의 이력서(CV)를 조회할 때, N+1 문제가 발생하는 것을 확인했다.

N+1 문제 발생

 

로그를 보면, 하나의 이력서(CV)를 조회하는 쿼리가 날아가고 추가적으로 연관된 객체들이 조회되는 것을 확인할 수 있다. 만약 여러 개의 이력서를 조회한다고 가정하고 이력서의 개수가 N개라면 N * (1 + 8)개의 쿼리가 발생한다. 극단적으로 예를 들면 10000개의 이력서가 있고 findAll()로 이력서들을 조회하면 90000개의 쿼리가 발생할 것이다.

 

따라서 1번의 쿼리에 N번의 쿼리가 추가 실행되므로, 쿼리 실행 횟수가 증가하여 성능에 부담을 주고 데이터베이스에 부하가 발생하게 된다.

 

 

 

 

왜 N+1 문제가 발생하게 되는 걸까?

 

해당 문제는 1:N 관계를 가진 엔티티를 JPA Repository를 활용해서 조회할 때 발생한다.

 

JPA는 기본적으로 연관된 엔티티를 지연로딩하는 방식을 사용하는데, 이는 연관된 엔티티를 초기에 로드하지 않고 실제로 접근해야할 때 로드하는 것이다.

(Fetch type은 default로 -ToMany에서는 Lazy, -ToOne에서는 Eager로 설정되어 있다.)

 

즉 상위 엔티티(CV)를 조회할 때 지연로딩이 설정되어 있으면, 실제로 하위 엔티티(cvSkillStack, education, project 등)들을 로드하기 위해 추가 쿼리가 발생한다.

 

 

 

 

 

N+1 문제를 해결하는 방법은 어떤 게 있을까?

 

구글링을 해보면 나오는 대표적인 방법은 Fetch Join, @EntityGraph, @BatchSize가 있다.

 

해당 방법들에 대해서는 아래에 기술하겠다.

 

 

 

즉시로딩(FetchType.EAGER)으로 연관된 엔티티들을 같이 조회하면 문제가 해결되지 않을까?

 

CV와 1:N 관계에 있는 Link에 EAGER을 적용했다고 가정해보면 CV가 조회됨과 동시에 Link까지 즉시로딩되면서 N+1 문제가 발생하지 않는 것처럼 보일 수 있다.

 

하지만 모든 CV를 조회한다면..? JPA는 Link에 EAGER가 걸려있는 것을 보고 select한 모든 CV에 대해 Link가 있는지 확인할 것이다. 즉 모든 CV를 조회하기 위해 select 쿼리를 하나 날렸지만, 각각의 CV가 가진 Link를 모두 검색하기 때문에 다시 N+1 문제가 발생한다.

 

따라서, 즉시 로딩은 EAGER를 감지하면서 N+1 문제가 다시 발생할 수 있어서 해결 방식으로는 적합하지 않다.

 

 

 

 

 

해결 방법 (Fetch Join, @EntityGraph, @BatchSize)


1. Fetch Join

 

N+1 문제가 발생하는 이유는 한쪽 테이블만 조회하고, 연결된 다른 테이블은 따로 조회하기 때문이다. 그래서 두 테이블을 JOIN 해서 한 번에 모든 데이터를 가져오게 하여 N+1 문제를 방지하는 방법이다.

 

public interface CvRepository extends JpaRepository<Cv, Long> {
    @Query("select distinct cv from Cv cv left join fetch cv.educations")
    List<Cv> findAll();
}

 

예를 들어 위 로직과 같이 join문에 fetch를 걸어주는 것이다. fetch는 지연 로딩이 걸려있는 연관관계에 대해 같이 즉시로딩 해주는 구문이다.

 

여기서 카테시안 곱 문제가 발생할 수 있다(@EntityGraph 동일). 카테시안 곱이란, 두 테이블 사이에 유효 JOIN 조건을 적지 않았을 때 해당 테이블에 대한 모든 데이터를 전부 결합해서 테이블에 존재하는 행 갯수를 곱한만큼의 값이 반환되는 것이다. 따라서 Set 컬렉션을 활용하거나 distinct를 사용하여 중복된 데이터를 방지해야 한다.

 

Fetch Join 방법의 단점으로는 쿼리 한번에 모든 데이터를 가져오기 때문에 Pageable 사용이 불가능하다. 그리고 뒤에서 다시 다룰 거지만 둘 이상의 Collection에 대해 fetch join이 불가능하다.

 

 

 

 

2. @EntityGraph

 

public interface CvRepository extends JpaRepository<Cv, Long> {
    @EntityGraph(attributePaths = "educations")
    List<Cv> findAll();
}

@EntityGraph는 fetch join을 편하게 사용하도록 도와주는 기능이다. attributePaths에 쿼리를 수행할 때 바로 가져올 필드명을 지정하면 Lazy가 아닌 Eager 조회로 가져온다. 마찬가지로 둘 이상의 Collection에는 적용할 수 없다.

 

 

 

 

3. @BatchSize

 

Hibernate가 제공하는 애너테이션으로, 연관된 엔티티를 조회할 때 지정된 size 만큼 엔티티를 where 절의 'in'으로 조회한다.

 

@BatchSize(size = 100)
@Entity
public class Cv {
}

위 로직처럼 클래스 단위로 설정하거나 application.yml 파일에서 전역으로 적용할 수도 있다.

spring.jpa.properties.hibernate.default_batch_fetch_size = 100

 

 

 

 

 

프로젝트에 적용하기


Fetch Join과 @EntityGraph의 방식은 에러가 발생한다.

 

찾아보니까 JPA에서 Fetch Join의 조건에 부합하지 않아서 발생하는 문제였다. 현재 cvSkillStacks, educations 등의 필드들은 List 컬렉션으로 구성되어 있다. 하지만 여기에 Fetch Join을 적용하게 되면 카테시안 곱에 의해서 중복 데이터가 발생할 수 있기 때문에 사전에 MultipleBagFetchExcepetion을 띄워서 에러 처리를 하는 것이다.

 

즉, JPA에서 Fetch Join을 사용하려면 아래의 조건에 부합해야 한다.

  1. -ToOne 관계에서는 개수 상관 x
  2. -ToMany 관계에서는 1개만 적용 가능

 

위와 같은 문제를 해결하려면 List 컬렉션들을 Set으로 변경해서 중복을 방지하는 방법이 있다. 하지만 서비스 로직까지 수정하는 작업이 필요하고 이력서는 중복 저장이 가능하기 때문에 다른 방법을 찾기로 했다.

 

--BatchSize, QueryDSL 고려중..