[Spring Data JPA] Save() 메서드 persist VS merge
Spring Data Jpa에서 엔티티를 영속화 하기 위해서 repository 인터페이스를 만들고 save 메서드를 정의해 사용하는 것이 일반적이다. 그런데 학습과정에서 JpaRepository 인터페이스의 save 메서드 구현 코드를 접하게 되었다. 아래에 나온 코드는 org.springframework.data.repository.CrudRepository 의 기본 구현체로 제공되는 SimpleJpaRepository 클래스에 구현되어 있는 save() 메서드다.
@Transactional
@Override
public <S extends T> S save(S entity) {
Assert.notNull(entity, "Entity must not be null");
if (entityInformation.isNew(entity)) {
em.persist(entity);
return entity;
} else {
return em.merge(entity);
}
}
간단한 코드이다. if 분기문의 조건은 save 메서드의 매개변수로 받은 엔티티가 새로운 것인지, 즉 비영속화 상태의 엔티티인지 확인한다. 그리고 새로운 엔티티라면 entityManager의 persist 로직이 수행되고 아니라면 merge 로직이 수행된다.
persist VS merge
그렇다면 entityManager의 persist와 merge는 동작 방식에서 어떤 차이점이 존재할까? 이를 확인할 수 있는 테스트 코드는 다음과 같다.
@Test
void memberPersistAndMerge() {
// persist
Member member = new Member("hiiro");
Member persistedMember = members.save(member);
assertThat(entityManager.contains(member)).isTrue();
assertThat(entityManager.contains(persistedMember)).isTrue();
assertThat(member == persistedMember).isTrue();
// merge
Member detachedMember = new Member(persistedMember.getId(), "updatedName");
Member mergedMember = members.save(detachedMember);
assertThat(entityManager.contains(mergedMember)).isTrue();
assertThat(entityManager.contains(detachedMember)).isFalse();
assertThat(mergedMember != detachedMember).isTrue();
}
위 테스트 코드의 assertion은 모두 통과된다. persist 주석이 달린 테스트 부분을 보면 persist를 통해 비영속화 상태였던 엔티티가 영속화될 때는 기존 비영속화 상태 엔티티가 영속성 컨텍스트에 캐싱되어 관리된다는 것을 말한다. 즉 이후 비즈니스 로직에서 member를 사용하든, persistedMember를 사용하든 동일하게 변경감지가 수행된다.
반면 merge 주석이 달린 테스트 부분은 아까와 다르게 detachedMember와 mergedMember가 같은 객체가 아니라는 테스트 코드가 통과하는 것을 볼 수 있다. 즉 준영속화 상태인 엔티티를 merge 하게 되면 기존 준영속화된 엔티티와는 별개로 영속화된 새로운 엔티티를 반환한다. 이때 영속성 컨텍스트에는 영속화된 새로운 엔티티만 관리되므로 기존의 detachedMember의 변경사항은 감지되지 않는다.
이러한 특징 때문에 항상 Spring Data Jpa의 save() 메서드를 사용해 영속화를 진행할 때는 반드시 반환 타입을 받아서 비즈니스 로직에 사용하는 것이 영속성 관리의 관점에서 안전하다.
merge는 어떤 절차로 수행될까?
persist 메서드는 설정된 id 관리 전략에 따라 id를 부여하고 영속화된 엔티티를 영속성 컨텍스트에서 캐싱하며 관리한다. 그런데 위에 작성한 테스트 코드의 결과를 보다보니 merge는 어떤 방식으로 영속화를 진행하는지가 궁금해져서 간단하게 알아보고 같이 정리하고자 한다.
baeldung의 레퍼런스를 참고하여 다음과 같은 방식으로 merge 메서드가 수행된다는 것을 확인할 수 있었다.
- 매개변수로 넘겨받은 준영속 상태의 엔티티 id로 동일한 id의 엔티티를 탐색한다.
- 이미 영속성 컨텍스트에 동일한 id 값을 가지는 엔티티가 있는 경우 이를 탐색한다.
- 영속성 컨텍스트에 동일한 id 값을 갖는 엔티티가 없는 경우 DB에서 조회한다.
- 이 때 동일한 id 값을 가진 엔티티 정보가 있다면 영속성 컨텍스트로 캐싱해온 뒤 이를 탐색한다.
- DB에도 동일한 id 값을 가진 엔티티 정보가 없다면 새로운 엔티티에 대한 insert 쿼리가 나가고 영속화가 진행된 뒤 이를 탐색한다.
- 탐색해온 기존 영속 상태 엔티티에게 준영속 상태의 엔티티 값들을 덮어 씌운다.
- 이 때 준영속 상태의 엔티티에 일부 값만 수정했다고 다른 값들은 null 상태로 merge를 수행하면 기존 값들도 null로 덮어 씌워져 영속화되므로 주의해야 한다.
- 새롭게 업데이트된 엔티티 인스턴스를 반환한다.
- 이후 트랜잭션이 종료되고 flush되면 변경사항을 DB에 반영하기 위한 update 쿼리가 수행된다.
@Test
void memberPersistAndMerge() {
// persist
Member member = new Member("hiiro");
Member persistedMember = members.save(member);
assertThat(entityManager.contains(member)).isTrue();
assertThat(entityManager.contains(persistedMember)).isTrue();
assertThat(member == persistedMember).isTrue();
// merge
Member detachedMember = new Member(persistedMember.getId(), "updatedName");
Member mergedMember = members.save(detachedMember);
assertThat(entityManager.contains(mergedMember)).isTrue();
assertThat(entityManager.contains(detachedMember)).isFalse();
assertThat(mergedMember != detachedMember).isTrue();
// 변경사항 update 쿼리가 수행된다.
members.flush();
entityManager.clear();
Member foundMember1 = members.findByName("updatedName");
Member foundMember2 = members.findByName("hiiro");
assertThat(foundMember1).isNotNull();
assertThat(foundMember2).isNull();
}
위 테스트 코드는 전부 통과한다. 주석에 작성되어 있는대로 members를 flush할 때 mergedMember의 변경사항을 DB로 반영하기 위한 update 쿼리가 수행된다. 또한 쓰기 지연 처리된 쿼리들을 모두 처리하고 clear한 뒤 다시 Member 엔티티를 기존 이름과 수정된 이름으로 조회를 해보면 수정된 이름의 엔티티만 조회되는 것까지 확인할 수 있었다.
마치면서
원래는 준영속화된 상태의 엔티티를 사용하면서 발생하는 문제점과 해결책에 대해 학습하고 포스팅을 작성하려고 했었는데 생각보다 글이 길어졌다. 다음 포스팅 때 이를 정리해보고자 한다.
Reference
- https://umanking.github.io/2019/04/12/jpa-persist-merge/
- https://velog.io/@sorzzzzy/JPA-변경-감지와-병합merge
- https://www.baeldung.com/hibernate-save-persist-update-merge-saveorupdate