Fetch Join을 활용해서 n+1쿼리 개선하기

전인표
Dong-gle
Published in
23 min readSep 24, 2023

이번 포스팅에서 N+1문제를 Fetch Join을 사용해서 개선한 과정을 작성해 보겠다!!

1. 글의 내용 가져오는 쿼리 성능 개선

프로젝트를 진행하면서 성능 최적화를 위해 “지연 로딩”(Lazy Loading)전략을 사용해서 DB의 데이터를 가져왔다.

위에 writing 도메인을 보면 writing이라는 엔티티 안에 필드로 Blocks를 가지고있다.

왜냐하면 위 사진과같이 글 내부에 형태(h1, h2,…리스트 등등)가 여러개로 구성될 수 있기 때문이다.

(파란색 블럭이 1개의 블럭을 나타낸다.)

즉, 하나의 글안에 여러개의 블럭을 가지고있는 1:N관계로 DB에 저장하고있고,

블럭마다 저장되는 형태도 다름으로 다음과같이 여러형태가 있고 Block으로 추상화를 적용하였다.

이러한 지연로딩은 글(writing)의 제목 수정, 글 제목 가져오기 등등 Lazy Loading을 통해 글의 내용물이 아닌 정보만 필요할 때 성능의 이점을 차지할 수 있었지만,

위와같이 1번. 글(writing)을 찾고,

2번. 클라이언트에서 html형태로 보여주기 위해 랜더링을 진행할때

블럭에 맞는 html형태로 랜더링 중 NormalBlock에 getStyles() 메소드를 실행하면

select 쿼리가 나가 Blocks에서 타입이 NormalBlock의 개수 만큼 styles을 select를 하고있다.

성능 최적화를 하려고 적용시킨 지연 로딩 전략이 오히려 다음과같이 n+1문제가 발생하고 있었다…

다음은 실제 글을 가져오고 랜더링과정까지의 실제 쿼리이다.

실제 쿼리(많이 길어요…)

