부분 업데이트에서 merge()를 사용하면 편해 보입니다. 요청으로 받은 값을 엔티티에 담고 entityManager.merge()를 호출하면 JPA가 알아서 DB에 반영해줄 것처럼 보이기 때문입니다. 하지만 이 방식은 PATCH 성격의 API에서 특히 위험합니다.
문제는 merge()가 “일부 필드만 변경한다”는 명령이 아니라, 준영속 엔티티의 전체 상태를 영속 엔티티에 복사하는 동작에 가깝다는 점입니다. 요청에 없던 값이 null인 상태로 엔티티에 들어오면, 그 null도 변경할 상태로 취급될 수 있습니다.
먼저 영속성 컨텍스트를 이해해야 합니다
JPA 업데이트를 이해하려면 Repository의 save()보다 영속성 컨텍스트를 먼저 봐야 합니다. 영속성 컨텍스트는 트랜잭션 안에서 엔티티 인스턴스를 관리하는 공간입니다. Hibernate는 이 경계를 바탕으로 1차 캐시, 쓰기 지연, 변경 감지를 제공합니다.
1차 캐시는 같은 식별자의 엔티티를 한 번만 관리합니다
영속성 컨텍스트는 엔티티 식별자와 엔티티 인스턴스를 함께 보관합니다. 같은 트랜잭션 안에서 이미 조회한 엔티티라면 DB를 다시 읽지 않고 영속성 컨텍스트 안의 인스턴스를 반환할 수 있습니다.

@Transactional
void firstLevelCache() {
Crew crew = new Crew("sancho");
entityManager.persist(crew);
Crew first = entityManager.find(Crew.class, crew.getId());
Crew second = entityManager.find(Crew.class, crew.getId());
assertThat(first).isSameAs(second);
}1차 캐시 동작 확인 — 같은 식별자 조회가 영속성 컨텍스트 안에서 해결되어 추가 SELECT가 발생하지 않는 구간입니다.


이 성질 때문에 같은 트랜잭션 안에서는 “DB row”보다 “영속성 컨텍스트가 관리하는 객체”가 더 중요한 기준이 됩니다.
쓰기 지연은 SQL 실행 시점을 뒤로 미룹니다
JPA는 엔티티 변경을 곧바로 DB에 반영하지 않습니다. 영속성 컨텍스트와 쓰기 지연 저장소에 변경 작업을 모아두고, flush 시점에 SQL을 DB로 보냅니다.
@Transactional
void writeBehind() {
Crew crew1 = new Crew("sancho");
Crew crew2 = new Crew("jayden");
entityManager.persist(crew1);
entityManager.persist(crew2);
crew1.changeName("samchon");
entityManager.remove(crew2);
}쓰기 지연 확인 로그 — persist, update, remove가 호출 즉시 DB로 나가지 않고 flush 시점에 반영되는 흐름입니다.


쓰기 지연은 성능 최적화이기도 하지만, 더 중요하게는 트랜잭션 안에서 변경 내용을 하나의 단위로 다루게 해줍니다.
변경 감지는 영속 엔티티의 차이만 추적합니다
변경 감지는 영속 상태의 엔티티가 트랜잭션 안에서 바뀌었는지 확인하는 기능입니다. Hibernate는 엔티티가 영속성 컨텍스트에 들어올 때의 상태를 스냅샷으로 저장하고, flush 전에 현재 상태와 비교해 필요한 UPDATE SQL을 만듭니다.
@Transactional
void dirtyChecking() {
Crew crew = entityManager.find(Crew.class, crewId);
crew.changeName("new-name");
}별도 save() 호출이 없어도, crew가 영속 상태라면 트랜잭션 종료 시점에 변경 감지가 동작합니다.

merge는 준영속 엔티티의 상태를 복사합니다
준영속 엔티티는 한 번 영속 상태였지만 현재 영속성 컨텍스트가 관리하지 않는 엔티티입니다. 예를 들어 조회한 뒤 영속성 컨텍스트가 종료되었거나, clear() 이후 남은 객체가 준영속 상태가 됩니다.

