[JPA] save() 없이 UPDATE가 나간 이유 — 더티 체킹(Dirty Checking) 정리

cheetah_hong·2025년 10월 11일

JPA

목록 보기
2/3
post-thumbnail

"왜 update 쿼리가 자동으로 나가지?", "save() 안 불러도 값이 바뀌네" 같은 착오를 겪기 쉽습니다.
이번 글에서는 그 이유인 더티 체킹(Dirty Checking)을 깊게 파고들어, 실무에서 자주 틀리는 사례와 성능/동작 원리를 정리합니다.


착오 예시

save()나 update()를 호출하지 않았는데 UPDATE가 발생.

@Service
@RequiredArgsConstructor
public class MemberService {
    private final MemberRepository memberRepository;
    
    @Transactional
    public void changeName(Long id) {
        Member m = memberRepository.findById(id).orElseThrow();
        m.setName("CHANGED");
        // repository.save(m); <-- 주석으로 인해 호출하지 않음
    }
}

로그:

Hibernate:
    select member0_.id as id1_0_0_, member0_.name as name2_0_0_ from member member0_ where member0_.id=?
Hibernate:
    update member set name=? where id=?

예상과 다르게 update가 자동으로 호출되는데 이유는 이전 글에서 확인했던 영속성 컨텍스트의 더티 체킹 기능 때문입니다.


다른 예시: detached 엔티티 수정 후 반영되지 않음

@Transactional
public void modifyDetached(Long id) {
    Member m = memberRepository.findById(id).orElseThrow();
    em.detach(m); // detach
    m.setName("WONT_SAVE");
}

UPDATE 없음 — detach 된 엔티티는 변경 추적 대상 ❌


더티 체킹이란?🤨

  • 영속성 컨텍스트에 관리되는(Managed) 엔티티의 상태 변화를 감지해 트랜잭션 커밋 또는 flush() 시점에서 변경된 필드만 DB에 반영하는 JPA의 메커니즘입니다.
  • 즉, setter로 값만 바꿔도 JPA가 알아서 UPDATE SQL을 생성해 실행합니다.

동작 흐름(요약):

  1. 엔티티를 조회하면 JPA가 영속성 컨텍스트에 등록하고 관리 상태(Managed)로 둡니다.
  2. 이때 JPA는 엔티티의 초기 상태 스냅샷(snapshot) 을 내부에 보관합니다.
  3. 트랜잭션 커밋 또는 flush() 시점에 현재 상태와 스냅샷을 비교하여 달라진 필드만 UPDATE SQL로 생성합니다.

더티 체킹은 단순히 필드 변경을 감지하는 것이 아니라, 엔티티의 스냅샷(초기 상태) 과 현재 상태를 비교하는 과정입니다.


flush / clear 와 더티 체킹의 상호작용

@Transactional
public void testFlushClear(Long id) {
    Member m = memberRepository.findById(id).orElseThrow();
    m.setName("A");
    em.flush(); // 변경을 DB에 바로  반영

    em.clear(); // 영속성 컨텍스트 초기화 -> m은 detached
    m.setName("B"); // 트랜잭션 커밋 시점에 'B'는 반영되지 않음
}
  • flush()는 영속성 컨텍스트의 변경 내용을 DB와 동기화합니다.

  • clear()는 영속성 컨텍스트를 비워, 이후 변경은 더 이상 추적되지 않습니다.

  • flush()는 변경 내용을 DB와 동기화하지만 트랜잭션은 여전히 열려 있으며, 커밋이 아님을 주의해야 합니다.


✅ 실전에서 자주 틀리는 포인트

1) detached와 managed를 혼동

  • detached 상태에서 set을 해도 DB에 반영되지 않습니다. 반영하려면 em.merge() 또는 repository.save()로 병합이 필요합니다.
  • merge()관리 객체(관리되는 인스턴스)를 반환합니다. merge() 호출 후 반환값을 사용해야 안전합니다.
Member detached = ...;
Member merged = em.merge(detached);
merged.setName("OK"); // 변경 추적 대상

