Fetch Join은 영속성 컨텍스트에서 어떻게 동작할까?🤔

5 minute read

Published:

목차

  1. 들어가며..
  2. 일반 Join
  3. Fetch Join
  4. 일반 Join vs Fetch Join
  5. Fetch Join은 영속성 컨텍스트에서 어떻게 동작할까?🤔
  6. 결론

N+1기

계좌가 많아질 경우, 어떻게 설계하면 좋을까?🤔
N+1은 왜 발생할까?🤔(Fetch Join 적용 전후 성능 비교)
N+1은 왜 JPA에서 발생할까?🤔(JPQL)
N+1은 메모리에 어떤 영향을 미칠까?🤔(JVM)
영속성 컨텍스트는 N+1과 어떤 관계가 있을까?🤔(Hibernate)
Fetch Join은 영속성 컨텍스트에서 어떻게 동작할까?🤔
Hibernate 내부에서 영속성 컨텍스트는 어떻게 동작할까?🤔


들어가며..

스프링은 직접 데이터베이스와 통신하지 않고, 다음 단계로 통신한다.

개발자 코드 → JPA(ORM을 위한 표준 인터페이스) → Hibernate(JPA 구현체) → JDBC(DB 연결 드라이버) → Database(H2 / MySQL 등)

Hibernate(ORM)가 자동으로 H2 데이터베이스에 테이블을 생성하고 쿼리를 실행해주는 관계이다.

영속성 컨텍스트

영속성 컨텍스트는 DB에서 조회되거나 저장된 엔티티를 캐싱하고 변경 사항을 추적하는 역할을 한다. 이곳에 저장된 객체는 Entity Manger가 관리하는 “영속 상태(Persistent State)”가 되며, JPA는 해당 객체들의 상태를 추적한다.

Hibernate에서 영속성 컨텍스트는 1차 캐시 역할을 하며 DB와 애플리케이션 사이에서 엔티티 상태를 관리힌다.

  • DB에서 조회된 엔티티는 영속성 컨텍스트에 저장
  • 동일 PK 엔티티는 항상 하나의 객체 인스턴스만 존재
  • 엔티티 변경을 dirty checking으로 추적
Application
    ↓
Persistence Context (1st Level Cache)
    ↓
Database

일반 Join

일반 Join은 SQL의 JOIN과 동일하게 동작하지만, 조회 대상 엔티티는 하나만 로딩한다. 즉 Join은 조회 조건이나 필터링을 위해 사용되며 연관 엔티티는 영속성 컨텍스트에 로딩되지 않는다. 연관된 엔티티는 실제로 사용하기 전까지는 프록시(가짜 객체) 상태로 남아있는다. 만약 연관된 데이터를 조회하려고 하면, 그때마다 별도의 SQL이 실행되는 N+1 문제가 발생한다.

단순히 “연관된 엔티티의 특정 필드로 필터링만 하고 싶고, 그 엔티티 자체는 필요 없을 때”는 일반 조인이 메모리 측면에서 효율적이다.


Fetch Join

SELECT 절에 명시하지 않아도 연관된 엔티티까지 한 번의 쿼리로 다 긁어오기에, 조회의 주체가 되는 Entity 이외에 Fetch Join이 걸린 연관 Entity도 함께 SELECT 하여 모두 영속화한다. 이로 인해 Entity를 참조하더라도 이미 영속성 컨텍스트 안에 들어있기 때문에 따로 쿼리가 실행되지 않은 채로 N+1 문제가 해결된다.

SQL의 문법은 아니며, JPA(JPQL)에서 성능 최적화를 위해 제공하는 기능이다.

Hibernate 내부 관점

Fetch Join은 SQL 결과를 기반으로 엔티티 그래프를 생성한다.

member_idteam_id
11
21
32

Hibernate는 이를 읽고 영속성 컨텍스트에서 다음과 같이 중복을 merge 한다.

  • Team 1 -> 이미 존재
  • Team 2 -> 새로 생성

그래서 row는 여러 개지만 entity는 하나가 된다. 이 때문에 Fetch Join이 영속성 컨텍스트 때문에 row를 merge한다.


일반 Join vs Fetch Join

데이터를 어디까지 가져와서 영속성 컨텍스트(메모리)에 올릴 것인가의 차이이다. 연관된 데이터를 화면에 같이 뿌려야 한다면 Fetch Join을, 단순히 조건 검색용으로만 쓰고 연관 데이터는 필요 없다면 일반 Join을 선택하는게 정석이다.

