예약에 병원 메모와 CMS 메모가 있고 화면에서는 상담 상태만 바꾼다고 하자.
{ "status": "CONSULTATION_WAITING" }
요청에 메모가 없다는 것은 두 메모를 지우라는 뜻일까. 전체 수정 DTO를 재사용하면 이 차이가 역직렬화 이후 사라질 수 있다.
data class ReservationUpdateRequest(
val status: ReservationStatus,
val hospitalMemo: String? = null,
val cmsMemo: String? = null
)
reservation.changeStatus(request.status)
reservation.changeHospitalMemo(request.hospitalMemo)
reservation.changeCmsMemo(request.cmsMemo)
누락된 메모가 null로 들어오는 매핑이라면 위 코드는 상태뿐 아니라 두 메모도 변경한다. 영속화는 이미 바뀐 값을 저장한다. 이 예제에서 먼저 고칠 경계는 저장소보다 요청이 표현한 변경 의도다.
Kotlin의 nullable 프로퍼티와 기본값만으로는 누락과 명시적 null을 구분하지 못한다. Jackson을 사용한다면 Kotlin 모듈의 등록과 기본값·null 처리 설정도 확인한다. 여기서는 상태가 필수이며, 누락한 메모는 기본값 null로 매핑되는 조건이다.
보내지 않은 필드와 지우려는 필드를 구분한다
PATCH라는 메서드 이름만으로 본문의 의미가 정해지지 않는다. JSON Merge Patch를 채택하면 누락한 멤버는 유지하고 null 멤버는 제거한다. RFC 7396의 의미를 도메인 필드에 어떻게 적용할지 계약으로 정한다.
{ "hospitalMemo": null }
이 계약에서는 병원 메모 제거 요청이며, CMS 메모는 유지한다. null 자체가 데이터 값이어야 하는 모델이라면 Merge Patch가 맞는지도 비교한다.
같은 메모 필드도 키의 존재와 값에 따라 다른 명령이 된다. 아래 흐름은 메모의 명시적 null을 삭제로 해석하는 계약이다.
메모 필드의 누락·null·값을 다른 명령으로 해석하기
매핑 이후에도 이 세 갈래가 남아야 한다. nullable 값 하나만 전달하면 미전송과 삭제 요청을 다시 구분할 수 없다.
변경 명령에는 값뿐 아니라 존재 여부를 담을 수 있다.
data class FieldPatch<T>(val present: Boolean, val value: T?) {
companion object {
fun <T> absent(): FieldPatch<T> = FieldPatch(false, null)
fun <T> of(value: T?): FieldPatch<T> = FieldPatch(true, value)
}
}
data class ReservationPatchCommand(
val status: FieldPatch<ReservationStatus>,
val hospitalMemo: FieldPatch<String>,
val cmsMemo: FieldPatch<String>
)
if (command.hospitalMemo.present) {
reservation.changeHospitalMemo(command.hospitalMemo.value)
}
누락은 FieldPatch.absent<String>(), 명시적 null은 FieldPatch.of<String>(null)로 변환하는 매핑이 필요하다. data class를 선언하는 것만으로 JSON의 존재 여부가 보존되지는 않는다. JSON 객체의 필드 존재를 검사해 이 명령으로 변환한다. 메모는 null로 지울 수 있지만 필수 상태에는 null을 허용하지 않는 식으로 다른 필드에도 존재 검사와 허용 값 검증을 적용한다.
그림의 분기점은 HTTP 요청의 크기가 아니라 변경 계약이다. 요청이 소유하지 않은 필드로 null이 전파되는지 확인한다.
업무가 명확하면 명령 자체를 좁힌다
여러 화면이 같은 큰 DTO를 공유할 필요가 없다면 상태와 메모를 다른 명령으로 분리한다.
data class ChangeConsultationStatusCommand(
val targetStatus: ReservationStatus, val expectedVersion: Long
)
data class ChangeHospitalMemoCommand(
val memo: String?, val expectedVersion: Long
)
data class ChangeCmsMemoCommand(
val memo: String?, val expectedVersion: Long
)
상태 명령에는 메모를 바꿀 값이 없다. 서비스와 도메인 메서드에서도 허용 필드를 제한하고 역할별 권한을 검사한다. 위 메모 명령의 null은 삭제를 뜻하는 계약일 때 허용한다. 메모 삭제를 별도 명령으로 표현하면 null의 의미도 줄어든다.
수정 조합이 유동적이면 표준 patch 문법이 편리할 수 있다. 업무 행위와 소유권이 명확하면 명령 분리가 계약과 권한을 읽기 쉽게 한다. endpoint 수와 정책 중복 비용을 비교해 선택한다.
오래된 화면의 수정은 별도의 충돌이다
필드 존재를 보존해도 두 편집자가 오래된 데이터를 바탕으로 변경하는 문제는 남는다. CMS가 version 12를 읽은 뒤 병원 메모가 version 13으로 바뀌었다면, CMS의 요청을 받아들일지 정책이 필요하다.
// 필드 접근 방식을 사용하는 Reservation 엔티티 내부
@field:Version
var version: Long = 0
// ReservationService 내부
@Transactional
fun changeStatus(id: Long, command: ChangeConsultationStatusCommand) {
val reservation = repository.findById(id).orElseThrow()
if (reservation.version != command.expectedVersion) {
throw StaleReservationException(
command.expectedVersion, reservation.version
)
}
reservation.changeStatus(command.targetStatus)
}
요청의 expectedVersion 비교는 화면이 읽은 이후의 충돌을 잡는다. @Version은 서버가 읽은 뒤 flush하는 동안의 경쟁 변경을 탐지한다. Jakarta Persistence Version 명세의 보장과 요청 시점 검사는 별개다.
@field:Version은 버전 어노테이션을 JVM 필드에 붙이며 @Id 등도 필드 접근 방식으로 맞춘다. 엔티티의 기본 생성자는 kotlin-jpa로 준비할 수 있고, 엔티티의 open 설정은 별도로 맞춘다. 서비스는 kotlin-spring이 적용된 @Service 빈으로 두거나 클래스와 메서드를 open으로 만들고 트랜잭션 프록시를 통해 호출한다. Spring의 Kotlin 지원 안내를 참고한다.
잘못된 mapper가 같은 버전에서 메모를 지우는 문제는 낙관적 잠금으로 막지 못한다. 변경 계약과 충돌 정책을 각각 검사한다.
SQL을 좁힐 때도 업무 규칙은 남긴다
직접 변경 SQL을 사용한다면 다음처럼 필드와 버전을 함께 제한할 수 있다.
UPDATE reservation
SET consultation_status = :status, version = version + 1
WHERE id = :id AND version = :expectedVersion;
영향 행이 0인 경우 성공으로 넘기지 않는다. 이 방식은 메모를 건드리지 않지만 엔티티 메서드·리스너에만 있던 상태 전이와 감사 처리를 우회할 수 있다. 실제 쓰기 경로에서 업무 규칙을 어디에 둘지 결정한다.
검증은 상태만 비교하지 않는다. 두 메모를 저장한 뒤 상태 변경을 요청하고 영속성 컨텍스트를 비워 DB에서 다시 읽어 메모가 유지되는지 본다. 필드 누락, null, 빈 문자열, 두 화면 순차 수정, 같은 버전의 동시 요청도 구분한다.
이미 사라진 메모는 코드 수정만으로 돌아오지 않는다. 복구에는 접근 통제된 변경 이력이나 백업 같은 별도 근거가 필요하다. 다음 조사에서는 요청 원문부터 mapper, 엔티티 변경, 실제 UPDATE까지 따라가며 어느 단계에서 변경 의도가 손실됐는지 먼저 찾는다.

