
진행 중인 프로젝트에서 뉴스피드를 불러올 때,
팔로우한 유저들의 게시글과 함께 연관 엔티티인 미디어 파일까지 함께 가져오려고 Fetch Join을 적용해봤습니다.
Hibernate: select distinct p1_0.id,p1_0.content,p1_0.created_at,p1_0.likes,mf1_0.post_id,mf1_0.id,mf1_0.created_at,mf1_0.modified_at,mf1_0.url,p1_0.modified_at,p1_0.title,p1_0.user_id from post p1_0 left join media_file mf1_0 on p1_0.id=mf1_0.post_id where p1_0.user_id in (select f1_0.following_id from follows f1_0 where f1_0.follower_id=?) order by p1_0.created_at desc
Hibernate: select distinct p1_0.id,p1_0.content,p1_0.created_at,p1_0.likes,mf1_0.post_id,mf1_0.id,mf1_0.created_at,mf1_0.modified_at,mf1_0.url,p1_0.modified_at,p1_0.title,p1_0.user_id,u1_0.id,u1_0.created_at,u1_0.email,u1_0.follower_count,u1_0.following_count,u1_0.is_celeb,u1_0.modified_at,u1_0.password,u1_0.role,u1_0.username from post p1_0 join users u1_0 on u1_0.id=p1_0.user_id left join media_file mf1_0 on p1_0.id=mf1_0.post_id where u1_0.id in (select f1_0.following_id from follows f1_0 where f1_0.follower_id=?) and u1_0.is_celeb=true order by p1_0.created_at desc
Hibernate: select u1_0.id,u1_0.created_at,u1_0.email,u1_0.follower_count,u1_0.following_count,u1_0.is_celeb,u1_0.modified_at,u1_0.password,u1_0.role,u1_0.username from users u1_0 where u1_0.id=?
이건 페치조인후 쿼리
**Fetching posts without Fetch Join (N+1 risk)*** for user 23561
Hibernate: select p1_0.id,p1_0.content,p1_0.created_at,p1_0.likes,p1_0.modified_at,p1_0.title,p1_0.user_id from post p1_0 where p1_0.user_id in (select f1_0.following_id from follows f1_0 where f1_0.follower_id=?) order by p1_0.created_at desc fetch first ? rows only
Hibernate: select mf1_0.id,mf1_0.created_at,mf1_0.modified_at,mf1_0.post_id,mf1_0.url from media_file mf1_0 where mf1_0.post_id=?
Hibernate: select mf1_0.id,mf1_0.created_at,mf1_0.modified_at,mf1_0.post_id,mf1_0.url from media_file mf1_0 where mf1_0.post_id=?
Hibernate: select mf1_0.id,mf1_0.created_at,mf1_0.modified_at,mf1_0.post_id,mf1_0.url from media_file mf1_0 where mf1_0.post_id=?
Hibernate: select mf1_0.id,mf1_0.created_at,mf1_0.modified_at,mf1_0.post_id,mf1_0.url from media_file mf1_0 where mf1_0.post_id=?
Hibernate: select mf1_0.id,mf1_0.created_at,mf1_0.modified_at,mf1_0.post_id,mf1_0.url from media_file mf1_0 where mf1_0.post_id=?
Hibernate: select mf1_0.id,mf1_0.created_at,mf1_0.modified_at,mf1_0.post_id,mf1_0.url from media_file mf1_0 where mf1_0.post_id=?
Hibernate: select mf1_0.id,mf1_0.created_at,mf1_0.modified_at,mf1_0.post_id,mf1_0.url from media_file mf1_0 where mf1_0.post_id=?
Hibernate: select mf1_0.id,mf1_0.created_at,mf1_0.modified_at,mf1_0.post_id,mf1_0.url from media_file mf1_0 where mf1_0.post_id=?
Hibernate: select mf1_0.id,mf1_0.created_at,mf1_0.modified_at,mf1_0.post_id,mf1_0.url from media_file mf1_0 where mf1_0.post_id=?
Hibernate: select mf1_0.id,mf1_0.created_at,mf1_0.modified_at,mf1_0.post_id,mf1_0.url from media_file mf1_0 where mf1_0.post_id=?
Hibernate: select mf1_0.id,mf1_0.created_at,mf1_0.modified_at,mf1_0.post_id,mf1_0.url from media_file mf1_0 where mf1_0.post_id=?
Hibernate: select mf1_0.id,mf1_0.created_at,mf1_0.modified_at,mf1_0.post_id,mf1_0.url from media_file mf1_0 where mf1_0.post_id=?
Hibernate: select mf1_0.id,mf1_0.created_at,mf1_0.modified_at,mf1_0.post_id,mf1_0.url from media_file mf1_0 where mf1_0.post_id=?
Hibernate: select mf1_0.id,mf1_0.created_at,mf1_0.modified_at,mf1_0.post_id,mf1_0.url from media_file mf1_0 where mf1_0.post_id=?
Hibernate: select mf1_0.id,mf1_0.created_at,mf1_0.modified_at,mf1_0.post_id,mf1_0.url from media_file mf1_0 where mf1_0.post_id=?
Hibernate: select mf1_0.id,mf1_0.created_at,mf1_0.modified_at,mf1_0.post_id,mf1_0.url from media_file mf1_0 where mf1_0.post_id=?
Hibernate: select mf1_0.id,mf1_0.created_at,mf1_0.modified_at,mf1_0.post_id,mf1_0.url from media_file mf1_0 where mf1_0.post_id=?
Hibernate: select mf1_0.id,mf1_0.created_at,mf1_0.modified_at,mf1_0.post_id,mf1_0.url from media_file mf1_0 where mf1_0.post_id=?
Hibernate: select mf1_0.id,mf1_0.created_at,mf1_0.modified_at,mf1_0.post_id,mf1_0.url from media_file mf1_0 where mf1_0.post_id=?
Hibernate: select mf1_0.id,mf1_0.created_at,mf1_0.modified_at,mf1_0.post_id,mf1_0.url from media_file mf1_0 where mf1_0.post_id=?
Hibernate: select mf1_0.id,mf1_0.created_at,mf1_0.modified_at,mf1_0.post_id,mf1_0.url from media_file mf1_0 where mf1_0.post_id=?
Hibernate: select mf1_0.id,mf1_0.created_at,mf1_0.modified_at,mf1_0.post_id,mf1_0.url from media_file mf1_0 where mf1_0.post_id=?
Hibernate: select mf1_0.id,mf1_0.created_at,mf1_0.modified_at,mf1_0.post_id,mf1_0.url from media_file mf1_0 where mf1_0.post_id=?
Hibernate: select mf1_0.id,mf1_0.created_at,mf1_0.modified_at,mf1_0.post_id,mf1_0.url from media_file mf1_0 where mf1_0.post_id=?
Hibernate: select mf1_0.id,mf1_0.created_at,mf1_0.modified_at,mf1_0.post_id,mf1_0.url from media_file mf1_0 where mf1_0.post_id=?
Hibernate: select mf1_0.id,mf1_0.created_at,mf1_0.modified_at,mf1_0.post_id,mf1_0.url from media_file mf1_0 where mf1_0.post_id=?
Hibernate: select mf1_0.id,mf1_0.created_at,mf1_0.modified_at,mf1_0.post_id,mf1_0.url from media_file mf1_0 where mf1_0.post_id=?
Hibernate: select mf1_0.id,mf1_0.created_at,mf1_0.modified_at,mf1_0.post_id,mf1_0.url from media_file mf1_0 where mf1_0.post_id=?
Hibernate: select mf1_0.id,mf1_0.created_at,mf1_0.modified_at,mf1_0.post_id,mf1_0.url from media_file mf1_0 where mf1_0.post_id=?
Hibernate: select mf1_0.id,mf1_0.created_at,mf1_0.modified_at,mf1_0.post_id,mf1_0.url from media_file mf1_0 where mf1_0.post_id=?
Hibernate: select distinct p1_0.id,p1_0.content,p1_0.created_at,p1_0.likes,mf1_0.post_id,mf1_0.id,mf1_0.created_at,mf1_0.modified_at,mf1_0.url,p1_0.modified_at,p1_0.title,p1_0.user_id,u1_0.id,u1_0.created_at,u1_0.email,u1_0.follower_count,u1_0.following_count,u1_0.is_celeb,u1_0.modified_at,u1_0.password,u1_0.role,u1_0.username from post p1_0 join users u1_0 on u1_0.id=p1_0.user_id left join media_file mf1_0 on p1_0.id=mf1_0.post_id where u1_0.id in (select f1_0.following_id from follows f1_0 where f1_0.follower_id=?) and u1_0.is_celeb=true order by p1_0.created_at desc
Hibernate: select u1_0.id,u1_0.created_at,u1_0.email,u1_0.follower_count,u1_0.following_count,u1_0.is_celeb,u1_0.modified_at,u1_0.password,u1_0.role,u1_0.username from users u1_0 where u1_0.id=?
이건 페치조인 안했을때 쿼리
최근 게시물 30개 뽑아와서 각각의 포스트마다 미디어파일을 불러오는 쿼리가 발생했습니다. (N+1문제)
문제는 아래 사진을 보면됩니다.

