페치 조인은 JPQL에서 제공하는 성능 최적화 기능이다. SQL의 조인 종류가 아니라는 점을 주의해야 한다. 연관된 엔티티나 컬렉션을 SQL 한 번에 함께 조회할 수 있게 해주는 JPQL만의 특별한 기능이다.
-- 기본 문법
[ LEFT [OUTER] | INNER ] JOIN FETCH 조인경로
페치 조인의 핵심은 연관된 엔티티를 SQL 한 번에 함께 가져온다는 것이다. 이를 통해 JPA의 고질적인 N+1 문제를 해결할 수 있다.
N+1 문제
연관된 엔티티를 조회할 때 최초 쿼리 1번 + 연관 엔티티 조회 N번이 발생하는 성능 이슈
회원을 조회하면서 연관된 팀도 함께 조회하는 상황을 생각해보자
// JPQL
select m from Member m join fetch m.team
// 실제 실행되는 SQL
SELECT M.*, T.*
FROM MEMBER M
INNER JOIN TEAM T ON M.TEAM_ID = T.ID
일반 조회와 다른 점은 SELECT 절에 회원(M.*)뿐만 아니라 팀(T.*)도 함께 조회한다는 것이다. 즉시 로딩에서 봤던 방식과 유사하다.
실제 사용 코드
String jpql = "select m from Member m join fetch m.team";
List<Member> members = em.createQuery(jpql, Member.class)
.getResultList();
for (Member member : members) {
// 페치 조인으로 회원과 팀을 함께 조회해서 지연 로딩 발생 안함
System.out.println("username = " + member.getUsername() +
", teamName = " + member.getTeam().getName());
}
출력 결과
username = 회원1, teamname = 팀A
username = 회원2, teamname = 팀A
username = 회원3, teamname = 팀B
중요한 포인트는 지연 로딩으로 설정되어 있어도 페치 조인이 항상 우선한다. 페치 조인을 사용하면 지연 로딩 설정과 관계없이 즉시 조회된다.
이번엔 반대 방향이다. 팀을 조회하면서 해당 팀에 속한 회원들을 함께 조회하는 경우다.
// JPQL
select t
from Team t join fetch t.members
where t.name = '팀A'
// 실행 SQL
SELECT T.*, M.*
FROM TEAM T
INNER JOIN MEMBER M ON T.ID = M.TEAM_ID
WHERE T.NAME = '팀A'
실제 사용 코드
String jpql = "select t from Team t join fetch t.members where t.name = '팀A'";
List<Team> teams = em.createQuery(jpql, Team.class).getResultList();
for(Team team : teams) {
System.out.println("teamname = " + team.getName() + ", team = " + team);
for (Member member : team.getMembers()) {
// 페치 조인으로 팀과 회원을 함께 조회해서 지연 로딩 발생 안함
System.out.println("-> username = " + member.getUsername() +
", member = " + member);
}
}
출력 결과
teamname = 팀A, team = Team@0x100
-> username = 회원1, member = Member@0x200
-> username = 회원2, member = Member@0x300
teamname = 팀A, team = Team@0x100
-> username = 회원1, member = Member@0x200
-> username = 회원2, member = Member@0x300
여기서 이상한 점이 보인다. 팀A가 중복되어 출력된다. 이는 일대다 조인의 특성상 데이터가 뻥튀기되기 때문이다.
과거에는 위의 중복 문제를 해결하기 위해 JPQL에 DISTINCT를 명시적으로 추가해야 했다. 그런데 하이버네이트 6부터는 DISTINCT를 사용하지 않아도 애플리케이션 레벨에서 중복이 자동으로 제거된다.
왜 자동화되었을까?
일대다 페치 조인에서 중복은 거의 항상 의도하지 않은 결과다. 매번 개발자가 DISTINCT를 기억해서 작성해야 하는 불편함을 없애고, 더 직관적인 결과를 제공하기 위한 개선이다.
페치 조인과 일반 조인은 완전히 다르다.
// JPQL
select t
from Team t join t.members m
where t.name = '팀A'
// 실행 SQL
SELECT T.* -- 팀만 조회!
FROM TEAM T
INNER JOIN MEMBER M ON T.ID = M.TEAM_ID
WHERE T.NAME = '팀A'
일반 조인은 SELECT 절에 지정한 엔티티만 조회한다. JPQL은 결과를 반환할 때 연관관계를 고려하지 않는다. 따라서 팀 엔티티만 조회되고, 회원 엔티티는 조회되지 않는다. 이후 회원에 접근하면 지연 로딩이 발생한다.
// JPQL
select t
from Team t join fetch t.members
where t.name = '팀A'
// 실행 SQL
SELECT T.*, M.* -- 팀과 회원 모두 조회!
FROM TEAM T
INNER JOIN MEMBER M ON T.ID = M.TEAM_ID
WHERE T.NAME = '팀A'
페치 조인은 연관된 엔티티를 함께 조회(즉시 로딩)한다. 이것이 바로 객체 그래프를 SQL 한 번에 조회하는 개념이다.
페치 조인으로 왠만한 N+1 문제를 해결할 수 있다.
강력한 만큼 제약사항도 있다.
// ❌ 잘못된 사용
select t from Team t join fetch t.members m
where m.username = '회원1'
페치 조인의 의미는 나와 연관된 것을 전부 가져오겠다는 것이다. 그런데 WHERE 절로 필터링하면 일부만 가져오게 되어 데이터 정합성 문제가 발생할 수 있다.
그럼 조건을 걸고 싶으면?
일대다 방향이 아니라 다대일 방향으로 페치 조인을 사용하거나, 아예 별도의 쿼리로 조회해야 한다. 예를 들어 "김"이 들어간 회원과 그 팀 정보를 가져오려면
select m from Member m join fetch m.team where m.username like '%김%'처럼 Member에서 시작하면 된다.
// ❌ 불가능
select t from Team t
join fetch t.members
join fetch t.orders
일대다 페치 조인이 하나만 있어도 데이터가 뻥튀기 되는데, 컬렉션이 2개면 데이터가 기하급수적으로 증가(곱연산)한다. 예를 들어 팀에 회원 3명, 주문 2개가 있으면 결과가 3*2=6건으로 뻥튀기 된다. 이는 심각한 성능 저하와 예상치 못한 결과를 초래한다.
// ❌ 일대다 페치 조인 + 페이징 = 위험!
String jpql = "select t from Team t join fetch t.members";
List<Team> teams = em.createQuery(jpql, Team.class)
.setFirstResult(0)
.setMaxResults(10) // 페이징 시도
.getResultList();
일대다 컬렉션 페치 조인에서는 페이징이 불가능하다. 하이버네이트는 경고 로그를 남기고 메모리에서 페이징을 시도하는데, 이는 매우 위험하다. 데이터가 많으면 메모리 부족으로 장애가 발생할 수 있다.
단, 일대일이나 다대일 같은 단일 값 연관 필드는 페치 조인해도 페이징이 가능하다. 데이터 뻥튀기가 발생하지 않기 때문이다.
해결책: @BatchSize
@Entity
public class Team {
@BatchSize(size = 100)
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
}
// 또는 글로벌 설정
// hibernate.default_batch_fetch_size = 100
@BatchSize란?
컬렉션을 지연 로딩할 때 한 번에 여러 건을 IN 쿼리로 조회하는 설정이다. 예를 들어 size=100이면 팀 100개의 회원을 한 번에 조회한다. 페치 조인 없이도 N+1을 크게 줄일 수 있다.
객체 그래프를 유지할 때, 즉 엔티티를 그대로 사용할 때 효과적이다. member.getTeam() 처럼 연관 엔티티를 탐색하는 경우에 페치 조인을 사용하면 좋다.
여러 테이블을 조인해서 엔티티가 가진 모양이 아닌 전혀 다른 결과가 필요하면, 페치 조인보다는 일반 조인을 사용하고 필요한 데이터만 조회해서 DTO로 반환하는 것이 효과적이다.
1. 페치 조인으로 엔티티 조회 → 그대로 사용
2. 페치 조인으로 엔티티 조회 → 애플리케이션에서 DTO로 변환
3. JPQL에서 new 연산자로 처음부터 DTO로 조회
첫 번째 방법은 엔티티를 그대로 쓸 때, 두 번째 방법은는 엔티티로 비즈니스 로직을 처리한 후 화면에는 DTO로 반환할 때, 세 번째 방법은 조회 전용이고 성능이 중요할 때 사용한다.
// 3단계 예시
String jpql = "select new com.example.dto.MemberDto(m.username, t.name) " +
"from Member m join m.team t";
List<MemberDto> results = em.createQuery(jpql, MemberDto.class)
.getResultList();