orm

save()를 안 했는데 왜 UPDATE가 될까? - 영속성 컨텍스트와 Dirty Checking

영속성 컨텍스트는 Entity 인스턴스를 관리하는 논리적인 공간이다. JPA 명세는 이를 "같은 식별자를 가진 Entity 인스턴스가 하나만 존재하는 Entity 집합"으로 정의한다. JPA로 조회하거나 persist()한 Entity는 여기에 들어가 Managed 상태가 되고, 구현체는 이 Entity들의 상태를 추적하다가 적절한 시점에 DB와 동기화한다.

co2plant
PersistenceContext

들어가며

@Transactional
public void changeName(Long id) {
	Member member = memberRepository.findById(id)
			.orElseThrow();

	member.changeName("Kim");

	// memberRepository.save(member);
}

save()를 호출하지 않았으니 DB에는 아무 변화가 없을 것 같다. 그런데 실제로 실행해 보면 메서드가 끝날 때 UPDATE SQL이 나간다.
트랜잭션 안에서 JPA로 조회한 Entity는 영속성 컨텍스트(Persistence Context)가 관리하고 있고, 관리 중인 Entity의 값이 바뀌면 JPA 구현체가 이를 알아서 DB에 반영하기 때문이다. 이 글에서는 JPA가 변경을 어떻게 알아채는지, 그리고 이 동작이 성립하려면 어떤 조건이 필요한지를 정리한다.
1차 캐시, 쓰기 지연, persist()와 merge()의 차이 같은 영속성 컨텍스트의 다른 기능은 이번 글에서 다루지 않는다.

영속성 컨텍스트와 Dirty Checking

영속성 컨텍스트

Entity 인스턴스를 관리하는 논리적인 공간이다. JPA 명세는 이를 "같은 식별자를 가진 Entity 인스턴스가 하나만 존재하는 Entity 집합"으로 정의한다. JPA로 조회하거나 persist()한 Entity는 여기에 들어가 Managed 상태가 되고, 구현체는 이 Entity들의 상태를 추적하다가 적절한 시점에 DB와 동기화한다.

Managed Entity

현재 영속성 컨텍스트가 관리하고 있는 Entity를 말한다. @Entity가 붙은 클래스의 객체라고 해서 모두 Managed Entity인 것은 아니다.
예를 들어 MyBatis Mapper로 조회한 Member는 JPA Entity와 같은 클래스라도 영속성 컨텍스트와 아무 관계가 없다. 이 객체의 setter를 아무리 호출해도 UPDATE는 나가지 않는다.

Dirty Checking

Managed Entity의 값이 바뀌었는지 확인하는 과정이다.
한 가지 짚고 넘어갈 점은 Dirty Checking이 JPA 명세에 나오는 용어가 아니라는 것이다. 명세는 "Managed Entity의 상태는 flush나 commit 시점에 DB와 동기화된다"는 규칙만 정해두고, 변경을 어떻게 알아낼지는 구현체에 맡긴다. 이 글에서는 가장 많이 쓰이는 구현체인 Hibernate ORM을 기준으로 설명한다.

JPA (Jakarta Persistence) Hibernate ORM
Java 영속성 처리를 위한 표준 명세 JPA 명세를 구현한 Persistence Provider
EntityManager 같은 API와 Entity 생명주기, flush 규칙 정의 영속성 컨텍스트, 상태 추적, Dirty Checking을 실제로 구현
내부 자료구조나 변경 감지 알고리즘은 정하지 않음 SQL 생성과 JDBC 연동, 명세 밖의 고유 기능도 제공

애플리케이션은 EntityManager(Spring Data JPA를 쓰면 Repository 뒤에 숨어 있다)를 통해 영속성 컨텍스트와 대화하고, 그 뒤의 실제 동작은 Hibernate가 담당한다.

동작 과정 따라가기

전체 흐름은 다음과 같다.

flowchart TD
	A["findById()로 조회"]
	B["Managed Entity + 조회 시점 값(loadedState) 보관"]
	C["애플리케이션이 Entity 값 변경"]
	D["트랜잭션 커밋 직전 Flush"]
	E["Dirty Checking<br/> 현재 값과 loadedState 비교"]
	F["UPDATE 실행"]

	A --> B
	B --> C
	C --> D
	D --> E
	E --> F