⚠️ merge() 후에도 기존 객체(detached)를 수정하면 반영되지 않습니다

2) 양방향 연관관계에서 '주인(owning side)'를 업데이트하지 않음

  • @OneToMany(mappedBy = "parent") 처럼 inverse side만 수정하면 DB 반영 안 됨.
  • 항상 연관관계의 주인 쪽을 변경하거나 메서드를 통해 양쪽을 일관되게 유지.
public void addChild(Child c) {
    children.add(c);
    c.setParent(this); // owning side 변경
}

3) 컬렉션 변경은 특별하게 추적

  • Hibernate는 컬렉션을 래핑하여 변경을 추적하지만, 컬렉션 전체를 new ArrayList()로 교체하면(=참조 교체) 기존 추적이 끊길 수 있음.
  • 가능한 add()/remove()로 수정.

4) bulk update와 동기화 문제

  • JPQL UPDATE(bulk)는 영속성 컨텍스트를 거치지 않고 DB에 바로 반영.
  • 따라서 영속성 컨텍스트에는 기존(구) 상태가 남아 있어 불일치 발생.
  • 해결: bulk 수행 후 em.clear() 또는 영속성 컨텍스트를 수동으로 동기화.
em.createQuery("update Member m set m.age = m.age + 1").executeUpdate();
em.clear();

5) 성능 고려: 많은 엔티티가 관리될 때

  • 영속성 컨텍스트에 많은 엔티티가 쌓이면 더티 체크 비용 증가.
  • 해결책: 배치 처리 시 flush()/clear() 주기적 호출, 혹은 DTO 조회나 bulk query 사용.

안전하게 쓰는 체크리스트 (실무 팁)

  1. 읽기 전용 트랜잭션에는 @Transactional(readOnly = true) 사용 (읽기 최적화). ✅ 동작 세부는 구현체에 따라 다를 수 있음
  2. 대량 수정은 JPQL bulk로 처리하고, 그 즉시 em.clear()로 영속성 컨텍스트 정리.
  3. 배치 처리 시 일정 주기(batchSize)로 flush() + clear() 하여 메모리와 더티체킹 비용 관리.
  4. 양방향 관계는 편의 메서드로 항상 양쪽을 맞춰서 관리.
  5. Detached 상태에서 변경하려면 merge()를 사용하고, 반환값을 사용해 추가 작업.

코드 예제 모음(요약)

변경만 하면 자동 UPDATE

@Transactional
public void rename(Long id) {
    Member m = em.find(Member.class, id);
    m.setName("NEW");
}
// commit 시 UPDATE 발생

detach 하면 추적 종료

@Transactional
public void detachExample(Long id) {
    Member m = em.find(Member.class, id);
    em.detach(m);
    m.setName("NOPE"); // 반영 안 됨
}

merge 사용법

@Transactional
public void mergeExample(Member detached) {
    Member managed = em.merge(detached);
    managed.setEmail("a@b.com"); // 변경 추적
}

bulk 업데이트 후 주의

@Transactional
public void incrAgeForAll() {
    em.createQuery("update Member m set m.age = m.age + 1").executeUpdate();
    em.clear(); //동기화
}

✅ 포인트 정리

  • 더티 체킹은 관리 상태(Managed) 엔티티의 변경을 자동으로 DB로 동기화하는 메커니즘.
  • flush()commit 시점에 변경 감지 후 SQL 생성.
  • detach()/clear()하면 변경 추적이 멈추고, merge()는 병합 후 반환값을 사용해야 안전.
  • 양방향 연관관계는 항상 주인(owning side) 를 변경.
  • 대량 처리나 성능 민감 구간에서는 bulk update, flush/clear 전략, 혹은 DTO 조회를 고려.

🤔 이를 통해 답할 수 있는 질문

  • save()를 호출하지 않았는데도 UPDATE가 나가는 이유는?
  • merge()save()의 차이는 무엇인가?
  • JPQL bulk + 영속성 컨텍스트 동기화 문제는 어떻게 해결하나?

profile
치타는 지금 웃고있다 🐆

0개의 댓글