JPA Persistence Context와 Dirty Checking — Hibernate가 SQL을 늦추는 이유
처음 JPA를 쓰는 백엔드 개발자가 가장 자주 묻는 두 가지 — "왜
save()를 부르지 않았는데 UPDATE가 나가요?"와 "왜 같은 ID로 두 번 조회했는데 SELECT는 한 번만 찍히죠?" — 에 답하기 위한 글입니다. Hibernate 6 / Jakarta Persistence 3.2 기준으로 Persistence Context의 동작을 따라가면서, 1차 캐시·Dirty Checking·ActionQueue·Flush 시점이 같은 이야기의 네 면임을 정리합니다.
JPA를 처음 쓰면 SQL이 두 가지 의미에서 의외의 시점에 나갑니다. 한 번도 본 적 없는 SQL이 나갈 때가 있고, 분명히 "저장"한 줄을 지웠는데도 SQL이 남아 있을 때가 있습니다. 이 두 가지는 모두 한 가지 사실에서 출발합니다 — JPA는 즉시 SQL을 만들지 않습니다. 대신 메모리 안에 변경 사항을 모아 두었다가, 정해진 순간에 한꺼번에 SQL로 번역합니다. 그 메모리의 이름이 Persistence Context이고, 번역 작업의 이름이 Flush입니다.
이 글은 다음 다섯 단계로 그 흐름을 따라갑니다.
- Persistence Context가 무엇인지 정의하고
- 그 안에서 엔티티가 거치는 네 가지 상태(transient/managed/detached/removed)를 보고
- 변경을 추적하는 메커니즘 — Dirty Checking과 ActionQueue를 살펴본 다음
- SQL이 실제로 나가는 시점 — Flush 모드를 정리하고
- Spring Data JPA의
save()가persist와merge사이에서 어떻게 갈라지는지로 마무리합니다.
마지막으로 실전에서 자주 부딪히는 다섯 가지 함정을 따로 묶어 정리합니다.
큰 그림 — 한 줄짜리 변경이 어떻게 SQL이 되는가
다음 코드를 봅시다. @Transactional 메서드 안에서 엔티티의 필드 하나를 바꿉니다.
@Transactional
public void rename(Long id, String newName) {
Member member = entityManager.find(Member.class, id);
member.setName(newName);
// save 호출 없음
}
save도 update도 부르지 않았는데 메서드가 끝나면 UPDATE member SET name = ? WHERE id = ?가 나갑니다. 이 동작은 마법이 아니라 다음 네 가지 사실의 조합입니다.
find로 조회한 엔티티는 Persistence Context가 관리하는 상태(managed) 가 된다.- managed 엔티티의 필드를 바꾸면 Persistence Context는 그 변경을 Dirty Checking 으로 감지한다.
- 감지된 변경은 즉시 SQL로 번역되지 않고 ActionQueue 에 쌓인다.
- 트랜잭션 커밋 직전(또는 다른 트리거)에 Flush 가 실행되어 ActionQueue의 항목을 SQL로 번역한다.
flowchart TB
A[find returns managed entity] --> B[setter modifies field in memory]
B --> C[Persistence Context detects change via dirty checking]
C --> D[Action queued in ActionQueue]
D --> E[Flush at commit translates queue to SQL]
E --> F[(Database)]
이 글의 나머지는 이 다섯 박스 각각에 대한 클로즈업입니다.
Persistence Context는 무엇인가
Persistence Context는 한 트랜잭션(또는 한 EntityManager) 안에서 엔티티 인스턴스를 관리하는 메모리 영역입니다. Hibernate User Guide의 정의를 옮기면 "the persistence context acts as a transactional write-behind cache, queuing any entity state change" 입니다. 이 한 문장에 두 가지 책임이 있습니다.
- 1차 캐시(first-level cache) — 같은 식별자(primary key)를 가진 엔티티는 하나의 인스턴스만 존재한다. 같은 트랜잭션 안에서 같은 ID로 두 번 조회해도 SELECT는 한 번만 나간다.
- 변경 버퍼(write-behind cache) — 엔티티의 상태 변화는 즉시 DB로 전송되지 않고 메모리에 쌓였다가 Flush 시점에 한꺼번에 SQL로 번역된다.
flowchart LR
subgraph PC[Persistence Context]
IC[1st Level Cache: id -> entity]
SS[Snapshot Map: id -> original state]
AQ[ActionQueue: pending DML]
end
EM[EntityManager API] --> PC
PC -->|flush| DB[(Database)]
DB -->|find| PC
세 가지 내부 자료구조가 있다는 점을 미리 기억해 두면 뒤가 편해집니다 — 1차 캐시(엔티티 인스턴스를 가지고 있는 맵), 스냅샷 맵(엔티티가 처음 매니지드가 됐을 때의 필드 값을 복사해 둔 맵), ActionQueue(아직 실행되지 않은 INSERT/UPDATE/DELETE 목록). 이 셋이 협력해 Dirty Checking과 Flush를 만들어 냅니다.
1차 캐시 — 같은 ID는 같은 인스턴스
Persistence Context가 1차 캐시 역할을 한다는 건, 같은 트랜잭션 안에서 같은 PK로 조회한 결과가 == 비교에서 true 라는 뜻입니다.
@Transactional
public void readTwice(Long id) {
Member m1 = entityManager.find(Member.class, id);
Member m2 = entityManager.find(Member.class, id);
assert m1 == m2; // 같은 인스턴스
}
두 번째 find는 DB에 가지 않고 Persistence Context 내부 맵에서 그대로 돌려줍니다. 이는 단순한 캐싱 최적화 이상의 의미를 갖습니다 — JPA의 모든 변경 추적이 "한 트랜잭션 안에서 한 엔티티는 하나의 인스턴스"라는 가정 위에 서 있기 때문입니다.
1차 캐시 vs 2차 캐시
Hibernate에는 1차 캐시 외에 선택적으로 켤 수 있는 2차 캐시(second-level cache, @Cacheable + EhCache/Infinispan/Caffeine 등)가 있습니다. 둘은 책임이 다릅니다.
- 1차 캐시는 트랜잭션 안에서만 살아 있고, 엔티티 인스턴스를 그대로 보관해 동일성을 보장합니다. 끄고 켤 수 없습니다.
- 2차 캐시는 EntityManagerFactory(=SessionFactory) 단위로 살아 있고, 직렬화 가능한 상태만을 보관해 트랜잭션을 가로질러 재사용합니다. 명시적으로 켜야 합니다.
이 글은 1차 캐시만 다룹니다.
엔티티의 네 가지 상태
JPA 명세는 엔티티 인스턴스가 거치는 네 가지 상태를 정의합니다 — new(transient), managed, detached, removed. 같은 자바 객체가 시간에 따라 상태를 바꾸기 때문에, 어떤 시점에 어떤 상태인지를 의식하지 않으면 SQL이 예상과 다르게 나갑니다.
flowchart LR
NEW[New / Transient] -->|persist| MANAGED[Managed]
MANAGED -->|remove| REMOVED[Removed]
MANAGED -->|detach / clear / close| DETACHED[Detached]
DETACHED -->|merge| MANAGED
REMOVED -->|flush+commit| GONE((Deleted))
NEW -.->|never reaches DB without persist| NEW
각 상태의 정의와 진입 조건은 다음과 같습니다.
New(Transient) — 갓 만들어진 자바 객체
new Member(...)로 만든 직후의 인스턴스입니다. Persistence Context와 아무 연결도 없고, DB에도 대응되는 행이 없습니다. 필드를 아무리 바꿔도 SQL이 나가지 않습니다.
Member m = new Member("alice"); // new / transient
m.setName("bob"); // SQL 안 나감
Managed — Persistence Context에 들어옴
persist로 새로 등록되거나 find/JPQL/merge의 결과로 받은 인스턴스입니다. 이 상태에서는 setter 호출이 곧 변경 추적의 입력이 됩니다.
entityManager.persist(m); // m은 이제 managed
m.setName("carol"); // dirty checking으로 감지됨
persist 직후라고 해서 INSERT가 즉시 나가는 건 아닙니다. ID 생성 전략이 IDENTITY라면 ID를 받기 위해 즉시 INSERT가 필요하지만, SEQUENCE나 TABLE 전략이라면 ID는 시퀀스에서 받고 INSERT는 Flush 시점까지 미뤄집니다.
Detached — 한때 managed였지만 분리됨
다음 중 하나가 일어나면 managed 엔티티는 detached가 됩니다.
- 트랜잭션이 끝나서
EntityManager가 닫혔다 (em.close()). - 명시적으로
em.detach(entity)나em.clear()를 호출했다. - 영속성 컨텍스트 밖으로 엔티티가 반환되었다(예: 컨트롤러로 돌아간 응답 객체).
detached 엔티티는 더 이상 변경 추적의 대상이 아닙니다. 필드를 바꿔도 DB에 반영되지 않습니다. 다시 반영하려면 merge로 새 managed 인스턴스를 받아야 합니다.
Removed — 삭제 예정
em.remove(entity)를 호출하면 managed 엔티티는 removed 상태로 바뀝니다. 단, 이때 즉시 DELETE가 나가지 않습니다 — Hibernate는 ActionQueue에 EntityDeleteAction을 쌓아 두고 Flush 시점에 DELETE를 실행합니다.
entityManager.remove(m); // removed 상태, 아직 DELETE 안 나감
// flush 시점에야 DELETE member WHERE id = ?
removed 상태의 엔티티에 setter를 호출해도 의미가 없습니다(어차피 곧 DELETE 됩니다).
EntityManager의 핵심 메서드 일곱 가지
Persistence Context의 모든 조작은 EntityManager API를 거칩니다. JPA 3.2의 핵심 메서드 일곱 가지를 표로 정리해 둡니다.
| 메서드 | 입력 상태 | 결과 상태 | 핵심 의미 |
|---|---|---|---|
persist(e) |
new | managed | 새 엔티티를 등록한다. detached 객체에 호출하면 EntityExistsException. |
find(Class, id) |
— | managed | 1차 캐시에서 먼저 찾고, 없으면 DB SELECT. 없으면 null. |
getReference(Class, id) |
— | managed(프록시) | DB에 가지 않고 프록시를 반환. 실제 접근 시 lazy load. |
merge(e) |
new / detached | (반환값이) managed | 입력 인스턴스의 상태를 복사한 새 managed 인스턴스를 반환. 입력 인스턴스 자체는 detached로 남는다. |
remove(e) |
managed | removed | DELETE를 ActionQueue에 등록. Flush 시점에 실행. |
detach(e) |
managed | detached | 추적 중지. cascade detach가 걸리면 연관 엔티티도 함께. |
flush() |
— | — | 지금까지 쌓인 ActionQueue를 DB로 내보낸다. |
clear() |
— | — | Persistence Context 전체를 비운다. 모든 managed 엔티티가 detached가 된다. |
merge가 다른 동작들과 다른 점은 반환값을 써야 한다는 것입니다. 자주 틀리는 코드:
// 잘못 — m1은 여전히 detached 상태이므로 setter가 반영되지 않음
em.merge(m1);
m1.setName("dave");
// 올바름 — m2가 새로 만들어진 managed 인스턴스
Member m2 = em.merge(m1);
m2.setName("dave");
Dirty Checking — 변경을 어떻게 감지하는가
managed 엔티티의 필드를 바꾸면 Hibernate가 자동으로 그 변경을 잡아내는 메커니즘이 Dirty Checking입니다. 동작 원리는 단순합니다.
- 엔티티가 managed가 되는 순간(
persist/find/JPQL 결과/merge), Hibernate는 그 시점의 모든 필드 값을 스냅샷으로 복사해 Persistence Context 내부에 저장합니다. - Flush가 시작되면 모든 managed 엔티티에 대해 현재 필드 값과 스냅샷을 필드 단위로 비교 합니다.
- 다른 필드가 있으면 그 엔티티에 대해
EntityUpdateAction을 ActionQueue에 등록합니다. - 비교 결과 모든 필드가 같으면 UPDATE 자체를 만들지 않습니다.
flowchart TB
L[Entity becomes managed] --> S[Hibernate captures snapshot copy]
S --> M[App mutates entity fields]
M --> F[Flush triggered]
F --> C[Compare current vs snapshot per field]
C -->|same| N[No UPDATE generated]
C -->|diff| U[Enqueue EntityUpdateAction]
U --> SQL[Flush emits UPDATE SQL]
Bytecode Enhancement — 비교 없이 추적
기본 Dirty Checking은 모든 managed 엔티티의 모든 필드를 매 Flush마다 비교합니다. 엔티티가 많으면 비용이 누적됩니다. Hibernate는 이를 줄이기 위해 Bytecode Enhancement 라는 빌드 타임 기능을 제공합니다.
hibernate-enhance-maven-plugin또는 Gradle 플러그인을 빌드에 추가합니다.- 엔티티 클래스의 setter가 컴파일 시점에 추가 코드(필드가 바뀌었다고 표시하는 비트 플래그 갱신)를 갖게 됩니다.
- Flush 시점에 Hibernate는 스냅샷 비교 대신 그 비트 플래그만 봅니다.
기본값으로는 꺼져 있습니다. 엔티티 수가 수천 개를 넘기 시작할 때 측정해 보고 결정하면 됩니다.
@DynamicUpdate — 바뀐 컬럼만 UPDATE
기본 동작에서 Hibernate는 어떤 필드가 바뀌든 모든 컬럼을 포함한 UPDATE 를 만듭니다. 이는 같은 모양의 SQL을 캐시하기 위한 의도적인 선택입니다. 컬럼 수가 많거나 BLOB이 있어 모든 컬럼 업데이트가 부담스러우면 클래스에 @DynamicUpdate를 붙입니다.
@Entity
@DynamicUpdate
class Member { ... }
이러면 Flush 시점에 실제로 바뀐 컬럼만 포함된 UPDATE가 생성됩니다. 트레이드오프는 SQL 다양성이 증가해 PreparedStatement 캐시 효율이 떨어진다는 점입니다.
ActionQueue — Flush 전에 변경이 쌓이는 곳
Hibernate User Guide는 "Object insertions, updates, and deletions have list semantics because they must happen in the right order to respect referential integrity" 라고 표현합니다. 이 "올바른 순서"를 보장하는 자료구조가 org.hibernate.engine.spi.ActionQueue입니다.
ActionQueue는 종류별로 분리된 여러 리스트를 가지고 있습니다.
| Action 종류 | 어떤 변경이 쌓이는가 |
|---|---|
OrphanRemovalAction |
orphanRemoval = true인 연관에서 분리된 자식 |
EntityInsertAction / EntityIdentityInsertAction |
persist된 새 엔티티 |
EntityUpdateAction |
dirty checking으로 감지된 변경 |
QueuedOperationCollectionAction |
extra-lazy 컬렉션의 지연 큐 |
CollectionRemoveAction |
사라진 컬렉션 원소 |
CollectionUpdateAction |
변경된 컬렉션 원소 |
CollectionRecreateAction |
새로 만들어진 컬렉션 |
EntityDeleteAction |
remove된 엔티티 |
Flush가 트리거되면 이 리스트들이 위 표 순서대로 실행됩니다. 즉, 코드 안에서 remove → persist를 호출한 순서와 무관하게 INSERT가 DELETE보다 먼저 나갑니다.
@Transactional
public void replace(Long id, Member newOne) {
Member old = em.find(Member.class, id);
em.remove(old); // ActionQueue: deletions
em.persist(newOne); // ActionQueue: insertions
}
// flush 시점:
// 1) INSERT new member ...
// 2) UPDATE ... (dirty)
// 3) DELETE old member ...
UNIQUE 인덱스가 걸린 컬럼에서 같은 값을 가진 행을 지우고 새로 넣는 경우, 이 순서가 충돌의 원인이 됩니다(INSERT가 먼저 시도되어 unique constraint violation). 해결책 두 가지:
- 중간에 명시적으로
em.flush()를 호출해 DELETE를 먼저 내보낸다. - UPDATE로 표현 가능한 변경이라면 새 인스턴스를 만들지 않고 기존 인스턴스의 필드를 바꾼다.
Flush — SQL이 실제로 나가는 시점
Flush는 ActionQueue의 항목을 실제 SQL로 번역해 DB로 보내는 동작입니다. 언제 Flush가 일어나는지를 결정하는 것이 FlushMode 입니다.
JPA 명세는 두 가지 모드를 정의합니다.
AUTO(기본) — "The Session is sometimes flushed before query execution in order to ensure that queries never return stale state." 트랜잭션 커밋 직전에는 반드시 flush 되고, 쿼리 실행 전에도 필요하면 flush 됩니다.COMMIT— "The Session is flushed when EntityTransaction.commit() is called. It is never automatically flushed before query execution." 쿼리 전에는 절대 flush 되지 않습니다.
Hibernate는 두 가지를 추가로 정의합니다.
ALWAYS— 모든 쿼리 실행 전에 flush 합니다. "This is usually unnecessary and inefficient." 라고 javadoc이 말해 둔 만큼 거의 쓰지 않습니다.MANUAL—Session.flush()를 명시적으로 호출하지 않으면 flush 되지 않습니다. read-only 화면에서 의도치 않은 UPDATE를 막을 때 씁니다.
AUTO의 미묘한 점 — JPQL은 보고 native SQL은 못 본다
JPA의 AUTO 정의를 그대로 받아들이면 "쿼리 실행 전에 flush" 가 모든 쿼리에 적용될 것 같지만, Hibernate의 실제 구현은 그렇지 않습니다.
- JPQL/HQL 쿼리 — Hibernate는 쿼리 안에서 참조되는 엔티티가 dirty 상태에 있는지 검사하고, 그러면 flush를 트리거합니다(query space synchronization). 따라서 같은 트랜잭션에서
setName("bob")직후select m from Member m where m.name = 'bob'을 실행하면 'bob'을 찾을 수 있습니다. - Native SQL 쿼리 — Hibernate는 SQL 텍스트를 파싱하지 않으므로 어떤 테이블이 영향을 받는지 모릅니다. 따라서 native SQL 직전에는 자동으로 flush 하지 않습니다. 결과적으로 같은 트랜잭션의 dirty 변경이 native SQL에 보이지 않을 수 있습니다.
해결책: native SQL을 쓸 때는 query.setHint(NativeQueryHints.HINT_NATIVE_SPACES, "table_name") 으로 영향 받는 테이블을 알려 주거나, 직전에 명시적으로 em.flush()를 호출합니다.
트랜잭션 커밋 직전의 흐름
@Transactional 메서드가 정상 종료될 때 Spring의 트랜잭션 매니저가 호출하는 절차는 다음과 같습니다.
flowchart TB
A[transactional method returns] --> B[TransactionInterceptor.commitTransactionAfterReturning]
B --> C[PlatformTransactionManager.commit]
C --> D[JpaTransactionManager.doCommit]
D --> E[EntityManager.flush implicit]
E --> F[ActionQueue executes inserts updates deletes]
F --> G[Database COMMIT]
G --> H[Persistence Context closed -> all entities become detached]
이 흐름이 "왜 save도 부르지 않았는데 UPDATE가 나가요?" 의 답입니다. UPDATE는 setter가 호출된 시점이 아니라 트랜잭션 커밋 직전의 자동 flush에서 만들어집니다.
Spring Data JPA의 save() — persist냐 merge냐
Spring Data JPA의 JpaRepository#save(entity)는 내부적으로 다음과 같습니다.
@Transactional
public <S extends T> S save(S entity) {
if (this.entityInformation.isNew(entity)) {
em.persist(entity);
return entity;
} else {
return em.merge(entity);
}
}
핵심은 isNew(entity)의 판정 기준입니다. 기본 동작은 다음 우선순위를 따릅니다.
- 엔티티가
Persistable인터페이스를 구현하면 →entity.isNew()의 반환값. @Version필드가 있으면 → 그 필드가null이면 new.- 그 외에는 →
@Id필드가null이면 new(primitive 타입이면 0이면 new).
이 단순한 판정이 다음과 같은 함정을 만듭니다.
// 자연 키(natural key)를 ID로 쓸 때
@Entity
class Order {
@Id
private String orderNumber; // 애플리케이션이 채워서 넣음
...
}
orderRepository.save(new Order("ORD-001"));
// orderNumber != null 이므로 isNew = false → merge가 호출됨
// merge는 먼저 SELECT로 기존 행이 있는지 확인 → 없으면 INSERT
// 결과: 불필요한 SELECT 한 번이 추가됨
해결: Order가 Persistable<String>을 구현하고 isNew()를 자체적으로 결정합니다.
@Entity
class Order implements Persistable<String> {
@Id private String orderNumber;
@Transient private boolean isNew = true;
@Override public boolean isNew() { return isNew; }
@Override public String getId() { return orderNumber; }
@PostPersist @PostLoad
void markNotNew() { this.isNew = false; }
}
또는 @CreatedDate를 활용해 그 필드가 null이면 new로 간주하는 패턴도 있습니다(Spring Data Commons에 AuditingEntityListener와 함께 정의되어 있음).
운영 함정 다섯 가지
1. @Transactional 밖에서 setter를 부른다
가장 흔한 오류입니다. 컨트롤러 → 서비스 → 리포지토리 흐름에서 서비스 메서드에 @Transactional을 붙이지 않은 채 엔티티를 받아 setter를 호출하면 dirty checking이 동작하지 않습니다.
// service
public void rename(Long id, String newName) {
Member m = repo.findById(id).orElseThrow();
m.setName(newName);
// 이 시점에 Persistence Context는 이미 닫혔다
// → m은 detached, setter는 무효
}
findById는 자체적으로 짧은 트랜잭션을 열고 닫습니다. 반환된 엔티티는 즉시 detached 상태가 됩니다. setName은 자바 객체의 필드를 바꿀 뿐 DB에 반영되지 않습니다.
해결: 서비스 메서드에 @Transactional을 붙여 메서드 전체가 한 트랜잭션 안에서 실행되게 합니다.
2. 컨트롤러로 반환된 엔티티에 setter를 호출한다
Spring MVC 컨트롤러가 서비스로부터 받은 엔티티는 이미 detached입니다. 응답을 만드는 과정에서 그 엔티티의 setter를 호출해도 DB에 반영되지 않습니다. 이는 1번의 변형이지만 더 잡기 어렵습니다 — 의도가 "응답 가공"이라면 setter 자체가 부적절하므로, DTO로 옮기는 것이 정답 입니다.
3. merge 반환값을 무시한다
merge는 입력 인스턴스를 managed로 만들지 않습니다. 새로 만들어 준 인스턴스를 반환합니다. 이 반환값을 받지 않으면 이후 수정은 detached 인스턴스에 대한 것이 되어 무효입니다.
// 잘못
em.merge(detached);
detached.setName("x"); // 무효
// 올바름
Member managed = em.merge(detached);
managed.setName("x"); // 추적됨
Spring Data JPA의 save(entity)는 내부에서 merge의 반환값을 돌려주므로 항상 그 반환값을 받아 써야 합니다.
order = orderRepository.save(order); // 항상 반환값을 다시 대입
4. clear 없이 큰 배치를 돌린다
수만 개의 엔티티를 한 트랜잭션에서 persist하면 1차 캐시가 점점 커지고, Flush마다 모든 managed 엔티티를 dirty check 해야 하므로 시간 복잡도가 누적됩니다. 결국 OOM 또는 느린 flush로 이어집니다.
해결: 일정 단위(예: 100~1000개)마다 em.flush()로 DB에 내보내고 em.clear()로 1차 캐시를 비웁니다.
for (int i = 0; i < total; i++) {
em.persist(newEntity(i));
if (i % 500 == 499) {
em.flush();
em.clear();
}
}
hibernate.jdbc.batch_size도 함께 설정해야 INSERT가 묶여 나갑니다.
5. native SQL이 dirty 변경을 보지 못한다
같은 트랜잭션 안에서 setName("bob") 직후 entityManager.createNativeQuery("select * from member where name = ?") 를 실행하면 'bob'으로 찾지 못할 수 있습니다. AUTO flush가 native SQL 앞에서는 자동으로 트리거되지 않기 때문입니다.
해결책 세 가지:
- native SQL 직전에
em.flush()를 명시적으로 호출. query.setHint(NativeQueryHints.HINT_NATIVE_SPACES, "member")로 영향 받는 테이블을 알려 줌.- 가능하면 native SQL 대신 JPQL을 쓴다(JPQL은 자동으로 flush 됨).
정리
JPA의 동작이 처음에 의외로 보이는 까닭은, 우리가 SQL을 머리에 그리며 코드를 쓰는데 JPA는 SQL을 한 단계 늦추기 때문입니다. 그 늦추는 공간이 Persistence Context이고, 그 안에서 일어나는 일은 단순한 두 가지 — 변경을 모으고(스냅샷·ActionQueue), 정해진 시점에 한꺼번에 번역(Flush)하는 — 입니다.
이 모델이 주는 이득은 분명합니다. 같은 ID에 대한 SELECT가 한 번으로 줄고, 같은 엔티티의 여러 필드 변경이 하나의 UPDATE로 합쳐지고, INSERT/UPDATE/DELETE가 참조 무결성을 깨지 않는 순서로 정렬됩니다. 비용은 추적할 만한 함정 다섯 가지로 한정되며, 그 함정들은 모두 "이 시점에 어떤 상태인가" 라는 질문 하나로 환원됩니다.
JPA 코드를 디버깅할 때 머리에 떠올릴 두 가지 질문:
- 지금 이 인스턴스는 네 상태(new/managed/detached/removed) 중 어느 것인가?
- Flush가 언제 일어나는가 — 트랜잭션 커밋 직전인가, JPQL 직전인가, 명시적 호출인가, 아니면 영영 안 일어나는가?
이 두 질문이 SQL이 의외의 시점에 나가는 모든 경우를 설명합니다.
참고자료
- Hibernate ORM 6.6 User Guide — Persistence Context, FlushMode, Dirty Checking 정의의 1차 자료.
- Jakarta Persistence 3.2 Specification — 엔티티 상태 4종과 EntityManager API 계약의 명세.
- Jakarta Persistence 3.2 EntityManager Javadoc —
persist/merge/find/getReference메서드 시그니처와 의미론. - Hibernate FlushMode Javadoc — AUTO/COMMIT/ALWAYS/MANUAL 네 모드의 정확한 정의.
- Hibernate ActionQueue Javadoc — Flush 시점에 실행되는 Action 종류와 순서.
- Hibernate ORM source: ActionQueue.java — 구현 코드.
- Spring Data JPA Reference — Persisting Entities —
JpaRepository#save의 isNew 판정 규칙. - Spring Data JPA SimpleJpaRepository source —
save구현. - Vlad Mihalcea — How do JPA and Hibernate define the AUTO flush mode — JPQL은 보고 native SQL은 못 보는 AUTO 모드의 비대칭 설명.
- Vlad Mihalcea — Hibernate flush operations order — ActionQueue 실행 순서가 만드는 함정.

