N + 1 문제에 대해서는 정말 많은 사람들이 마주하는 문제이고 이에 관한 글들도 많다. 간단히 말하자면 Spring Boot 에서는 연관관계를 Join할 때 fetchType을 설정할 수 있다. 크게 Eager, Lazy로 구분된다.

FetchType.EAGER - 엔티티를 조회할 때 연관된 데이터를 한 번에 로드하는 방식 FetchType.LAZY - 연관관계의 엔티티 데이터가 사용되기 전까지는 프록시로 두고 실제 사용이 일어나게 되었을 때 로드하는 방식.

N + 1문제는 LAZY 방식을 사용했을 때 직면하게 되는 문제이다. 처음 엔티티를 가져올 때 쿼리가 한 번 실행되고 이후, 연관된 데이터를 실제 사용을 하게 되면 그 때 연관관계 엔티티에 대한 추가적인 쿼리가 실행되기 때문에 이를 N + 1 문제라고 한다.

쿼리가 많아지게 되면 어떤 문제가 발생할까?

당연히, 성능이 저하되고 DB의 부하가 생기게 된다 (당연한거 아니야?). 맞다, 당연하다. 그런데 고려해야할 사항이 하나 더 존재한다. 1000개의 데이터를 한 번의 쿼리로 가져오는 것과 1개의 데이터를 1000번의 쿼리로 가져오는 것 중에 어느 것이 더 빠른지에 대한 문제이다. 이 또한, 당연히 전자의 경우가 성능이 더 좋다.

<aside> 💡 1. 쿼리가 많이 발생하면 성능저하가 발생하고 DB의 부하가 커진다. 2. 1개의 데이터를 1000번 쿼리하는 것보다 1000개의 데이터를 1번 쿼리하는 것이 성능이 더 좋다.

</aside>

문제 해결 과정

아래는 N + 1문제가 발생한 쿼리이다. 목표 데이터 10개를 쿼리한 결과 목표 데이터 10개를 쿼리한 뒤 데이터 수 만큼 연관관계의 엔티티에 대한 쿼리가 실행되었음을 볼 수 있다.

// 목표 데이터 10개 쿼리문 
Hibernate: 
    select
        t1_0.id,
        t1_0.created_date,
        d1_0.id,
        d1_0.created_date,
        d1_0.name,
        d1_0.picture,
        t1_0.thumbnail,
        t1_0.title,
        t1_0.user_id 
    from
        travel t1_0 
    left join
        destination d1_0 
            on d1_0.id=t1_0.destination_id 
    order by
        t1_0.created_date desc 
    limit
        ?, ?
Hibernate: 
    select
        count(t1_0.id) 
    from
        travel t1_0
// 왼쪽 쿼리에 이어서 추가적으로 발생하는 
// 연관관계 엔티티 쿼리 (N + 1)
Hibernate: 
    select
        t1_0.travel_id,
        t1_1.id,
        t1_1.created_date,
        t1_1.name 
    from
        travel_tag t1_0 
    join
        tag t1_1 
            on t1_1.id=t1_0.tag_id 
    where
        t1_0.travel_id=?
.
.
.
// 총 10 번 반복

처음에는 @EntityGraph 어노테이션을 추가하여 Fetch Join으로 연관관계의 엔티티를 한 번에 로드함으로써 해결하였다.

Hibernate: 
    select
        t1_0.id,
        t1_0.created_date,
        d1_0.id,
        d1_0.created_date,
        d1_0.name,
        d1_0.picture,
        t2_0.travel_id,
        t2_1.id,
        t2_1.created_date,
        t2_1.name,
        t1_0.thumbnail,
        t1_0.title,
        t1_0.user_id    
  from
        travel t1_0 
left join
        destination d1_0 
            on d1_0.id=t1_0.destination_id 
    left join
        travel_tag t2_0 
            on t1_0.id=t2_0.travel_id 
    left join
        tag t2_1 
            on t2_1.id=t2_0.tag_id 
    order by
        t1_0.created_date desc
Hibernate: 
    select
        count(t1_0.id) 
    from
        travel t1_0

N 번의 추가 쿼리 없이 한 번의 쿼리로 연관관계의 데이터까지 모두 로드한 것을 볼 수 있다.

얼마나 쿼리 성능이 개선되었는지 총 1만개의 데이터를 삽입하여 10개, 100개, 1000개의 데이터 쿼리 시간을 비교하여 분석해보자.

데이터 양 N + 1 쿼리 Fetch Join을 이용한 쿼리
10개 32ms 405ms
100개 42ms 430ms
1000개 358ms 391ms

뭔가… 뭔가 이상하다. 분명 쿼리는 1개로 줄었는데 왜 심각한 성능저하를 보인다.


새로운 문제의 등장