본문으로 건너뛰기
김신건의 로그

[JPA] fetch join: JOIN FETCH 로 연관 entity 즉시 로드

· 수정 · 📖 약 5분 · 1,848자/단어 #jpa #hibernate #fetch-join #jpql #n-plus-one #performance
fetch join, JOIN FETCH, 페치 조인, JPQL fetch join, LEFT JOIN FETCH, JPA fetch join, join fetch

정의

fetch join 은 JPQL 에서 연관 entity 를 한 번의 SQL 로 함께 로드하는 문법. JOIN FETCH 로 선언한다.

SELECT p FROM Post p JOIN FETCH p.author

일반 JOIN 은 SQL JOIN 만 수행하고 결과 entity 만 반환한다. JOIN FETCH 는 JOIN 을 수행하면서 연관 entity 를 함께 로드해 영속성 컨텍스트 에 등록한다. 이후 lazy proxy 접근 없이 필드 사용이 가능하다.

주된 목적은 N+1 쿼리 문제 해결이다. 매핑 자체는 LAZY 로 두고, 필요한 쿼리에서만 명시적으로 함께 가져오는 패턴.

관련 개념 정리는 spring-jpa-fetch-type 참고. FetchType.LAZY/EAGER 의 기본 동작과 EntityGraph, @BatchSize 대안은 그 페이지에서 다룬다.

시각화

LAZY 만 사용 시 N+1 발생, JOIN FETCH 로 1쿼리에 해결하는 흐름.

기본 문법

INNER JOIN FETCH

List<Post> posts = em.createQuery("""
    SELECT p
    FROM Post p
    JOIN FETCH p.author
""", Post.class).getResultList();

발생 SQL:

sql
SELECT p, a
FROM Post p
JOIN FETCH p.author a
실행된 SQL
SELECT p.id, p.title, p.author_id, p.created_at, ...,
     a.id, a.name, a.email, ...
FROM post p
INNER JOIN user a ON p.author_id = a.id;
-- 단 1번, author 까지 함께 SELECT

INNER JOIN 이므로 author 가 없는 Post 는 결과에서 제외된다.

LEFT JOIN FETCH (LEFT OUTER JOIN FETCH)

FK 가 null 인 row 도 포함:

SELECT p FROM Post p LEFT JOIN FETCH p.author

발생 SQL 은 LEFT OUTER JOIN. author 가 없는 Post 는 author = null 로 채워져 반환된다.

optional 연관 (@ManyToOne(optional = true), nullable FK) 은 대부분 LEFT JOIN FETCH 가 안전하다.

fetch join 이 하는 일

Hibernate 관점에서 JOIN FETCH p.author 는 두 단계를 한 번에 처리:

  1. SQL JOIN: INNER JOIN user a ON p.author_id = a.id
  2. 연관 entity 초기화: 조회된 author 를 즉시 영속성 컨텍스트에 로드하고 Post.author 필드에 세팅

이 결과:

  • post.getAuthor() 호출 시 lazy proxy 초기화 SELECT 가 발생하지 않는다
  • 트랜잭션이 끝난 뒤 (detached 상태에서) 접근해도 LazyInitializationException 이 발생하지 않는다

JOIN vs JOIN FETCH 차이

-- (1) 일반 JOIN
SELECT p FROM Post p JOIN p.author a WHERE a.name = :name

-- (2) JOIN FETCH
SELECT p FROM Post p JOIN FETCH p.author WHERE p.author.name = :name
항목JOINJOIN FETCH
SQLINNER JOININNER JOIN (동일)
SELECT 대상조인 대상 컬럼 안 가져옴조인 대상 컬럼 모두 가져옴
연관 entity 초기화❌ (여전히 lazy proxy)✅ (managed entity)
후속 lazy 초기화있음 (N+1 가능)없음
반환 타입명시 대상만명시 대상만 (author 는 내부 로딩)

일반 JOIN 은 WHERE 조건용, JOIN FETCH 는 조회 + 초기화용. 조건과 초기화를 모두 원하면 두 개를 조합할 수도 있다.

SELECT p
FROM Post p
JOIN FETCH p.author a
WHERE a.email LIKE :domain

같은 관계에 JOIN FETCH 한 번이면 조건도 걸 수 있다 (implicit alias 활용).

컬렉션 fetch join 과 카르테시안 곱

@OneToMany / @ManyToMany 컬렉션을 fetch join 하면 row 가 곱해진다.

