변경 감지와 병합에 대해서 이해하기 위해서는 영속성 컨텍스트와 준영속 엔티티에 대해서 먼저 알아야 한다. 먼저 영속성 컨텍스트에 대해서 알아보자.
영속성 컨텍스트
엔티티 인스턴스를 효율적으로 관리하는 공간이다. JPA 구현체(하이버네이트)와 같은 것들은 영속성 컨텍스트를 이용해서 1차 캐시, 쓰기 지연(Write-behind), 변경 감지(dirty-checking) 등을 지원해준다.
1차 캐시
엔티티와 그 식별자가
Map형태
로 저장되어 있는 캐시로
이 캐시에 엔티티가 존재한다면 DB 접근의 필요가 없어진다.

void firstLevelCache() {
Crew crew = new Crew("산초");
System.out.println("엔티티를 영속화시키겠습니다.");
entityManager.persist(crew); ==> 영속화
System.out.println("엔티티를 조회하겠습니다.");
entityManager.find(Crew.class, crew.getId()); ==> 조회
System.out.println("엔티티를 조회하겠습니다.");
entityManager.find(Crew.class, crew.getId()); ==> 조회
assertThat(crew.getId()).isNotNull();
}

-> SELECT 문이 없음!

쓰기 지연(Write-behind)
엔티티 인스턴스에 대한 쿼리를 바로 DB로 보내지 않고 1차 캐시에만 반영하고, 쿼리는 쓰기 지연 저장소에 쌓아둔다. 이렇게 되면 '1차 캐시-DB' 간의 불일치가 발생하는데, 이를 동기화(flush) 시켜줄 때 모아두었던 쿼리를 한번에 DB로 보낸다.
void writeBehind() {
Crew crew1 = new Crew("산초");
Crew crew2 = new Crew("제이든");
entityManager.persist(crew1);
entityManager.persist(crew2); ==> 영속화
System.out.println("엔티티를 변경하겠습니다.");
crew1.setName("삼촌"); ==> 변경
System.out.println("엔티티를 삭제했습니다.");
entityManager.remove(crew2); ==> 삭제
assertThat(crew1.getName()).isEqualTo("삼촌");
assertThat(entityManager.contains(crew2)).isFalse();
}

-> 쿼리문을 한번에 보냄!

변경 감지(Dirty-check)
자동으로 변경을 감지해주는 것 JPA는 엔티티가 영속성 컨텍스트에 처음 들어올때 그 상태를 복사해 저장한다.(스냅샷) flush를 하기 전, 스냅샷과 현재 상태를 비교해서 UPDATE 쿼리를 쓰기 지연 저장소에 추가한다.
void dirtyCheck() {
Crew crew = new Crew("산초"); ==> 영속화
entityManager.persist(crew);
crew.setName("삼촌"); ==> 변경
Crew updatedCrew = entityManager.find(Crew.class, crew.getId());
assertThat(updatedCrew.getName()).isEqualTo("삼촌");
}
-
영속화된 엔티티를 변경만 했는데, 조회할 때 반영이 된다!

이를 통해서 영속성 컨텍스트에 대해서 알 수 있었고 우리가 알고자하는 변경 감지와 병합(merge)의 차이를 이해하기 위해서는 준영속 엔티티를 알아야한다.
준영속 엔티티
영속성 컨텍스트가 더이상 관리하지 않는 엔티티를 의미한다.
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String email;
// Getters and Setters
}
// 준영속 엔티티를 병합하는 예제
public void mergeExample(EntityManager em) {
// 영속 상태의 엔티티 생성
User user = new User();
user.setName("John Doe");
user.setEmail("john.doe@example.com");
em.persist(user); // 영속 상태로 전환
em.flush(); // 데이터베이스에 반영
em.clear(); // 영속성 컨텍스트 초기화 → user는 준영속 상태가 됨
// 준영속 상태의 엔티티 변경
user.setEmail("new.email@example.com");
}
준영속 엔티티를 수정하는 방법
- 변경 감지 사용
- 병합(merge) 사용
- 변경 감지 사용
@Transactional
public void updateUser(Long userId, String name, String email) {
// 영속 상태의 엔티티를 조회 (준영속 상태의 엔티티와 동일한 식별자 사용)
User persistentUser = entityManager.find(User.class, userId);
// 영속 상태의 엔티티 값을 수정 (변경 감지 기능이 작동)
persistentUser.setName(name); // 이름 변경
persistentUser.setEmail(email); // 이메일 변경
// 변경 감지에 의해 트랜잭션 종료 시점에 변경 사항이 데이터베이스에 반영됩니다.
}
- 병합(merge) 사용
@Transactional
public void updateUser(Long userId, String name, String email) {
// 준영속 상태의 엔티티 생성 (보통 클라이언트 요청으로 전달받은 데이터)
User detachedUser = new User();
detachedUser.setId(userId); // 기존 엔티티의 식별자 설정
detachedUser.setName(name); // 변경할 이름 설정
detachedUser.setEmail(email); // 변경할 이메일 설정
// 병합(Merge) 호출
User mergedUser = entityManager.merge(detachedUser);
// 병합된 엔티티는 영속 상태가 되며, 트랜잭션 종료 시 변경 사항이 DB에 반영됩니다.
}
예제 데이터 시나리오
- 데이터베이스 초기 상태: User 엔티티:
text
ID: 1
Name: "Alice"
Email: "alice@example.com"
- 클라이언트에서 변경 요청:
text
ID: 1
Name: "Bob"
Email: 설정되지 않음 (null)
- merge 호출 결과: 병합된 엔티티:
text
ID: 1
Name: "Bob"
Email: null
- 결과적으로 데이터베이스에 반영된 상태: User 엔티티:
text
ID: 1
Name: "Bob"
Email: null
=> 이메일 필드가 null로 덮어씌워지는 문제가 발생.
문제 원인
merge는 준영속 상태의 엔티티 값을 그대로 복사하여 영속성 컨텍스트에 반영합니다. 따라서 설정되지 않은 필드(예: email)는 null 값으로 간주되어 데이터베이스에 반영됩니다.
문제 해결
@Transactional
public void updateUser(Long userId, String name, String email) {
User persistentUser = entityManager.find(User.class, userId);
if (persistentUser == null) {
throw new EntityNotFoundException("User with ID " + userId + " not found");
}
if (name != null) {
persistentUser.setName(name);
}
if (email != null) {
persistentUser.setEmail(email);
}
}
💡 변경 감지 기능을 사용하면 원하는 속성만 선택해서 변경할 수 있지만, 병합을 사용하면 모든 속성이 변경된다. 병합시 값이 없으면 null 로 업데이트 할 위험도 있다.
result
이러한 이유들로 인해서 준영속 상태의 엔티티의 값을 변경할 경우가 있을 때에는 웬만하면 변경 감지 기능을 사용하자!