detached 엔티티를 merge()하면 왜 UPDATE 전에 SELECT가 먼저 나가는가

seonwooj0810·2일 전

1. em.merge(entity)를 호출했는데 다음 줄에서 필드가 그대로였다

영속성 컨텍스트가 닫힌 뒤 캐시에 들고 있던 detached 엔티티 하나를 꺼내 필드를 바꾸고 em.merge(entity)만 호출한 뒤, 정작 entity 자체를 다시 참조해 값을 읽었더니 원래 DB에 있던 값 그대로였다. merge()가 인자로 넘긴 객체를 그 자리에서 managed로 승격시켜준다고 생각했다면 이 결과가 이상하게 느껴진다. Jakarta Persistence API의 EntityManager#merge Javadoc과 Hibernate의 DefaultMergeEventListener 소스를 열어보면, merge()는 애초에 그런 계약을 하지 않는다.

2. merge()는 "복사본을 만들어 반환"하는 연산이다

Javadoc은 이렇게 명시한다.

"Return a managed instance with the same persistent state as the given entity instance, but a distinct Java object identity."

핵심은 distinct Java object identity다. 인자로 넘긴 detached 인스턴스는 호출이 끝난 뒤에도 여전히 detached 상태로 남고, merge()가 반환한 값만 managed다. 예외는 인자가 이미 managed인 경우뿐 — 이때는 인자 자체가 그대로 반환된다. 그래서 entity = em.merge(entity)처럼 반환값을 재대입하는 패턴이 관용구로 굳어져 있다.

3. 내부적으로는 4가지 상태로 분기한다

MergeEventDefaultMergeEventListener.onMerge()doMerge()는 넘어온 엔티티를 PersistenceContext에서 EntityKey로 조회해 네 갈래로 나눈다.

merge(entity)
      │
PersistenceContext에서 EntityKey 조회
      │
 ┌────┼─────────────┬──────────────┐
 ▼    ▼              ▼              ▼
PERSISTENT       TRANSIENT      DETACHED        (default) DELETED
그대로 반환       복제+save     session.find()   세션에 있으면
+cascade만        (신규 INSERT)  로 SELECT→복사   ObjectDeletedException,
                                +cascade=MERGE    아니면 삭제예약 취소 후
                                                  DETACHED로 재위임

이 글의 주제인 DETACHED 분기(entityIsDetached)에서 실제로 벌어지는 일은 세 단계다.

  1. 식별자를 복제해 session.find(entityName, clonedIdentifier)CascadingFetchProfile.MERGE 프로필로 호출한다 — 여기서 SELECT가 나간다.
  2. 조회된 managed 인스턴스에 원본(detached) 값을 필드 단위로 복사하고, cascade=MERGE로 지정된 연관관계를 재귀적으로 병합한다.
  3. find()null을 반환하면(row가 이미 삭제됨) persister.isTransient(entity, session)으로 재확인한다. 생성 식별자나 버전 프로퍼티가 있어 "확실히 detached였다"고 판정 가능하면 StaleObjectStateException을 던진다. 할당식 id + 버전 없음처럼 애매한 경우에만 "원래 transient였을 것"으로 보고 신규 INSERT 경로로 진행한다.

merge()가 "UPDATE 전에 SELECT부터 던지는" 이유는 구현 디테일이 아니라, 넘겨받은 detached 인스턴스가 지금 DB 상태와 같은지 먼저 확인하지 않고는 안전하게 상태를 덮어쓸 수 없기 때문이다.

4. SQL 로그로 확인하기

// detached 상태의 엔티티 (영속성 컨텍스트가 이미 닫힌 뒤)
Member detached = new Member(1L, "seonwoo");
detached.setName("changed"); // 필드 하나만 변경

Member managed = em.merge(detached);
// detached != managed — 서로 다른 객체
System.out.println(detached == managed); // false

spring.jpa.show-sql=true로 로그를 켜고 위 코드를 실행하면 session.find()가 만든 SELECT가 먼저 찍히고, flush 시점에 dirty checking이 변경된 컬럼만 UPDATE로 내보낸다. detached의 필드를 하나도 안 바꾸고 merge()만 호출하면 SELECT만 나가고 UPDATE는 아예 생략될 수 있다 — "merge는 항상 UPDATE를 보낸다"는 통념이 깨지는 지점이다. 분기 자체를 확인하고 싶으면 org.hibernate.event.internal 로거를 TRACE로 올려 mergingDetachedInstance 같은 로그를 직접 보면 된다.

5. 정리

merge(detached)는 "그 객체를 managed로 되돌리는" 연산이 아니라, session.find()로 최신 상태를 SELECT한 뒤 그 위에 값을 복사해 넣는 별개 인스턴스를 만드는 연산이다. 반환값을 버리고 원본 참조를 계속 쓰면 변경이 반영되지 않은 것처럼 보이는 버그가 생기고, DB에서 이미 지워진 row를 detached 엔티티로 merge하면 조용히 재삽입되는 대신 StaleObjectStateException을 만날 수도 있다. @Version 낙관적 락과 merge()가 어떻게 얽히는지는 낙관적 락 글에서, merge 이후 flush 순서가 궁금하면 ActionQueue flush 순서 글에서 이어서 볼 수 있다. cascade=MERGE가 없는 연관관계의 컬렉션이 merge 이후 dirty로 잡히는지는 다음에 따로 파볼 만하다.

참고 자료

  • Jakarta Persistence API 소스 — jakartaee/persistence, api/src/main/java/jakarta/persistence/EntityManager.java
  • Hibernate ORM 소스 — hibernate/hibernate-orm, hibernate-core/src/main/java/org/hibernate/event/internal/DefaultMergeEventListener.java
  • Hibernate ORM User Guide, "Merging detached objects" 섹션

0개의 댓글