변경 감지(Dirty Checking) 동작 원리

한 줄 정의

Dirty Checking은 영속성 컨텍스트가 Entity의 스냅샷과 현재 상태를 비교해서, 변경된 필드를 자동으로 UPDATE하는 메커니즘이다.

실무에서 왜 중요한가

변경 감지를 모르면 다음 상황에서 혼란이 생긴다.

  • save()를 호출하지 않았는데 UPDATE가 실행된다.
  • Entity를 수정했는데 DB에 반영되지 않는다 (트랜잭션 밖).
  • 의도하지 않은 필드까지 UPDATE에 포함된다.
  • 대량 데이터를 수정할 때 한 건씩 UPDATE가 나가서 성능이 나빠진다.

동작 원리

1. Entity 조회 → 영속성 컨텍스트에 스냅샷 저장
2. Entity 필드 변경
3. 트랜잭션 커밋 → flush 발생
4. 스냅샷과 현재 상태 비교
5. 변경된 Entity에 대해 UPDATE SQL 생성 및 실행
@Transactional
public void updateUserName(Long id, String newName) {
    User user = userRepository.findById(id).orElseThrow();
    user.setName(newName);
    // flush 시점에 스냅샷과 비교 → name이 변경됨 → UPDATE 실행
}

userRepository.save(user)를 호출하지 않아도 트랜잭션 커밋 시 자동으로 UPDATE가 실행된다.

변경 감지가 동작하는 조건

조건필요 여부
Entity가 영속 상태(managed)필수
@Transactional 안에서 수정필수
save() 호출불필요
setter 호출필드 값이 바뀌면 됨 (setter가 아니어도 가능)

변경 감지가 동작하지 않는 경우

// 트랜잭션 없음 → 준영속 상태 → 변경 감지 X
public void updateWithoutTransaction(Long id, String newName) {
    User user = userRepository.findById(id).orElseThrow();
    user.setName(newName);
    // UPDATE 실행되지 않음
}
// 새로 생성한 객체 → 비영속 상태 → 변경 감지 X
@Transactional
public void createAndModify() {
    User user = new User("kim");
    user.setName("lee");
    // persist하지 않으면 아무 일도 안 일어남
}

UPDATE 범위

Hibernate의 기본 동작은 모든 컬럼을 UPDATE한다.

-- name만 바꿨지만 전체 컬럼이 UPDATE에 포함
UPDATE users SET name=?, email=?, status=?, updated_at=? WHERE id=?

변경된 컬럼만 UPDATE하려면 @DynamicUpdate를 사용한다.

@Entity
@DynamicUpdate
public class User {
    // 변경된 필드만 UPDATE SQL에 포함
}

단, @DynamicUpdate는 매번 SQL을 새로 생성하므로 PreparedStatement 캐시 효과가 줄어든다. 컬럼 수가 많고 일부만 자주 변경되는 경우에 고려한다.

대량 수정 시 주의점

변경 감지는 한 건씩 UPDATE를 실행한다. 대량 수정에는 벌크 연산이 효율적이다.

// 변경 감지: 1000건이면 UPDATE 1000번
@Transactional
public void deactivateAll(List<Long> userIds) {
    for (Long id : userIds) {
        User user = userRepository.findById(id).orElseThrow();
        user.setStatus(UserStatus.INACTIVE);
    }
}
 
// 벌크 연산: UPDATE 1번
@Modifying(clearAutomatically = true)
@Query("UPDATE User u SET u.status = :status WHERE u.id IN :ids")
int bulkUpdateStatus(@Param("ids") List<Long> ids, @Param("status") UserStatus status);

벌크 연산은 영속성 컨텍스트를 거치지 않으므로 clearAutomatically = true로 캐시를 비워야 한다.

자주 나는 실수

  • 트랜잭션 밖에서 Entity를 수정하고 반영될 것으로 기대한다.
  • save()를 호출하지 않아서 반영이 안 된다고 생각하고, 불필요하게 save()를 호출한다.
  • 대량 데이터를 변경 감지로 수정해서 UPDATE 쿼리가 수천 번 나간다.
  • 벌크 연산 후 영속성 컨텍스트를 비우지 않아서 캐시된 이전 데이터를 읽는다.
  • @DynamicUpdate를 무조건 좋은 것으로 생각하고 모든 Entity에 적용한다.

핵심 요약

Dirty Checking은 영속 상태 Entity의 스냅샷과 현재 상태를 비교해서 변경된 필드를 자동으로 UPDATE하는 메커니즘입니다. @Transactional 안에서 Entity 필드를 수정하면 save() 호출 없이도 커밋 시 UPDATE가 실행됩니다.

변경 감지가 동작하려면 Entity가 영속 상태여야 하고 트랜잭션 안에서 수정해야 합니다.

대량 수정에는 변경 감지 대신 벌크 연산(@Modifying + JPQL)을 사용하고, 벌크 연산 후에는 영속성 컨텍스트를 반드시 비워야 합니다.

꼬리 질문

점검 퀴즈

아래 문항은 개념을 실제로 설명할 수 있는지 점검하기 위한 것이다. 선택지를 누르면 정답 여부와 이유가 표시된다.

객관식변경 감지가 동작하는 핵심 조건은?

OX벌크 update/delete 실행 후에는 영속성 컨텍스트의 Entity 상태와 DB 상태가 달라질 수 있다.

객관식@DynamicUpdate를 항상 쓰지 않는 이유로 적절한 것은?

관련 문서