CATEGORY

카테고리 (672)
AI (70)
Language & Specs (260)
FrameWork (36)
Library (20)
App (41)
Git (10)
Build & Dependency (2)
AWS (15)
DataBase (45)
OS (33)
Tool (17)
IT (120)
SEEMINGLY ONLINE

Seemingly
Online

이모저모 방방곡곡 두루두루 개발지식 저장소

RECENT POSTS

Language & Specs/JPA

JPA merge vs persist 차이 - 준영속 엔티티와 null 덮어쓰기 함정

반응형

persist()는 새 엔티티를 영속 상태로 만드는 메서드라 반환값이 없고 넘긴 객체 자체가 관리 대상이 됩니다. merge()는 준영속(detached) 엔티티의 상태를 복사해 오는 메서드라 새로운 영속 객체를 반환하고, 넘긴 객체는 끝까지 준영속으로 남습니다. 그래서 merge()는 반환값을 받아 써야 하고, id만 세팅한 객체를 persist()에 넘기면 detached entity passed to persist 예외가 납니다. 아래 실행 결과는 Hibernate ORM 6.5.3 + H2에서 show_sql을 켜고 직접 확인한 것입니다.

1. persist와 merge, 한 줄 비교

항목 persist() merge()
대상 비영속(transient) 새 엔티티 준영속(detached) 엔티티가 주 용도
반환값 void <T> T — 상태가 병합된 영속 객체
넘긴 객체 그 객체가 영속 상태가 됨 계속 준영속 (관리 안 됨)
실행 SQL insert select 후 update(또는 insert)
준영속 객체를 넘기면 EntityExistsException 정상 동작(본래 용도)

Jakarta Persistence 3.1 명세 기준으로 persist(Object)는 반환형이 void, merge(T)는 "the managed instance that the state was merged to"를 반환한다고 정의돼 있습니다. 이 반환값 차이가 실무 버그의 절반입니다.

2. 실행해서 본 persist() — 넘긴 객체가 곧 영속 객체

엔티티는 @Id @GeneratedValue(strategy = GenerationType.IDENTITY)를 쓰는 단순한 Member입니다.

em.getTransaction().begin();
Member m = new Member("kim");
em.persist(m);
System.out.println(em.find(Member.class, m.getId()) == m);
em.getTransaction().commit();

실행 결과입니다.

Hibernate: insert into Member (name,id) values (?,default)
persist 인자 == 영속객체? true
생성된 id = 1

persist()에 넘긴 m이 그대로 영속 객체이므로, persist() 이후 m.setName(...)을 해도 커밋 시점에 변경 감지로 반영됩니다. IDENTITY 전략이라 id를 얻으려고 insert가 즉시 나갔고, 그 id가 원본 객체에 채워졌습니다.

3. persist()에 준영속 엔티티를 넘기면 - detached entity passed to persist

id를 직접 세팅한 객체(= 이미 DB에 있는 행을 가리키는 준영속 상태)를 persist()에 넘기면 이렇게 됩니다.

Member detached = new Member("kim-changed");
detached.setId(1L);      // 이미 DB에 있는 id
em.persist(detached);
예외: jakarta.persistence.EntityExistsException
      / detached entity passed to persist: MergeDemo$Member
⚠️ detached entity passed to persist가 보이면 "새 엔티티인 줄 알고 persist 했는데 사실 id가 들어 있었다"는 뜻입니다. 화면에서 넘어온 DTO를 엔티티로 바꿀 때 id까지 같이 세팅한 경우가 대표적입니다. 이때 필요한 건 persist()가 아니라 merge()(또는 조회 후 값 변경)입니다.

4. merge()는 왜 반환값을 받아야 하나

Member d2 = new Member("kim-merged");
d2.setId(1L);                       // 준영속
Member managed = em.merge(d2);      // 반환값이 영속 객체

System.out.println(managed == d2);            // false
System.out.println(em.contains(d2));          // false (준영속 그대로)
System.out.println(em.contains(managed));     // true