User user = new User();
user.setName("Alice");
user.setEmail("alice@example.com");
entityManager.persist(user);
entityManager.flush();
entityManager.clear();
// 여기서 user는 더 이상 영속성 컨텍스트가 관리하지 않는 준영속 객체입니다.
user.setEmail("new.email@example.com");merge()는 이 준영속 객체를 다시 영속 상태로 “붙이는” 것처럼 보입니다. 하지만 정확히는 전달받은 객체 자체를 영속 상태로 바꾸는 것이 아니라, 같은 식별자의 영속 엔티티를 찾거나 새로 만들고, 전달받은 객체의 상태를 그 영속 엔티티에 복사합니다.
@Transactional
public User mergeUser(User detachedUser) {
User merged = entityManager.merge(detachedUser);
return merged;
}여기서 중요한 점은 detachedUser가 아니라 merged가 영속 상태라는 것입니다. merge() 이후에도 원래 전달한 객체를 계속 수정하면 그 변경은 자동으로 추적되지 않습니다.
부분 업데이트에서 merge가 위험한 이유
문제는 클라이언트가 일부 필드만 보냈을 때 시작됩니다.
초기 DB 상태가 아래와 같다고 가정합니다.
id: 1
name: Alice
email: alice@example.com
address: Seoul사용자는 이름만 변경하고 싶어서 다음 요청을 보냅니다.
{
"name": "Bob"
}서버가 이 요청을 엔티티로 바로 바꾸면 나머지 필드는 비어 있을 수 있습니다.
User detachedUser = new User();
detachedUser.setId(1L);
detachedUser.setName("Bob");
// email, address는 null
entityManager.merge(detachedUser);이 경우 merge()는 “name만 바꾼다”고 이해하지 않습니다. 전달된 준영속 객체의 전체 상태를 복사합니다.
id: 1
name: Bob
email: null
address: null부분 업데이트에서 위험한 지점은 바로 여기에 있습니다. 요청에 없던 값이 “변경하지 않음”이 아니라 “null로 변경”처럼 해석될 수 있습니다.
| 업데이트 방식 | 의미 | 부분 업데이트에서의 위험 |
|---|---|---|
merge() | 전달된 객체의 전체 상태를 복사 | 비어 있는 필드가 null로 덮일 수 있음 |
| 변경 감지 | 영속 엔티티에서 실제 바꾼 필드만 추적 | 변경 의도를 코드로 제한할 수 있음 |
변경 감지는 업데이트 의도를 코드에 남깁니다
부분 업데이트에서는 먼저 영속 상태의 엔티티를 조회하고, 요청에 포함된 값만 명시적으로 변경하는 편이 안전합니다.
@Transactional
public void updateUser(Long userId, UpdateUserRequest request) {
User user = userRepository.findById(userId)
.orElseThrow(EntityNotFoundException::new);
if (request.name() != null) {
user.changeName(request.name());
}
if (request.email() != null) {
user.changeEmail(request.email());
}
}이 방식은 코드가 조금 더 길지만 중요한 장점이 있습니다.
- 어떤 필드를 변경할 수 있는지 코드에 드러납니다.
- null을 무시할지, 실제 null 변경으로 볼지 결정할 수 있습니다.
- 도메인 메서드 안에서 검증을 수행할 수 있습니다.
- 변경 이력이나 감사 로그를 남기기 쉽습니다.
- JPA는 변경된 영속 엔티티를 스냅샷과 비교해 필요한 UPDATE만 생성합니다.
부분 업데이트에서는 편한 저장 호출보다, 요청에 없던 값과 null로 바꾸려는 값을 구분하는 일이 먼저입니다.
save()가 merge()를 감추는 경우도 있습니다
실무에서 merge()를 직접 호출하지 않아도 같은 문제가 숨어들 수 있습니다. Spring Data JPA의 save()는 신규 엔티티라고 판단되면 persist() 경로를 타지만, 이미 존재하는 엔티티라고 판단되면 내부적으로 merge() 경로를 탈 수 있습니다.
@PostMapping("/users/{id}")
public void update(@PathVariable Long id, @RequestBody User user) {
user.setId(id);
userRepository.save(user);
}이 코드는 entityManager.merge(user)를 직접 쓰지 않았기 때문에 안전해 보일 수 있습니다. 하지만 요청 본문이 엔티티 전체 상태가 아니라 일부 필드만 담고 있다면 위험은 같습니다. id만 맞춘 새 객체를 저장하는 순간, 서버는 “기존 엔티티 일부 변경”이 아니라 “이 객체 상태를 기준으로 저장”하는 흐름에 가까워집니다.
그래서 부분 업데이트에서는 save() 호출 여부보다 업데이트 기준 객체가 어디서 왔는지를 먼저 봐야 합니다. DB에서 조회해 영속성 컨텍스트가 관리하는 객체라면 변경 감지가 의도한 필드만 추적할 수 있습니다. 반대로 요청에서 만든 객체라면 값이 비어 있는 필드까지 상태로 해석될 수 있습니다.
DTO와 엔티티를 분리해야 하는 이유
merge() 문제가 자주 생기는 이유는 요청 DTO와 엔티티를 같은 것으로 취급하기 때문입니다. 요청 DTO는 사용자가 보낸 변경 의도를 담습니다. 반면 엔티티는 현재 DB에 저장된 전체 도메인 상태와 규칙을 담습니다.
이 둘을 섞으면 API 입력이 곧 엔티티 전체 상태가 됩니다.
@PostMapping("/users/{id}")
public void update(@PathVariable Long id, @RequestBody User user) {
user.setId(id);
entityManager.merge(user);
}이런 코드는 짧지만, 위험이 큽니다.
- 클라이언트가 수정하면 안 되는 필드까지 보낼 수 있습니다.
- 요청에 없는 필드가 null로 들어올 수 있습니다.
- 엔티티 변경 규칙이 Controller 입력 형식에 끌려갑니다.
- 검증과 권한 체크가 필드별로 분리되기 어렵습니다.
더 안전한 구조는 요청 DTO를 받고, 서비스 계층에서 허용된 변경만 도메인 메서드로 반영하는 방식입니다.
public record UpdateProfileRequest(
String name,
String email,
String profileImageUrl
) {}
@Transactional
public void updateProfile(Long userId, UpdateProfileRequest request) {
User user = userRepository.findById(userId)
.orElseThrow(EntityNotFoundException::new);
user.updateProfile(
request.name(),
request.email(),
request.profileImageUrl()
);
}도메인 메서드 내부에서는 정책을 한 곳에 둘 수 있습니다.
public void updateProfile(String name, String email, String profileImageUrl) {
if (name != null) {
this.name = validateName(name);
}
if (email != null) {
this.email = validateEmail(email);
}
if (profileImageUrl != null) {
this.profileImageUrl = profileImageUrl;
}
}이 구조에서는 엔티티가 단순 데이터 운반 객체가 아니라, 변경 규칙을 가진 도메인 객체로 남습니다.
null의 의미를 명시해야 합니다
부분 업데이트에서 가장 자주 빠지는 질문은 null의 의미입니다.
{
"email": null
}이 요청은 이메일을 변경하지 않겠다는 뜻일 수도 있고, 이메일을 제거하겠다는 뜻일 수도 있습니다. API가 이 둘을 구분하지 않으면 서버 구현도 흔들립니다.
| 요청 상태 | 가능한 의미 | 처리 기준 |
|---|---|---|
| 필드가 없음 | 변경하지 않음 | 기존 값 유지 |
| 필드가 null | 값 제거 또는 잘못된 요청 | API 정책으로 명시 |
| 필드에 값이 있음 | 해당 값으로 변경 | 검증 후 반영 |
단순 DTO에서는 “필드 없음”과 “필드가 null”을 구분하기 어려울 수 있습니다. 이런 경우 API 설계에서 null을 허용하지 않거나, 명시적인 command 필드를 두거나, JSON Patch 같은 별도 모델을 검토할 수 있습니다. merge()는 이 의미를 대신 판단해주지 않습니다.
특히 값 제거가 실제 기능이라면 더 조심해야 합니다. null을 무조건 무시하면 사용자는 값을 지울 수 없고, null을 무조건 반영하면 누락된 필드까지 지워질 수 있습니다. 결국 API가 먼저 “필드 미전송”, “명시적 null”, “새 값”을 어떻게 구분할지 정해야 서비스 코드의 업데이트 규칙도 흔들리지 않습니다.
merge를 써도 되는 경우와 피해야 하는 경우
merge()를 무조건 금지할 필요는 없습니다. 하지만 업데이트의 성격과 입력 신뢰도를 먼저 봐야 합니다.
| 상황 | merge() 적합도 | 이유 |
|---|---|---|
| 전체 필드를 다시 제출하는 관리 화면 | 검토 가능 | 입력이 엔티티 전체 상태에 가까움 |
| 짧은 생명주기의 내부 배치 | 검토 가능 | 입력 데이터 생성 경로가 통제됨 |
| 공개 PATCH API | 피하는 편이 안전 | 요청에 없는 필드와 null 의미가 섞임 |
| 권한별 수정 가능 필드가 다른 API | 피하는 편이 안전 | 전체 상태 복사가 권한 경계를 흐릴 수 있음 |
| 감사 로그가 중요한 도메인 | 피하는 편이 안전 | 어떤 필드가 의도적으로 바뀌었는지 남기기 어려움 |
기준은 단순합니다. 입력 객체가 “엔티티의 완전한 최신 상태”라고 신뢰할 수 있으면 merge()를 검토할 수 있습니다. 반대로 입력 객체가 “일부 변경 의도”라면 영속 엔티티를 조회한 뒤 필요한 필드만 바꾸는 편이 안전합니다.
운영 관점에서도 명시적 변경이 유리합니다
부분 업데이트는 단순히 DB row를 바꾸는 문제가 아닙니다. 운영에서는 누가 어떤 필드를 왜 바꿨는지 추적해야 하는 경우가 많습니다. 사용자 상태, 권한, 결제 정보, 보안 설정처럼 민감한 필드는 특히 그렇습니다.
명시적 변경 메서드는 이런 지점에서 유리합니다.
user.changeEmail(request.email());
user.changeMarketingConsent(request.marketingConsent());
user.deactivate(reason);메서드 이름이 곧 변경 의도가 됩니다. 로그를 남길 지점도 명확해지고, 필드별 검증과 권한 체크도 넣기 쉽습니다. 반면 merge() 기반 업데이트는 전체 상태 복사에 가까워서 어떤 필드가 의도적으로 바뀌었는지 추적하기 어렵습니다.
PATCH 요청에서 먼저 확인할 것
merge()는 준영속 엔티티를 다시 영속 상태로 편입할 때 사용할 수 있는 기능입니다. 하지만 부분 업데이트에서는 위험해질 수 있습니다. 전달된 객체의 전체 상태를 영속 엔티티에 복사하기 때문에, 요청에 없던 값이 null로 덮이는 문제가 생길 수 있습니다.
부분 수정에서 먼저 봐야 하는 기준은 merge() 사용 여부 자체가 아니라, 업데이트 입력을 엔티티의 전체 상태로 신뢰할 수 있는지입니다. 요청이 일부 필드만 의미하는 PATCH라면 그 객체를 엔티티 전체 상태처럼 다루는 순간 변경 의도가 너무 넓어집니다.
merge()는 일부 필드 변경 명령이 아니라 전체 상태 복사에 가깝습니다.save()도 상황에 따라merge()경로를 탈 수 있으므로, 요청 객체를 엔티티로 바로 넘기는 구조는 조심해야 합니다.- 부분 업데이트에서는 요청 DTO와 엔티티를 분리해야 합니다.
- 영속 엔티티를 먼저 조회하고, 허용된 필드만 도메인 메서드로 변경하는 편이 안전합니다.
- null이 “변경 없음”인지 “값 제거”인지 API 정책으로 명시해야 합니다.
- 권한, 검증, 감사 로그가 중요한 도메인에서는 명시적 변경 메서드가 더 적합합니다.
merge()를 피해야 하는 이유는 기능이 나빠서가 아닙니다. 부분 업데이트에서 입력 객체를 엔티티의 전체 상태로 받아들이면, 빠진 필드가 “변경 없음”인지 “값 제거”인지 코드만으로 구분하기 어려워집니다. 권한, 검증, 감사 로그가 중요한 도메인에서는 영속 엔티티를 먼저 조회하고, 허용된 필드만 도메인 메서드로 바꾸는 편이 더 설명 가능합니다. JPA의 변경 감지는 이처럼 변경 의도를 좁혀 둘 때 가장 안전하게 읽힙니다.