@Entity
class Post {
    @Id Long id;
    @OneToMany(mappedBy = "post", fetch = FetchType.LAZY)
    List<Comment> comments = new ArrayList<>();
}
SELECT p FROM Post p JOIN FETCH p.comments
sql
-- Post id=1 에 comments 3개
-- Post id=2 에 comments 2개
SELECT p, c FROM post p INNER JOIN comment c ON c.post_id = p.id;
반환된 결과
p.id | c.id
-----|-----
1  |  10   -- Post 1 (comment 10)
1  |  11   -- Post 1 (comment 11) <- 중복
1  |  12   -- Post 1 (comment 12) <- 중복
2  |  20   -- Post 2 (comment 20)
2  |  21   -- Post 2 (comment 21) <- 중복

-- getResultList().size() == 5 (2가 아님)

Hibernate 는 카르테시안 곱으로 부풀어난 row 를 그대로 넘긴다. 결과 List 에 같은 Post 인스턴스가 여러 번 등장한다. (인스턴스는 동일하다. == 비교는 true 다. 1차 캐시 덕분.)

DISTINCT 로 해결

SELECT DISTINCT p FROM Post p JOIN FETCH p.comments

Hibernate 는 두 곳에서 dedup 한다:

  1. SQL 에 DISTINCT 붙임 (실제로는 대부분 무의미, Post 컬럼이 같아도 comment 컬럼이 다르니 SQL DISTINCT 로는 안 걸러짐)
  2. 결과 List 에서 같은 id 의 Post 인스턴스 제거 (핵심 동작)

Hibernate 6+ 는 SQL DISTINCT 를 기본으로 생략한다 (hibernate.query.passDistinctThrough=false 와 동일). 애초에 SQL 성능 문제라서 자동 회피.

hibernate.query.passDistinctThrough

spring:
  jpa:
    properties:
      hibernate:
        query.passDistinctThrough: false   # (Hibernate 5) SQL DISTINCT 생략

Hibernate 5 에서 fetch join + DISTINCT 성능 개선용. Hibernate 6+ 는 기본값이 false 라 신경 쓸 필요 없음.

MultipleBagFetchException

두 개 이상의 List (bag) 를 동시에 JOIN FETCH 하면 Hibernate 가 카르테시안 곱을 감당 못 해 예외를 던진다.

@Entity
class Post {
    @OneToMany(mappedBy = "post") List<Comment> comments;
    @OneToMany(mappedBy = "post") List<Tag> tags;
}
SELECT p FROM Post p
JOIN FETCH p.comments
JOIN FETCH p.tags     -- ❌ MultipleBagFetchException

이유: Post 3개, comment 5개, tag 4개면 카르테시안 곱은 3 * 5 * 4 = 60 row. 두 컬렉션의 index 를 잃어버려 dedup 순서를 보장할 수 없음.

해결 1: 하나만 Set 으로 변경

@OneToMany(mappedBy = "post") Set<Comment> comments;
@OneToMany(mappedBy = "post") List<Tag> tags;   // List 하나만 남김

Set 은 order 보장이 없어 Hibernate 가 다르게 다룬다. bag 은 List 만 해당.

해결 2: 하나만 fetch join, 나머지는 @BatchSize / EntityGraph

SELECT p FROM Post p JOIN FETCH p.comments
  • Tag@BatchSize(size = 100) 나 별도 조회.

이게 실무에서 가장 흔한 패턴. 자세히는 @BatchSize 항목 참고.

해결 3: 두 단계 쿼리

// 1) posts 만 조회
List<Post> posts = em.createQuery("SELECT p FROM Post p", Post.class).getResultList();
List<Long> ids = posts.stream().map(Post::getId).toList();

// 2) 각 컬렉션 별도 IN 쿼리 (Hibernate 가 1차 캐시로 자동 매칭)
em.createQuery("SELECT p FROM Post p JOIN FETCH p.comments WHERE p.id IN :ids", Post.class)
  .setParameter("ids", ids).getResultList();

em.createQuery("SELECT p FROM Post p JOIN FETCH p.tags WHERE p.id IN :ids", Post.class)
  .setParameter("ids", ids).getResultList();

같은 트랜잭션 안에서 영속성 컨텍스트 가 동일한 Post 인스턴스에 comments 와 tags 를 각각 채워준다.

Pageable + 컬렉션 fetch join = in-memory paging

Spring Data JPA 의 Pageable 을 컬렉션 fetch join 과 조합하면 Hibernate 가 경고 후 메모리에서 페이징한다.