select
*
from
writing w
where
w.member_id = ?
and w.id = ?
and w.status != 'DELETED'
select
b1_0.writing_id,
b1_0.id,
b1_0.dtype,
b1_0.block_type,
b1_0.created_at,
b1_0.depth,
b1_0.updated_at,
b1_1.language,
b1_1.raw_text,
b1_2.raw_text,
b1_3.image_caption,
b1_3.image_url,
b1_4.raw_text,
b1_4.id
from
block b1_0
left join
code_block b1_1
on b1_0.id=b1_1.id
left join
horizontal_rules_block b1_2
on b1_0.id=b1_2.id
left join
image_block b1_3
on b1_0.id=b1_3.id
left join
normal_block b1_4
on b1_0.id=b1_4.id
where
b1_0.writing_id=?
select
s1_0.normal_block_id,
s1_0.id,
s1_0.created_at,
s1_0.end_index,
s1_0.start_index,
s1_0.style_type,
s1_0.updated_at
from
style s1_0
where
s1_0.normal_block_id=?
Hibernate:
select
s1_0.normal_block_id,
s1_0.id,
s1_0.created_at,
s1_0.end_index,
s1_0.start_index,
s1_0.style_type,
s1_0.updated_at
from
style s1_0
where
s1_0.normal_block_id=?
Hibernate:
select
s1_0.normal_block_id,
s1_0.id,
s1_0.created_at,
s1_0.end_index,
s1_0.start_index,
s1_0.style_type,
s1_0.updated_at
from
style s1_0
where
s1_0.normal_block_id=?
Hibernate:
select
s1_0.normal_block_id,
s1_0.id,
s1_0.created_at,
s1_0.end_index,
s1_0.start_index,
s1_0.style_type,
s1_0.updated_at
from
style s1_0
where
s1_0.normal_block_id=?
Hibernate:
select
s1_0.normal_block_id,
s1_0.id,
s1_0.created_at,
s1_0.end_index,
s1_0.start_index,
s1_0.style_type,
s1_0.updated_at
from
style s1_0
where
s1_0.normal_block_id=?
Hibernate:
select
s1_0.normal_block_id,
s1_0.id,
s1_0.created_at,
s1_0.end_index,
s1_0.start_index,
s1_0.style_type,
s1_0.updated_at
from
style s1_0
where
s1_0.normal_block_id=?
Hibernate:
select
s1_0.normal_block_id,
s1_0.id,
s1_0.created_at,
s1_0.end_index,
s1_0.start_index,
s1_0.style_type,
s1_0.updated_at
from
style s1_0
where
s1_0.normal_block_id=?
Hibernate:
select
s1_0.normal_block_id,
s1_0.id,
s1_0.created_at,
s1_0.end_index,
s1_0.start_index,
s1_0.style_type,
s1_0.updated_at
from
style s1_0
where
s1_0.normal_block_id=?
Hibernate:
select
s1_0.normal_block_id,
s1_0.id,
s1_0.created_at,
s1_0.end_index,
s1_0.start_index,
s1_0.style_type,
s1_0.updated_at
from
style s1_0
where
s1_0.normal_block_id=?
Hibernate:
select
s1_0.normal_block_id,
s1_0.id,
s1_0.created_at,
s1_0.end_index,
s1_0.start_index,
s1_0.style_type,
s1_0.updated_at
from
style s1_0
where
s1_0.normal_block_id=?
Hibernate:
select
s1_0.normal_block_id,
s1_0.id,
s1_0.created_at,
s1_0.end_index,
s1_0.start_index,
s1_0.style_type,
s1_0.updated_at
from
style s1_0
where
s1_0.normal_block_id=?
Hibernate:
select
s1_0.normal_block_id,
s1_0.id,
s1_0.created_at,
s1_0.end_index,
s1_0.start_index,
s1_0.style_type,
s1_0.updated_at
from
style s1_0
where
s1_0.normal_block_id=?
Hibernate:
select
s1_0.normal_block_id,
s1_0.id,
s1_0.created_at,
s1_0.end_index,
s1_0.start_index,
s1_0.style_type,
s1_0.updated_at
from
style s1_0
where
s1_0.normal_block_id=?
Hibernate:
select
s1_0.normal_block_id,
s1_0.id,
s1_0.created_at,
s1_0.end_index,
s1_0.start_index,
s1_0.style_type,
s1_0.updated_at
from
style s1_0
where
s1_0.normal_block_id=?
Hibernate:
select
s1_0.normal_block_id,
s1_0.id,
s1_0.created_at,
s1_0.end_index,
s1_0.start_index,
s1_0.style_type,
s1_0.updated_at
from
style s1_0
where
s1_0.normal_block_id=?
Hibernate:
select
s1_0.normal_block_id,
s1_0.id,
s1_0.created_at,
s1_0.end_index,
s1_0.start_index,
s1_0.style_type,
s1_0.updated_at
from
style s1_0
where
s1_0.normal_block_id=?
Hibernate:
select
s1_0.normal_block_id,
s1_0.id,
s1_0.created_at,
s1_0.end_index,
s1_0.start_index,
s1_0.style_type,
s1_0.updated_at
from
style s1_0
where
s1_0.normal_block_id=?
Hibernate:
select
s1_0.normal_block_id,
s1_0.id,
s1_0.created_at,
s1_0.end_index,
s1_0.start_index,
s1_0.style_type,
s1_0.updated_at
from
style s1_0
where
s1_0.normal_block_id=?
Hibernate:
select
s1_0.normal_block_id,
s1_0.id,
s1_0.created_at,
s1_0.end_index,
s1_0.start_index,
s1_0.style_type,
s1_0.updated_at
from
style s1_0
where
s1_0.normal_block_id=?
Hibernate:
select
s1_0.normal_block_id,
s1_0.id,
s1_0.created_at,
s1_0.end_index,
s1_0.start_index,
s1_0.style_type,
s1_0.updated_at
from
style s1_0
where
s1_0.normal_block_id=?
Hibernate:
select
s1_0.normal_block_id,
s1_0.id,
s1_0.created_at,
s1_0.end_index,
s1_0.start_index,
s1_0.style_type,
s1_0.updated_at
from
style s1_0
where
s1_0.normal_block_id=?
Hibernate:
select
s1_0.normal_block_id,
s1_0.id,
s1_0.created_at,
s1_0.end_index,
s1_0.start_index,
s1_0.style_type,
s1_0.updated_at
from
style s1_0
where
s1_0.normal_block_id=?
Hibernate:
select
s1_0.normal_block_id,
s1_0.id,
s1_0.created_at,
s1_0.end_index,
s1_0.start_index,
s1_0.style_type,
s1_0.updated_at
from
style s1_0
where
s1_0.normal_block_id=?
Hibernate:
select
s1_0.normal_block_id,
s1_0.id,
s1_0.created_at,
s1_0.end_index,
s1_0.start_index,
s1_0.style_type,
s1_0.updated_at
from
style s1_0
where
s1_0.normal_block_id=?
Hibernate:
select
s1_0.normal_block_id,
s1_0.id,
s1_0.created_at,
s1_0.end_index,
s1_0.start_index,
s1_0.style_type,
s1_0.updated_at
from
style s1_0
where
s1_0.normal_block_id=?
Hibernate:
select
s1_0.normal_block_id,
s1_0.id,
s1_0.created_at,
s1_0.end_index,
s1_0.start_index,
s1_0.style_type,
s1_0.updated_at
from
style s1_0
where
s1_0.normal_block_id=?
Hibernate:
select
s1_0.normal_block_id,
s1_0.id,
s1_0.created_at,
s1_0.end_index,
s1_0.start_index,
s1_0.style_type,
s1_0.updated_at
from
style s1_0
where
s1_0.normal_block_id=?
Hibernate:
select
s1_0.normal_block_id,
s1_0.id,
s1_0.created_at,
s1_0.end_index,
s1_0.start_index,
s1_0.style_type,
s1_0.updated_at
from
style s1_0
where
s1_0.normal_block_id=?
Hibernate:
select
s1_0.normal_block_id,
s1_0.id,
s1_0.created_at,
s1_0.end_index,
s1_0.start_index,
s1_0.style_type,
s1_0.updated_at
from
style s1_0
where
s1_0.normal_block_id=?
Hibernate:
select
s1_0.normal_block_id,
s1_0.id,
s1_0.created_at,
s1_0.end_index,
s1_0.start_index,
s1_0.style_type,
s1_0.updated_at
from
style s1_0
where
s1_0.normal_block_id=?

