이전에 1차 캐시(Persistence Context)를 정리하면서 flush가 자동으로 일어난다는 사실은 짚었지만, "정확히 언제 flush가 일어나는가"는 따로 다루지 않았다. 실무에서 헷갈렸던 케이스가 두 개 있었다.
save() 직후 같은 트랜잭션 안에서 JPQL로 조회했더니 INSERT된 행이 보였다 → 어디서 INSERT가 나갔지?FlushModeType.COMMIT을 깔아 두니까 JPQL 결과에 직전 변경이 빠져 있었다.둘 다 같은 원인 — flush 시점 — 이라는 걸 알고 나서야 정리됐다.
이 글에서 다룰 것:
flush가 트리거되는 시점 (스펙 기준)FlushModeType.AUTO vs COMMIT의 차이flush는 "Persistence Context 안의 변경분을 DB로 동기화하는 행위"고, commit과는 별개다.
Jakarta Persistence Specification §3.2에 따르면 flush는 변경된 영속 엔티티의 상태를 데이터베이스에 반영하는 행위로 정의된다. 트랜잭션을 끝내는 것이 아니다. commit이 flush를 포함할 수는 있지만, 그 반대는 성립하지 않는다.
왜 flush 시점이 따로 정의돼 있나? ORM은 setter 호출마다 UPDATE를 날리지 않고 변경을 모았다가 한꺼번에 보내려고 한다. 그러나 모아둔 변경이 DB 쿼리 결과에 반영돼야 하는 순간이 있는데, 그 시점에는 자동으로 flush가 나가야 일관성이 유지된다.
스펙과 Hibernate 공식 문서에 따르면 flush는 보통 다음 세 지점에서 일어난다고 알려져 있다.
| 지점 | 발생 조건 |
|---|---|
| 트랜잭션 commit 직전 | 항상 |
em.flush() 명시 호출 | 항상 |
| JPQL/Criteria 쿼리 실행 직전 | FlushModeType.AUTO(기본값)일 때 |
세 번째가 함정이다. 같은 트랜잭션 안에서 member.setName("foo") 직후에 select m from Member m where m.name = 'foo' JPQL을 던지면, JPQL을 실행하기 직전에 UPDATE가 자동으로 나간다. 단순 em.find() 같은 PK 조회는 1차 캐시에서 해결되므로 자동 flush를 트리거하지 않는 것으로 알려져 있다.
JPA 스펙은 두 값만 정의한다.
AUTO — 쿼리 정합성 보장 (JPQL 전 자동 flush)
COMMIT — commit 시점에만 flush
COMMIT을 쓰면 JPQL 결과가 stale일 수 있다. 그래서 기본값이 AUTO다. Hibernate는 추가로 ALWAYS, MANUAL 같은 확장 모드를 갖는다고 알려져 있는데, 표준 스펙에는 없다.
flush가 호출되면 Hibernate는 PC 안의 managed 엔티티들을 순회하며 다음을 수행한다.
[1] 엔티티 현재 필드 vs 저장된 snapshot 비교
[2] 다른 게 있으면 UPDATE 쿼리 생성
[3] 새로 persist된 엔티티는 INSERT 쿼리 생성
[4] remove된 엔티티는 DELETE 쿼리 생성
[5] ActionQueue를 정해진 순서대로 DB로 전송
(INSERT → UPDATE → DELETE 순으로 알려져 있음)
[6] 1차 캐시는 그대로 유지, snapshot만 새 값으로 갱신
여기서 flush가 commit이 아니라는 점이 다시 중요해진다. flush 후에도 트랜잭션은 살아 있고, 이어지는 작업에서 또 변경이 일어나면 다음 flush 또는 commit 때 다시 동기화된다.
em.flush()를 호출해도 1차 캐시는 비워지지 않는다. 캐시 비우기는 em.clear()다.save() 직후 ID가 즉시 채워지는 경우가 있다. IDENTITY 전략은 PK 확보를 위해 즉시 INSERT가 나간다고 알려져 있다. SEQUENCE/TABLE 전략은 flush 시점까지 지연된다.@Transactional(readOnly = true) 안에서는 dirty checking 비용을 아끼려고 FlushMode를 MANUAL로 바꾸는 동작이 있다고 알려져 있다 (Spring/Hibernate 통합 기준).flush 타이밍을 코드로 따라가 본다.
@Transactional
public void rename(Long id) {
// [1] SELECT 1번 - PC에 진입, snapshot 저장
Member m = em.find(Member.class, id);
// [2] setter - UPDATE는 아직 안 나감
m.setName("changed");
// [3] JPQL 실행 직전 → 자동 flush 발생
// UPDATE member SET name='changed' ...
// SELECT m FROM Member m WHERE m.name='changed'
List<Member> hits = em.createQuery(
"select m from Member m where m.name = 'changed'",
Member.class
).getResultList();
// [4] 메서드 종료 → commit
// 이미 flush됐으므로 추가 SQL 없음 (이후 변경이 더 없다면)
}
만약 FlushModeType.COMMIT으로 바꾸면 [3]에서 자동 flush가 일어나지 않아 hits가 비어 있을 수 있다. JPQL은 DB 상태를 조회하는데, DB에는 아직 변경이 반영되지 않았기 때문이다.
flush = "PC 변경분의 DB 반영"이고, commit과는 독립적이다. AUTO 모드는 JPQL 직전에 자동으로 끼워 넣어서 쿼리 정합성을 맞춰 준다.
성능 측면에서 보면 flush는 모아 보내기 최적화의 핵심이고, dirty checking은 그 모아 보내기를 가능하게 하는 메커니즘이다. 둘은 한 쌍으로 이해해야 헷갈리지 않았다.
다음에 더 파고들 만한 주제:
OptimisticLockException이 flush 시점에 발생하는 이유 (version 비교 시점)readOnly 트랜잭션과 FlushMode 처리