2026년 4월 2일 작성
JPA Fetch Join - LAZY 연관 관계를 한 번의 query로 load하는 방법
fetch join은 JPQL의 `JOIN FETCH` keyword로 연관 entity를 한 번의 SQL에 포함하여 조회하며, LAZY 설정을 유지하면서 필요한 시점에만 즉시 loading을 적용하는 N+1 해결의 핵심 전략입니다.
Fetch Join이란
- fetch join은 JPQL에서
JOIN FETCHkeyword를 사용하여 연관 entity를 한 번의 SQL JOIN으로 함께 조회하는 기능입니다.- JPA 표준 명세에 정의된 기능이며, 일반 SQL에는 없는 JPQL 전용 문법입니다.
- 모든 연관 관계를
LAZY로 설정한 뒤, 연관 entity가 필요한 query에서만 fetch join을 적용하는 것이 실무 권장 pattern입니다.
- 일반
JOIN은SELECT대상에 연관 entity를 포함하지 않지만,JOIN FETCH는 연관 entity까지SELECT대상에 포함합니다.- 일반
JOIN으로 조회하면 연관 entity는 여전히 proxy 상태이므로, 접근 시 추가 SELECT가 발생합니다. JOIN FETCH는 연관 entity를 이미 초기화된 상태로 반환하여 추가 query 없이 바로 접근 가능합니다.
- 일반
// 일반 JOIN : member는 proxy 상태, 접근 시 추가 SELECT 발생
em.createQuery("SELECT o FROM Order o JOIN o.member m", Order.class);
// fetch join : member가 초기화된 상태로 반환, 추가 query 없음
em.createQuery("SELECT o FROM Order o JOIN FETCH o.member", Order.class);
동작 원리
- JPQL의
JOIN FETCH는 SQL의INNER JOIN으로 변환되고, 연관 entity의 column이SELECT절에 포함됩니다.LEFT JOIN FETCH를 사용하면 연관 entity가 없는 row도 포함하여 조회합니다.
flowchart LR
jpql["JPQL : SELECT o FROM Order o JOIN FETCH o.member"]
sql["SQL : SELECT o.*, m.* FROM orders o INNER JOIN member m ON o.member_id = m.id"]
result["결과 : Order + Member 한 번에 반환"]
jpql --> sql --> result
- Hibernate는 결과
ResultSet에서 연관 entity의 column을 읽어 proxy 대신 실제 entity 객체를 생성합니다.- 영속성 context에 이미 초기화된 entity가 등록되므로, 이후 LAZY field에 접근해도 추가 query가 발생하지 않습니다.
- entity가 초기화된 상태로 반환되므로 Detached 상태에서도 접근이 가능합니다.
sequenceDiagram
participant app as Application
participant jpa as Hibernate
participant db as Database
app->>jpa: JOIN FETCH query 실행
jpa->>db: SELECT o.*, m.* FROM orders o INNER JOIN member m ...
db-->>jpa: ResultSet 반환 (Order + Member column 포함)
Note over jpa: Order entity 생성
Note over jpa: Member entity 생성 (proxy 아님)
Note over jpa: 영속성 context에 등록
jpa-->>app: 초기화된 Order 반환 (member 접근 시 추가 query 없음)
기본 사용법
- Spring Data JPA에서는
@Queryannotation에 JPQL fetch join을 작성합니다.
@ManyToOne Fetch Join
@ManyToOne관계는 단일 entity를 참조하므로 fetch join 시 결과 row 수가 변하지 않습니다.
@Query("SELECT o FROM Order o JOIN FETCH o.member")
List<Order> findAllWithMember();
- 생성되는 SQL은
INNER JOIN으로 변환됩니다.
SELECT o.*, m.*
FROM orders o
INNER JOIN member m ON o.member_id = m.id
@OneToMany Fetch Join
@OneToMany관계는 collection을 참조하므로 join 결과에 부모 entity가 중복됩니다.- 자식이 3건인 부모는 결과에 3번 등장하므로,
DISTINCT를 추가하여 중복을 제거해야 합니다. - Hibernate 6부터는 collection fetch join에
DISTINCT가 자동 적용됩니다.
- 자식이 3건인 부모는 결과에 3번 등장하므로,
// Hibernate 5 : DISTINCT 명시 필요
@Query("SELECT DISTINCT t FROM Team t JOIN FETCH t.members")
List<Team> findAllWithMembers();
// Hibernate 6 : DISTINCT 자동 적용
@Query("SELECT t FROM Team t JOIN FETCH t.members")
List<Team> findAllWithMembers();
graph TD
subgraph before_distinct["DISTINCT 적용 전"]
row1["Team A - Member 1"]
row2["Team A - Member 2"]
row3["Team A - Member 3"]
row4["Team B - Member 4"]
end
subgraph after_distinct["DISTINCT 적용 후"]
team_a["Team A (members : 1, 2, 3)"]
team_b["Team B (members : 4)"]
end
before_distinct --> after_distinct
LEFT JOIN FETCH
LEFT JOIN FETCH는 연관 entity가 없는 부모도 결과에 포함합니다.JOIN FETCH(INNER)는 연관 entity가 없는 부모를 결과에서 제외합니다.- 연관 entity가 없을 수 있는 경우
LEFT JOIN FETCH를 사용합니다.
// member가 없는 Order는 제외됨
@Query("SELECT o FROM Order o JOIN FETCH o.member")
List<Order> findAllWithMember();
// member가 없는 Order도 포함됨
@Query("SELECT o FROM Order o LEFT JOIN FETCH o.member")
List<Order> findAllWithMemberIncludeNull();
@EntityGraph
@EntityGraph는 annotation 기반으로 fetch join과 동일한 효과를 제공하는 JPA 표준 기능입니다.- JPQL을 직접 작성하지 않아도 되므로 method naming query와 함께 사용하기 편리합니다.
attributePaths에 함께 조회할 연관 field 이름을 지정합니다.
@EntityGraph(attributePaths = {"member"})
List<Order> findByStatus(OrderStatus status);
// 여러 연관 entity를 동시에 지정
@EntityGraph(attributePaths = {"member", "delivery"})
List<Order> findAll();
@EntityGraph는 기본적으로LEFT OUTER JOIN을 생성합니다.JOIN FETCH와 달리 연관 entity가 없는 부모도 항상 결과에 포함됩니다.
| 구분 | JOIN FETCH |
@EntityGraph |
|---|---|---|
| 문법 | JPQL 직접 작성 | annotation 선언 |
| 기본 JOIN 방식 | INNER JOIN |
LEFT OUTER JOIN |
| 조건 추가 | JPQL WHERE 절 자유 |
method naming 조건 |
| 복잡한 query | 유연하게 대응 | 단순 fetch에 적합 |
한계점과 주의 사항
- fetch join은 강력한 N+1 해결 수단이지만, collection fetch join에서 pagination과 다중 collection에 대한 제약이 있습니다.
Collection Fetch Join + Pagination
@OneToManyfetch join에Pageable을 함께 사용하면 DB level에서LIMIT을 적용하지 못합니다.- join으로 인해 부모 entity가 중복된 상태에서
LIMIT을 걸면 부모 기준이 아닌 row 기준으로 잘려 결과가 부정확해집니다. - Hibernate는 전체 결과를 memory에 올린 뒤 application level에서 paging하며,
HHH90003004경고를 출력합니다. - 대용량 data에서 OOM(Out of Memory) 위험이 있습니다.
- join으로 인해 부모 entity가 중복된 상태에서
HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
해결 방법 1 : 2-Query 방식
- 부모 ID를 pagination으로 먼저 조회한 뒤, 해당 ID 목록으로 fetch join을 실행합니다.
- 첫 번째 query에서 DB level
LIMIT이 정상 적용되고, 두 번째 query에서 연관 entity를 한 번에 조회합니다.
- 첫 번째 query에서 DB level
// 1단계 : 부모 ID paging
@Query("SELECT t.id FROM Team t")
Page<Long> findTeamIds(Pageable pageable);
// 2단계 : ID로 fetch join
@Query("SELECT DISTINCT t FROM Team t JOIN FETCH t.members WHERE t.id IN :ids")
List<Team> findByIdsWithMembers(@Param("ids") List<Long> ids);
해결 방법 2 : @BatchSize 사용
- collection fetch join 대신
@BatchSize를 적용하면 pagination이 DB level에서 정상 동작합니다.- query 수는
1 + ceil(N / size)로 증가하지만, memory 안전성을 확보합니다.
- query 수는
@OneToMany(mappedBy = "team")
@BatchSize(size = 100)
private List<Member> members;
MultipleBagFetchException
List(bag) type의@OneToManycollection 2개 이상을 동시에 fetch join하면MultipleBagFetchException이 발생합니다.- bag은 중복을 허용하는 비순서 collection이며, 두 bag의 Cartesian product로 인해 Hibernate가 정확한 row-entity mapping을 보장할 수 없습니다.
// Team이 members(List)와 projects(List)를 모두 가진 경우
@Query("SELECT t FROM Team t JOIN FETCH t.members JOIN FETCH t.projects")
List<Team> findAllWithMembersAndProjects(); // MultipleBagFetchException 발생
- bag을 제거하거나 동시 fetch join을 피하는 방향으로 해결합니다.
Set으로 변경 : collection type을Set으로 변경하면 bag이 아니므로 다중 fetch join이 가능합니다.- 단, 순서가 보장되지 않습니다.
- 하나만 fetch join + @BatchSize : 한 collection은 fetch join으로, 나머지는
@BatchSize로 처리합니다. - query 분리 : 각 collection을 별도 query로 조회합니다.
// 해결 1 : Set으로 변경
@OneToMany(mappedBy = "team")
private Set<Member> members = new HashSet<>();
// 해결 2 : 하나만 fetch join, 나머지 @BatchSize
@Query("SELECT t FROM Team t JOIN FETCH t.members")
List<Team> findAllWithMembers(); // projects는 @BatchSize로 처리
Fetch Join에서 ON 절 제한
- fetch join에서는
ON절로 연관 entity에 조건을 적용하면 안 됩니다.- fetch join의 목적은 연관 entity 전체를 loading하는 것이며, 조건으로 일부만 가져오면 collection의 data 정합성이 깨집니다.
- 조건이 필요한 경우 일반
JOIN과WHERE절을 사용하거나, 조회 후 application level에서 filtering합니다.
// 잘못된 사용 : fetch join에 ON 조건
@Query("SELECT t FROM Team t JOIN FETCH t.members m ON m.status = 'ACTIVE'")
// -> members collection에 ACTIVE만 들어가 data 정합성 훼손
// 올바른 사용 : WHERE 절로 전체 결과 필터링
@Query("SELECT t FROM Team t JOIN FETCH t.members m WHERE m.status = 'ACTIVE'")
Fetch Join vs 다른 N+1 해결 방법
- N+1 해결 방법마다 query 수, 적용 방식, 적합한 상황이 다르므로 용도에 맞게 선택합니다.
| 구분 | Fetch Join | @EntityGraph | @BatchSize | @Fetch(SUBSELECT) |
|---|---|---|---|---|
| query 수 | 1 | 1 | 1 + ceil(N / size) |
2 |
| 적용 방식 | JPQL JOIN FETCH |
annotation | annotation / global 설정 | annotation |
| 표준 여부 | JPA 표준 | JPA 표준 | Hibernate 전용 | Hibernate 전용 |
| pagination 호환 | @ManyToOne만 가능 |
@ManyToOne만 가능 |
가능 | 가능 |
| 다중 collection | 1개만 가능 | 1개만 가능 | 제한 없음 | 제한 없음 |
| 적합한 상황 | 특정 query의 N+1 해결 | method naming query | 전역 N+1 완화 | 전체 연관 collection 조회 |
- 실무에서는
@BatchSize를 global 설정으로 적용하여 기본적인 N+1을 완화하고, 성능이 중요한 query에 fetch join을 추가하는 조합이 효과적입니다.
# global BatchSize로 기본 N+1 완화
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
// 성능이 중요한 query에 fetch join 추가
@Query("SELECT o FROM Order o JOIN FETCH o.member WHERE o.status = :status")
List<Order> findByStatusWithMember(@Param("status") OrderStatus status);
Reference
- https://docs.jboss.org/hibernate/orm/6.6/userguide/html_single/Hibernate_User_Guide.html#fetching
- https://jakarta.ee/specifications/persistence/3.1/jakarta-persistence-spec-3.1.html
- https://vladmihalcea.com/hibernate-facts-the-importance-of-fetch-strategy/
- https://vladmihalcea.com/hibernate-multiplebagfetchexception/