✅ Fetch Join이란?
fetch join은 JPQL에서 연관된 엔티티를 한 번의 쿼리로 함께 조회하는 기능이다.
JOIN과 달리, 연관 엔티티를 즉시 로딩해서 실제 객체로 채워준다.
일반적인 JOIN은 연관 관계를 단순히 결합하는 데 그치지만
FETCH JOIN은 연관된 엔티티를 즉시 로딩(EAGER) 하도록 만들어 실제 객체로 채워준다.
📌 예시
// 일반 JOIN (연관된 team은 프록시 객체)
@Query("SELECT m FROM Member m JOIN m.team t")
// Fetch JOIN (연관된 team을 함께 조회하여 채워 넣음)
@Query("SELECT m FROM Member m JOIN FETCH m.team")
✅ 왜 써야 할까? → N+1 문제 해결
@ManyToOne(fetch = LAZY)나 @OneToMany 관계에서 루프를 돌며 연관 데이터를 접근하면 N+1 문제가 발생한다.
fetch join은 단 한 번의 SQL 쿼리로 모든 데이터를 가져오기 때문에 지연 로딩으로 발생하는 불필요한 추가 쿼리 N번을 없애준다.
🔍 SQL 동작 비교
👎 일반 JOIN (지연 로딩)
-- 1차 조회
SELECT * FROM member;
-- 루프 돌며 발생
SELECT * FROM team WHERE id = 1;
SELECT * FROM team WHERE id = 2;
...
👍 FETCH JOIN 사용 시
-- 한 번의 쿼리로 모든 정보 조회
SELECT m.*, t.*
FROM member m
JOIN team t ON m.team_id = t.id;
❗ fetch join + 컬렉션(@OneToMany) 주의사항
컬렉션에 대해 fetch join을 하면 조인으로 인해 부모 엔티티가 중복 조회될 수 있다.
만약, 이런 식으로 조회를 한다면
@Query("SELECT m FROM Member m JOIN FETCH m.articles")
List<Member> findAllWithArticles();
SQL 결과는 다음처럼 나올 수 있다.
m.id | m.name | a.id | a.title
-----|--------|------|--------
1 | Alice | 101 | 글 A
1 | Alice | 102 | 글 B
-> member가 중복되어 저장되는 상황이 발생한다.
✅ 해결 방법:
- distinct 사용 (JPQL 수준에서 중복 제거):
@Query("SELECT DISTINCT m FROM Member m JOIN FETCH m.articles")
- 또는 Java 코드에서 Set 등으로 중복 제거
✅ JOIN vs FETCH JOIN의 차이
| 항목 | JOIN | FETCH JOIN |
| 목적 | SQL처럼 단순히 조인만 함 | 조인 + 연관 엔티티를 즉시 로딩 |
| 연관 객체 채움 여부 | ❌ 프록시 객체 (Lazy 상태 유지) | ✅ 실제 객체로 즉시 로딩 |
| N+1 해결 여부 | ❌ 해결 안 됨 | ✅ 해결 가능 |
| 영속성 컨텍스트 | ❌ 이용 안 함 | ✅ 이용 함 |
✅ Fetch Join – 장점, 단점
✅ 장점
| 장점 | 설명 |
| N+1 문제 해결 | 가장 확실한 방법. 연관 엔티티를 조인하여 한 번에 로딩함 |
| 성능 향상 | 연관 객체를 미리 로딩해서 추가 쿼리 없이 사용할 수 있음 |
| 쿼리 제어 가능 | JPQL 안에서 where, order by, join 조건 등을 함께 조합 가능 |
| 예측 가능 | 언제 어떤 연관 객체까지 로딩되는지 JPQL에서 직접 명시함 |
❗ 단점
| 단점 | 설명 |
| 페이징 불가 (컬렉션 조인 시) | @OneToMany나 @ManyToMany에 fetch join을 걸면 Pageable 동작 안 함 (JPA가 count 쿼리 못 만듦) |
| 중복 데이터 발생 가능 | 컬렉션(fetch join) 시 부모 엔티티가 조인 결과로 여러 번 중복 조회됨 → DISTINCT 필수 |
| 데이터 폭발 가능성 | 조인 대상이 많아질수록 결과 행 수가 많아져 메모리 사용량 급증 |
| 동적 쿼리 어려움 | 복잡한 조건부 fetch join 조합은 QueryDSL 등 추가 도구 없이는 불편 |
| 테스트 및 디버깅 어려움 | 내부적으로 로딩되는 연관 객체를 명확히 구분해 보기 어렵기도 함 |
✅ 페이징 시 Fetch Join 오류 상황 발생 예시
📌 기본 페이징 동작 방식
JPA에서 Pageable을 사용할 경우 JPA는 내부적으로 두 가지 쿼리를 자동 생성한다.
- 데이터 조회 쿼리 (실제 결과 가져옴)
- 총 개수 조회 쿼리 (count(*)로 전체 데이터 수 계산)
-- 1. 데이터 조회
select * from team limit 10 offset 0;
-- 2. 전체 개수 조회
select count(*) from team;
✅ 그런데 컬렉션 fetch join을 사용하면?
예를 들어 아래처럼 팀과 멤버를 일대다 관계로 가져온다고 가정해보자
@Entity
public class Team {
@OneToMany(mappedBy = "team")
private List<Member> members;
}
@Query("SELECT t FROM Team t JOIN FETCH t.members")
Page<Team> findAllWithMembers(Pageable pageable);
💥 이 경우 문제가 발생할 수 있다. → 예외가 터짐
❗ 왜 예외가 발생할까?
- fetch join은 조인 결과의 row 수가 늘어남
- 팀 1개 + 멤버 3명 → 결과 row 3개
- 팀 10개 + 멤버 100명 → 결과 row 100개
- → Team 엔티티는 중복돼서 여러 row로 나타남
- JPA는 이 데이터를 Page<Team>으로 매핑하려고 하는데,
- 중복된 팀을 제거해야 함
- 하지만 JPQL의 fetch join은 하이버네이트가 count 쿼리를 제대로 생성하지 못함
- 결과
- org.hibernate.loader.MultipleBagFetchException 발생
- 또는 count 쿼리가 실패하거나, 잘못된 값 반환
- 즉, 정확한 페이징이 불가능
✅ 해결 방법
| 방법 | 설명 |
| @EntityGraph | 페이징 가능한 fetch join 대체 수단 (연관 엔티티를 그래프 형태로 미리 로딩) |
| @BatchSize | 지연 로딩 그대로 두고, Hibernate가 연관 데이터 한번에 IN 쿼리로 가져오게 함 |
📌 예시 – EntityGraph 사용
@EntityGraph(attributePaths = "members")
Page<Team> findAll(Pageable pageable);
위에 처럼 하면 Team만 페이징해서 가져오고 members는 한꺼번에 조인해서 로딩한다.
→ 정상적으로 페이징 + 연관 엔티티도 로딩 가능 ✅
@EntityGraph는 내부적으로 연관 엔티티를 fetch join처럼 한 번에 불러오면서도
페이징을 위해 필요한 count 쿼리는 건드리지 않기 때문에 안전하게 사용할 수 있는 방법이다.
reference
https://hstory0208.tistory.com/entry/JPA-JPQL%EC%9D%98-fetch-join%ED%8E%98%EC%B9%98-%EC%A1%B0%EC%9D%B8%EC%9D%B4%EB%9E%80https://velog.io/@j3beom/JPA-JPQL-Fetch-Join
'JPA' 카테고리의 다른 글
| [JPA] QueryDSL 이란? (0) | 2025.06.28 |
|---|---|
| [JPA] 다중성이란? (0) | 2025.06.26 |
| [JPA] N+1 문제 원인 & 해결 (0) | 2025.06.24 |