코드 리뷰 과제에서 엔티티를 수정할 때마다 새 객체를 만들어 전체 필드를 복사하고 save() 하는 패턴을 만났다. 동작은 한다. 그런데 JPA 에 익숙한 코드는 조회한 객체의 필드만 바꾸고 save() 를 부르지 않는다. 왜 그래도 되는지 정확히 설명할 수 없어서, 영속성 컨텍스트를 공식 레퍼런스로 확인했다.
findById 로 얻은 엔티티는 managed 상태다. JPA 는 조회 시점의 상태를 스냅샷으로 보관하고, flush 때 비교해서 바뀐 필드가 있으면 UPDATE 를 자동 생성한다. 이 동작을 변경 감지(dirty checking)라 부른다. save() 호출 여부는 스타일 선택이 아니라 엔티티가 어느 상태에 있는지에 달린 문제였다.
엔티티 3상태
JPA 엔티티는 영속성 컨텍스트와의 관계에 따라 상태가 나뉜다. 주로 다루게 되는 상태는 세 가지다.
- Transient —
new로 만든 직후. 영속성 컨텍스트가 모르는 객체라 추적되지 않는다. - Managed — 조회하거나 persist 된 뒤. 영속성 컨텍스트가 추적한다.
- Detached — 컨텍스트가 닫힌 뒤. 한때 managed 였지만 더는 추적되지 않는다.
stateDiagram-v2
[*] --> Transient: new
Transient --> Managed: persist
[*] --> Managed: find / JPQL 조회
Managed --> Detached: 영속성 컨텍스트 종료
Detached --> Managed: merge
Dirty Checking
영속성 컨텍스트는 managed entity 의 조회 시점 상태를 스냅샷으로 보관한다. flush 시점이 되면 현재 상태와 스냅샷을 비교하고, 달라진 엔티티에 대해 UPDATE 를 생성한다. 기본 설정에서 flush 는 트랜잭션 커밋 직전과 JPQL 쿼리 실행 전에 일어난다.
@Transactional
public void reserve(String slotId, String userId) {
Slot slot = slotRepository.findById(slotId).orElseThrow();
slot.reserve(); // 필드 변경만으로 끝 — 커밋 때 UPDATE 가 나간다
reservationRepository.save(new Reservation(slotId, userId));
}
slot.reserve() 처럼 도메인 메소드로 상태를 바꾸면 저장 코드가 따로 없다. 리뷰했던 전체 필드 복사 패턴은 이 지점에서 어색해진다. 필드를 하나하나 복사하는 과정에서 값 하나가 잘못 들어가는 버그를 리뷰에서 실제로 발견했다. managed entity 를 직접 수정하면 바뀌는 필드만 코드에 드러난다.
save() 가 하는 일
Spring Data JPA 의 save() 는 SQL 실행 명령이 아니다. 구현은 엔티티가 새 객체인지부터 판정한다. 기본 판정 기준은 @Version 필드 또는 id 가 null 인지이고, 새 객체면 persist, 아니면 merge 를 호출한다. persist 는 transient 객체를 영속성 컨텍스트에 등록하는 요청이고, merge 는 detached 객체의 상태를 같은 id 의 managed 인스턴스로 복사하는 요청이다. 실제 SQL 은 두 경우 모두 flush 가 만든다.
리뷰에서 만난 “새 객체 생성 + save” 패턴이 동작한 이유도 여기 있다. 새로 만든 객체가 기존 id 를 들고 있으면 isNew 판정에서 걸러져 merge 로 처리된다. merge 는 상태를 복사할 managed 인스턴스가 컨텍스트에 없으면 조회를 한 번 더 거친다. 결과는 같지만, 조회한 엔티티를 직접 수정하는 방식보다 우회가 하나 늘어난다.
영속성 컨텍스트의 수명
이 자동 동작의 전제는 두 가지다. 엔티티가 managed 상태이고, 트랜잭션 안에서 flush 가 일어나는 것. Spring 에서 영속성 컨텍스트는 기본적으로 트랜잭션 범위로 동작하고, 변경이 DB 에 반영되는 flush 는 트랜잭션 커밋 직전에 일어난다. 트랜잭션 없이 엔티티 필드를 바꾸면 반영되지 않는다. 변경을 추적해서 UPDATE 로 만들어 줄 트랜잭션 범위의 컨텍스트가 없기 때문이다.
그래서 “save() 를 안 불렀는데 왜 저장되지?” 와 “필드를 바꿨는데 왜 저장이 안 되지?” 는 같은 질문이다. 답은 둘 다 영속성 컨텍스트에 있다. 엔티티가 managed 상태였는지, 그 컨텍스트가 트랜잭션 안에서 flush 됐는지가 저장 여부를 결정한다.
정리
save() 를 부르는지가 아니라 엔티티가 어느 상태에 있는지가 저장 방식을 결정한다. 조회한 managed entity 는 필드만 바꾸면 dirty checking 이 UPDATE 를 만들고, transient 객체는 persist 로, detached 객체는 merge 로 영속성 컨텍스트에 등록된다. 리뷰에서 만난 전체 필드 복사 패턴은 틀린 코드가 아니라 merge 를 한 번 더 거치는 코드였다. 그 차이를 설명할 수 있게 된 것이 이번 학습의 수확이었다.
참고한 공식 문서: Spring Data JPA — Persisting Entities, Hibernate ORM User Guide — Flushing