영속성 컨텍스트와 @Transactional — EntityManager·1차 캐시·Dirty Checking·Flush 메커니즘
들어가며
save()를 안 불렀는데 DB에 UPDATE가 찍힌다. 반대로
save()를 불렀는데도 insert가 안 보인다. 희한하게도 이 두
장면은 같은 메커니즘에서 나온다. 그 메커니즘의 이름이
영속성 컨텍스트(Persistence Context)고, 그걸 열고 닫는 트리거가 스프링
쪽에서는 @Transactional이다. JPA를 1년쯤 쓰다 보면 거의
모든 기묘한 버그가 이 두 축 — "컨텍스트가 언제 열리고 언제 닫히냐"와
"Flush가 언제 일어나냐" — 사이에서 발생한다는 걸 알게 된다.
이 글은 8편 @Transactional 심화의 ORM 관점 자매편이다. propagation/isolation/rollback rules 같은 "트랜잭션 경계의 속성"은 8편에서 다뤘으니, 여기서는 그 경계 안에서 EntityManager가 무슨 짓을 하고 있는지만 본다. 1차 캐시, Dirty Checking, Write-Behind, FlushMode, OSIV — 전부 영속성 컨텍스트라는 한 덩어리의 서로 다른 얼굴이다.
find()두 번 불렀는데 SELECT는 한 번만 나간다 — 1차 캐시 동일성- 필드만 바꿨는데 UPDATE가 찍힌다 — Dirty Checking 스냅샷 비교
saveAndFlush()가 습관이 되면 1차 캐시 이점을 버리는 것과 같다 — Write-Behind 큐 강제 flush- OSIV를 끄면 컨트롤러에서
LazyInitializationException이 터진다 — 컨텍스트 수명이 트랜잭션에 묶인다 - 트랜잭션 밖에서 필드를 바꾸면 아무 일도 안 일어난다 — 준영속(detached) 상태
이 글은 역할 → EntityManager 종류 → 바인딩 → 1차 캐시 → Dirty Checking → Write-Behind → FlushMode → save/merge 함정 → Flush 타이밍 → OSIV → 준영속 순으로, Spring Boot 3.x / Hibernate 6.x / JPA 3.x (jakarta.persistence) 기준으로 정리한다.
목차
- 1) 영속성 컨텍스트란 무엇인가: 세 가지 역할
- 2) EntityManager 종류: Application-managed vs Container-managed
- 3) @Transactional과 EntityManager 생명주기 바인딩
- 4) 1차 캐시와 엔티티 동일성 보장
- 5) Dirty Checking: 스냅샷 기반 변경 감지
- 6) Write-Behind: SQL 지연 실행 큐
- 7) FlushMode: AUTO / COMMIT / MANUAL / ALWAYS
- 8) persist / merge / save / saveAndFlush 함정
- 9) Flush 타이밍 전체 그림
- 10) OSIV (Open Session In View) on/off
- 11) 준영속(detached) 함정과 트랜잭션 경계 밖 수정
- 12) 실무에서 이렇게 읽고 쓴다
- 13) 한 줄 정리
1) 영속성 컨텍스트란 무엇인가: 세 가지 역할
영속성 컨텍스트는 **"엔티티를 영구 저장하는 환경"**이라는 JPA 스펙의 표현 그대로, 엔티티 객체를 담아두는 메모리 저장소다. 하지만 실무에서 중요한 건 정의가 아니라 역할이다. 한 덩어리짜리 객체가 세 가지 책임을 동시에 진다.
첫째, **1차 캐시(first-level cache)**다. 같은 영속성 컨텍스트 안에서
같은 @Id로 조회하면 SQL을 한 번만 날리고 두 번째부터는
메모리에서 돌려준다. 둘째, Dirty Checking이다. 영속
상태 엔티티의 필드를 바꾸면 flush 시점에 변경을 스스로 찾아 UPDATE를
만든다. 셋째, Write-Behind (쓰기 지연)다.
persist나 변경 감지로 만들어진 SQL을 즉시 보내지 않고 쓰기
지연 SQL 저장소(action queue)에 쌓아뒀다가 flush 시점에 한꺼번에
내보낸다. 이 세 가지가 합쳐져 JPA의 "SQL을 덜 쓰는 ORM" 특성이
나온다.
| 역할 | 무엇을 하는가 | 체감 지점 |
|---|---|---|
| 1차 캐시 | @Id 기준 엔티티 인스턴스 저장 |
반복 조회 시 SELECT 1회 |
| Dirty Checking | 스냅샷과 비교해 변경 필드 추출 | setXxx()만 해도 UPDATE |
| Write-Behind | SQL을 큐에 쌓아 flush 시 일괄 전송 | persist() 직후 INSERT 미발생 |
엔티티 상태는 네 가지로 나뉜다 —
비영속(new/transient), 영속(managed),
준영속(detached), 삭제(removed).
영속성 컨텍스트가 관리하는 건 managed 상태 엔티티뿐이다. new 상태는
persist()로 managed가 되고, managed가
detach()·clear()·컨텍스트 종료로 detached가
되며, remove()는 removed로 표시만 해두고 실제 DELETE는 역시
flush에서 나간다. 이 상태 머신이 뒤에서 계속 나올 테니 머릿속에 한 번
그려두자.
영속성 컨텍스트에 접근하는 손잡이가 바로
EntityManager다. 엄밀히는 Hibernate의
Session이 EntityManager를 구현하고, Spring
Data JPA가 그 위에 얹혀 있다. 그래서 코드에서
EntityManager em을 직접 쓰든, JpaRepository로
간접적으로 쓰든, 뒤에서 돌아가는 건 결국 같은 Session 한
개다.
2) EntityManager 종류: Application-managed vs Container-managed
JPA 스펙은 EntityManager를 두 종류로 나눈다. Application-managed와 Container-managed. 이 구분이 실무에서 "EntityManager를 new로 만들 수 있는가, 없는가"를 가른다. 스프링에서 대부분 쓰는 건 Container-managed 쪽이고, 더 정확히는 Container-managed Transaction-scoped다.
// Application-managed (스프링에서는 거의 안 씀)
EntityManagerFactory emf = Persistence.createEntityManagerFactory("app");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
try {
em.persist(new Member("kim"));
em.getTransaction().commit();
} catch (Exception e) {
em.getTransaction().rollback();
} finally {
em.close(); // 명시적 close 필수
}
// Container-managed Transaction-scoped (스프링 표준)
@PersistenceContext
private EntityManager em; // 주입받은 "프록시". 트랜잭션 경계 안에서 실제 EM을 찾아준다@PersistenceContext로 주입받는 em은 실제
EntityManager 인스턴스가 아니라
SharedEntityManager라는 스프링의 프록시다. 이 프록시는
호출될 때마다 TransactionSynchronizationManager에서 현재
스레드에 바인딩된 실제 EM을 꺼내서 위임한다. 트랜잭션 밖에서 쓰면
대부분의 연산은 TransactionRequiredException을 던진다.
반대로 트랜잭션 안이면 그 트랜잭션 수명 동안 같은 EM이 계속
사용된다.
| 속성 | Application-managed | Container-managed Transaction-scoped |
|---|---|---|
| 생성 주체 | 개발자 (emf.createEntityManager()) |
컨테이너 (스프링) |
| close 책임 | 개발자 (em.close()) |
컨테이너 |
| 트랜잭션 경계 | em.getTransaction() 수동 |
@Transactional |
| 컨텍스트 수명 | em.close()까지 |
트랜잭션 종료까지 |
| 스레드 안전 | 아님 | 프록시는 Thread-safe (내부 EM은 스레드 바인딩) |
스프링에서 application-managed를 쓸 일은 보통 없다 — 배치 외 경우
대부분 @Transactional 또는
TransactionTemplate으로 해결된다. 그럼에도 둘을 구분해 두는
이유는, 후자의 **"트랜잭션 수명 = 컨텍스트 수명"**이라는 공식이 이 글
나머지 전체의 기반이기 때문이다.
한편 extended persistence context
(@PersistenceContext(type = EXTENDED))라는 변형도 있지만,
이건 EJB/Stateful 세션 빈에서 쓰는 개념이고 스프링 싱글톤 서비스에는
맞지 않는다. 스프링에서는 쓰지 않는다고 봐도 무방하다.
3) @Transactional과 EntityManager 생명주기 바인딩
이 섹션이 이 글의 허리다. @Transactional이 붙은 메서드에
진입하는 순간, 스프링이 내부적으로 어떤 단계를 밟아 "트랜잭션 = 영속성
컨텍스트"라는 등식을 성립시키는지 본다. propagation/rollback 규칙 자체는
8편
§3~§5에서 다뤘으므로, 여기서는 JpaTransactionManager
경로에 초점을 맞춘다.
흐름은 이렇다. 프록시가 메서드를 가로채면
TransactionInterceptor가
PlatformTransactionManager.getTransaction()을 호출하고,
스프링 부트의 JpaAutoConfiguration이 등록한 구현체
JpaTransactionManager가 움직인다. 이 매니저는 내부적으로
EntityManagerFactory에서 EntityManager를 하나
만든 뒤, 그 EM을 EntityManagerHolder로 감싸서
TransactionSynchronizationManager의 ThreadLocal에
바인딩한다. 같은 팩토리에서 만든 JDBC DataSource도
ConnectionHolder로 같이 바인딩된다. 이 바인딩 덕분에
@PersistenceContext로 주입된 SharedEntityManager 프록시가
"이 스레드의 현재 EM"을 찾아낼 수 있다.
여기서 **"하나의 트랜잭션 = 하나의 EntityManager = 하나의 영속성
컨텍스트"**라는 가장 중요한 등식이 성립한다. 메서드 진입 시점에 EM이
생성되고 commit/rollback 직후에 close된다. 중간에 다른 트랜잭션 메서드를
호출해도 REQUIRED 참여라면 같은 홀더를 재사용하므로
컨텍스트도 공유된다. 반면 REQUIRES_NEW로 새 트랜잭션을
시작하면 새 EM이 만들어지고 새 1차 캐시가 열린다 — 바깥
컨텍스트와 엔티티 동일성이 깨진다. 이게 8편
§3의 REQUIRES_NEW가 JPA에서 특히 까다로운 이유다.
트랜잭션 밖에서 @PersistenceContext em을 건드리면 어떻게
될까. SharedEntityManager 프록시가 TSM.getResource(emf)로
조회하면 없으므로, 연산 종류에 따라 다르게 동작한다. find()
같은 읽기 연산은 "임시 EM"을 만들어 조회 후 바로 close하고(단
tx-scoped이기에 결과 엔티티는 즉시 detached가 됨),
persist·merge·remove 같은 쓰기
연산은 TransactionRequiredException을 던진다.
읽기도 쓰기도 트랜잭션 안에서 하는 게 원칙이라고 보면
된다.
3-1) TransactionSynchronizationManager의 역할
TransactionSynchronizationManager는 이름은 길지만 본질은
ThreadLocal 기반의 리소스 저장소다. key는
EntityManagerFactory·DataSource 같은
팩토리/소스 객체, value는
EntityManagerHolder·ConnectionHolder. 한
스레드 안에서 "지금 진행 중인 JPA EM"을 찾는 단일 진입점이다. 리액티브
스택에서는 ThreadLocal이 의미가 없어서
TransactionContextManager가 대체하지만, 여기서는 전통 MVC
기준.
// 내부적으로 대강 이렇게 돌아간다 (의사 코드)
EntityManagerFactory emf = ...;
EntityManagerHolder holder =
(EntityManagerHolder) TransactionSynchronizationManager.getResource(emf);
EntityManager em = holder != null ? holder.getEntityManager() : null;디버깅할 때
TransactionSynchronizationManager.getResourceMap()을
찍어보면 현재 스레드가 어떤 리소스를 잡고 있는지 한눈에 보인다. "왜 내
EM이 null이지?"라고 물어야 할 때 제일 먼저 보는 곳이다.
4) 1차 캐시와 엔티티 동일성 보장
1차 캐시는 Map 한 개라고 생각하면 가깝다. key는
(엔티티 타입, @Id 값) 조합, value는 엔티티 인스턴스.
find()든 getReference()든 JPQL이든, 영속성
컨텍스트를 거치는 모든 조회는 이 Map을 거친다. 같은 키로 두 번 찾으면
두 번째는 SQL이 나가지 않는다.
@Transactional
public void cacheTest() {
Member a = em.find(Member.class, 1L); // SELECT 1회
Member b = em.find(Member.class, 1L); // SELECT 없음. 1차 캐시 hit
assert a == b; // 동일성(identity) 보장 — 같은 인스턴스
}같은 트랜잭션 안에서 같은 ID로 조회한 엔티티는 ==로
비교해도 true다. 이게 JPA가 말하는 **"반복 가능한 읽기(repeatable
read)를 애플리케이션 레벨에서 보장"**의 실체다. DB 격리 수준이
READ_COMMITTED여도, 한 트랜잭션 안에서 같은 엔티티를 두 번
조회하면 같은 인스턴스가 돌아오기 때문에 상태가 바뀔 수가 없다. 단,
다른 트랜잭션에서 바뀐 실제 DB 값을 보지 못할 수 있다는
뜻도 된다. 이 트레이드오프는 리서치 단계에서 늘 인지되어야 한다.
JPQL은 살짝 다르다. JPQL은 항상 DB로 쿼리를 보내서 결과 ID 리스트를 가져온 뒤, 그 ID가 1차 캐시에 있으면 캐시 인스턴스로 교체한다. 즉 SELECT는 나가지만 반환되는 엔티티는 캐시와 동일한 인스턴스가 된다.
Member a = em.find(Member.class, 1L); // SELECT 1회
List<Member> list = em.createQuery( // SELECT 또 나감 (JPQL)
"select m from Member m where m.id = 1", Member.class).getResultList();
assert a == list.get(0); // 그래도 동일성은 유지여기서 틀리기 쉽다. "JPQL이면 1차 캐시가 안 먹는구나"라고 넘어가면 오해다. 쿼리는 나가되 결과 엔티티 인스턴스는 캐시 것이 우선이다. 이 규칙 덕분에 한 트랜잭션 안에서 "find로 가져온 엔티티"와 "JPQL로 가져온 엔티티"를 섞어 써도 동일성이 깨지지 않는다.
getReference()는 한 발 더 나간다. 프록시만 만들어
반환하고 실제 SELECT는 필드 접근 시점까지 미룬다. 연관관계 FK만 박아둘
때 자주 쓴다.
// 피하기: 굳이 풀 로딩
Member author = em.find(Member.class, memberId);
Post post = new Post("hello", author);
// 선호: 프록시로 충분
Member authorRef = em.getReference(Member.class, memberId);
Post post = new Post("hello", authorRef); // SELECT 없이 INSERT만 나간다
em.persist(post);1차 캐시는 트랜잭션 종료 시 같이 사라진다. 메서드가
끝나면 컨텍스트도 끝이니 캐시 hit을 기대하며 메서드를 쪼갰다가 오히려
SELECT가 더 늘어나는 패턴이 자주 보인다. 캐시 효과를 보려면 하나의
@Transactional 경계 안에 묶어야 한다.
5) Dirty Checking: 스냅샷 기반 변경 감지
JPA에서 가장 "마법"처럼 느껴지는 지점이 여기다. UPDATE 쿼리를 한 줄도 쓰지 않았는데 DB 값이 바뀐다.
@Transactional
public void rename(Long id, String newName) {
Member m = em.find(Member.class, id);
m.setName(newName); // 그냥 setter만 호출
// em.merge(m); 필요 없다
// memberRepository.save(m); 필요 없다
}
// 메서드 종료 시 flush → UPDATE member SET name=? WHERE id=? 가 찍힌다이게 되는 이유는 스냅샷이다. 영속성 컨텍스트는 엔티티를 1차 캐시에 넣을 때 그 시점의 필드 값을 복사한 스냅샷을 함께 저장한다. flush 시점에 캐시의 엔티티와 스냅샷을 필드 단위로 비교해서 달라진 필드가 있으면 UPDATE 쿼리를 만든다. 이 비교 과정을 Hibernate 용어로 **"Automatic Dirty Checking"**이라고 부른다.
| 필드 | 스냅샷 | 현재 | dirty? |
|---|---|---|---|
| name | "kim" | "park" | yes |
| age | 30 | 30 | no |
| "a@x" | "a@x" | no |
기본적으로 Hibernate가 만드는 UPDATE는 변경된 필드만이 아니라
모든 필드를 포함한다. 이건 PreparedStatement 캐시 효율
때문이다. 매번 SQL 모양이 달라지면 DB 쪽 파싱 캐시를 재활용하기 어렵다.
꼭 변경된 필드만 보내고 싶으면 @DynamicUpdate를 붙이면
되지만, 대부분의 경우 기본값이 낫다. 자세한 근거는 Hibernate
User Guide — Dynamic update에 한 문단으로 정리돼 있다.
여기서 틀리기 쉬운 지점 하나. Dirty Checking은 managed 상태
엔티티에만 작동한다. em.find()나
persist()로 managed가 된 엔티티만 스냅샷이 있다. new 객체를
만들어서 setter만 호출하면 당연히 아무 일도 안 일어나고, detached
엔티티의 setter는 컨텍스트가 모르기 때문에 역시 UPDATE가 안 나간다.
// 피하기: detached에 setter
@Transactional
public Member readOnly(Long id) {
return em.find(Member.class, id);
}
// 호출 쪽에서
Member m = service.readOnly(1L);
m.setName("foo"); // 트랜잭션 밖 + 컨텍스트 닫힘 → 아무 일도 안 일어남Dirty Checking을 끄고 싶을 때의 정석은
@Transactional(readOnly = true)다. Hibernate가
FlushMode=MANUAL로 전환하고 스냅샷 저장도 스킵하므로 변경
감지가 통째로 꺼진다. 상세 효과는 8편
§6에서 다뤘다.
6) Write-Behind: SQL 지연 실행 큐
persist()를 호출하자마자 INSERT가 나갈 것 같지만,
실제로는 안 나간다. Hibernate는 INSERT/UPDATE/DELETE SQL을 만들어서
action queue (쓰기 지연 SQL 저장소)에 쌓아두고, flush
시점에 한꺼번에 내보낸다. 이게 Write-Behind다.
@Transactional
public void batchInsert() {
for (int i = 0; i < 3; i++) {
em.persist(new Member("m" + i)); // INSERT 아직 안 나감
}
// 여기까지 SQL 로그: 없음
// 메서드 종료 → commit → flush → INSERT x3 한 번에
}이 지연에는 세 가지 이득이 있다. 첫째, 배치 INSERT
최적화가 가능하다. hibernate.jdbc.batch_size=50 설정과 함께
쓰면 여러 INSERT가 한 PreparedStatement 배치로 묶인다. 둘째, SQL
재정렬 여지가 생긴다. Hibernate는 FK 제약을 지키기 위해 action
queue를 INSERT → UPDATE → DELETE 순, 같은 종류 안에서는 타입별로
정렬해서 내보낸다. 셋째, 불필요한 SQL 제거다. 같은
엔티티를 persist 후 remove하면 둘 다 큐에
있다가 상쇄될 수 있다(구현에 따라 다르지만 Hibernate는 처리한다).
그런데 persist 한 가지는 예외다.
@GeneratedValue(strategy = IDENTITY)로 ID를 DB
auto-increment에 맡기면 persist() 호출 시점에
INSERT가 즉시 나간다. 이유는 단순하다 — ID가 없으면 1차 캐시에
넣을 키가 만들어지지 않으니, INSERT를 먼저 보내고 DB가 할당한 ID를
받아와야 한다. 배치 INSERT 최적화도 이 경우엔 거의 못 받는다.
| GenerationType | persist 시점 INSERT? | 배치 INSERT 가능? |
|---|---|---|
IDENTITY |
즉시 | 불가 |
SEQUENCE |
지연 | 가능 |
TABLE |
지연 | 가능 |
AUTO |
구현체/DB에 따라 다름 | 조건부 |
대용량 배치에서 ID 전략을 SEQUENCE로 바꾸라는 조언은
여기서 나온다. MySQL처럼 시퀀스가 없는 DB에서는 TABLE 또는
수동 할당으로 우회한다.
action queue에 쌓인 SQL을 강제로 보내고 싶으면
em.flush()를 부르면 된다. 다만 flush는 커밋이
아니다 — 트랜잭션은 그대로 열려 있고, 롤백하면 flush된 변경도
같이 되돌아간다. "지금까지의 작업을 DB에 일단 보내두고 이어서 작업"이
필요한 경우에만 쓴다. 대표적인 경우가 뒤에서 볼
saveAndFlush()와 JPQL 실행 전 auto-flush.
7) FlushMode: AUTO / COMMIT / MANUAL / ALWAYS
FlushMode는 "언제 자동으로 flush가 일어날지"를 정하는 설정이다. JPA
스펙이 정의한 두 값(AUTO, COMMIT)과 Hibernate
확장 두 값(MANUAL, ALWAYS)이 있다.
| 모드 | 정의 | 실무 용도 |
|---|---|---|
AUTO |
쿼리 실행 전 + commit 전에 flush (JPA 기본) | 대부분의 서비스 코드 |
COMMIT |
commit 직전에만 flush | 읽기 위주 배치에서 중간 flush 방지 |
MANUAL |
명시적 flush() 호출 때만 |
@Transactional(readOnly=true) 최적화 |
ALWAYS |
쿼리 실행 시 항상 flush (Hibernate 확장) | 거의 안 씀 |
기본값인 AUTO가 가장 이해하기 까다롭다. "쿼리 실행 전"이
조건이다 보니, JPQL이나 Native Query를 실행하기 직전에
변경사항이 DB에 반영되어야 그 쿼리가 최신 값을 본다는
정합성 이유로 자동 flush가 일어난다.
@Transactional
public void autoFlushTest() {
Member m = em.find(Member.class, 1L);
m.setName("updated"); // 변경 감지 대상
// 여기서 JPQL 실행 → auto-flush 발동
// UPDATE member ... 가 먼저 나가고
Long count = em.createQuery(
"select count(m) from Member m where m.name = 'updated'", Long.class)
.getSingleResult();
// 그다음 SELECT count(*) 가 나간다
// count = 1 (방금 한 수정이 반영됨)
}여기서 미묘한 함정이 있다. auto-flush는 JPQL/Native Query
앞에서만 일어난다. em.find()는 1차 캐시를 먼저
보므로 auto-flush 트리거가 아니다. 같은 트랜잭션에서
persist 후 바로 find로 같은 엔티티를 조회하면
1차 캐시 hit으로 돌아오지만, 그 시점 DB에는 해당 row가 아직 없을 수
있다.
또 하나, Native Query의 auto-flush는 같은 테이블을 건드려도
기본적으로 flush를 보장하지 않는다. Hibernate는 JPQL에서만 참조
테이블을 추론해 필요한 경우에만 flush를 하는 반면, Native Query에
대해서는 보수적으로 항상 flush하거나 혹은 설정에 따라 안 할 수도 있다.
대량 쓰기 뒤 Native로 조회한다면 em.flush()를 명시적으로
부르는 쪽이 안전하다.
@Transactional(readOnly = true)는 스프링이 내부적으로
FlushMode.MANUAL로 바꿔준다. 읽기만 할 건데도 매번 쿼리
앞에서 dirty 검사를 돌리는 건 낭비니까, 아예 꺼두는 것. 읽기 전용 서비스
메서드에 관용적으로 붙는 이유다.
8) persist / merge / save / saveAndFlush 함정
JPA 네 메서드의 의미가 다 다른데 이름이 비슷해서 혼선이 잦다. 여기서 정리한다.
8-1) persist: new → managed
em.persist(entity)는 비영속(new) 상태를
영속(managed)으로 전환한다. 이미 영속이면 무시되고, 준영속이면
EntityExistsException 계열을 뱉는다. 반환값이 없다는 점이
중요하다 — 전달한 객체 자체가 managed가 되므로 반환할 게 없다.
Member m = new Member("kim");
em.persist(m); // 이 시점부터 m은 managed. 1차 캐시에 들어감.
m.setName("kim2"); // Dirty Checking 대상
// flush → INSERT ... VALUES ('kim2', ...) ← 변경 반영된 채로 한 번만 INSERT8-2) merge: detached → managed (복사본)
merge는 다르다. 준영속 엔티티를 받아서 같은 ID의
managed 엔티티를 찾고(없으면 SELECT), 그 managed 엔티티에 detached 필드
값을 복사해 붙여넣은 뒤, 그 managed 인스턴스를 반환한다.
전달받은 원본 detached 객체는 여전히 detached 그대로다.
// 피하기: merge 결과를 버리기
Member detached = ...; // 준영속
em.merge(detached); // <-- 반환값을 안 받으면 managed 인스턴스를 놓친다
detached.setName("foo"); // 얘는 detached라서 Dirty Checking 안 됨
// 선호: 반환값을 받아서 이어 쓴다
Member managed = em.merge(detached);
managed.setName("foo"); // 이 쪽이 managed. 변경 감지 작동이게 save()가 오해를 부르는 출발점이다.
8-3) save: Spring Data JPA의 편의 메서드
JpaRepository.save()는 내부적으로 이렇게 생겼다.
// SimpleJpaRepository (요약)
public <S extends T> S save(S entity) {
if (entityInformation.isNew(entity)) {
em.persist(entity);
return entity;
} else {
return em.merge(entity);
}
}ID가 null이면 persist, 아니면 merge. Dirty Checking을 쓸 거면
save()를 호출할 필요가 없다. 이미 managed 상태
엔티티의 setter만 바꿔놓으면 commit 시 자동 UPDATE가 나간다. 그럼에도
save()를 습관처럼 부르는 코드가 많은데, managed 엔티티를
save에 넘기면 isNew가 false이므로 merge 경로를
타고, merge는 해당 엔티티 값을 다시 읽어올 SELECT를 유발할 수
있다. 이 불필요한 SELECT가 쌓이면 N+1 수준의 성능 이슈가
된다.
8-4) saveAndFlush: 즉시 flush
saveAndFlush()는 save() 후에
em.flush()를 호출한다. ID 즉시 반환, DB 레벨 제약 즉시 검증
같은 목적엔 유용하지만, 1차 캐시와 Write-Behind의 이점을 대부분
버리는 행위다. 배치 INSERT 묶기가 안 되고, action queue 정렬
최적화 여지도 사라진다. 테스트에서 saveAndFlush()를 쓰는 건
이해되지만, 프로덕션 서비스 코드에 습관적으로 박혀 있으면 성능이
야금야금 깎인다.
| 상황 | 권장 |
|---|---|
| managed 엔티티 필드 수정 | save() 안 부르고 setter만 |
| 새 엔티티 저장 | persist() 또는 save() 한 번 |
| 준영속 엔티티 재병합 | merge() 반환값 받기 |
| 즉시 DB 제약 검증 필요 | saveAndFlush() (선택적) |
| 대량 반복 저장 | persist() + hibernate.jdbc.batch_size |
9) Flush 타이밍 전체 그림
지금까지 나온 조각들 — find 시 스냅샷, setter 후 변경 감지, JPQL 앞 auto-flush, commit 전 마지막 flush, 종료 시 detach — 을 한 트랜잭션 안에서 시퀀스로 그려보자.
시간 축을 따라가면 flush는 세 번 일어날 수 있다.
JPQL 앞(1회), commit 직전(1회), 그리고 개발자가 명시적으로
em.flush()를 부르면 추가로. 트랜잭션이 끝나면 1차 캐시는
통째로 비워지고 그 안의 모든 엔티티가 detached로 전환된다. 이 마지막 한
줄이 다음 섹션 OSIV와 §11 준영속 함정의 뿌리다.
한편 @Transactional(readOnly = true) 메서드에서는
FlushMode=MANUAL이기 때문에, JPQL 앞 auto-flush도 commit 전
flush도 일어나지 않는다. 스냅샷 저장 자체를 Hibernate가 스킵할 수도
있다(read-only entity 최적화). 읽기 전용 경로의 부하가 낮아지는
이유다.
10) OSIV (Open Session In View) on/off
OSIV는 "영속성 컨텍스트(=Hibernate Session)를 HTTP 요청
전체 동안 열어두자"라는 패턴이다. 스프링 부트에서는
spring.jpa.open-in-view 속성으로 제어하며 기본값은
true다. on이면 서블릿 필터 단계에서 EM을 열고, 뷰 렌더링까지
끝난 뒤 닫는다. off면 @Transactional 경계에서만 EM이
열린다.
| 구분 | OSIV on (기본) | OSIV off (권장 경향) |
|---|---|---|
| EM 수명 | HTTP 요청 전체 | @Transactional 경계만 |
| 컨트롤러에서 lazy 접근 | 가능 | LazyInitializationException |
| DB 커넥션 점유 | 요청 내내 | 트랜잭션 종료 시 반환 |
| 대량 요청 시 커넥션 풀 압박 | 높음 | 낮음 |
| 쓰기 경로 경계 | 느슨함 | 명확함 |
OSIV on의 장점은 템플릿 엔진에서 lazy 관계를 바로 렌더할 수 있다는 점 하나다. 단점은 그보다 많다. 컨트롤러가 lazy 접근을 자유롭게 할 수 있다는 건 거꾸로 말하면 DB 접근이 어디서 일어나는지 예측이 어렵다는 뜻이고, 트랜잭션이 이미 끝났으니 lazy로 추가 발생한 SELECT는 트랜잭션 밖의 autoCommit 쿼리로 나간다. 그리고 HTTP 요청이 끝날 때까지 DB 커넥션이 반납되지 않아, API 응답 직렬화가 느려지면 풀이 빠르게 말라간다.
# application.yml
spring:
jpa:
open-in-view: false
off로 돌리면 설계가 약간 바뀐다. 컨트롤러로 나가기 전에
필요한 연관 엔티티를 전부 로딩해둔 DTO로 변환해야 한다.
fetch join이나 @EntityGraph로 필요한 관계를 트랜잭션 안에서
초기화하고, LAZY 프록시를 그대로 반환하지 않는 습관이 서야 한다.
처음에는 LazyInitializationException을 몇 번 맞겠지만, 이
예외가 터지는 자리가 곧 "트랜잭션 경계가 잘못 그어진 자리"다 — 일부러
드러내서 고치는 편이 장기적으로 낫다.
부트 기본값이 true인 이유는 시작 장벽을 낮추기 위한
것이고, 서비스 크기가 커지면 off로 내리는 게 일반적인 권장이다.
스프링 팀도 로그에 경고를 남긴다:
spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering.
이 경고를 본 적이 있다면, 한 번쯤 방향을 정할 시점이다.
11) 준영속(detached) 함정과 트랜잭션 경계 밖 수정
트랜잭션이 끝나면 그 안에 있던 모든 엔티티가 **준영속(detached)**으로 전환된다. 1차 캐시에서 빠지고, 스냅샷도 사라지고, Dirty Checking 대상에서 제외된다. 이 상태의 엔티티 필드를 바꾸면 아무 일도 안 일어난다 — 이게 JPA 초보자가 제일 많이 밟는 지뢰다.
// 피하기: 서비스에서 조회만 하고, 컨트롤러에서 수정
@Transactional(readOnly = true)
public Member get(Long id) {
return em.find(Member.class, id);
}
// Controller
Member m = service.get(1L);
m.setName("changed"); // <-- detached. UPDATE 안 나간다
return m;해법은 두 가지 중 하나다. 변경 책임을 트랜잭션 안으로 밀어
넣거나, 명시적으로 merge로
재병합하거나. 둘 중에 앞쪽이 더 안전하다.
// 선호 1: 변경 책임을 서비스 메서드 안으로
@Transactional
public void rename(Long id, String newName) {
Member m = em.find(Member.class, id);
m.setName(newName); // managed → Dirty Checking
}
// 선호 2 (REST API에서 전체 교체가 의미 있을 때만): merge
@Transactional
public Member replace(Member detached) {
return em.merge(detached); // 반환값을 사용할 것
}merge는 편리해 보이지만 함정이 하나 더 있다. 전달받은
detached 엔티티의 모든 필드가 그대로 복사된다. 클라이언트가
비워 보낸 필드는 null로 덮어쓴다. "일부 필드만 수정"하려고 merge를 쓰면
기대와 정반대 결과가 나올 수 있다. 부분 수정은 서비스 안에서
find → setter 경로가 훨씬 예측 가능하다.
또 한 가지, detached 엔티티의 Lazy 프록시 필드는
컨텍스트가 없으므로 접근 시 LazyInitializationException이
터진다. OSIV off 환경에서 컨트롤러로 나간 엔티티의
member.getOrders().size()를 호출하면 바로 이 예외다. DTO
변환 시점이 반드시 트랜잭션 안이어야 하는 이유다.
detach를 명시적으로 하고 싶으면 em.detach(entity) 또는
em.clear()를 쓴다. 후자는 1차 캐시 전체를 비운다 — 대량
배치에서 메모리 압박을 줄이려 주기적으로 부르는 패턴이 있다. 단 clear
이후 같은 엔티티를 다시 변경하려면 find부터 다시 해야
한다는 걸 잊지 않아야 한다.
12) 실무에서 이렇게 읽고 쓴다
- "왜 이 메서드에
@Transactional이 붙어야 하나"를 영속성 컨텍스트 관점으로 설명할 수 있어야 한다. 경계 안이어야 Dirty Checking·1차 캐시·Write-Behind가 모두 산다. 경계 밖이면 셋 다 없다. save()를 습관처럼 부르지 말자. managed 엔티티 필드 수정은 setter로 충분하다.saveAndFlush()는 특수 목적(ID 즉시 확보·DB 제약 즉시 검증)에만.open-in-view: false로 내리고 DTO 변환을 트랜잭션 안에서 마쳐라. lazy 예외가 터지는 자리가 경계가 잘못 그어진 자리다. 이 과정에서@EntityGraph·fetch join 사용처가 자연스럽게 드러난다.REQUIRES_NEW는 "별도 컨텍스트·별도 1차 캐시"라는 뜻이다. 같은 엔티티를 바깥과 공유할 거라 착각하면 동일성·동기화에서 사고가 난다. 8편 §3 같이 보면서 판단할 것.- 배치에는
SEQUENCE+batch_size+ 주기적flush()/clear(). IDENTITY는 쓰기 지연을 못 받으니 대량 INSERT에서 선택지에서 내려둔다. 1천건마다 flush/clear로 메모리를 턴다.
13) 한 줄 정리
영속성 컨텍스트는 "1차 캐시 + 스냅샷(Dirty Checking) + 쓰기
지연 큐(Write-Behind)" 세 가지를 한 덩어리로 품는 메모리 공간이고,
스프링에서는 @Transactional 경계가 그 수명이 된다.
save()가 아니라 setter 한 줄로 UPDATE가 나가는
이유와, 반대로 경계 밖에서는 setter가 무효가 되는 이유가 전부 이 한
문장에 담겨 있다. propagation·isolation 같은 트랜잭션 속성은 8편에서, 여기서는
경계 안쪽에서 EM이 무슨 일을 하는지를 잡아두면 JPA
디버깅이 훨씬 예측 가능해진다.
태그: JPA, Hibernate, 영속성 컨텍스트, EntityManager, @Transactional, Dirty Checking, FlushMode, 1차 캐시, Write-Behind, OSIV, Spring Data JPA, TransactionSynchronizationManager
'CS > Spring' 카테고리의 다른 글
| Spring MVC DispatcherServlet — Front Controller 패턴, 초기화와 요청 디스패치 (0) | 2026.04.13 |
|---|---|
| JPA 실전 — N+1, Fetch Join/EntityGraph/BatchSize, flush 타이밍과 readOnly 최적화 (0) | 2026.04.13 |
| Spring Data JPA @Repository 내부 — JpaRepository 계층과 프록시 조립, PartTree까지 (1) | 2026.04.13 |
| ApplicationEvent와 @Async: 컨테이너 안의 비동기 (0) | 2026.04.13 |
| Environment와 설정 조립: PropertySource / @Value / @ConfigurationProperties (0) | 2026.04.13 |