왼쪽 노란색 그래프: Fetch Join 없이 → N+1 발생
가운데 초록색 그래프: Fetch Join 적용
🙈 예상과 다르게 Fetch Join을 적용했을 때 오히려 더 느려졌습니다.
원인을 파악하고자 PostgreSQL의 EXPLAIN ANALYZE로 직접 쿼리를 뜯어봤습니다.
SELECT p FROM Post p
JOIN FETCH p.mediaFiles
하나의 Post가 MediaFile 3개를 갖고 있다면 → Post row가 3배 중복되어 조회됨
이후 ORDER BY 및 LIMIT 30을 걸면 → 실제 Post는 10개일 수 있음 (중복 포함 30개가 기준이 되기 때문)
📌 페이징 기준은 "Post"인데, MediaFile로 인한 중복 row 때문에 정확한 페이징이 불가능
Fetch Join + ORDER BY 조합 시 → 중복 row가 정렬 대상에 포함됨
→ 정렬 처리량 증가
→ 메모리 소비 증가 및 쿼리 실행 시간 지연
결과적으로:
❌ 불안정한 페이징 결과
❌ 느린 응답 시간
| 전략 | 설명 | 장점 |
|---|---|---|
| 1. 기본 엔티티만 페이징 후, 서브 엔티티는 IN 절 조회 | Post만 페이징 후 MediaFile은 IN으로 따로 조회 | ✅ 정확한 페이징 ✅ N+1 방지 |
// Step 1: Post만 페이징
List<Post> posts = postRepository.findByUserIn(userIds, pageable);
// Step 2: Post ID로 MediaFile 일괄 조회
List<MediaFile> mediaFiles = mediaFileRepository.findByPostIdIn(postIds);
SELECT id, content, created_at, ...
FROM post
WHERE user_id IN (
SELECT following_id FROM follows WHERE follower_id = 23561
)
ORDER BY created_at DESC
LIMIT 30;
📌 결과
16.7mstop-N heapsort 사용SELECT DISTINCT p.id, p.content, m.url
FROM post p
LEFT JOIN media_file m ON p.id = m.post_id
WHERE p.user_id IN (
SELECT following_id FROM follows WHERE follower_id = 23561
)
ORDER BY p.created_at DESC
LIMIT 30;

