JPA 변경 감지의 흐름과 안전한 업데이트 범위
JPA를 처음 사용할 때 가장 편리하게 느껴지는 부분은 save()를 호출하지 않아도 수정된 값이 데이터베이스에 반영된다는 점이다. 반대로 이 동작을 정확히 이해하지 못하면 언제 SQL이 나가는지, 왜 의도하지 않은 필드까지 바뀌는지 추적하기 어려워진다. 이 글은 변경 감지(dirty checking)가 실행되는 조건과 운영 코드에서 지켜야 할 경계를 정리한다.
변경 감지는 무엇을 비교하는가
조회 결과가 영속성 컨텍스트에 들어오면 Hibernate는 엔티티의 최초 상태를 스냅샷으로 보관한다. 같은 트랜잭션 안에서 엔티티의 필드를 변경하고 트랜잭션을 커밋하면 다음 흐름이 이어진다.
- 트랜잭션이 시작되고 엔티티가 영속성 컨텍스트에 들어간다.
- Hibernate가 조회 시점의 상태를 스냅샷으로 저장한다.
- 애플리케이션 코드가 영속 엔티티의 필드를 변경한다.
- 커밋 전에 flush가 실행되고 현재 상태와 스냅샷을 비교한다.
- 변경된 엔티티에 대한 UPDATE SQL이 쓰기 지연 저장소에 들어간다.
- 데이터베이스 트랜잭션이 커밋되면서 변경 내용이 확정된다.
@Transactional
public void rename(Long memberId, String name) {
Member member = memberRepository.findById(memberId)
.orElseThrow(MemberNotFoundException::new);
member.changeName(name);
// 별도의 save 호출 없이 트랜잭션 종료 시 UPDATE 실행
}
핵심은 엔티티가 현재 영속 상태라는 점이다. new Member()로 만든 비영속 객체나, 영속성 컨텍스트가 닫힌 뒤의 준영속 객체를 바꾼다고 해서 같은 방식으로 SQL이 만들어지지는 않는다.
모든 필드가 항상 업데이트되는가
기본 Hibernate 설정에서는 변경 감지가 동작할 때 UPDATE 문에 엔티티의 여러 컬럼이 포함될 수 있다. 실제 변경된 컬럼만 갱신하고 싶다면 @DynamicUpdate를 검토할 수 있지만, SQL 생성과 캐시 효율에 영향을 주므로 무조건 켜는 기능은 아니다. 더 중요한 기준은 엔티티에 외부 입력을 그대로 덮어쓰지 않는 것이다.
public void changeName(String name) {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("이름은 비어 있을 수 없습니다.");
}
this.name = name.trim();
}
컨트롤러에서 받은 요청 DTO를 BeanUtils.copyProperties로 엔티티 전체에 복사하면, 화면에 없는 값까지 null로 바뀌는 사고가 생길 수 있다. 수정 가능한 필드를 엔티티의 명시적인 메서드로 제한하면 변경 감지의 편리함과 도메인 규칙을 함께 지킬 수 있다.
flush와 commit은 같은 시점이 아니다
flush는 영속성 컨텍스트의 변경 내용을 데이터베이스에 SQL로 전달하는 작업이고, commit은 데이터베이스 트랜잭션을 확정하는 작업이다. save()가 즉시 디스크에 기록되었다고 생각하면 안 된다. 반대로 flush가 끝났다고 해서 다른 트랜잭션에서 바로 확정된 값을 볼 수 있는 것도 아니다.
조회 직전에 변경 내용을 SQL로 보내야 한다면 entityManager.flush()를 사용할 수 있다. 다만 flush를 자주 호출하면 쓰기 지연의 이점을 잃고 SQL 호출 시점을 코드 곳곳에 흩어 놓게 된다. 기본은 트랜잭션 경계에서 처리하고, 외부 시스템과 상태를 맞춰야 하는 특별한 이유가 있을 때만 명시적으로 호출한다.
변경 감지가 동작하지 않는 대표적인 경우
@Transactional이 적용되지 않아 영속성 컨텍스트가 닫힌 뒤 수정함- 조회 메서드가 DTO나 프로젝션을 반환해 엔티티를 관리하지 않음
- 다른 스레드에서 엔티티를 수정해 트랜잭션의 영속성 컨텍스트와 분리됨
- 트랜잭션을 읽기 전용으로 선언하고 쓰기 작업을 섞음
- 벌크 JPQL이나 네이티브 SQL로 DB를 직접 변경한 뒤 1차 캐시를 그대로 사용함
벌크 쿼리는 영속성 컨텍스트를 거치지 않으므로 실행 후 이미 관리 중인 엔티티가 오래된 값을 가질 수 있다. 벌크 작업 뒤에는 clear()로 1차 캐시를 비우거나, 다음 조회를 새로운 영속성 컨텍스트에서 수행하는 기준을 정해야 한다.
운영 코드에서 확인할 체크리스트
- 수정 로직에 명확한 트랜잭션 경계가 있는가?
- 요청 DTO에서 수정 가능한 필드만 엔티티 메서드로 전달하는가?
- SQL 로그나 테스트로 예상한 UPDATE 범위를 확인했는가?
- 벌크 쿼리 뒤에 1차 캐시를 정리하는가?
- 긴 트랜잭션에서 엔티티를 계속 보관하지 않는가?
변경 감지는 SQL을 줄이는 마법이 아니라, 영속성 컨텍스트 안에서 상태 변화를 추적하는 규칙이다. 조회와 변경을 하나의 짧은 트랜잭션으로 묶고, 엔티티가 허용하는 변경만 노출하면 예측 가능한 업데이트를 만들 수 있다.
함께 읽기:
원문 기록: Spring Day 8: Dirty Checking