d2.setName("no-effect");            // 반영 안 됨
managed.setName("kim-merged2");     // 반영됨
em.getTransaction().commit();

실행 로그입니다.

Hibernate: select m1_0.id,m1_0.name from Member m1_0 where m1_0.id=?
merge 반환 == 인자? false
인자 관리대상? false / 반환 관리대상? true
Hibernate: update Member set name=? where id=?
최종 name = kim-merged2

정리하면 merge()는 ① id로 DB를 조회해 영속 객체를 만들고 ② 넘긴 객체의 필드 값을 그 영속 객체로 복사한 뒤 ③ 영속 객체를 반환합니다. 그래서 merge(entity)만 호출하고 이후 entity를 계속 고치면 아무 일도 일어나지 않습니다. 반드시 entity = em.merge(entity)처럼 반환값을 다시 받아 쓰세요.

💡 merge()는 조회 select가 한 번 더 나갑니다(1차 캐시에 없을 때). 조회 → 값 변경 방식(변경 감지)은 이미 읽어 둔 영속 객체를 고치므로 select가 늘지 않습니다. 성능 차이가 아니라 이미 조회했는지가 선택 기준입니다.

5. 가장 위험한 함정 - merge()는 null까지 덮어쓴다

실무에서 "수정했더니 다른 컬럼이 전부 null이 됐다"는 사고가 여기서 납니다. id·name·email을 가진 엔티티에서, 화면이 name만 보내와 email을 비운 채로 merge()를 하면 이렇게 됩니다.

// DB 상태: id=1, name=kim, email=kim@test.com
Member2 partial = new Member2(1L, "kim-new", null);   // email을 안 채움
em.merge(partial);
em.getTransaction().commit();
Hibernate: select m1_0.id,m1_0.email,m1_0.name from Member2 m1_0 where m1_0.id=?
Hibernate: update Member2 set email=?,name=? where id=?
name=kim-new / email=null

merge()는 넘어온 객체의 모든 필드를 통째로 복사합니다. "값이 없는 필드는 건드리지 않는다" 같은 배려는 없습니다. 그래서 부분 수정에 merge를 쓰면 안 됩니다. 안전한 방식은 조회 후 필요한 필드만 바꾸는 변경 감지입니다.

@Transactional
public void changeName(Long id, String name) {
    Member2 member = em.find(Member2.class, id);   // 영속 상태
    member.setName(name);                          // merge 호출 없음
}   // 커밋 시점에 update 자동 실행
Hibernate: select m1_0.id,m1_0.email,m1_0.name from Member2 m1_0 where m1_0.id=?
Hibernate: update Member2 set email=?,name=? where id=?
name=kim-dirty

영속 상태 객체는 merge()도 save()도 부를 필요가 없습니다. 트랜잭션 커밋 때 스냅샷과 비교해 바뀐 것만 반영됩니다(변경 감지). 트랜잭션 밖으로 나간 엔티티를 만질 때 생기는 다른 문제는 JPA save()와 saveAndFlush() 차이 글에서 flush 시점과 함께 정리했습니다.

6. id가 없거나 DB에 없는 id로 merge하면?

둘 다 insert가 됩니다. 다만 결과가 직관과 다릅니다.

// (1) id가 null인 새 객체를 merge
Member fresh = new Member("lee");
Member r = em.merge(fresh);
// 원본 id = null / 반환 id = 2

// (2) DB에 없는 id=999로 merge
Member ghost = new Member("ghost");
ghost.setId(999L);
Member g = em.merge(ghost);
// select ... where id=?  →  insert  →  반환 id = 3   (999가 아님)

(1) id가 원본 객체에 채워지지 않습니다. persist()였다면 fresh.getId()로 바로 id를 얻지만, merge()는 반환 객체에만 id가 들어 있습니다. 새 엔티티를 저장할 때 merge()를 쓰면 안 되는 이유입니다.

