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개로 줄었는데 왜 심각한 성능저하를 보인다.