조회한 엔티티의 setter만 호출하고 트랜잭션을 끝내면 DB가 바뀐다. save()나 update()를 부르지 않았는데도 그렇다. 이 자동 UPDATE를 만드는 동작이 Dirty Checking, 즉 변경 감지다. Hibernate가 어떤 필드가 바뀌었는지를 무엇과 비교해 알아내는지가 동작의 핵심이다.
스냅샷
비교의 기준은 스냅샷이다. 엔티티가 영속성 컨텍스트에 처음 등록되는 순간 Hibernate는 모든 필드 값을 복사해 별도로 보관한다. 이게 DB와 일치하는 최초 상태다. 내부적으로는 엔티티 식별자를 키로 하고, 각 필드 값을 순서대로 담은 Object 배열을 값으로 갖는다.
스냅샷은 두 시점에 만들어진다. find()나 JPQL로 DB에서 엔티티를 조회할 때, 그리고 persist()로 새 엔티티를 영속화할 때다. merge()는 조금 다르다. 준영속 엔티티의 값을 영속 엔티티에 복사한 뒤 그 영속 엔티티에 대한 스냅샷을 만들고, 그 영속 엔티티를 반환한다. 원본 준영속 엔티티는 그대로 준영속 상태로 남는다.
flush가 호출되면 Hibernate는 영속성 컨텍스트의 모든 엔티티를 돌면서 현재 값과 스냅샷을 필드 단위로 비교한다. 이때 Java의 equals()가 아니라 Hibernate 내부의 타입별 비교 로직을 쓴다. 기본 타입은 ==로, 객체 타입은 null 체크 후 equals()로 비교한다. 차이가 있으면 그 엔티티에 dirty 플래그가 서고, UPDATE가 쓰기 지연 SQL 저장소에 등록된다.
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
User user = em.find(User.class, 1L); // 스냅샷 보관: {id=1, name="홍길동", email="[email protected]"}
user.setName("김철수"); // 메모리만 변경, 아직 DB 반영 안 됨
em.getTransaction().commit(); // commit이 flush 유발 → 스냅샷과 비교 → UPDATE
commit 직전 flush가 돌면서 현재 값 {name="김철수"}와 스냅샷 {name="홍길동"}을 비교하고, name이 바뀐 것을 확인해 UPDATE를 만든다. 개발자가 작성한 SQL은 없다.
flush의 역할
flush는 영속성 컨텍스트를 비우는 작업이 아니다. Dirty Checking으로 변경 엔티티를 찾고, 쓰기 지연 저장소에 모인 INSERT/UPDATE/DELETE를 DB로 전송하는 두 가지를 한다. 엔티티는 그대로 영속성 컨텍스트에 남고, 실제 커밋은 트랜잭션이 끝날 때 일어난다.
flush가 자동으로 도는 시점은 트랜잭션 커밋 직전, JPQL/Criteria 쿼리 실행 직전, em.flush() 직접 호출 세 가지다. 이 중 쿼리 실행 직전 flush는 변경 사항을 DB에 먼저 반영해야 방금 바꾼 값이 쿼리 결과에 보이기 때문에 필요하다.
User user = em.find(User.class, 1L);
user.setName("변경됨");
// 이 JPQL 실행 직전 자동 flush → 위 변경이 DB에 먼저 반영됨
List<User> users = em.createQuery("SELECT u FROM User u", User.class).getResultList();
// 그래서 결과의 user에 "변경됨"이 반영되어 있음
기본 FlushModeType은 AUTO다. COMMIT으로 바꾸면 커밋 시점에만 flush가 돌고, JPQL 실행 전 자동 flush는 생략된다.
한 필드만 바꿔도 전체 컬럼이 UPDATE된다
10개 컬럼짜리 엔티티에서 name 하나만 바꿔도 Hibernate가 만드는 SQL은 다음과 같다.
update users set
name=?, email=?, status=?, age=?, created_at=?,
updated_at=?, address=?, phone=?, bio=?, role=?
where id=?
바뀌지 않은 email, status까지 전부 SET에 들어간다. 의도된 설계다. UPDATE 문 형태가 항상 같으므로 애플리케이션은 PreparedStatement를 한 번 만들어 캐싱하고, DB도 같은 쿼리의 실행 계획을 재사용한다. 매번 파싱할 필요가 없다.
@DynamicUpdate
전체 컬럼 UPDATE가 부담스러우면 Hibernate 전용 @DynamicUpdate를 붙인다. 매 flush마다 스냅샷과 비교해 바뀐 컬럼만 담은 UPDATE를 동적으로 만든다.
@Entity
@DynamicUpdate
public class Article {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@Lob
private String content; // 대용량 컬럼
private LocalDateTime updatedAt;
}
title만 바꾸면 SQL은 다음으로 좁아진다.
update article set title=?, updated_at=? where id=?
대용량인 content가 SET에서 빠진다. 대가도 있다. 쿼리 문자열이 매번 달라져 PreparedStatement 캐싱 이점을 잃고, 변경 컬럼 탐지와 동적 SQL 생성에 런타임 비용이 든다. @DynamicUpdate는 컬럼이 수십 개인데 일부만 자주 바뀌거나, TEXT/BLOB 같은 대용량 컬럼의 불필요한 전송을 피해야 하거나, DB의 컬럼 수준 잠금 경합을 줄여야 할 때 쓴다. 모든 엔티티에 기본으로 붙일 것은 아니다.
영속 상태에서만 작동한다
Dirty Checking은 영속성 컨텍스트가 관리하는 영속(managed) 상태 엔티티에만 작동한다. 이를 놓치면 setter를 호출했는데 DB가 바뀌지 않는 상황이 생긴다.
User user = em.find(User.class, 1L); // 영속 상태
em.detach(user); // 준영속으로 전환
user.setName("변경"); // Dirty Checking 작동 안 함 → DB 반영 안 됨
User merged = em.merge(user); // 새 영속 엔티티를 반환
merged.setName("또 변경"); // 이제부터 변경 감지 작동
detach()로 분리됐거나, clear()로 컨텍스트가 초기화됐거나, close()로 EntityManager가 닫힌 엔티티는 준영속(detached) 상태가 되어 감지 대상에서 빠진다. new로 만들기만 하고 persist()하지 않은 비영속(transient) 엔티티도 컨텍스트와 무관하다. 다시 관리받게 하려면 merge()를 쓰고, 반환된 객체가 영속 엔티티라는 점에 주의한다.
대량 수정
만 건을 수정할 때 Dirty Checking은 적합하지 않다. 엔티티마다 개별 UPDATE를 만들어 만 개의 UPDATE가 나간다. 영속성 컨텍스트에 엔티티가 쌓일수록 flush 시점의 스냅샷 비교 비용과 메모리도 늘어난다.
이때는 JPQL 벌크 연산을 쓴다. 단일 UPDATE 문으로 조건에 맞는 레코드를 한 번에 수정한다.
@Modifying
@Query("UPDATE User u SET u.status = :status WHERE u.lastLoginAt < :date")
int bulkUpdateStatus(@Param("status") UserStatus status, @Param("date") LocalDateTime date);
int count = userRepository.bulkUpdateStatus(UserStatus.INACTIVE, LocalDateTime.now().minusYears(1));
em.clear(); // 벌크는 컨텍스트를 우회하므로 초기화 필수
벌크 연산은 영속성 컨텍스트를 거치지 않고 DB를 직접 친다. 실행 후 컨텍스트 안의 엔티티 상태와 DB가 어긋나므로, 벌크 뒤에는 clear()로 컨텍스트를 비우거나 필요한 엔티티를 다시 조회해야 한다.
엔티티를 개별로 다루되 건수를 줄이려면 JDBC 배치를 쓴다. hibernate.jdbc.batch_size를 설정하고 hibernate.order_updates를 true로 두면 동일한 UPDATE를 모아 한 번의 네트워크 왕복으로 보낸다. @Version 낙관적 잠금을 쓰면 hibernate.jdbc.batch_versioned_data를 true로 해야 배치가 정상 동작한다.