구분일반 JoinFetch Join
핵심 목적조건 필터링 및 조회N+1 문제 해결을 위한 성능 최적화
영속성 컨텍스트연관 엔티티는 프록시 상태 (로딩 X)연관 엔티티까지 실제 객체로 즉시 로딩
SELECT 절조회 대상 엔티티만 포함연관 엔티티 컬럼까지 모두 포함
로딩 전략Lazy Loading 기반Eager Loading처럼 동작
정리Join만 수행, 연관 엔티티는 미로딩Join + 연관 엔티티까지 함께 로딩

Fetch Join은 영속성 컨텍스트에서 어떻게 동작할까?🤔

일반 Join과 Fetch Join의 동작을 확인해보자.

// 일반 Join
SELECT o FROM Order o
JOIN o.member m

// Fetch Join
SELECT o FROM Order o
JOIN FETCH o.member
상태일반 JoinFetch Join
Order영속 상태영속 상태
Member프록시 객체 (지연 로딩)실제 엔티티 (즉시 로딩)
접근 시추가 SQL 발생 (N+1 가능)추가 쿼리 없음

일반 JOIN은 연관 엔티티를 즉시 초기화하지 않는다. Fetch 전략에 따라 추가 쿼리가 발생할 수 있다. Fetch Join은 연관 엔티티를 부모 엔티티와 함께 즉시 초기화하여 한 번의 쿼리로 로딩한다.

Hibernate 내부 특징

Hibernate는 PK 기준으로 엔티티를 병합한다. 이는 영속성 컨텍스트가 동일 PK 엔티티를 하나만 유지하기 때문이다. 따라서 Fetch Join 결과는 SQL row 수와 엔티티 수가 다를 수 있다.

Order 1 - Item A
Order 1 - Item B
Order 1 - Item C

// SQL 결과
3 rows

// Hibernate
Order(1) 하나만 생성
items = [A,B,C]

Fetch Join이 중복 row를 merge하는 이유

Fetch Join이 중복 row를 merge하는 이유는 영속성 컨텍스트의 “엔티티 동일성(identity) 보장 규칙” 때문이다. Hibernate/JPA는 같은 PK를 가진 엔티티는 영속성 컨텍스트에 단 하나의 객체만 존재하도록 강제한다. 그래서 Fetch Join으로 SQL 결과에 중복 row가 생겨도 하나의 엔티티로 merge된다.

Entity Identity

JPA 스펙에서 영속성 컨텍스트는 다음을 보장한다.

For any persistent entity identity there is a unique entity instance.
하나의 엔티티 식별자에 대해 영속성 컨텍스트에는 하나의 객체 인스턴스만 존재한다.

이는 다음과 같은 구조이다. 같은 PK가 나오면 새 객체를 만들지 않고 기존 객체를 재사용한다.

Persistence Context
Map<EntityKey, Entity>

// EntityKey(Order, 1) -> Order@1234
// EntityKey(Member, 10) -> Member@5678

Hibernate 내부 알고리즘

Hibernate는 내부적으로 다음 로직을 사용한다.

1 row 읽기
↓
PK 추출
↓
PersistenceContext.contains(entityKey)?
↓
YES → 기존 entity 반환
NO → 새 entity 생성

Fetch Join 동작 전체 흐름

JPQL 실행
     │
     ▼
SQL Join 실행
     │
     ▼
ResultSet 생성
     │
     ▼
Hibernate row 순회
     │
     ├─ EntityKey 확인
     │
     ├─ 없으면 생성
     │
     └─ 있으면 재사용
     │
     ▼
Persistence Context 저장
     │
     ▼
완성된 Entity Graph 반환

결론

Fetch Join에서 SQL 결과는 연관 엔티티 때문에 중복 row가 발생하지만, Hibernate는 영속성 컨텍스트에서 엔티티 식별자(PK)를 기준으로 동일성을 보장하기 때문에 같은 엔티티를 여러 번 생성하지 않고 하나의 엔티티로 병합한다.

참고

Gemini
ChatGPT
Claude.ai
Inner Join 과 Fetch Join 의 차이
[JPA] 일반 Join과 Fetch Join의 차이
[Spring] JPA 영속성 컨텍스트와 JOIN 활용
[Spring JPA] 일반 Join과 Fetch Join의 차이
JPA의 영속성 컨텍스트와 N+1 문제 및 해결방법, 로딩 전략 (Fetch join)
12. Fetching
Jakarta Persistence 3.2
Hibernate ORM User Guide