1. 조회하면 Managed 상태가 된다

Member member = memberRepository.findById(id)
		.orElseThrow();

바깥에 @Transactional이 걸려 있으므로, 이 트랜잭션에 묶인 영속성 컨텍스트가 조회된 Member를 관리하기 시작한다. 이 조건이 왜 중요한지는 뒤에서 다시 본다.

2. Hibernate는 조회 시점의 값을 따로 들고 있다

여기서부터는 명세가 아니라 Hibernate의 구현 이야기다.
Hibernate의 PersistenceContext 소스를 보면 Entity 외에도 컬렉션, 스냅샷, 프록시 등 여러 상태를 함께 관리한다. 그중 Dirty Checking과 직접 관련된 것이 Entity마다 하나씩 붙는 EntityEntry다.

flowchart LR
	M["Member 객체<br>id = 1<br>name = 'Lee'"]
	E["EntityEntry<br>status = MANAGED<br>id = 1<br>loadedState = [1, 'Lee', ...]<br>version, persister ..."]

	E -- "관리 정보" --> M

EntityEntry의 loadedState에는 Entity를 조회했을 때의 필드 값이 배열로 저장된다. 나중에 값이 바뀌었는지 판단할 때 비교 기준이 되는 게 이 값이다.

3. 애플리케이션이 값을 바꾼다

member.changeName("Kim");

이 코드는 그냥 Java 객체의 필드를 바꿀 뿐이다. Hibernate가 이 순간 무언가를 가로채는 것은 아니고(기본 설정 기준), DB에도 아직 아무 일이 일어나지 않는다. 이 시점에 Member의 name은 "Kim", loadedState의 값은 여전히 "Lee"다.

4. Flush 때 비교한다

영속성 컨텍스트의 변경 내용을 DB에 반영하는 과정을 Flush라고 한다. 이 예제에서는 changeName() 메서드가 끝나고 트랜잭션이 커밋되기 직전에 Flush가 일어난다.
Flush가 시작되면 Hibernate는 관리 중인 Entity를 하나씩 돌면서 현재 값과 loadedState를 비교한다. DefaultFlushEntityEventListener의 dirtyCheck()에서 이 비교가 이루어지고, 변경된 필드가 있으면 Update Action을 만든다.

flowchart LR
	A["Member 현재 값<br>name = 'Kim'"]
	B["loadedState<br>name = 'Lee'"]
	C{"값이 다른가?"}
	D["name이 dirty<br>→ UPDATE 필요"]

	A --> C
	B --> C
	C -- "다르다" --> D

기본 설정에서는 이렇게 필드 값을 직접 비교한다. Bytecode Enhancement를 켜면 필드 값이 바뀌는 시점에 변경된 필드를 기록해 두는 방식(Dirty Tracking)으로 바뀌어 전체 비교를 생략할 수 있지만, 이건 별도 설정이 필요한 최적화다.

- JPA가 스냅샷을 비교해서 변경을 감지한다.
+ JPA는 "Managed Entity의 변경은 DB와 동기화된다"는 규칙을 정한다.
+ 변경을 어떻게 감지할지(스냅샷 비교, Dirty Tracking 등)는 Hibernate 같은 구현체가 정한다.

5. UPDATE가 실행된다

변경이 확인되면 Hibernate는 UPDATE SQL을 만들어 실행한다.
여기서 흔히 하는 오해가 하나 있다. name만 바꿨으니 name 컬럼만 UPDATE될 것 같지만, Hibernate는 기본적으로 모든 컬럼을 포함한 UPDATE 문을 사용한다. 미리 만들어 둔 SQL을 재사용하기 위해서다.
바뀐 컬럼만 UPDATE하고 싶다면 Entity에 @DynamicUpdate를 붙이면 된다.

참고) @Transactional을 빼면 어떻게 될까?

처음 예제에서 save()가 없어도 UPDATE가 나가는 진짜 조건은 @Transactional이다. 이걸 빼보면 알 수 있다.

public void changeName(Long id) {
	Member member = memberRepository.findById(id)
			.orElseThrow();

	member.changeName("Kim"); // DB에 반영되지 않는다
}