(2) DB에 없는 id를 넘기면 Hibernate가 select로 확인한 뒤 새 행을 insert합니다. IDENTITY 전략이라 지정한 999가 아니라 시퀀스가 정한 새 id(3)로 저장됐습니다. "id를 지정했으니 그 id로 들어가겠지"라는 기대는 틀립니다.

7. 그럼 Spring Data JPA의 save()는 뭘 부르나

JpaRepository.save()는 둘 중 하나를 골라 부릅니다. SimpleJpaRepository 원본 소스입니다.

@Override
@Transactional
public <S extends T> S save(S entity) {

    Assert.notNull(entity, ENTITY_MUST_NOT_BE_NULL);

    if (entityInformation.isNew(entity)) {
        entityManager.persist(entity);
        return entity;
    } else {
        return entityManager.merge(entity);
    }
}

즉 새 엔티티면 persist, 아니면 merge입니다. 여기서 두 가지가 따라옵니다.

  • id가 들어 있는 엔티티를 save()하면 내부적으로 merge가 돌아 select가 한 번 더 나가고, null 덮어쓰기 위험도 그대로 따라옵니다.
  • save()의 반환값도 같은 이유로 중요합니다. merge 경로에서는 넘긴 객체가 아니라 반환 객체가 영속 상태입니다.

그래서 수정은 save()를 부르는 대신 @Transactional 안에서 조회 후 필드를 바꾸는 쪽이 안전합니다. 연관 엔티티까지 다룰 때 조회 방식과 성능 문제는 JPA N+1 문제 해결(Fetch Join vs EntityGraph) 글을 함께 보세요.

자주 묻는 질문 (FAQ)

Q. merge()와 변경 감지(dirty checking), 뭘 써야 하나요?
트랜잭션 안에서 조회한 엔티티라면 변경 감지가 기본입니다. merge()는 트랜잭션 밖에서 만들어졌거나 세션이 끝난 뒤 넘어온 준영속 엔티티를 다시 붙일 때만 씁니다.

Q. merge() 후에 원래 객체를 계속 써도 되나요?
안 됩니다. em.contains(원본)이 false로 확인됐듯 원본은 준영속 그대로입니다. entity = em.merge(entity)로 덮어써서 이후 코드가 영속 객체를 쓰게 하세요.

Q. persist() 후에 id가 바로 나오나요?
GenerationType.IDENTITY라면 id를 얻기 위해 insert가 즉시 실행되므로 바로 나옵니다(위 실행 로그에서 확인). SEQUENCE는 시퀀스를 미리 조회해 id를 채우고 insert는 flush 때 나갑니다.

Q. detached entity passed to persist를 없애려면?
새로 저장할 엔티티라면 id를 세팅하지 말고 persist()하세요. 수정이라면 persist()가 아니라 조회 후 값 변경(또는 merge())으로 바꾸면 됩니다.

마무리

정리하면 persist는 새 엔티티 저장, merge는 준영속 엔티티 재부착이고, 실무 버그는 대부분 ① merge 반환값을 안 받거나 ② 부분 수정에 merge를 써서 다른 컬럼이 null로 덮어써지는 두 가지에서 나옵니다. 수정 로직은 @Transactional 안에서 조회 후 필드 변경을 기본으로 두고, merge는 준영속 객체를 다룰 때만 쓰세요. 이 글의 SQL·예외 메시지는 Hibernate ORM 6.5.3에서 직접 실행해 얻은 결과이며, 사용하는 버전이 다르면 show_sql을 켜고 같은 코드를 한 번 돌려 보길 권합니다.


📚 참고 출처 (2026년 7월 21일 확인)
· Jakarta Persistence 3.1 API - EntityManager (persist / merge)
· Spring Data JPA - SimpleJpaRepository 원본 소스
· 실행 환경: Hibernate ORM 6.5.3.Final + H2 2.3.232 (hibernate.show_sql=true)

반응형

COMMENTS