JPA 더티 체킹 — save() 안 해도 되는데 왜? 그리고 언제 안 될까?
JPA를 처음 쓸 때 가장 놀라는 순간이 있다.
"어? save() 안 했는데 UPDATE가 나갔어?"
이게 더티 체킹(Dirty Checking)이다.
편하지만, 원리를 모르면 왜 되는지도 왜 안 되는지도 모른다.
1. 메모장으로 이해하기
JPA의 영속성 컨텍스트 = 메모장
1. DB에서 회원을 꺼내옴
→ 메모장에 "이름: 홍길동" 이라고 적어둠 (스냅샷)
2. 코드에서 이름을 "김철수"로 바꿈
→ 메모장에 적힌 건 여전히 "홍길동"
3. 트랜잭션 끝날 때
→ 메모장 확인: "홍길동" ≠ "김철수" → 달라졌네!
→ UPDATE 쿼리 자동 실행
이게 더티 체킹이다.
"메모장(스냅샷)이랑 현재 상태를 비교해서, 다르면 자동 업데이트."
2. 코드로 보기
save() 없이 UPDATE가 되는 경우
@Service
public class MemberService {
@Transactional
public void updateName(Long id, String newName) {
Member member = memberRepository.findById(id).orElseThrow();
// member는 영속 상태 (영속성 컨텍스트가 관리 중)
member.setName(newName);
// save() 안 함!
// 그런데 UPDATE 나감!
}
}
-- 트랜잭션 커밋 시점에 자동 실행됨
UPDATE member SET name = '김철수' WHERE id = 1
// save()를 호출해도 결과는 같다
member.setName(newName);
memberRepository.save(member); // 이미 영속 상태면 save()는 의미 없음
// JPA가 어차피 더티 체킹으로 UPDATE 날림
영속 상태 엔티티는 set만 해도 UPDATE가 나간다. save()는 불필요하다.
3. 동작 원리 — 영속성 컨텍스트와 스냅샷
[영속성 컨텍스트 (1차 캐시)]
┌─────────────────────────────────────────────────┐
│ ID │ Entity (현재 상태) │ Snapshot (최초 상태) │
├───────┼────────────────────┼─────────────────────┤
│ 1 │ Member(김철수) │ Member(홍길동) │ ← 다르다!
│ 2 │ Member(이영희) │ Member(이영희) │ ← 같다
└───────┴────────────────────┴─────────────────────┘
트랜잭션 커밋 시점:
ID=1: 현재 ≠ 스냅샷 → UPDATE 실행
ID=2: 현재 = 스냅샷 → UPDATE 안 함
상세 흐름
@Transactional
public void updateName(Long id, String newName) {
// 1. DB에서 조회 → 영속성 컨텍스트에 저장 + 스냅샷 생성
Member member = memberRepository.findById(id).orElseThrow();
// 1차 캐시: { entity: Member(홍길동), snapshot: Member(홍길동) }
// 2. 엔티티 값 변경
member.setName(newName);
// 1차 캐시: { entity: Member(김철수), snapshot: Member(홍길동) }
// 3. 트랜잭션 커밋 시점 (메서드 종료)
// → flush() 호출
// → 1차 캐시의 entity와 snapshot을 필드 단위로 비교
// → name이 다르다 → UPDATE 쿼리 생성 & 실행
}
flush() 타이밍:
1. 트랜잭션 커밋 직전 (자동)
2. JPQL 쿼리 실행 직전 (자동)
3. em.flush() 직접 호출 (수동)
4. 더티 체킹이 안 되는 경우
경우 1 — @Transactional이 없을 때
@Service
public class MemberService {
// @Transactional 없음!
public void updateName(Long id, String newName) {
Member member = memberRepository.findById(id).orElseThrow();
member.setName(newName);
// UPDATE 안 나감!
}
}
왜? @Transactional이 없으면 영속성 컨텍스트의 생명주기가 findById() 호출 때 시작되고 즉시 끝난다. 반환된 member는 준영속(detached) 상태다.
영속(managed): 영속성 컨텍스트가 관리 중 → 더티 체킹 O
준영속(detached): 영속성 컨텍스트에서 분리됨 → 더티 체킹 X
비영속(transient): 아직 저장 안 됨 → 더티 체킹 X
경우 2 — detach로 분리했을 때
@Transactional
public void updateName(Long id, String newName) {
Member member = memberRepository.findById(id).orElseThrow();
entityManager.detach(member); // 영속성 컨텍스트에서 분리!
member.setName(newName);
// UPDATE 안 나감! detach 됐으니까.
}
// clear()도 마찬가지
entityManager.clear(); // 영속성 컨텍스트 전체 초기화
member.setName(newName);
// UPDATE 안 나감!
경우 3 — 벌크 연산 후
이게 실무에서 가장 위험한 케이스다.
@Transactional
public void bulkUpdate() {
// 1. 회원 조회 → 영속성 컨텍스트에 저장
Member member = memberRepository.findById(1L).orElseThrow();
System.out.println(member.getAge()); // 20
// 2. 벌크 연산: DB에서 직접 UPDATE
entityManager.createQuery(
"UPDATE Member m SET m.age = m.age + 1"
).executeUpdate();
// DB에는 age = 21
// 영속성 컨텍스트에는 age = 20 (반영 안 됨!)
// 3. 다시 조회해도 1차 캐시에서 가져옴
Member member2 = memberRepository.findById(1L).orElseThrow();
System.out.println(member2.getAge()); // 20 ← DB는 21인데!
// DB와 영속성 컨텍스트 불일치!
}
벌크 연산의 문제:
DB에 직접 쿼리를 날림 → 영속성 컨텍스트를 건너뜀
→ DB와 1차 캐시가 다른 상태
→ 이후 조회가 틀린 값을 반환
해결: 벌크 연산 후 em.clear()
@Transactional
public void bulkUpdate() {
Member member = memberRepository.findById(1L).orElseThrow();
System.out.println(member.getAge()); // 20
entityManager.createQuery(
"UPDATE Member m SET m.age = m.age + 1"
).executeUpdate();
entityManager.flush(); // 아직 안 보낸 변경사항 보내기
entityManager.clear(); // 1차 캐시 초기화!
// 이제 DB에서 새로 가져옴
Member member2 = memberRepository.findById(1L).orElseThrow();
System.out.println(member2.getAge()); // 21 ← 정확!
}
Spring Data JPA에서는 더 간단하게 할 수 있다.
public interface MemberRepository extends JpaRepository<Member, Long> {
@Modifying(clearAutomatically = true) // 자동 clear
@Query("UPDATE Member m SET m.age = m.age + 1")
int bulkAgePlusOne();
}
5. merge() vs 더티 체킹
초보가 자주 헷갈리는 부분이다.
// 더티 체킹: 영속 상태에서 set → 자동 UPDATE
@Transactional
public void updateByDirtyChecking(Long id, String name) {
Member member = memberRepository.findById(id).orElseThrow(); // 영속
member.setName(name); // 변경 감지 → UPDATE
}
// merge(): 준영속 객체를 다시 영속화
@Transactional
public void updateByMerge(MemberDto dto) {
Member detachedMember = new Member();
detachedMember.setId(dto.getId());
detachedMember.setName(dto.getName());
// detachedMember는 비영속/준영속 상태
Member mergedMember = entityManager.merge(detachedMember);
// mergedMember = 영속 상태의 새 객체
// detachedMember는 여전히 준영속!
}
merge()의 함정
@Transactional
public void mergeTrap() {
Member detached = new Member();
detached.setId(1L);
detached.setName("새이름");
Member merged = entityManager.merge(detached);
detached.setName("또다른이름");
// ← 이건 반영 안 됨! detached는 여전히 준영속!
merged.setName("또다른이름");
// ← 이건 반영됨! merged가 영속 상태!
}
merge()의 동작:
1. 1차 캐시에서 id로 엔티티 찾기
2. 없으면 DB에서 조회
3. 찾은 엔티티에 전달받은 값을 복사
4. 찾은 엔티티(영속)를 반환
주의: 원본 객체가 영속이 되는 게 아니라, 새 객체가 반환됨!
실무에서는 merge()보다 더티 체킹이 안전하다. 가능하면 merge() 대신 "조회 → 값 변경" 패턴을 쓰자.
6. save()가 필요한 경우 vs 불필요한 경우
// save() 불필요: 이미 영속 상태
@Transactional
public void update(Long id, String name) {
Member member = memberRepository.findById(id).orElseThrow(); // 영속
member.setName(name);
// memberRepository.save(member); ← 불필요! 더티 체킹이 해줌
}
// save() 필요: 새 엔티티 저장
@Transactional
public void create(String name) {
Member member = new Member(); // 비영속
member.setName(name);
memberRepository.save(member); // ← 필요! persist() 호출
}
// save()가 헷갈리는 경우: id가 있는 새 객체
@Transactional
public void upsert(MemberDto dto) {
Member member = new Member();
member.setId(dto.getId()); // id 세팅
member.setName(dto.getName());
memberRepository.save(member);
// id가 있으면 → merge() 호출 (기존 데이터 덮어쓰기)
// id가 없으면 → persist() 호출 (새로 저장)
}
Spring Data JPA의 save() 내부:
if (entity.isNew()) {
em.persist(entity); // INSERT
} else {
em.merge(entity); // SELECT + UPDATE
}
isNew() 판단: @Id 값이 null이면 new, 아니면 기존
7. 성능 관점
더티 체킹은 편하지만, 모든 상황에서 최선은 아니다.
// 더티 체킹: 한 건씩 UPDATE
@Transactional
public void updateAllAges() {
List<Member> members = memberRepository.findAll(); // 1000건
for (Member member : members) {
member.setAge(member.getAge() + 1);
}
// 커밋 시 UPDATE 1000번 실행!
}
// 벌크 연산: 한 번에 UPDATE
@Transactional
public void bulkUpdateAllAges() {
memberRepository.bulkAgePlusOne(); // UPDATE 1번!
entityManager.clear();
}
더티 체킹:
✅ 코드 간결, 엔티티 단위 로직 처리
❌ 대량 데이터 시 느림 (건건이 UPDATE)
벌크 연산:
✅ 대량 데이터 빠름 (한 번에 UPDATE)
❌ 영속성 컨텍스트 불일치 주의 (clear 필수)
1~10건: 더티 체킹, 100건 이상: 벌크 연산을 고려하자.
8. 정리
더티 체킹이란:
→ @Transactional 안에서 영속 엔티티의 필드를 바꾸면
→ 트랜잭션 커밋 시 스냅샷과 비교
→ 다르면 UPDATE 자동 실행
→ save() 불필요
더티 체킹이 안 되는 경우:
1. @Transactional 없음 → 준영속 상태
2. detach/clear로 분리 → 관리 안 됨
3. 벌크 연산 후 → 영속성 컨텍스트 불일치
실무 규칙:
1. 수정은 "조회 → set" 패턴 (merge 지양)
2. 벌크 연산 후 em.clear() 또는 @Modifying(clearAutomatically = true)
3. 대량 데이터는 벌크 연산 사용
4. save()는 새 엔티티에만 (기존 엔티티는 더티 체킹)
다음 편 예고: Entity를 API 응답으로 직접 반환하면 생기는 일
"귀찮으니까 Entity 그대로 반환하면 안 돼?" 안 된다. 무한 재귀, 비밀번호 노출, LazyInitializationException... Entity를 절대 API 응답으로 쓰면 안 되는 5가지 이유와, 올바른 DTO 패턴을 정리한다.
JPA, 더티체킹, Dirty Checking, 영속성 컨텍스트, 스냅샷, flush, merge, detach, 벌크연산, Spring Data JPA, Hibernate, save, persist, 변경감지
'Java' 카테고리의 다른 글
| 실무에서 자주 터지는 Spring 버그 모음 — 면접에도 나오는 것들 (0) | 2026.04.20 |
|---|---|
| Entity를 API 응답으로 직접 반환하면 생기는 일 (0) | 2026.04.20 |
| @Transactional의 함정 — 붙였는데 왜 안 돼? (1) | 2026.04.20 |
| N+1 문제 총정리 — EAGER만 문제가 아니다 (1) | 2026.04.20 |
| Java NIO의 고급 사용법을 비동기 처리, 파일 처리, 서버 구현 (2) | 2025.07.17 |