Spring Data JPA의 findById()는 자체적으로 읽기 전용 트랜잭션을 가지고 있다. 바깥에 트랜잭션이 없으면 findById()가 끝나는 순간 그 트랜잭션도 끝나고, member는 영속성 컨텍스트와 연결이 끊긴 준영속(detached) 상태가 된다. 준영속 Entity는 값을 바꿔도 아무도 추적하지 않는다.
(OSIV가 켜진 웹 요청이라면 영속성 컨텍스트는 요청이 끝날 때까지 열려 있어서 member는 여전히 Managed 상태다. 하지만 커밋할 트랜잭션이 없으니 Flush가 일어나지 않고, 결과적으로 반영되지 않는 건 마찬가지다.)
정리하면 UPDATE가 나가려면 두 가지가 필요하다. Entity가 Managed 상태여야 하고, 그 상태에서 Flush가 일어나야 한다.

주의할 점

SQL이 코드에 드러나지 않는다

member.changeName("Kim")만 보면 메모리의 객체를 바꾼 것처럼 보이지만, Managed Entity라면 이 한 줄이 나중에 UPDATE로 이어진다. 편리한 대신 SQL이 언제 나가는지가 코드에서 보이지 않는다.
JPA와 MyBatis를 함께 쓰는 프로젝트라면 이 차이가 문제가 될 수 있다. JPA로 Entity를 바꿨지만 아직 Flush되기 전에 MyBatis로 같은 Row를 조회하면, MyBatis는 DB를 직접 읽으므로 변경 전 값을 가져온다. Flush 시점은 다음 글에서 자세히 다룬다.

Managed Entity에 save()를 또 부를 필요는 없다

member.changeName("Kim");
memberRepository.save(member); // 없어도 UPDATE는 나간다

같은 트랜잭션에서 조회한 Entity라면 마지막 save()는 UPDATE를 위해 필요한 코드가 아니다. 호출한다고 문제가 생기지는 않지만(기존 Entity라면 내부적으로 merge()를 거쳐 같은 객체가 반환된다), 코드를 읽는 사람에게 "save()를 해야 반영된다"는 잘못된 인상을 줄 수 있다. save()의 신규/기존 Entity 처리 방식은 별도 글에서 다룬다.

의도하지 않은 UPDATE

Dirty Checking은 변경의 의도를 묻지 않는다. 화면에 보여줄 값을 가공하다가 Entity를 직접 건드리면 그대로 DB에 반영된다.

@Transactional
public MemberResponse getMember(Long id) {
	Member member = memberRepository.findById(id).orElseThrow();
	member.maskName(); // 화면용 마스킹이었는데 DB 값까지 바뀐다
	return MemberResponse.from(member);
}

이런 가공은 Entity가 아니라 DTO에서 하는 게 맞다.
여기에 더해 조회 전용 메서드에는 @Transactional(readOnly = true)를 붙여 두면 좋다. Spring과 Hibernate를 함께 쓰면 읽기 전용 트랜잭션에서는 Flush가 자동으로 일어나지 않고 스냅샷도 만들지 않아서, 실수로 값을 바꿔도 UPDATE가 나가지 않는다. 다만 이미 쓰기 트랜잭션이 진행 중인 곳에서 호출되면 바깥 트랜잭션 설정을 따르므로 만능은 아니다.

정리

save()를 호출하지 않았는데 UPDATE가 나간 이유는 다음과 같다.
트랜잭션 안에서 조회한 Member는 영속성 컨텍스트가 관리하는 Managed Entity가 되고, Hibernate는 조회 시점의 값을 loadedState에 보관한다. 트랜잭션이 커밋되기 직전 Flush가 일어나면 Hibernate는 현재 값과 loadedState를 비교하고, 달라진 값이 있으면 UPDATE를 실행한다.
JPA는 "Managed Entity의 변경은 DB와 동기화된다"는 규칙을 정했을 뿐이고, 실제로 변경을 찾아내는 일은 Hibernate의 EntityEntry와 DefaultFlushEntityEventListener가 한다.
그러면 Flush는 정확히 언제 일어날까? 커밋 직전 말고도 JPQL을 실행하기 직전이나 saveAndFlush()를 호출할 때처럼 Flush가 일어나는 시점이 몇 가지 더 있다.