N + 1 문제에 대한 고찰

야부엉·2025년 2월 23일

1. 문제 상황

현재 프로젝트의 가게 정보를 보여 줄 때, 가게의 리뷰에 대한 평균 평점을 함께 보여줄려고 한다. 아래와 같이 일대다 양방향 관계로 맵핑이 되어있고, 지연로딩을 사용했다. 문제는 가게를 조회할 때 리뷰의 평점까지 함께 조회하면, 가게의 개수 만큼 리뷰(n개)의 쿼리가 발생한다. -> N + 1 문제

문제의 원인?

그렇다면 원인은 뭘까?

현재 findAll() 메서드는 Spring Data JPA에서 제공하는 쿼리 메서드 기능을 사용했다. 그렇다면 Spring Data JPA의 쿼리 메서드 작동부터 차근차근 생각해보자

  • Spring Data JPA는 메서드 이름을 분석해서 JPQL을 생성하고 실행한다. 즉, findAll()을 사용 시에, 아래와 같은 JPQL을 실행한다.
	select * from Store; 
  • 지연로딩이기 때문에, 반환된 객체들의 List는 프록시 객체가 들어있을거다.
  • for 구문을 통해 반환된 객체들의 각 List에 접근하면 객체가 초기화 되며 아래와 같은 쿼리가 연속적으로 발생할 것이다.
	select r from Review r where r.review = 'id'
  • 결론적으로 JPQL로 변환 시에 연관 관계에 관한 쿼리가 없기 때문에 아래와 같은 문제가 발생하는 것이다.

해결 과정

위에서 말했다시피 쿼리 메서드를 JPQL로 변환하는 과정에서의 문제이기 때문에, 쿼리 메서드를 사용하지 않고 두 테이블을 join하는 쿼리를 JPQL로 직접 정의하면 되지 않을까?

  1. 단순 join
    실제 join을 사용하는 JPQL을 아래와 같이 작성하고, Test를 진행하면,

    	@Test
    	void getAllStoresByJoin() {
    		List<Store> testStoreListUsingJoin = storeService.findAllWithReviewUsingJoin();
    		System.out.println(testStoreListUsingJoin.get(0).getReviews());
    	}
    

    아래와 같이 inner join 쿼리는 잘 작동되지만, 다음과 같은 에러가 발생한다.

    각 Store의 Lazy Entity인 Reviews가 아직 초기화되지 않았다는 것을 의미하는데, 그 이유는 JPQL은 결과를 반환 할 때 연관관계를 고려하지 않고, 단지 select 절에 지정한 entity만 조회하기 때문이다. Join은 store에 대한 엔터티만 조회하고, 연관된 review 엔터티는 조회하지 않는다는 것을 알 수 있다.

  2. Fetch Join
    위의 문제를 해결하기 위해 JPQL은 Fetch Join을 제공하는데, Fetch join은 연관된 엔터티를 한 번에 같이 조회한다.
    아래와 같이 작성하고, 테스트를 진행하면,

    	@Test
    	void getAllStoresByFetchJoin() {
    		List<Store> testStoreListUsingJoin = storeService.findAllWithReviewUsingFetchJoin();
    		for(Store store : testStoreListUsingJoin){
    			System.out.println(store.getName());
    			int tmp = 0;
    			for(Review review : store.getReviews()){
    				tmp += review.getScore();
    				System.out.println(tmp);
    			}
    		}
    		System.out.println(testStoreListUsingJoin.size());
    	}

    아래와 같이 에러 없이 결과값이 나오는 것을 알 수 있다.

    실제로 프로젝트를 실행하고, 요청을 시도하면 아래와 같이 원하는 결과값을 얻을 수 있다.

    하이버네이트가 5.x 이전 버전을 사용하는 경우 DISTINCT를 사용해서, 중복을 제거해야한다.

  3. @EntityGraph
    Fetch Join으로 N+1 문제를 해결하는 것을 확인했다. 그럼 다른 방법은 없을까? 대표적으로는 @EntityGraph가 있다.
    실제 @EntityGraph를 사용해서 테스트를 진행해보자. 아래와 같이 코드를 작성하고 위와 같은 똑같은 테스트를 진행했다.

    실제 결과를 보면, 아래와 같이 다른 결과를 볼 수 있다. EntityGraph를 사용할 경우 left join을 기본적으로 적용되는 것을 볼 수 있다.

2. 추가 고려 사항

만약 반환 타입이 List가 아닌 Page일 경우에는 어떻게 해야할까?
먼저 Fetch Join으로 한 번 접근해보자.

  1. Fetch Join
    아래와 같이 쿼리를 작성하여, 실행하면

    아래와 같은 경고 메세지가 발생한다.

    이 메세지는 Hibernate에서 페이징(firstResult, maxResults)과 컬렉션 Fetch Join을 함께 사용할 수 없다는 경고 메시지고, Hibernate가 데이터베이스에서 페이징을 적용할 수 없기 때문에, 메모리에서 페이징을 강제로 수행하고 있다는 의미라는 것을 알 수 있다. Fetch Join 이외의 방법은 없을까?
  2. @EntityGraph
    위에서 제시한 방법 중 하나인 @EntityGraph를 사용하면, 어떻게 될까?
    아래와 같이 코드를 작성하고, 위와 같이 테스트를 진행했지만 결과는 똑같은 경고 메세지를 받았다.

    즉, Hibernate는 일대다 관계에서는 limit과 offset이 적용이 안되고, 연관된 엔티티를 모두 불러오는 문제가 발생한다.
  3. Batch Size
    위의 두 방식은 결국 Limit이 적용이 안되는 문제가 있는 것이다. 그렇다면 Batch Size를 사용하면 어떨까?
    JPA Batch Size는 JPA의 성능 개선을 위한 옵션으로 간단하게 where이 같은 여러 개의 쿼리를 하나의 In쿼리로 만들어주는 옵션이다.
    일단 Batch Size를 아래와 같이 10으로 설정하고, 테스트를 진행해보자.


    테스트 진행 시, 하나의 쿼리가 더 추가되는 부분만 제외하고는 정상적으로 작동되는 것을 알 수 있다.

    결국 Batch Size 크기를 조절하여, N + 1 문제와 Page 작업을 해결 할 수 있다.

3. 추가 고려 사항

여러 검색 조건이 붙는 경우와 각 조건을 재사용성을 높이는 방법은 무엇일까? 예를 들면 isDeleted= boolean은 조회 시에, 무조건 적용되는 조건이다. 이러한 설정들을 함수화하여 재사용성을 높일 수는 없을까? 이러한 문제점을 해결하기 위해 내가 채택한 방식은 QueryDSL이다.

Batch Size를 사용하는 경우에 Batch Size 설정에 따라 쿼리의 개수가 결정되지만, QueryDsl을 이용하면 설정과 상관없이 해결 할 수 있다. 아래는 내가 최종적으로 구현한 store 조회 기능이다.

결론적으로 QueryDsl을 사용하여, N + 1 문제 뿐만 아니라 반환되는 평균값도 쿼리를 통해 해결하도록했고, where문에 있는 조건들도 메서드화 하여, 재사용성을 높였다.

profile
밤낮없는개발자

0개의 댓글