@Query("SELECT p FROM Post p JOIN FETCH p.comments")
Page<Post> findAllWithComments(Pageable pageable);

로그:

HHH000104: firstResult/maxResults specified with collection fetch;
           applying in memory!

Hibernate 가 전체 row 를 메모리에 로드 후 잘라낸다. Post 100만 건 + comment 각 10개면 1천만 row 를 JVM 힙에 올린 뒤 잘라낸다. OOM 위험이 매우 크다.

해결 패턴 (2 단계 fetch)

// 1) id 만 paging (join fetch 없이)
@Query("SELECT p.id FROM Post p ORDER BY p.createdAt DESC")
Page<Long> findPostIds(Pageable pageable);

// 2) 그 id 들만 fetch join 으로 컬렉션 로드
@Query("SELECT p FROM Post p JOIN FETCH p.comments WHERE p.id IN :ids")
List<Post> findWithCommentsByIds(@Param("ids") List<Long> ids);

두 단계로 나누면 정확한 SQL LIMIT 이 걸린다.

컬렉션 아닌 fetch join 은 OK

@ManyToOne / @OneToOne 은 row 가 곱해지지 않으므로 Pageable 과 함께 사용해도 SQL 페이징이 정상 적용된다.

@Query("SELECT p FROM Post p JOIN FETCH p.author")
Page<Post> findAll(Pageable pageable);   // OK: LIMIT 정상 적용

Nested fetch join (여러 depth)

연쇄 fetch join 가능:

SELECT p
FROM Post p
JOIN FETCH p.author a
JOIN FETCH a.profile pr

Post + author + profile 을 한 쿼리에 로드. 3개 테이블 INNER JOIN.

주의:

  • 단일 연관 (ToOne) 이 이어질 때는 무방
  • 중간에 컬렉션이 껴 있으면 카르테시안 곱 폭발
-- ⚠️ 위험
SELECT p FROM Post p
JOIN FETCH p.comments c
JOIN FETCH c.author       -- comment 마다 author, row 곱해짐

정말 필요하다면 별도 조회로 분리하거나 EntityGraph 로 명시적으로 depth 제한.

Spring Data JPA @Query 통합

public interface PostRepository extends JpaRepository<Post, Long> {
    // 단일 fetch join
    @Query("SELECT p FROM Post p JOIN FETCH p.author WHERE p.id = :id")
    Optional<Post> findByIdWithAuthor(@Param("id") Long id);

    // countQuery 필수 (fetch join 은 count 만들 때 문제)
    @Query(
        value = "SELECT p FROM Post p LEFT JOIN FETCH p.author",
        countQuery = "SELECT COUNT(p) FROM Post p"
    )
    Page<Post> findAllWithAuthor(Pageable pageable);

    // 컬렉션 + DISTINCT
    @Query("SELECT DISTINCT p FROM Post p LEFT JOIN FETCH p.comments WHERE p.id IN :ids")
    List<Post> findAllWithCommentsByIds(@Param("ids") List<Long> ids);
}

countQuery 를 반드시 지정해야 하는 이유: Spring 이 자동 생성하는 count 쿼리에 JOIN FETCH 가 남으면 SQL syntax error (SELECT COUNT(p) FROM Post p JOIN FETCH p.author 는 표준 위반).

fetch join 이 적용되지 않는 경우

1. Native query

@Query(value = "SELECT * FROM post JOIN user ON ...", nativeQuery = true)

JOIN FETCH 는 JPQL 문법이라 native query 에는 없다. Native 는 그냥 SQL JOIN 이라 연관 초기화가 안 된다.

2. @Modifying UPDATE / DELETE

Bulk 쿼리는 영속성 컨텍스트 를 우회하므로 fetch join 이 의미 없다.

3. Cross join

JPA 표준은 카르테시안 곱 명시적 cross join 을 fetch 로 표현 못 한다.

실전 패턴

패턴 1: 단건 상세 조회

@Query("""
    SELECT p FROM Post p
    LEFT JOIN FETCH p.author
    LEFT JOIN FETCH p.category
    WHERE p.id = :id
""")
Optional<Post> findDetail(@Param("id") Long id);

ToOne 두 개까지는 fetch join 안정적. 상세 페이지 최적화.

패턴 2: 목록 + 요약 정보

@Query("""
    SELECT p FROM Post p
    JOIN FETCH p.author
    WHERE p.status = 'PUBLISHED'
    ORDER BY p.publishedAt DESC
""")
Page<Post> findPublishedList(Pageable pageable);

