JPA Fetch Join, 쿼리 분석으로 성능 비교

김준호·2025년 3월 25일

DB

목록 보기
2/3
post-thumbnail

🧠 JPA Fetch Join, 과연 뉴스피드에 적합할까?

진행 중인 프로젝트에서 뉴스피드를 불러올 때,
팔로우한 유저들의 게시글과 함께 연관 엔티티인 미디어 파일까지 함께 가져오려고 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로 직접 쿼리를 뜯어봤습니다.

❗ 결론 먼저

  • 정렬/페이징 + Fetch Join 조합은 성능상 불리하거나 비정상적인 결과를 유발할 수 있음
    → 특히 1:N 관계에서 JOIN 결과 row 수가 폭증하면서 페이징이 제대로 안 먹히거나, 메모리 낭비 발생

🔥 왜 성능이 저하될까?

1. Fetch Join은 중복 row를 유발

SELECT p FROM Post p
JOIN FETCH p.mediaFiles
  1. 하나의 Post가 MediaFile 3개를 갖고 있다면 → Post row가 3배 중복되어 조회됨

  2. 이후 ORDER BY 및 LIMIT 30을 걸면 → 실제 Post는 10개일 수 있음 (중복 포함 30개가 기준이 되기 때문)

📌 페이징 기준은 "Post"인데, MediaFile로 인한 중복 row 때문에 정확한 페이징이 불가능

2. 메모리 낭비와 성능 저하

Fetch Join + ORDER BY 조합 시 → 중복 row가 정렬 대상에 포함됨

→ 정렬 처리량 증가

→ 메모리 소비 증가 및 쿼리 실행 시간 지연

결과적으로:

❌ 불안정한 페이징 결과

❌ 느린 응답 시간

✅ 대안 (추천 전략)

전략설명장점
1. 기본 엔티티만 페이징 후, 서브 엔티티는 IN 절 조회Post만 페이징 후 MediaFile은 IN으로 따로 조회✅ 정확한 페이징 ✅ N+1 방지

💡 참고 코드 예시 (Spring Data JPA)

// Step 1: Post만 페이징
List<Post> posts = postRepository.findByUserIn(userIds, pageable);

// Step 2: Post ID로 MediaFile 일괄 조회
List<MediaFile> mediaFiles = mediaFileRepository.findByPostIdIn(postIds);

⚙️ 실험 조건

  • PostgreSQL 17 사용
  • Hibernate SQL 로그 기반 쿼리 확인
  • 성능 측정 도구: EXPLAIN ANALYZE

1️⃣ 기본 쿼리: post만 조회

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;

📌 결과

  • Execution Time: 16.7ms
  • Sort Method: top-N heapsort 사용
  • 효율적이고 빠름

2️⃣ Fetch Join 방식

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;

📌 결과

  • Execution Time: ❌ 30.1ms
  • JOIN 결과 row 수 증가 → 11,213 rows
  • DISTINCT 처리 비용 발생 (HashAggregate)
  • 성능 저하, 메모리 사용 ↑

3️⃣ 최적화 전략: 서브쿼리 방식

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
);


📌 결과

  • Execution Time: ✅ 17.4ms
  • Subquery는 정렬 + LIMIT 정확히 반영
  • 외부 쿼리는 Hash Semi Join으로 최적화
  • post 30개에만 연결된 media만 조회

📊 성능 비교 요약

방식post만 조회Fetch Join서브쿼리 방식
정렬 방식top-N heapsorttop-N heapsorttop-N heapsort
조인 row 수3011,213744
중복 제거❌ 없음✅ 필요 (DISTINCT)❌ 없음
실행 시간✅ 16.7ms❌ 30.1ms✅ 17.4ms
구조 안정성✅ 좋음❌ 복잡✅ 매우 좋음

✅ 최종 결론

뉴스피드처럼:

  • 정렬 (ORDER BY created_at DESC)
  • 페이징 (LIMIT 30)
  • 연관 데이터 (media_file)

이 함께 필요한 상황에서는:

👉 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

profile
공부한 내용 정리하고 복습하는 블로그

0개의 댓글