그래서 글을 랜더링을 하기 전에만 글의 모든 내용을 가져오도록 하기위해 fetch join을 적용하기로했다.

Fetch Join이란?

fetch join은 SQL에서 사용하는 조인이 아니고 성능최적화를 위해 JPQL에서 제공하는 기능이다.

다시말해 fetch join의 주요 목적은 관련된 엔티티나 컬렉션을 즉시 로직하기 위한것이다.

실제 코드에 적용

Hibernate: 
select
*
from
writing w
where
w.member_id = ?
and w.id = ?
and w.status != 'DELETED'
Hibernate:
select
b1_0.writing_id,
b1_0.id,
b1_0.dtype,
b1_0.block_type,
b1_0.created_at,
b1_0.depth,
b1_0.updated_at,
b1_1.language,
b1_1.raw_text,
b1_2.raw_text,
b1_3.image_caption,
b1_3.image_url,
b1_4.raw_text,
b1_4.id
from
block b1_0
left join
code_block b1_1
on b1_0.id=b1_1.id
left join
horizontal_rules_block b1_2
on b1_0.id=b1_2.id
left join
image_block b1_3
on b1_0.id=b1_3.id
left join
normal_block b1_4
on b1_0.id=b1_4.id
where
b1_0.writing_id=?

원래 쿼리는 위와같이 삭제되지 않은 글을 select하고 그다음으로 글이 가지고있는 Blocks를 select해서 가져온다.(2번 select)

글을 랜더링할때는 무조건 Blocks를 모두 가져와야하기때문에

Fetch Join을 적용한 findByWithBlocks메소드를 구현하여 다음과 같이 하나의 쿼리로 줄였다.

다음으로 현재 NormalBlock이 가지고있는 Styles를 가져오는 쿼리가 밑에처럼 n번 나가고있었다.

Hibernate: 
select
s1_0.normal_block_id,
s1_0.id,
s1_0.created_at,
s1_0.end_index,
s1_0.start_index,
s1_0.style_type,
s1_0.updated_at
from
style s1_0
where
s1_0.normal_block_id=?
Hibernate:
select
s1_0.normal_block_id,
s1_0.id,
s1_0.created_at,
s1_0.end_index,
s1_0.start_index,
s1_0.style_type,
s1_0.updated_at
from
style s1_0
where
s1_0.normal_block_id=?
.
.
.
.
. select normal_block의 스타일
.
.
.
.
n번 select