📌 결과
30.1ms11,213 rowsHashAggregate)SELECT * FROM media_file
WHERE post_id IN (
SELECT id FROM post
WHERE user_id IN (1,2,3,4,5)
ORDER BY created_at DESC
LIMIT 30
);

📌 결과
17.4msHash Semi Join으로 최적화| 방식 | post만 조회 | Fetch Join | 서브쿼리 방식 |
|---|---|---|---|
| 정렬 방식 | top-N heapsort | top-N heapsort | top-N heapsort |
| 조인 row 수 | 30 | 11,213 | 744 |
| 중복 제거 | ❌ 없음 | ✅ 필요 (DISTINCT) | ❌ 없음 |
| 실행 시간 | ✅ 16.7ms | ❌ 30.1ms | ✅ 17.4ms |
| 구조 안정성 | ✅ 좋음 | ❌ 복잡 | ✅ 매우 좋음 |
뉴스피드처럼:
이 함께 필요한 상황에서는:
👉 Fetch Join은 오히려 성능을 악화시킬 수 있습니다. 쿼리 구조와 데이터 양, 조건, 정렬 여부 등을 고려해서 필요한 방식으로 유연하게 접근해야 합니다.
실제 최적화 이후 성능분석은 아래 링크 참고
https://velog.io/@junho_99/Fetch-Join-vs-IN%EC%A0%88-%EC%84%B1%EB%8A%A5-%EB%B9%84%EA%B5%90-%EC%8B%A4%ED%97%98%EA%B8%B0