JPA 영속성 컨텍스트 완벽 가이드: 동작 원리부터 실무 함정까지
JPA 영속성 컨텍스트 완벽 가이드: 동작 원리부터 실무 함정까지
JPA를 사용하는 개발자에게 영속성 컨텍스트(Persistence Context)는 가장 중요한 개념이다. em.persist()를 호출하면 무슨 일이 일어나는가? setter를 호출했을 뿐인데 왜 UPDATE 쿼리가 나가는가? findById()를 두 번 호출하면 SELECT가 한 번만 나가는 이유는? 이 모든 질문의 답이 영속성 컨텍스트에 있다.
영속성 컨텍스트를 “1차 캐시”로만 이해하면 JPA의 절반만 아는 것이다. 영속성 컨텍스트는 1차 캐시, 동일성 보장, 쓰기 지연, 변경 감지, 지연 로딩이라는 다섯 가지 기능을 하나의 메커니즘으로 통합한 JPA의 핵심 엔진이다. 이 글은 각 기능의 내부 동작 원리, 엔티티의 생명주기 상태 전환, Spring이 영속성 컨텍스트를 어떻게 생성하고 바인딩하고 공유하는지, 그리고 OSIV, 프록시, 2차 캐시까지 실무에서 마주치는 모든 주제를 깊이 있게 다룬다.
1. 영속성 컨텍스트란 무엇인가
1.1 정의
영속성 컨텍스트는 엔티티를 영구 저장하는 환경이다. JPA 스펙(JSR 338)의 정의는 다음과 같다.
“A persistence context is a set of managed entity instances in which for any persistent entity identity there is a unique entity instance.”
해석하면: 영속성 컨텍스트는 관리되는 엔티티 인스턴스의 집합이며, 각 엔티티 식별자에 대해 유일한 엔티티 인스턴스가 존재한다.
핵심 키워드는 두 가지다.
- 관리되는(managed): 영속성 컨텍스트가 엔티티의 상태 변화를 추적한다
- 유일한(unique): 같은 식별자를 가진 엔티티는 하나의 인스턴스만 존재한다
1.2 영속성 컨텍스트는 “논리적 공간”이다
영속성 컨텍스트는 물리적인 클래스가 아니다. 직접 new PersistenceContext()를 할 수 없다. 영속성 컨텍스트는 EntityManager를 통해 접근하는 논리적 공간이다.
1
2
3
4
5
6
[개발자] → [EntityManager] → [영속성 컨텍스트]
│
├── 1차 캐시 (Map<EntityKey, Entity>)
├── 스냅샷 저장소 (Map<EntityKey, Object[]>)
├── 쓰기 지연 SQL 저장소 (List<SQL>)
└── 지연 로딩 프록시 관리
EntityManager는 영속성 컨텍스트의 인터페이스다. 개발자는 EntityManager의 메서드(persist(), find(), merge(), remove())를 통해 영속성 컨텍스트에 엔티티를 넣고, 조회하고, 변경하고, 삭제한다.
1.3 EntityManager와 영속성 컨텍스트의 관계
Java SE 환경에서는 EntityManager와 영속성 컨텍스트가 1:1로 대응한다.
1
2
3
4
// Java SE: EntityManager 하나 = 영속성 컨텍스트 하나
EntityManagerFactory emf = Persistence.createEntityManagerFactory("myPU");
EntityManager em = emf.createEntityManager();
// em이 자신만의 영속성 컨텍스트를 가진다
Spring(Java EE) 환경에서는 같은 트랜잭션 내에서 여러 EntityManager가 하나의 영속성 컨텍스트를 공유할 수 있다. 이것은 뒤의 “JPA가 영속성 컨텍스트를 어떻게 보장하는가” 섹션에서 자세히 다룬다.
1.4 왜 영속성 컨텍스트가 필요한가
영속성 컨텍스트 없이 JPA를 설계한다면 어떻게 될까?
1
2
3
4
5
6
7
8
// 영속성 컨텍스트가 없는 가상의 JPA
Member member1 = em.find(Member.class, 1L); // SELECT 실행
Member member2 = em.find(Member.class, 1L); // 또 SELECT 실행 (중복!)
System.out.println(member1 == member2); // false (서로 다른 인스턴스!)
member1.setName("변경된이름");
em.update(member1); // 명시적으로 update를 호출해야 한다
영속성 컨텍스트가 있으면:
1
2
3
4
5
6
7
8
// 실제 JPA
Member member1 = em.find(Member.class, 1L); // SELECT 실행
Member member2 = em.find(Member.class, 1L); // 1차 캐시에서 반환 (SELECT 안 함!)
System.out.println(member1 == member2); // true (같은 인스턴스!)
member1.setName("변경된이름");
// update() 호출 불필요! 트랜잭션 커밋 시 변경 감지로 자동 UPDATE
영속성 컨텍스트가 제공하는 이점을 정리하면 다음과 같다.
| 기능 | 설명 | 이점 |
|---|---|---|
| 1차 캐시 | 같은 엔티티를 메모리에서 반환 | DB 접근 최소화 |
| 동일성 보장 | 같은 식별자 = 같은 인스턴스 | == 비교 가능, 일관성 |
| 쓰기 지연 | SQL을 모아서 한 번에 전송 | 네트워크 비용 절감, 배치 최적화 |
| 변경 감지 | 엔티티 변경을 자동으로 감지 | UPDATE 명시 호출 불필요 |
| 지연 로딩 | 필요한 시점에 연관 엔티티 로딩 | 불필요한 데이터 조회 방지 |
2. EntityManager의 생명주기
2.1 Java SE 환경: 개발자가 직접 관리
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// EntityManagerFactory는 애플리케이션 당 하나 (비용이 크다)
EntityManagerFactory emf = Persistence.createEntityManagerFactory("myPU");
// EntityManager는 요청마다 생성 (비용이 작다)
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
try {
tx.begin();
Member member = new Member("김철수");
em.persist(member); // 영속성 컨텍스트에 저장
tx.commit(); // 커밋 → flush → DB 반영
} catch (Exception e) {
tx.rollback();
} finally {
em.close(); // 영속성 컨텍스트 종료, 엔티티는 준영속 상태
}
// 애플리케이션 종료 시
emf.close();
Java SE에서는 개발자가 begin(), commit(), close()를 모두 직접 관리한다. 트랜잭션 범위가 영속성 컨텍스트의 범위와 정확히 일치한다.
2.2 Spring 환경: 컨테이너가 관리
Spring에서는 이 모든 보일러플레이트를 컨테이너가 대신 처리한다.
1
2
3
4
5
6
7
8
9
10
11
12
@Service
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
@Transactional
public void createMember(String name) {
Member member = new Member(name);
memberRepository.save(member);
// 메서드가 끝나면 자동으로 commit → flush → 영속성 컨텍스트 종료
}
}
Spring의 @Transactional이 하는 일을 풀어쓰면 다음과 같다.
1
2
3
4
5
6
7
8
9
10
[Spring @Transactional의 실제 동작]
1. AOP 프록시가 메서드 호출을 가로챈다
2. TransactionManager가 트랜잭션을 시작한다
3. EntityManager를 생성하고 현재 스레드에 바인딩한다
4. 실제 메서드를 실행한다
5. 정상 종료 시:
└── flush() → commit() → EntityManager.close()
6. 예외 발생 시:
└── rollback() → EntityManager.close()
2.3 EntityManager의 스레드 안전성
EntityManager는 스레드 안전하지 않다(NOT thread-safe). 내부에 1차 캐시, 스냅샷 등 상태를 보유하고 있으므로, 여러 스레드가 동시에 접근하면 데이터가 꼬인다.
그런데 Spring에서는 @PersistenceContext나 @Autowired로 EntityManager를 주입받는다. 싱글톤 빈에 주입된 EntityManager를 여러 스레드가 사용하면 문제 아닌가?
1
2
3
4
5
@Repository
public class MemberRepository {
@PersistenceContext
private EntityManager em; // 싱글톤 빈에 주입! 스레드 안전한가?
}
안전하다. 이것이 가능한 이유는 Spring이 주입하는 것이 실제 EntityManager가 아니라 프록시(SharedEntityManagerCreator)이기 때문이다.
1
2
3
4
5
6
7
8
9
10
11
12
[SharedEntityManagerCreator 프록시의 동작]
Thread A가 em.find() 호출
→ 프록시가 현재 스레드(Thread A)에 바인딩된 실제 EntityManager를 찾는다
→ TransactionSynchronizationManager.getResource(EntityManagerFactory)
→ Thread A의 ThreadLocal에서 실제 EntityManager를 가져온다
→ 실제 EntityManager의 find()를 호출한다
Thread B가 em.persist() 호출
→ 프록시가 현재 스레드(Thread B)에 바인딩된 실제 EntityManager를 찾는다
→ Thread B의 ThreadLocal에서 별도의 EntityManager를 가져온다
→ Thread A와 완전히 다른 EntityManager의 persist()를 호출한다
핵심은 ThreadLocal이다. 각 스레드가 자신만의 EntityManager를 갖고, 프록시가 현재 스레드에 맞는 EntityManager로 라우팅한다.
3. 엔티티의 4가지 생명주기 상태
JPA에서 엔티티는 네 가지 상태를 가진다. 이 상태를 정확히 이해하지 않으면 LazyInitializationException, merge와 변경 감지의 차이, 준영속 엔티티 처리 등에서 혼란이 생긴다.
3.1 상태 전환 다이어그램
1
2
3
4
5
6
7
8
9
10
11
12
13
persist()
[비영속(new)] ─────────────────→ [영속(managed)]
↑ │
│ │ remove()
merge() │ ↓
[준영속(detached)] ←───────────── │ [삭제(removed)]
detach() │
clear() │
close() │
│
find() │
[DB] ─────────────────────────────┘
JPQL
3.2 비영속 (new/transient)
엔티티 객체를 생성만 하고 영속성 컨텍스트에 넣지 않은 상태다. JPA와 아무 관계가 없다.
1
2
3
4
5
6
// 비영속 상태: 그냥 Java 객체일 뿐이다
Member member = new Member();
member.setName("김철수");
member.setAge(25);
// 이 시점에서 member는 JPA가 전혀 관리하지 않는다
// DB에도 저장되지 않았고, 영속성 컨텍스트에도 없다
비영속 상태의 엔티티는:
- 영속성 컨텍스트에 없다
- 식별자(
@Id)가 없을 수 있다 (아직 할당 전) - 변경 감지 대상이 아니다
em.contains(member)→false
3.3 영속 (managed)
영속성 컨텍스트에 의해 관리되는 상태다. 이 상태의 엔티티만 JPA의 기능(변경 감지, 지연 로딩, 1차 캐시 등)을 사용할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
// 방법 1: persist()로 영속 상태로 만든다
Member member = new Member("김철수");
em.persist(member);
// → member는 이제 영속 상태
// → 1차 캐시에 저장됨
// → 스냅샷이 생성됨
// 방법 2: find()로 DB에서 조회하면 자동으로 영속 상태
Member found = em.find(Member.class, 1L);
// → found는 영속 상태
// → DB에서 가져온 데이터가 1차 캐시에 저장됨
// 방법 3: JPQL로 조회해도 영속 상태
List<Member> members = em.createQuery("SELECT m FROM Member m", Member.class)
.getResultList();
// → 조회된 모든 Member가 영속 상태
영속 상태의 엔티티는:
- 영속성 컨텍스트의 1차 캐시에 존재한다
- 식별자(
@Id)를 반드시 가진다 - 변경 감지 대상이다 (setter로 값을 바꾸면 자동 UPDATE)
- 지연 로딩이 가능하다
em.contains(member)→true
3.4 준영속 (detached)
영속 상태였다가 영속성 컨텍스트에서 분리된 상태다. 한 번은 영속 상태였으므로 식별자는 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 방법 1: 특정 엔티티를 분리
em.detach(member);
// → member만 영속성 컨텍스트에서 제거
// 방법 2: 영속성 컨텍스트를 완전히 초기화
em.clear();
// → 모든 엔티티가 준영속 상태. 1차 캐시 완전 삭제
// 방법 3: 영속성 컨텍스트를 종료
em.close();
// → 영속성 컨텍스트 자체가 사라짐
// 방법 4: 트랜잭션이 끝난 후 (Spring 환경, OSIV OFF)
@Transactional
public Member getMember(Long id) {
return memberRepository.findById(id).orElseThrow();
}
// 메서드 종료 후 반환된 Member는 준영속 상태
준영속 상태의 엔티티는:
- 영속성 컨텍스트에 없다
- 식별자는 있다 (한 번은 영속 상태였으므로)
- 변경 감지 안 된다 (setter 호출해도 DB에 반영 안 됨)
- 지연 로딩 불가 (
LazyInitializationException) em.contains(member)→false
3.5 삭제 (removed)
remove()로 삭제가 예약된 상태다. 트랜잭션 커밋(flush) 시 DELETE 쿼리가 실행된다.
1
2
3
4
5
6
7
8
9
Member member = em.find(Member.class, 1L);
em.remove(member);
// → member는 삭제 상태
// → 아직 DELETE가 실행되지는 않았다
// → flush() 시점에 DELETE SQL이 실행된다
// 삭제 상태의 엔티티를 다시 persist()하면?
em.persist(member);
// → 다시 영속 상태로 전환된다 (삭제 취소)
3.6 상태 전환의 실제 흐름 예제
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@Test
void 엔티티_생명주기_테스트() {
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();
// 1. 비영속
Member member = new Member("김철수");
System.out.println("비영속: " + em.contains(member)); // false
// 2. 비영속 → 영속
em.persist(member);
System.out.println("영속: " + em.contains(member)); // true
// 3. 영속 → 준영속
em.detach(member);
System.out.println("준영속: " + em.contains(member)); // false
// 4. 준영속 → 영속 (merge)
Member merged = em.merge(member);
System.out.println("merge 반환: " + em.contains(merged)); // true
System.out.println("원본: " + em.contains(member)); // false! 원본은 여전히 준영속
// 5. 영속 → 삭제
em.remove(merged);
System.out.println("삭제: " + em.contains(merged)); // false
tx.commit();
em.close();
}
merge()의 중요한 특성: merge는 파라미터로 전달한 엔티티를 영속 상태로 만드는 것이 아니다. 새로운 영속 상태 엔티티를 반환한다. 원본은 여전히 준영속이다. 이 차이가 실무에서 많은 버그의 원인이 된다.
4. 1차 캐시 심층 분석
4.1 1차 캐시의 자료구조
1차 캐시의 내부는 본질적으로 Map 구조다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
[1차 캐시 내부 구조]
Map<EntityKey, EntityEntry>
EntityKey = (엔티티 클래스, 식별자 값)
예: (Member.class, 1L)
예: (Team.class, "TEAM_A")
EntityEntry = {
entity: 실제 엔티티 인스턴스,
status: MANAGED | DELETED | GONE | ...,
loadedState: Object[] (스냅샷 — 로드 시점의 필드 값들),
...
}
Hibernate의 실제 구현에서는 StatefulPersistenceContext 클래스가 이 역할을 수행한다. 내부적으로 여러 Map을 유지한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
StatefulPersistenceContext {
// 엔티티 저장소
Map<EntityKey, Object> entitiesByKey;
// 엔티티 상태 정보
Map<Object, EntityEntry> entityEntryContext;
// 컬렉션 저장소 (OneToMany 등의 컬렉션)
Map<CollectionKey, PersistentCollection> collectionsByKey;
// 프록시 저장소
Map<EntityKey, Object> proxiesByKey;
// 스냅샷은 EntityEntry.loadedState에 저장
}
4.2 find() 시 1차 캐시 동작
1
2
3
4
5
6
7
8
9
10
11
12
Member member1 = em.find(Member.class, 1L);
// 실행 흐름:
// 1. 1차 캐시에서 EntityKey(Member.class, 1L)을 검색
// 2. 없다 → DB에 SELECT 쿼리 실행
// 3. 결과를 엔티티로 변환
// 4. 1차 캐시에 저장 + 스냅샷 생성
// 5. 엔티티 반환
Member member2 = em.find(Member.class, 1L);
// 실행 흐름:
// 1. 1차 캐시에서 EntityKey(Member.class, 1L)을 검색
// 2. 있다! → DB 조회 없이 바로 반환
실제 SQL 로그:
1
2
3
4
5
-- member1 = em.find(Member.class, 1L)
SELECT m.id, m.name, m.age, m.team_id FROM member m WHERE m.id = 1;
-- member2 = em.find(Member.class, 1L)
-- SQL 없음! 1차 캐시에서 반환
1
System.out.println(member1 == member2); // true (같은 인스턴스!)
4.3 JPQL과 1차 캐시의 관계
JPQL은 1차 캐시를 우회하여 항상 DB에 쿼리를 실행한다. 이것은 매우 중요한 특성이다.
1
2
3
4
5
6
7
8
9
// find()로 조회 → 1차 캐시에 저장
Member member1 = em.find(Member.class, 1L);
// JPQL로 같은 데이터를 조회
List<Member> members = em.createQuery(
"SELECT m FROM Member m WHERE m.id = 1", Member.class)
.getResultList();
Member member2 = members.get(0);
실제 SQL 로그:
1
2
3
4
5
-- em.find() → 1차 캐시에 없으므로 DB 조회
SELECT m.id, m.name, m.age, m.team_id FROM member m WHERE m.id = 1;
-- JPQL → 1차 캐시를 무시하고 항상 DB 조회!
SELECT m.id, m.name, m.age, m.team_id FROM member m WHERE m.id = 1;
JPQL이 DB를 조회했는데, 1차 캐시에 이미 같은 식별자의 엔티티가 있으면 어떻게 되는가?
1
2
3
4
5
6
7
[JPQL 조회 결과와 1차 캐시 충돌 시 동작]
1. JPQL이 DB에서 데이터를 가져온다
2. 결과의 각 행에 대해 식별자를 확인한다
3. 1차 캐시에 해당 식별자의 엔티티가 이미 있는가?
├── 없다 → 새 엔티티를 생성하여 1차 캐시에 저장
└── 있다 → DB에서 가져온 데이터를 **버리고** 1차 캐시의 기존 엔티티를 반환
DB에서 가져온 최신 데이터를 버린다! 이것은 놀라운 동작이지만, 이유가 있다. 영속성 컨텍스트의 동일성 보장을 지키기 위해서다. 1차 캐시의 엔티티를 DB 데이터로 교체해버리면, 그 엔티티를 참조하고 있던 다른 코드의 변경 사항이 사라질 수 있다.
1
2
System.out.println(member1 == member2); // true!
// JPQL이 DB에서 새로 가져왔지만, 1차 캐시의 기존 인스턴스를 반환했다
4.4 1차 캐시의 한계
한계 1: 트랜잭션 범위에서만 유효하다
1차 캐시는 영속성 컨텍스트에 속한다. Spring 환경에서 영속성 컨텍스트의 기본 범위는 트랜잭션이다. 트랜잭션이 끝나면 영속성 컨텍스트와 함께 1차 캐시도 사라진다.
1
2
3
4
5
6
7
8
9
10
11
@Transactional
public void method1() {
Member m = memberRepository.findById(1L).orElseThrow(); // SELECT 실행, 1차 캐시 저장
Member m2 = memberRepository.findById(1L).orElseThrow(); // 1차 캐시 히트!
}
@Transactional
public void method2() {
// method1의 1차 캐시는 이미 사라졌다
Member m = memberRepository.findById(1L).orElseThrow(); // 다시 SELECT 실행
}
한계 2: 식별자 기반 조회에서만 동작한다
1차 캐시의 키는 (엔티티 클래스, 식별자)다. findById()처럼 PK로 조회하는 경우에만 1차 캐시가 동작한다. 조건 검색, JPQL, Querydsl은 항상 DB를 조회한다.
1
2
3
4
5
6
// 1차 캐시 활용 O
em.find(Member.class, 1L); // PK 조회
// 1차 캐시 활용 X (항상 DB 조회)
em.createQuery("SELECT m FROM Member m WHERE m.name = '김철수'");
memberRepository.findByName("김철수");
한계 3: 같은 영속성 컨텍스트 내에서만 공유된다
서로 다른 트랜잭션(=서로 다른 영속성 컨텍스트)은 1차 캐시를 공유하지 않는다. 동시에 요청하는 사용자 A와 B는 각자의 1차 캐시를 가진다.
4.5 1차 캐시 vs 2차 캐시
| 기준 | 1차 캐시 | 2차 캐시 |
|---|---|---|
| 범위 | 영속성 컨텍스트(트랜잭션) | EntityManagerFactory(애플리케이션) |
| 공유 | 스레드(트랜잭션) 내부 | 모든 스레드에서 공유 |
| 저장 형태 | 엔티티 인스턴스 | 직렬화된 값 복사본 |
| 동일성 보장 | O (같은 인스턴스 반환) | X (새 인스턴스 반환, 값만 동일) |
| 동시성 문제 | 없음 (단일 스레드) | 있을 수 있음 (락/동기화 필요) |
| 기본 활성화 | O (항상 활성) | X (별도 설정 필요) |
| 성능 이점 | 같은 트랜잭션 내 반복 조회 | 여러 트랜잭션에 걸친 반복 조회 |
5. 동일성(Identity) 보장
5.1 == 비교가 가능한 이유
영속성 컨텍스트는 같은 식별자를 가진 엔티티에 대해 항상 같은 인스턴스를 반환한다. 이것이 동일성(Identity) 보장이다.
1
2
3
4
5
6
7
8
@Transactional
public void identityTest() {
Member m1 = memberRepository.findById(1L).orElseThrow();
Member m2 = memberRepository.findById(1L).orElseThrow();
System.out.println(m1 == m2); // true (같은 인스턴스)
System.out.println(m1.equals(m2)); // true
}
이것이 가능한 이유는 1차 캐시 때문이다. findById(1L)을 두 번 호출하면, 두 번째 호출은 1차 캐시에서 첫 번째 호출 때 저장한 동일한 인스턴스를 반환한다.
5.2 동일성 보장의 의미
Java에서 ==은 참조 비교(두 변수가 같은 메모리 주소를 가리키는가)이다. 영속성 컨텍스트가 동일성을 보장한다는 것은 JPA가 마치 Java 컬렉션처럼 동작한다는 의미다.
1
2
3
4
5
6
7
8
9
10
11
12
// Java 컬렉션과 JPA의 동작 유사성
Map<Long, Member> map = new HashMap<>();
map.put(1L, new Member("김철수"));
Member m1 = map.get(1L);
Member m2 = map.get(1L);
System.out.println(m1 == m2); // true — 같은 인스턴스
// JPA도 동일하게 동작한다
Member m1 = em.find(Member.class, 1L);
Member m2 = em.find(Member.class, 1L);
System.out.println(m1 == m2); // true — 같은 인스턴스
이 동일성 보장은 REPEATABLE READ 격리 수준을 애플리케이션 레벨에서 구현한 것과 같다. 같은 트랜잭션 내에서 같은 데이터를 읽으면 항상 같은 결과를 얻는다.
5.3 준영속 상태에서의 동일성 문제
영속성 컨텍스트가 종료되면(트랜잭션 끝, close()) 동일성 보장도 사라진다.
1
2
3
4
5
6
7
8
9
10
11
Member m1;
Member m2;
// 트랜잭션 1
memberService.method1(); // m1을 조회하고 반환
// 트랜잭션 2
memberService.method2(); // m2를 조회하고 반환 (같은 id=1)
System.out.println(m1 == m2); // false!
// 서로 다른 영속성 컨텍스트에서 조회했으므로 다른 인스턴스다
이것이 JPA 엔티티에서 equals()와 hashCode()를 적절히 오버라이드해야 하는 이유다. 준영속 상태에서도 같은 데이터를 가진 엔티티를 동등하게 비교하려면 equals()가 식별자 기반으로 구현되어야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Member)) return false;
Member member = (Member) o;
// id가 null이 아닌 경우에만 비교 (비영속 상태 방어)
return id != null && id.equals(member.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
}
6. 쓰기 지연 (Transactional Write-Behind) 심층
6.1 쓰기 지연의 동작 원리
em.persist()를 호출하면 INSERT SQL이 즉시 DB에 전송되지 않는다. 대신 영속성 컨텍스트 내부의 쓰기 지연 SQL 저장소에 SQL이 쌓여있다가, flush() 시점에 한꺼번에 DB로 전송된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();
Member memberA = new Member("회원A");
Member memberB = new Member("회원B");
em.persist(memberA);
// INSERT SQL을 쓰기 지연 SQL 저장소에 저장
// 아직 DB에 전송하지 않음!
em.persist(memberB);
// INSERT SQL을 쓰기 지연 SQL 저장소에 저장
// 아직 DB에 전송하지 않음!
System.out.println("=== 커밋 전 ===");
tx.commit();
// 1. flush() 호출 → 쓰기 지연 SQL 저장소의 SQL을 DB에 전송
// 2. 실제 DB 커밋
실제 SQL 로그:
1
2
3
4
=== 커밋 전 ===
-- tx.commit() 시점에 한꺼번에 나간다
INSERT INTO member (name) VALUES ('회원A');
INSERT INTO member (name) VALUES ('회원B');
6.2 쓰기 지연의 내부 흐름
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
[em.persist(memberA) 호출 시]
1. memberA를 1차 캐시에 저장한다
→ Map.put(EntityKey(Member.class, 1L), memberA)
2. memberA의 현재 상태를 스냅샷으로 저장한다
→ loadedState = {name: "회원A", age: 25, ...}
3. INSERT SQL을 생성하여 쓰기 지연 SQL 저장소에 추가한다
→ actionQueue.addAction(new EntityInsertAction(memberA))
4. memberA를 반환한다 (이미 영속 상태)
[tx.commit() 호출 시]
1. em.flush()가 자동으로 호출된다
2. flush()의 동작:
a. 변경 감지(Dirty Checking) 수행 → UPDATE SQL 생성
b. 쓰기 지연 SQL 저장소의 SQL을 정렬한다
(INSERT → UPDATE → DELETE 순서)
c. 정렬된 SQL을 DB에 전송한다
3. DB 트랜잭션을 커밋한다
6.3 쓰기 지연의 이점
이점 1: JDBC Batch 최적화
1
2
3
4
5
6
7
8
spring:
jpa:
properties:
hibernate:
jdbc:
batch_size: 50
order_inserts: true
order_updates: true
이 설정을 하면 Hibernate는 같은 타입의 INSERT를 모아서 JDBC Batch로 한 번에 전송한다.
1
2
3
4
5
6
7
8
[Batch 없이]
INSERT INTO member (name) VALUES ('회원A'); → 네트워크 라운드트립 1
INSERT INTO member (name) VALUES ('회원B'); → 네트워크 라운드트립 2
INSERT INTO member (name) VALUES ('회원C'); → 네트워크 라운드트립 3
[Batch 적용]
INSERT INTO member (name) VALUES ('회원A'), ('회원B'), ('회원C');
→ 네트워크 라운드트립 1번!
대량 데이터를 삽입할 때 batch_size 설정 하나로 수십 배의 성능 향상을 얻을 수 있다.
이점 2: 트랜잭션 내에서 유연한 로직 작성
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Transactional
public void complexLogic() {
Member member = new Member("김철수");
em.persist(member);
// INSERT가 나가지 않았으므로, 이 시점에서 member.id를 사용할 수도 있다
// (SEQUENCE 전략인 경우)
// 비즈니스 로직 수행...
if (someCondition) {
throw new BusinessException("조건 불충족");
// 예외 발생 → 롤백 → INSERT가 애초에 DB에 가지 않았으므로
// 불필요한 INSERT + ROLLBACK 비용이 없다
}
// 정상 → 커밋 시점에 INSERT 실행
}
6.4 ID 생성 전략에 따른 쓰기 지연의 차이
이것이 실무에서 가장 혼동되는 부분이다. ID 생성 전략에 따라 persist() 시점의 동작이 완전히 달라진다.
IDENTITY 전략: persist() 시 즉시 INSERT
1
2
3
4
5
6
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
}
IDENTITY 전략은 DB의 AUTO_INCREMENT를 사용한다. DB에 INSERT를 실행해야만 ID 값을 알 수 있다. 그런데 영속성 컨텍스트의 1차 캐시는 (엔티티 클래스, 식별자)를 키로 사용한다. 식별자를 모르면 1차 캐시에 저장할 수 없다.
그래서 IDENTITY 전략에서는 em.persist() 호출 시 즉시 INSERT 쿼리를 DB에 전송한다. 쓰기 지연이 동작하지 않는다.
1
2
3
4
5
em.persist(member);
// → 즉시! INSERT INTO member (name) VALUES ('김철수');
// → DB가 생성한 id를 반환받아 member.id에 설정
// → 1차 캐시에 저장
System.out.println(member.getId()); // 1 (DB가 할당한 값)
이것이 IDENTITY 전략에서 JDBC Batch가 동작하지 않는 이유다. persist()마다 즉시 INSERT가 실행되므로 SQL을 모을 수가 없다.
SEQUENCE 전략: persist() 시 시퀀스만 조회, INSERT는 지연
1
2
3
4
5
6
7
8
@Entity
@SequenceGenerator(name = "member_seq_gen", sequenceName = "member_seq",
allocationSize = 50)
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "member_seq_gen")
private Long id;
}
SEQUENCE 전략에서는 persist() 시 시퀀스에서 ID 값만 먼저 가져온다. INSERT는 flush 시점까지 지연된다.
1
2
3
4
5
6
7
8
9
10
11
12
em.persist(memberA);
// 1. SELECT nextval('member_seq') → id = 1 획득
// 2. memberA.id = 1 설정
// 3. 1차 캐시에 저장
// 4. INSERT SQL은 쓰기 지연 SQL 저장소에 저장 (아직 DB 전송 안 함!)
em.persist(memberB);
// 1. SELECT nextval('member_seq') → id = 2 획득
// 2. INSERT SQL은 쓰기 지연 SQL 저장소에 저장
tx.commit();
// 여기서 INSERT 두 개가 한꺼번에 전송!
allocationSize는 시퀀스 호출을 줄이기 위한 최적화다. allocationSize = 50이면 시퀀스를 한 번 호출해서 50개의 ID를 미리 확보한다. 51번째 persist()에서야 시퀀스를 다시 호출한다.
1
2
3
4
5
6
7
8
9
10
11
12
[allocationSize = 50의 동작]
persist() 1번째: SELECT nextval('member_seq') → DB 값: 50
→ 메모리에 1~50 범위 확보, id = 1 할당
persist() 2번째: 메모리에서 id = 2 할당 (DB 호출 없음!)
persist() 3번째: 메모리에서 id = 3 할당 (DB 호출 없음!)
...
persist() 50번째: 메모리에서 id = 50 할당 (DB 호출 없음!)
persist() 51번째: SELECT nextval('member_seq') → DB 값: 100
→ 메모리에 51~100 범위 확보, id = 51 할당
TABLE 전략
TABLE 전략은 시퀀스를 지원하지 않는 DB에서 시퀀스를 흉내내는 방식이다. 별도의 키 생성 테이블을 사용한다. 성능이 좋지 않으므로 실무에서는 거의 사용하지 않는다.
전략 비교
| 전략 | persist() 시 동작 | 쓰기 지연 | JDBC Batch |
|---|---|---|---|
| IDENTITY | 즉시 INSERT | 불가 | 불가 |
| SEQUENCE | 시퀀스 조회만 | 가능 | 가능 |
| TABLE | 테이블 조회 + UPDATE | 가능 | 가능 |
대량 삽입 성능이 중요하다면 SEQUENCE 전략을 사용해야 한다. MySQL에서는 SEQUENCE를 지원하지 않으므로(MySQL 8.0에서는 지원), IDENTITY를 사용하되 대량 삽입에는 JdbcTemplate.batchUpdate()를 사용하는 것이 일반적이다.
7. 변경 감지 (Dirty Checking) 심층
7.1 변경 감지란
변경 감지는 영속 상태의 엔티티 데이터가 변경되면, 트랜잭션 커밋 시점에 자동으로 UPDATE 쿼리를 생성하여 DB에 반영하는 기능이다.
1
2
3
4
5
6
@Transactional
public void updateMemberName(Long id, String newName) {
Member member = memberRepository.findById(id).orElseThrow();
member.setName(newName); // setter 호출만 했을 뿐!
// memberRepository.save(member); ← 이 줄이 필요 없다!
}
save()를 호출하지 않았는데 DB에 UPDATE가 나간다. 이것이 변경 감지다.
7.2 스냅샷 비교 메커니즘
변경 감지의 핵심은 스냅샷(Snapshot)이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
[변경 감지 내부 동작]
1. 엔티티가 영속성 컨텍스트에 들어갈 때 (find, persist, JPQL 조회)
→ 엔티티의 현재 상태를 **Object 배열로 복사**하여 스냅샷으로 저장
→ loadedState = {name: "김철수", age: 25, teamId: 1}
2. 트랜잭션 커밋 시 flush()가 호출된다
3. flush() 내부에서:
a. 영속성 컨텍스트의 모든 엔티티를 순회한다
b. 각 엔티티의 현재 상태와 스냅샷을 **필드 단위로 비교**한다
현재 상태: {name: "이영희", age: 25, teamId: 1}
스냅샷: {name: "김철수", age: 25, teamId: 1}
비교 결과: name이 다르다! → Dirty(변경됨)
c. 변경된 엔티티에 대해 UPDATE SQL을 생성한다
d. 생성된 UPDATE SQL을 쓰기 지연 SQL 저장소에 추가한다
e. DB에 전송한다
7.3 UPDATE SQL 생성 전략
기본: 전체 컬럼 UPDATE
1
2
3
4
-- 변경 감지가 생성하는 기본 UPDATE SQL
UPDATE member
SET name = '이영희', age = 25, team_id = 1
WHERE id = 1;
name만 변경했는데 모든 컬럼을 SET에 포함한다. 이것은 의도적인 설계다.
전체 컬럼 UPDATE의 장점:
- PreparedStatement 재사용: SQL 문자열이 항상 동일하므로 DB의 파싱 캐시 히트율이 높다
- 바인딩 데이터만 교체: 같은 구조의 SQL에 바인딩 값만 바꿔서 실행하므로 성능이 좋다
- SQL 생성 비용 절감: 어떤 필드가 변경되었든 같은 SQL을 사용하므로 동적 SQL 생성이 불필요하다
@DynamicUpdate: 변경된 컬럼만 UPDATE
1
2
3
4
5
6
7
8
9
10
11
12
@Entity
@DynamicUpdate
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
private int age;
// 컬럼이 30개 이상인 레거시 테이블
private String field1;
private String field2;
// ... field3 ~ field30
}
1
2
3
4
5
-- @DynamicUpdate가 생성하는 SQL
UPDATE member
SET name = '이영희'
WHERE id = 1;
-- name만 SET에 포함!
@DynamicUpdate가 필요한 경우:
- 컬럼이 매우 많은(20~30개 이상) 테이블
- 특정 컬럼에 인덱스가 걸려 있어서 불필요한 인덱스 갱신을 피하고 싶을 때
- UPDATE 쿼리의 네트워크 전송량을 줄이고 싶을 때
@DynamicUpdate의 단점:
- SQL이 매번 달라지므로 PreparedStatement 캐시를 활용할 수 없다
- 매번 어떤 컬럼이 변경되었는지 비교하여 SQL을 동적으로 생성해야 한다
대부분의 경우 기본 전략(전체 컬럼 UPDATE)이 더 효율적이다. @DynamicUpdate는 컬럼이 매우 많은 특수한 경우에만 사용한다.
7.4 변경 감지의 성능 비용
변경 감지는 공짜가 아니다. flush 시점에 영속성 컨텍스트의 모든 엔티티를 순회하며 스냅샷과 비교해야 한다. 영속성 컨텍스트에 엔티티가 1만 개 있으면 1만 번의 비교가 발생한다.
1
2
3
4
5
6
7
8
[변경 감지 비용]
1. 엔티티 N개에 대해 각각 필드 M개를 비교
→ 비교 연산: N × M
2. 각 필드의 Object.equals() 호출
3. 변경된 엔티티에 대해 SQL 생성
엔티티 10,000개, 필드 평균 10개
→ 100,000번의 비교 연산
이것이 @Transactional(readOnly = true)가 중요한 이유다.
1
2
3
4
5
6
@Transactional(readOnly = true)
public List<MemberDto> findMembers() {
return memberRepository.findAll().stream()
.map(MemberDto::from)
.toList();
}
readOnly = true가 하는 일:
- Hibernate의 FlushMode를
MANUAL로 변경한다 - flush()가 자동으로 호출되지 않는다
- flush()가 호출되지 않으므로 변경 감지(스냅샷 비교)가 수행되지 않는다
- 스냅샷 자체를 생성하지 않는 최적화도 가능하다 (Hibernate 구현에 따라 다름)
읽기 전용 트랜잭션에서 대량의 엔티티를 조회할 때 readOnly = true를 적용하면 스냅샷 비교 비용을 완전히 제거할 수 있다.
8. flush 메커니즘 상세
8.1 flush()가 하는 일
flush는 영속성 컨텍스트의 변경 내용을 DB에 동기화하는 작업이다. 중요한 것은 flush는 커밋이 아니다. DB 트랜잭션을 확정하는 것은 commit()의 역할이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
[flush()의 실행 순서]
1. 변경 감지(Dirty Checking) 수행
→ 영속 상태의 모든 엔티티와 스냅샷을 비교
→ 변경된 엔티티에 대해 UPDATE SQL 생성
→ 쓰기 지연 SQL 저장소에 추가
2. 쓰기 지연 SQL 저장소의 SQL을 정렬
→ INSERT → UPDATE → DELETE 순서로 정렬
→ 같은 타입의 INSERT끼리 묶기 (Batch 최적화)
3. 정렬된 SQL을 DB에 전송
→ JDBC를 통해 SQL 실행
→ 아직 커밋은 아님! (DB 트랜잭션은 여전히 진행 중)
4. 쓰기 지연 SQL 저장소를 비운다
→ 전송 완료된 SQL 제거
주의: flush() 후에도 1차 캐시는 유지된다!
영속성 컨텍스트가 초기화되는 것이 아니다!
8.2 flush가 발생하는 3가지 시점
시점 1: 직접 호출 — em.flush()
1
2
3
em.persist(member);
em.flush(); // 이 시점에 INSERT SQL이 DB에 전송된다
// 아직 커밋은 안 되었으므로 다른 트랜잭션에서는 안 보인다
직접 호출하는 경우는 드물지만, JPQL 실행 전에 데이터를 강제로 동기화해야 할 때 사용한다.
시점 2: 트랜잭션 커밋 — tx.commit()
1
2
3
tx.begin();
em.persist(member);
tx.commit(); // commit() 내부에서 flush()가 자동 호출된다
가장 일반적인 flush 시점이다. Spring의 @Transactional 메서드가 정상 종료하면 commit()이 호출되고, 그 안에서 flush()가 실행된다.
시점 3: JPQL 실행 전
1
2
3
4
5
6
em.persist(memberA); // INSERT SQL이 쓰기 지연 저장소에 대기 중
// JPQL 실행 → 이 전에 flush()가 자동 호출된다!
List<Member> members = em.createQuery(
"SELECT m FROM Member m", Member.class)
.getResultList();
왜 JPQL 실행 전에 flush가 필요한가?
1
2
3
4
5
6
7
8
9
10
em.persist(memberA); // 아직 DB에 INSERT 안 됨
em.persist(memberB); // 아직 DB에 INSERT 안 됨
// flush 없이 JPQL을 실행하면?
List<Member> members = em.createQuery(
"SELECT m FROM Member m", Member.class)
.getResultList();
// JPQL은 DB에 직접 쿼리한다
// memberA, memberB는 아직 DB에 없다!
// → 방금 persist한 데이터가 조회 결과에 빠진다!
이런 데이터 정합성 문제를 방지하기 위해, Hibernate는 JPQL 실행 직전에 자동으로 flush()를 호출한다. 이 동작은 FlushMode.AUTO(기본값)에서 활성화된다.
8.3 FlushMode
1
2
3
4
5
6
7
// FlushMode.AUTO (기본)
// → 커밋 시, JPQL 실행 전에 자동 flush
em.setFlushMode(FlushModeType.AUTO);
// FlushMode.COMMIT
// → 커밋 시에만 flush, JPQL 실행 전에는 flush 안 함
em.setFlushMode(FlushModeType.COMMIT);
COMMIT 모드를 사용하면 JPQL 실행 전 flush를 건너뛰므로 성능이 향상될 수 있다. 하지만 persist한 데이터가 JPQL 결과에 누락될 수 있으므로 주의가 필요하다. readOnly = true 트랜잭션에서는 Hibernate가 내부적으로 MANUAL 모드(flush가 자동으로 일어나지 않음)로 설정한다.
8.4 flush와 커밋의 차이
이 차이를 명확히 이해해야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
[flush vs commit]
flush():
- 영속성 컨텍스트의 SQL을 DB에 전송한다
- DB 트랜잭션은 여전히 진행 중이다
- 다른 트랜잭션에서는 이 변경을 볼 수 없다 (격리 수준에 따라)
- 1차 캐시는 유지된다
- 롤백이 가능하다
commit():
- 내부적으로 flush()를 먼저 호출한다
- 그 후 DB 트랜잭션을 확정(COMMIT)한다
- 다른 트랜잭션에서 이 변경을 볼 수 있다
- 롤백이 불가능하다
1
2
3
4
5
6
7
8
em.persist(member);
em.flush();
// 이 시점: INSERT가 DB에 전송되었지만, 아직 COMMIT 안 됨
// 다른 트랜잭션에서 member를 조회할 수 없음 (READ COMMITTED 이상)
// 여기서 예외가 발생하면 롤백되어 INSERT가 취소됨
tx.commit();
// 이 시점: COMMIT 확정, 다른 트랜잭션에서 조회 가능
9. 준영속 상태 심층
9.1 준영속 엔티티의 문제
준영속 엔티티는 영속성 컨텍스트의 기능을 사용할 수 없다. 실무에서 가장 자주 만나는 문제 두 가지를 보자.
문제 1: 변경 감지가 안 된다
1
2
3
4
5
6
7
8
9
10
11
@Transactional
public MemberDto getMember(Long id) {
return MemberDto.from(memberRepository.findById(id).orElseThrow());
}
// 메서드 종료 → 트랜잭션 종료 → 영속성 컨텍스트 종료 → 엔티티는 준영속 상태
public void updateMember(Long id, String newName) {
MemberDto dto = getMember(id);
// dto를 기반으로 엔티티를 수정하고 싶은데...
// 원본 엔티티는 이미 준영속 상태다!
}
문제 2: 지연 로딩이 안 된다
1
2
3
4
5
6
7
8
9
10
@Transactional
public Member getMember(Long id) {
return memberRepository.findById(id).orElseThrow();
}
// 컨트롤러에서 (OSIV OFF 환경)
Member member = memberService.getMember(1L);
member.getTeam().getName();
// → LazyInitializationException!
// 영속성 컨텍스트가 이미 종료되었으므로 프록시를 초기화할 수 없다
9.2 merge()의 동작 원리
merge()는 준영속 엔티티를 기반으로 새로운 영속 상태 엔티티를 만들어 반환한다.
1
2
3
4
5
6
7
8
9
10
@Transactional
public void updateWithMerge(MemberUpdateDto dto) {
Member detachedMember = new Member();
detachedMember.setId(dto.getId()); // 식별자 설정
detachedMember.setName(dto.getName()); // 변경할 값 설정
Member mergedMember = em.merge(detachedMember);
// mergedMember는 영속 상태
// detachedMember는 여전히 준영속 상태!
}
merge()의 내부 흐름:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
[merge(detachedMember) 내부 동작]
1. detachedMember의 식별자(id)를 확인한다
→ id = 1
2. 1차 캐시에서 EntityKey(Member.class, 1)을 검색한다
├── 있다 → 1차 캐시의 영속 엔티티를 사용
└── 없다 → 3단계로
3. DB에서 SELECT 한다
→ SELECT * FROM member WHERE id = 1;
→ 결과를 새 엔티티로 생성하여 1차 캐시에 저장
4. detachedMember의 모든 필드 값을 영속 엔티티에 복사한다
→ managedMember.setName(detachedMember.getName())
→ managedMember.setAge(detachedMember.getAge())
→ ... 모든 필드를 덮어씀
5. 영속 엔티티를 반환한다 (detachedMember는 여전히 준영속)
9.3 merge()의 치명적 주의점
merge()는 모든 필드를 덮어쓴다. null인 필드도 예외 없이 덮어쓴다.
1
2
3
4
5
6
7
8
9
10
// 기존 DB 데이터: {id: 1, name: "김철수", age: 25, email: "kim@test.com"}
Member detached = new Member();
detached.setId(1L);
detached.setName("이영희");
// age와 email은 설정하지 않음 → null
Member merged = em.merge(detached);
// 결과: {id: 1, name: "이영희", age: null, email: null}
// age와 email이 null로 덮어써졌다!!
9.4 변경 감지 vs merge: 실무에서의 선택
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 방법 1: 변경 감지 (권장)
@Transactional
public void updateMember(Long id, MemberUpdateDto dto) {
Member member = memberRepository.findById(id).orElseThrow();
// 필요한 필드만 변경
member.setName(dto.getName());
// age, email은 건드리지 않음 → 기존 값 유지
// 자동으로 변경 감지 → UPDATE
}
// 방법 2: merge (비권장)
@Transactional
public void updateMember(MemberUpdateDto dto) {
Member detached = new Member();
detached.setId(dto.getId());
detached.setName(dto.getName());
em.merge(detached);
// 설정하지 않은 필드가 null로 덮어써질 위험!
}
변경 감지를 사용해야 하는 이유:
- 의도한 필드만 변경된다 (null 덮어쓰기 위험 없음)
- 코드가 명확하다 (어떤 필드를 변경하는지 한눈에 보임)
- 불필요한 SELECT를 줄일 수 있다 (merge는 항상 SELECT 먼저 실행)
10. JPA가 영속성 컨텍스트를 어떻게 보장하는가
이 섹션이 이 글의 핵심이다. Spring 환경에서 JPA가 영속성 컨텍스트의 일관성을 어떻게 생성하고, 바인딩하고, 공유하고, 종료하는지 내부 메커니즘을 상세히 다룬다.
10.1 핵심 질문
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Service
public class OrderService {
private final MemberRepository memberRepository;
private final OrderRepository orderRepository;
@Transactional
public void createOrder(Long memberId, OrderRequest request) {
Member member = memberRepository.findById(memberId).orElseThrow();
Order order = new Order(member, request);
orderRepository.save(order);
// 질문: memberRepository와 orderRepository는 서로 다른 빈이다.
// 각자 내부에서 EntityManager를 사용할 텐데,
// 같은 영속성 컨텍스트를 공유하는가?
// member가 1차 캐시에 있으면 order에서도 접근 가능한가?
}
}
답: 같은 트랜잭션 내에서는 같은 영속성 컨텍스트를 공유한다. 이것이 어떻게 가능한지 살펴보자.
10.2 TransactionSynchronizationManager와 ThreadLocal
Spring의 트랜잭션 관리의 핵심은 TransactionSynchronizationManager다. 이 클래스는 ThreadLocal을 사용하여 현재 스레드의 트랜잭션 리소스(EntityManager, DataSource Connection 등)를 관리한다.
1
2
3
4
5
6
7
8
9
10
11
12
[TransactionSynchronizationManager의 내부 구조]
ThreadLocal<Map<Object, Object>> resources = new ThreadLocal<>();
// 각 스레드가 자신만의 리소스 맵을 가진다
Thread A의 resources:
{ EntityManagerFactory → EntityManagerHolder(em1) }
Thread B의 resources:
{ EntityManagerFactory → EntityManagerHolder(em2) }
// Thread A와 B는 서로 다른 EntityManager를 사용한다
10.3 @Transactional이 EntityManager를 바인딩하는 전체 과정
@Transactional 메서드가 호출될 때 내부적으로 어떤 일이 일어나는지, 단계별로 살펴보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
[@Transactional 메서드 호출 시 전체 흐름]
1. AOP 프록시가 메서드 호출을 가로챈다
→ TransactionInterceptor.invoke()
2. TransactionManager.getTransaction()이 호출된다
→ JpaTransactionManager.doBegin()
3. JpaTransactionManager.doBegin() 내부:
a. EntityManagerFactory.createEntityManager()로 새 EntityManager를 생성한다
b. EntityManager 내부에 새 영속성 컨텍스트가 생성된다
c. EntityManager를 EntityManagerHolder로 감싼다
d. TransactionSynchronizationManager.bindResource(emf, holder)를 호출한다
→ 현재 스레드의 ThreadLocal에 (EntityManagerFactory → EntityManager) 매핑을 저장
4. 이제 현재 스레드에서 EntityManagerFactory를 키로 검색하면
이 EntityManager를 찾을 수 있다
5. 실제 비즈니스 메서드가 실행된다
→ 메서드 내에서 Repository 메서드를 호출하면...
6. Repository가 EntityManager를 사용할 때:
a. SharedEntityManagerCreator 프록시가 호출을 가로챈다
b. TransactionSynchronizationManager.getResource(emf)로
현재 스레드에 바인딩된 EntityManager를 찾는다
c. 찾은 EntityManager에 실제 작업을 위임한다
7. 메서드가 정상 종료하면:
a. EntityManager.flush() → 변경 사항 DB 전송
b. Connection.commit() → DB 트랜잭션 커밋
c. TransactionSynchronizationManager.unbindResource(emf)
→ ThreadLocal에서 EntityManager 제거
d. EntityManager.close() → 영속성 컨텍스트 종료
8. 예외 발생 시:
a. Connection.rollback() → DB 트랜잭션 롤백
b. ThreadLocal에서 EntityManager 제거
c. EntityManager.close()
10.4 같은 트랜잭션 = 같은 영속성 컨텍스트
위 메커니즘 덕분에, 같은 트랜잭션(=같은 스레드) 내에서 호출되는 모든 Repository는 같은 EntityManager(=같은 영속성 컨텍스트)를 사용한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Transactional
public void createOrder(Long memberId, OrderRequest request) {
// memberRepository 내부: SharedEntityManagerCreator가
// ThreadLocal에서 EntityManager 'em1'을 가져와서 사용
Member member = memberRepository.findById(memberId).orElseThrow();
// → member가 em1의 1차 캐시에 저장됨
// orderRepository 내부: SharedEntityManagerCreator가
// ThreadLocal에서 같은 EntityManager 'em1'을 가져와서 사용
Order order = new Order(member, request);
orderRepository.save(order);
// → order가 em1의 1차 캐시에 저장됨
// → member도 같은 1차 캐시에 있으므로 접근 가능
}
이것을 그림으로 표현하면:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Thread A
├── @Transactional 시작
│ └── ThreadLocal: { EMF → EntityManager(em1) }
│
├── memberRepository.findById(1L)
│ └── SharedEntityManagerProxy → ThreadLocal에서 em1 획득
│ └── em1.find(Member.class, 1L) → SELECT + 1차 캐시 저장
│
├── orderRepository.save(order)
│ └── SharedEntityManagerProxy → ThreadLocal에서 같은 em1 획득
│ └── em1.persist(order) → 쓰기 지연 SQL 저장소에 INSERT
│
├── @Transactional 정상 종료
│ └── em1.flush() → DB 전송
│ └── commit() → DB 확정
│ └── em1.close() → 영속성 컨텍스트 종료
│ └── ThreadLocal에서 em1 제거
10.5 트랜잭션 전파와 영속성 컨텍스트
트랜잭션 전파(Propagation) 옵션에 따라 영속성 컨텍스트의 공유 여부가 달라진다.
REQUIRED (기본): 같은 영속성 컨텍스트 공유
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Service
public class OrderService {
@Transactional // 트랜잭션 시작, EntityManager em1 생성
public void createOrder(Long memberId) {
Member member = memberRepository.findById(memberId).orElseThrow();
// em1의 1차 캐시에 member 저장
paymentService.processPayment(member);
// 이 호출은 어떤 EntityManager를 사용하는가?
}
}
@Service
public class PaymentService {
@Transactional // REQUIRED(기본): 기존 트랜잭션에 참여
public void processPayment(Member member) {
// 기존 트랜잭션에 참여하므로 같은 em1을 사용!
// member는 em1의 1차 캐시에 있으므로 영속 상태!
member.setPoint(member.getPoint() - 100);
// 변경 감지 정상 동작!
}
}
1
2
3
4
5
6
7
8
9
10
11
[REQUIRED 전파의 영속성 컨텍스트]
OrderService.createOrder() ────── 트랜잭션 T1 ─────────────────────
│ │
│ PaymentService.processPayment() ── T1에 참여 ── │
│ │ │ │
│ │ 같은 em1, 같은 영속성 컨텍스트 │ │
│ │ │ │
│ └──────────────────────────────────────────────┘ │
│ │
└──────────────────── commit → flush → close ─────────────────────
REQUIRES_NEW: 새로운 영속성 컨텍스트 생성
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Service
public class OrderService {
@Transactional // 트랜잭션 T1, EntityManager em1
public void createOrder(Long memberId) {
Member member = memberRepository.findById(memberId).orElseThrow();
// em1의 1차 캐시에 member 저장
auditService.log("ORDER_CREATED", memberId);
}
}
@Service
public class AuditService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
// 새 트랜잭션 T2, 새 EntityManager em2!
public void log(String action, Long targetId) {
// em2는 em1과 완전히 다른 영속성 컨텍스트!
// em1의 1차 캐시에 있는 member를 em2에서 접근할 수 없다!
AuditLog auditLog = new AuditLog(action, targetId);
auditRepository.save(auditLog);
// em2에 persist → em2의 쓰기 지연 SQL 저장소에 INSERT
}
// 메서드 종료 → T2 커밋 → em2.flush() → em2.close()
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
[REQUIRES_NEW 전파의 영속성 컨텍스트]
OrderService.createOrder() ────── 트랜잭션 T1 ─────────────────────
│ │
│ AuditService.log() ────── 새 트랜잭션 T2 ────── │
│ │ │ │
│ │ T1 일시 중단 │ │
│ │ 새 em2, 새 영속성 컨텍스트 │ │
│ │ em1의 1차 캐시 접근 불가! │ │
│ │ │ │
│ └── T2 커밋/롤백 → em2.close() ────────────────┘ │
│ │
│ T1 재개 │
└──────────────────── T1 커밋 → em1.close() ─────────────────────
핵심: REQUIRES_NEW에서 생성된 영속성 컨텍스트는 기존 영속성 컨텍스트와 완전히 독립적이다. 1차 캐시도 공유하지 않고, 한쪽의 롤백이 다른 쪽에 영향을 주지 않는다.
10.6 영속성 컨텍스트 보장의 전체 아키텍처
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
[Spring JPA의 영속성 컨텍스트 보장 아키텍처]
@Transactional AOP Proxy
│
▼
JpaTransactionManager
│
├── doBegin()
│ ├── EMF.createEntityManager() → em 생성
│ ├── em.getTransaction().begin() → DB 트랜잭션 시작
│ └── TransactionSynchronizationManager
│ .bindResource(EMF, em) → ThreadLocal에 바인딩
│
├── (비즈니스 로직 실행)
│ │
│ ├── Repository A
│ │ └── SharedEntityManagerProxy
│ │ └── TransactionSynchronizationManager
│ │ .getResource(EMF) → ThreadLocal에서 em 획득
│ │
│ └── Repository B
│ └── SharedEntityManagerProxy
│ └── TransactionSynchronizationManager
│ .getResource(EMF) → 같은 em 획득!
│
├── doCommit()
│ ├── em.flush() → 변경 감지 + SQL 전송
│ └── tx.commit() → DB 커밋
│
└── 정리
├── TransactionSynchronizationManager
│ .unbindResource(EMF) → ThreadLocal에서 제거
└── em.close() → 영속성 컨텍스트 종료
이 아키텍처의 핵심 포인트:
- ThreadLocal로 스레드 안전성 보장: 각 스레드가 독립된 EntityManager를 가진다
- SharedEntityManagerProxy로 투명한 라우팅: Repository는 프록시를 통해 현재 트랜잭션의 EntityManager를 자동으로 획득한다
- 트랜잭션 경계 = 영속성 컨텍스트 경계: 트랜잭션이 시작되면 영속성 컨텍스트가 생성되고, 트랜잭션이 끝나면 함께 종료된다
11. 프록시와 지연 로딩의 영속성 컨텍스트 의존성
11.1 프록시의 동작 원리
JPA에서 @ManyToOne(fetch = FetchType.LAZY)로 설정된 연관 엔티티는 프록시 객체로 로딩된다. 프록시는 실제 엔티티를 상속받은 가짜 객체로, 실제 데이터에 접근할 때 DB 쿼리를 실행한다.
1
2
3
4
5
6
7
8
9
10
11
Member member = em.find(Member.class, 1L);
// SQL: SELECT m.id, m.name, m.team_id FROM member m WHERE m.id = 1
Team team = member.getTeam();
// team은 프록시 객체다 (Team$HibernateProxy$xxx)
// 아직 SELECT 쿼리가 실행되지 않았다!
String teamName = team.getName();
// 이 시점에 프록시가 초기화된다!
// SQL: SELECT t.id, t.name FROM team t WHERE t.id = 1
// 프록시 내부의 target이 실제 Team 엔티티로 채워진다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[프록시 구조]
Team$HibernateProxy$xxx {
target: null → 초기화 후 → Team(id=1, name="개발팀")
initialized: false → 초기화 후 → true
// 프록시의 getName() 메서드:
getName() {
if (!initialized) {
// 영속성 컨텍스트를 통해 DB에서 실제 데이터를 로딩
target = persistenceContext.loadEntity(Team.class, teamId);
initialized = true;
}
return target.getName();
}
}
11.2 프록시가 영속성 컨텍스트에 의존하는 이유
프록시 초기화는 영속성 컨텍스트를 통해 이루어진다. 프록시가 getName()을 호출하면 영속성 컨텍스트에게 “Team id=1의 실제 데이터를 로딩해달라”고 요청한다. 영속성 컨텍스트는 1차 캐시를 확인하고, 없으면 DB에 쿼리한다.
영속성 컨텍스트가 없으면 프록시를 초기화할 수 없다.
11.3 LazyInitializationException
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Transactional
public Member getMember(Long id) {
return memberRepository.findById(id).orElseThrow();
// 트랜잭션 종료 → 영속성 컨텍스트 종료
}
// 서비스 외부 (컨트롤러 등)
Member member = memberService.getMember(1L);
// member는 준영속 상태
// member.team은 초기화되지 않은 프록시
String teamName = member.getTeam().getName();
// → LazyInitializationException!
// "could not initialize proxy - no Session"
// 영속성 컨텍스트(Session)가 이미 닫혔으므로 프록시를 초기화할 수 없다
해결 방법들:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 방법 1: 트랜잭션 내에서 미리 초기화
@Transactional
public Member getMemberWithTeam(Long id) {
Member member = memberRepository.findById(id).orElseThrow();
member.getTeam().getName(); // 강제 초기화
return member;
}
// 방법 2: Fetch Join
@Query("SELECT m FROM Member m JOIN FETCH m.team WHERE m.id = :id")
Optional<Member> findByIdWithTeam(@Param("id") Long id);
// 방법 3: @EntityGraph
@EntityGraph(attributePaths = {"team"})
Optional<Member> findById(Long id);
// 방법 4: DTO로 변환 (권장)
@Transactional(readOnly = true)
public MemberDto getMember(Long id) {
Member member = memberRepository.findByIdWithTeam(id).orElseThrow();
return MemberDto.from(member); // DTO로 변환하여 반환
}
11.4 프록시 확인 유틸리티
1
2
3
4
5
6
7
8
9
10
11
12
// 프록시 여부 확인
boolean isProxy = member.getTeam() instanceof HibernateProxy;
// 프록시 초기화 여부 확인
boolean isInitialized = Hibernate.isInitialized(member.getTeam());
// 프록시 강제 초기화
Hibernate.initialize(member.getTeam());
// 프록시의 실제 클래스 확인
Class<?> realClass = Hibernate.getClass(member.getTeam());
// Team$HibernateProxy$xxx가 아닌 Team.class를 반환
12. OSIV (Open Session In View)
12.1 OSIV란
OSIV는 영속성 컨텍스트의 생존 범위를 HTTP 요청 전체로 확장하는 전략이다.
1
2
3
4
# Spring Boot 기본값: true
spring:
jpa:
open-in-view: true
12.2 OSIV ON (기본값)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
[OSIV ON: 요청 시작~끝까지 영속성 컨텍스트 유지]
HTTP 요청 시작 ──────────────────────────────── HTTP 응답 반환
│ │
│ 영속성 컨텍스트 생성 ─────────────────────────│── 영속성 컨텍스트 종료
│ │ │
│ │ @Transactional │
│ │ │ │
│ │ │ DB 읽기/쓰기 가능 │
│ │ │ │
│ │ └── 트랜잭션 종료 │
│ │ │
│ │ 컨트롤러/뷰 렌더링 │
│ │ │ │
│ │ │ DB 읽기만 가능 (지연 로딩 O) │
│ │ │ DB 쓰기 불가 (트랜잭션 없음) │
│ │ │ │
│ └──┴─────────────────────────────────────────┘
│ │
└── DB 커넥션 반환 ─────────────────────────────┘
OSIV ON의 핵심: 트랜잭션이 끝나도 영속성 컨텍스트는 살아 있다. 컨트롤러나 뷰에서 member.getTeam().getName()을 호출하면 지연 로딩이 정상 동작한다.
12.3 OSIV ON의 치명적 문제: DB 커넥션 점유
OSIV ON에서 DB 커넥션은 HTTP 요청 시작부터 끝까지 점유된다. 영속성 컨텍스트가 살아 있으므로, 언제든 지연 로딩으로 DB에 접근할 수 있어야 하기 때문이다.
1
2
3
4
5
6
7
8
9
10
11
[OSIV ON에서 DB 커넥션 점유 문제]
요청 1: DB 조회 → API 응답 생성(500ms) → 응답 전송
├── DB 커넥션 점유: 전체 600ms ──────────────┤
요청 2: DB 조회 → 외부 API 호출(3초!) → 응답 전송
├── DB 커넥션 점유: 전체 3.2초 ──────────────┤
→ 외부 API가 느려지면 DB 커넥션이 3초 동안 점유된다
→ 커넥션 풀(기본 10개)이 금방 고갈된다
→ 다른 요청이 커넥션을 못 얻어 장애가 번진다!
이것이 OSIV의 가장 위험한 점이다. 트랜잭션은 짧지만 DB 커넥션은 요청 전체를 점유한다. 실시간 트래픽이 많은 서비스에서 외부 API 응답이 느려지면 DB 커넥션 풀 고갈 → 전체 서비스 장애로 이어질 수 있다.
12.4 OSIV OFF
1
2
3
spring:
jpa:
open-in-view: false
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
[OSIV OFF: 트랜잭션 종료 시 영속성 컨텍스트도 종료]
HTTP 요청 시작 ──────────────────────────────── HTTP 응답 반환
│ │
│ @Transactional │
│ │ │
│ │ 영속성 컨텍스트 생성 ─── 영속성 컨텍스트 종료│
│ │ DB 커넥션 획득 ──────── DB 커넥션 반환 │
│ │ │ │ │
│ │ │ DB 읽기/쓰기 가능 │ │
│ │ │ │ │
│ │ └───────────────────────────────────┘ │
│ │ │
│ │ 컨트롤러 │
│ │ │ │
│ │ │ 엔티티는 준영속 상태 │
│ │ │ 지연 로딩 불가! (LazyInitializationException) │
│ │ │ │
│ └──┴─────────────────────────────────────────┘
OSIV OFF의 장점: DB 커넥션이 트랜잭션 범위에서만 점유되므로 커넥션 풀 효율이 높다. OSIV OFF의 단점: 컨트롤러/뷰에서 지연 로딩이 불가능하다.
12.5 OSIV OFF에서 지연 로딩 해결 전략
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 전략 1: 서비스 계층에서 필요한 데이터를 모두 로딩하여 DTO로 반환
@Transactional(readOnly = true)
public MemberDetailDto getMemberDetail(Long id) {
Member member = memberRepository.findByIdWithTeamAndOrders(id)
.orElseThrow();
// Fetch Join으로 team, orders를 미리 로딩
return MemberDetailDto.from(member);
// DTO로 변환하여 반환 → 컨트롤러에서 지연 로딩 필요 없음
}
// 전략 2: 쿼리 서비스 분리 (CQRS 패턴의 간이 버전)
@Service
@RequiredArgsConstructor
public class MemberQueryService {
@Transactional(readOnly = true)
public MemberDetailDto getMemberDetail(Long id) {
// 읽기 전용 쿼리에 최적화된 서비스
// 필요한 데이터를 Fetch Join/DTO 조회로 가져온다
}
}
@Service
@RequiredArgsConstructor
public class MemberCommandService {
@Transactional
public void updateMember(Long id, MemberUpdateDto dto) {
// 쓰기 전용 서비스
}
}
12.6 실무에서의 OSIV 선택 기준
1
2
3
4
5
6
7
8
9
10
OSIV ON을 선택하는 경우:
├── 관리자 도구, 내부 시스템 등 트래픽이 적은 서비스
├── 빠른 개발이 중요한 초기 프로토타이핑
└── 외부 API 호출이 없거나 매우 빠른 경우
OSIV OFF를 선택하는 경우 (권장):
├── 실시간 트래픽이 많은 고객 대면 서비스
├── 외부 API 호출이 있는 서비스
├── DB 커넥션 풀 효율이 중요한 서비스
└── 장기적으로 유지보수할 서비스
실무 권장: OSIV OFF + 서비스 계층에서 DTO 변환. 초기에는 번거롭지만 장기적으로 DB 커넥션 관련 장애를 예방할 수 있다.
13. 2차 캐시 (Second-Level Cache)
13.1 2차 캐시의 필요성
1차 캐시는 트랜잭션 범위에서만 유효하다. 동일한 데이터를 여러 트랜잭션에서 반복 조회하면 매번 DB에 쿼리한다. 2차 캐시는 이 문제를 해결한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
[1차 캐시만 있는 경우]
트랜잭션 1: findById(1L) → DB SELECT → 결과 반환 → 트랜잭션 종료 (1차 캐시 사라짐)
트랜잭션 2: findById(1L) → DB SELECT → 결과 반환 → 트랜잭션 종료
트랜잭션 3: findById(1L) → DB SELECT → 결과 반환 → 트랜잭션 종료
→ 같은 데이터에 3번 DB 조회
[2차 캐시가 있는 경우]
트랜잭션 1: findById(1L) → 2차 캐시 MISS → DB SELECT → 2차 캐시 저장 → 결과 반환
트랜잭션 2: findById(1L) → 2차 캐시 HIT → 결과 반환 (DB 조회 없음!)
트랜잭션 3: findById(1L) → 2차 캐시 HIT → 결과 반환 (DB 조회 없음!)
→ DB 조회 1번!
13.2 2차 캐시의 동작 원리
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[2차 캐시의 위치]
em.find(Member.class, 1L)
│
▼
1차 캐시 확인 (영속성 컨텍스트)
│
├── HIT → 반환 (DB 조회 없음)
│
└── MISS
│
▼
2차 캐시 확인 (EntityManagerFactory 범위)
│
├── HIT → 값을 **복사**하여 1차 캐시에 저장 후 반환
│ (원본이 아닌 복사본을 반환 — 동시성 보호)
│
└── MISS → DB SELECT → 결과를 1차 캐시 + 2차 캐시에 저장
2차 캐시가 복사본을 반환하는 이유: 2차 캐시는 여러 스레드가 동시에 접근한다. 원본을 반환하면 한 스레드에서 수정한 것이 다른 스레드에 영향을 준다. 그래서 값의 복사본을 만들어 반환한다.
13.3 2차 캐시 설정
1
2
3
4
5
6
7
8
@Entity
@Cacheable // JPA 표준
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE) // Hibernate
public class Team {
@Id @GeneratedValue
private Long id;
private String name;
}
1
2
3
4
5
6
7
8
9
10
11
spring:
jpa:
properties:
hibernate:
cache:
use_second_level_cache: true
region:
factory_class: org.hibernate.cache.jcache.JCacheRegionFactory
javax:
cache:
provider: org.ehcache.jsr107.EhcacheCachingProvider
13.4 2차 캐시가 적합한 데이터 vs 부적합한 데이터
1
2
3
4
5
6
7
8
9
10
11
12
13
14
적합한 데이터:
├── 자주 조회되고 거의 변경되지 않는 데이터
│ 예: 코드 테이블, 카테고리, 설정 값
├── 전체 사용자가 공통으로 조회하는 데이터
│ 예: 공지사항, 약관, 메뉴 구조
└── 변경 시 약간의 지연(캐시 만료)이 허용되는 데이터
부적합한 데이터:
├── 자주 변경되는 데이터
│ 예: 재고 수량, 실시간 가격, 좋아요 수
├── 캐시 일관성이 중요한 금융 데이터
│ 예: 잔액, 포인트, 결제 정보
└── 사용자별로 다른 개인 데이터 (캐시 히트율 낮음)
예: 주문 내역, 메시지
14. 실무 주의사항과 Best Practices
14.1 벌크 연산과 영속성 컨텍스트 불일치
벌크 연산(@Modifying 쿼리)은 영속성 컨텍스트를 무시하고 DB에 직접 쿼리한다. 영속성 컨텍스트의 1차 캐시와 DB 데이터가 불일치할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
@Transactional
public void bulkUpdateProblem() {
Member member = memberRepository.findById(1L).orElseThrow();
System.out.println("변경 전: " + member.getAge()); // 25
// 벌크 UPDATE: 모든 회원의 나이를 +1
memberRepository.bulkAgePlus(1);
// DB에서는 member.age가 26이 되었다
Member sameMember = memberRepository.findById(1L).orElseThrow();
System.out.println("변경 후: " + sameMember.getAge()); // 25! (여전히!)
// 1차 캐시에서 반환하므로 DB의 변경이 반영되지 않았다!
}
해결 방법:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 방법 1: @Modifying에 clearAutomatically 설정
@Modifying(clearAutomatically = true)
@Query("UPDATE Member m SET m.age = m.age + :value")
int bulkAgePlus(@Param("value") int value);
// 벌크 연산 후 자동으로 em.clear() 호출 → 1차 캐시 초기화
// 방법 2: 수동으로 clear()
@Transactional
public void bulkUpdate() {
memberRepository.bulkAgePlus(1);
em.flush(); // 혹시 남은 변경 사항 DB 반영
em.clear(); // 1차 캐시 초기화
Member member = memberRepository.findById(1L).orElseThrow();
// 이제 DB에서 새로 조회하므로 변경된 값(26)이 반영된다
}
14.2 대량 데이터 처리 시 clear() 활용
대량 데이터를 한 트랜잭션에서 처리하면 영속성 컨텍스트에 엔티티가 쌓여 메모리 부족(OOM)이 발생할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
@Transactional
public void batchInsert() {
for (int i = 0; i < 100000; i++) {
Member member = new Member("회원" + i);
em.persist(member);
if (i % 100 == 0) {
em.flush(); // SQL 전송
em.clear(); // 1차 캐시 초기화 → 메모리 해제
}
}
}
100개마다 flush + clear를 수행하면:
- flush: 쌓인 INSERT SQL을 DB에 전송 (쓰기 지연 SQL 저장소 비움)
- clear: 1차 캐시의 엔티티와 스냅샷 제거 (메모리 해제)
이것은 JDBC Batch와 함께 사용하면 더욱 효과적이다.
1
2
3
4
5
6
7
spring:
jpa:
properties:
hibernate:
jdbc:
batch_size: 100 # 100개씩 배치
order_inserts: true # 같은 타입 INSERT 모으기
14.3 Spring Data JPA의 save() 동작
save()는 내부적으로 새 엔티티면 persist(), 기존 엔티티면 merge()를 호출한다.
1
2
3
4
5
6
7
8
9
10
// SimpleJpaRepository.save() 내부 구현
@Transactional
public <S extends T> S save(S entity) {
if (entityInformation.isNew(entity)) {
em.persist(entity);
return entity;
} else {
return em.merge(entity);
}
}
isNew() 판단 기준:
@Id가 참조 타입(Long, String 등)이면:id == null이면 새 엔티티@Id가 기본 타입(long, int 등)이면:id == 0이면 새 엔티티Persistable인터페이스 구현:isNew()메서드의 반환값으로 판단
주의: ID를 직접 할당하는 전략(@GeneratedValue 없이)에서는 save() 호출 시 항상 merge()가 호출된다. ID가 null이 아니므로 기존 엔티티로 판단하기 때문이다. 이 경우 불필요한 SELECT + INSERT 대신 직접 persist()를 호출하거나 Persistable을 구현해야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Entity
public class Item implements Persistable<String> {
@Id
private String id;
@CreatedDate
private LocalDateTime createdDate;
@Override
public String getId() {
return id;
}
@Override
public boolean isNew() {
return createdDate == null; // 생성일이 없으면 새 엔티티
}
}
14.4 영속성 컨텍스트 관련 실무 체크리스트
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
✅ @Transactional(readOnly = true)를 읽기 전용 메서드에 적용하고 있는가?
→ 변경 감지 비용 제거, Replica DB 라우팅 가능
✅ 벌크 연산(@Modifying) 후 clearAutomatically = true를 설정했는가?
→ 1차 캐시와 DB의 불일치 방지
✅ 대량 데이터 처리 시 주기적으로 flush() + clear()를 수행하는가?
→ OOM 방지
✅ OSIV 설정을 의도적으로 선택했는가?
→ 실시간 서비스에서는 OFF 권장
✅ 지연 로딩이 필요한 데이터를 트랜잭션 내에서 미리 로딩하는가?
→ LazyInitializationException 방지
✅ save() 호출 시 새 엔티티인지 기존 엔티티인지 인지하고 있는가?
→ 불필요한 merge (SELECT + UPDATE) 방지
✅ 트랜잭션 내에서 외부 API를 호출하고 있지 않는가?
→ DB 커넥션 점유 시간 최소화
✅ @Transactional이 같은 클래스 내부 호출로 무시되고 있지 않은가?
→ AOP 프록시 우회 문제 확인
결론
영속성 컨텍스트는 JPA의 모든 기능이 작동하는 근본 메커니즘이다.
1차 캐시는 같은 트랜잭션 내에서 동일한 엔티티에 대한 반복 조회를 메모리에서 처리하여 DB 접근을 줄인다. 다만 PK 기반 조회에서만 동작하며, JPQL은 1차 캐시를 우회한다는 점을 알아야 한다.
동일성 보장은 같은 식별자를 가진 엔티티가 같은 인스턴스임을 보장하여, JPA 엔티티를 Java 컬렉션처럼 자연스럽게 다룰 수 있게 한다. 이것은 애플리케이션 레벨의 REPEATABLE READ와 같다.
쓰기 지연은 SQL을 모아서 한 번에 전송하여 네트워크 비용을 절감한다. JDBC Batch와 결합하면 대량 삽입 성능이 극적으로 향상된다. 단, IDENTITY 전략에서는 쓰기 지연이 동작하지 않는다.
변경 감지는 스냅샷 비교로 변경된 엔티티를 자동으로 감지하여 UPDATE SQL을 생성한다. save()를 호출하지 않아도 setter만으로 DB가 업데이트되는 JPA의 핵심 편의 기능이다. readOnly 트랜잭션에서는 이 비용을 제거할 수 있다.
Spring의 영속성 컨텍스트 보장 메커니즘의 핵심은 ThreadLocal과 SharedEntityManagerProxy다. TransactionSynchronizationManager가 ThreadLocal로 현재 스레드의 EntityManager를 관리하고, SharedEntityManagerProxy가 모든 Repository 호출을 현재 트랜잭션의 EntityManager로 라우팅한다. 이 덕분에 같은 트랜잭션 내의 모든 Repository가 같은 영속성 컨텍스트를 공유한다. REQUIRES_NEW 전파를 사용하면 새로운 트랜잭션과 함께 독립된 영속성 컨텍스트가 생성된다.
OSIV는 영속성 컨텍스트의 생존 범위를 결정한다. ON이면 편리하지만 DB 커넥션 점유 문제가 있고, OFF면 안전하지만 지연 로딩 전략이 필요하다. 실시간 서비스에서는 OSIV OFF + 서비스 계층 DTO 변환이 권장된다.
영속성 컨텍스트를 이해하면 JPA의 동작을 예측할 수 있다. “왜 SELECT가 안 나가지?” → 1차 캐시. “왜 UPDATE를 안 했는데 DB가 바뀌었지?” → 변경 감지. “왜 LazyInitializationException이 나지?” → 영속성 컨텍스트 종료. 모든 답이 영속성 컨텍스트에 있다.