그래서 findStyleByNormalBlocks메소드 내부에 normalBlocks을 찾는 코드를 추가하고

findStylesForBlocks라는 Fetch Join을 적용한 메소드를 만들어 NormalBlock의 스타일을 한번에 찾아오도록 수정했다.

이렇게 쿼리를 수정해서 다음과같이 여러번의 쿼리를 제거하고 2번의 쿼리만으로 글을 불러오도록 개선할 수 있었다.

2. 글의 세부 내용(발행 정보)을 가져오는 쿼리 성능 개선

동글 서비스에서 카테고리를 클릭하면 카테고리안에있는 글의 제목과 발행한 블로그 플랫폼 등등 글의 대한 정보를 확인할 수 있는데

글의 정보를 가져오는 하나의 작업에서 사용하는 쿼리를 살펴보면

3,4,5번을 보면 발행한 블로그의 정보,테그 목록의 정보를 가져올때

글의 개수*2만큼 select 쿼리를 실행하는거를 볼 수 있다.

(글의 발행정보,발행한 테그정보 각각 n+1문제가 발생하고 있다.)

이러한 문제 또한 위에 normalBlock의 style을 가져온거처럼 Fetch Join을 적용해서 쿼리를 개선할 수 있다.

문제의 코드

문제의 코드를 보면 1,2번까지는 문제가 없다.

3번을 보면 정렬된 sortedWriting에 stream을 사용해서 Response를 만들고있는데

이때 글의 개수만큼 convertToPublishedDetailResponses메소드를 실행시켜

BlogWriting을 select를 하고(n+1 발생)

BlogWriting필드에서 tag정보는 @ElementCollection 어노테이션을 사용하고 있다.

@ElementCollection 어노테이션은 기본적으로 지연로딩이기 때문에 getTags()메소드를 호출할때마다 쿼리가 실행되서

n+1문제가 발생하여 총 2번의 n+1문제가 발생하고 있었다.

그래서 다음과같이 코드를 수정하였는데 먼저 findWithBlogWritings메소드를 통해 발행한 블로그 정보와 tags의 정보를 모두 가져오고 groupingBy메소드를 사용해서 Map<writing, list> 형식으로 변경하여 Response를 만드는 형태로 로직을 수정하였다.

이렇게 변경한 로직으로 쿼리를 다시 실행해보면 2번의 쿼리로 개선할 수 있었다.

select
w1_0.id,
w1_0.category_id,
w1_0.created_at,
w1_0.member_id,
w1_0.next_writing_id,
w1_0.status,
w1_0.title,
w1_0.updated_at
from
writing w1_0
where
w1_0.category_id=?
and w1_0.status=?
select
b1_0.id,
b2_0.id,
b2_0.blog_type,
b2_0.created_at,
b2_0.updated_at,
b1_0.created_at,
b1_0.published_at,
t1_0.blog_writing_id,
t1_0.tags,
b1_0.updated_at,
b1_0.url,
b1_0.writing_id
from
blog_writing b1_0
left join
blog b2_0
on b2_0.id=b1_0.blog_id
left join
blog_writing_tags t1_0
on b1_0.id=t1_0.blog_writing_id
where
b1_0.writing_id in (?,?,?)

마치며

동글의 서비스에 사용되는 쿼리를 하나하나 살펴보면서 n+1문제가 발생하는 부분에 Fetch Join을 써서 해결을하였다.

프로젝트에서 Fetch Join을 써서 바꿔봤지만 @EntityGraph 어노테이션을 통해서도 Fetch Join을 할 수 있다.

위에서 사진처럼, @EntityGraph 어노테이션을 사용하여 변경하는 것이 가능하다.

그러나 이 어노테이션을 사용할 때 주의할 점은, @EntityGraph어노테이션은 오직 LEFT JOIN만 수행할 수 있다는 것이다.

따라서 상황에 따라 적절한 JOIN 전략을 고려하여 사용하면 될거같다!!

--

--