목록에서 필요한 ToOne 관계만 fetch join. 댓글 등 컬렉션은 상세에서만 로드.

패턴 3: 컬렉션 + Pageable (2 단계)

@Query("SELECT p.id FROM Post p ORDER BY p.publishedAt DESC")
Page<Long> findIds(Pageable pageable);

@Query("""
    SELECT DISTINCT p FROM Post p
    LEFT JOIN FETCH p.comments
    WHERE p.id IN :ids
""")
List<Post> findWithComments(@Param("ids") List<Long> ids);

// 사용
Page<Long> ids = repo.findIds(pageable);
List<Post> posts = repo.findWithComments(ids.getContent());

메모리 페이징 우회 + 정확한 SQL LIMIT.

fetch join vs 대안 요약

상황추천이유
ToOne 단건 상세fetch join단순, 1 쿼리
ToOne 목록 + Pageablefetch joinrow 안 곱해져 SQL LIMIT OK
컬렉션 1 개 + 목록@EntityGraph + DISTINCT선언적
컬렉션 1 개 + Pageable2 단계 (id → in)in-memory paging 회피
컬렉션 2 개 이상@BatchSize 또는 분리 fetchMultipleBagFetchException 회피
조건에 따라 다름ad-hoc @EntityGraph(attributePaths=...)쿼리마다 결정

함정과 베스트 프랙티스

  • DISTINCT 는 필수 (컬렉션 fetch join): dedup 없으면 List 중복
  • Pageable + 컬렉션 fetch join 금지: in-memory paging → OOM 위험
  • countQuery 명시 (Spring Data JPA Page 반환 시)
  • 컬렉션 2 개 이상 동시 fetch join 금지: MultipleBagFetchException
  • Set 으로 바꾸면 우회 가능하지만 순서 보장 사라짐: 도메인 요구사항에 따라 선택
  • Nested fetch join 은 ToOne 만 안전: ToMany 가 껴 있으면 row 폭발
  • Native query 는 fetch join 없음: 관계 초기화하려면 별도 조회 필요
  • WHERE p.author.name = ... 대신 명시 alias 로 조건: WHERE a.name = ..., 실수 방지
  • implicit path 로 부주의하게 조건 걸면 unnecessary JOIN 추가 발생 가능

관련 위키

이 글의 용어 (7개)
[JPA] 동적 쿼리: Criteria API, QueryDSL, Specificationspring
정의 동적 쿼리 = 런타임 조건에 따라 WHERE / JOIN 이 달라지는 쿼리. 검색 필터, admin 도구, 리포팅 등에서 흔함. JPQL string concatenatio…
[JPA] FetchType: LAZY vs EAGER, N+1, fetch joinspring
정의 FetchType 은 JPA 가 연관 entity 를 언제 로드할지 결정하는 hint. - : entity 로딩 시 즉시 함께 SELECT - : 실제 사용 시점 (prox…
[JPA] JPQL DTO Projection: constructor expression, interface, recordspring
정의 DTO Projection 은 entity 전체가 아닌 필요한 컬럼만 query 해서 DTO 로 받기. 장점: - N+1 회피 (lazy 컬렉션 자동 무시) - entity…
[JPA] JPQL: Jakarta Persistence Query Languagespring
정의 JPQL 은 Jakarta Persistence 표준의 객체 지향 쿼리 언어. 문법은 SQL 과 유사하지만 entity 와 그 필드를 대상으로 작동 (table / colu…
[JPA] PersistenceContext: 1차 캐시, dirty checking, flushspring
정의 PersistenceContext 는 가 관리하는 entity 의 메모리 저장소 + 변경 추적기. 트랜잭션 안에서만 살아 있다 ( 진입 시 생성, commit/rollbac…
[Spring] Spring Data JPA: Repository, Query Methodsspring
정의 Spring Data JPA는 JPA(Hibernate) 위에 Repository 추상화를 얹어 boilerplate를 제거. 인터페이스만 정의하면 Spring이 구현체 자…
N+1 쿼리 문제database-internals
정의 N+1 쿼리 문제 = ORM 이 부모 entity N 개를 조회한 뒤, 각 부모의 연관 entity 를 하나씩 로드하면서 추가 SQL 이 N 번 발생하는 성능 안티패턴. 이…

💬 댓글

사이트 검색 / 명령어

검색

스크롤 = 확대/축소 · 드래그 = 이동 · 0 = 원래 크기 · ESC = 닫기