이미지와 가격 정책을 담은 revision 1을 제출하고 반려됐다고 하자. 사용자가 텍스트만 수정해 revision 2를 다시 제출한다. 이때 승인이 읽어야 할 데이터는 현재 편집 중인 행일까, 검수자가 확인한 revision 2일까.
승인 상태만 APPROVED로 바뀌어도 본문과 이미지가 다른 버전이면 사용자가 검토한 결과와 달라진다. 이 글은 반려와 재요청에서 무엇을 검수했고 무엇을 적용하는지 보존하는 모델을 설명한다.
상태 전이와 대상 revision을 함께 검사한다
제출·반려·재요청·승인은 문자열 대입보다 좁은 업무 행위다.
| 현재 상태 | 행위 | 다음 상태 | 검사할 조건 |
|---|---|---|---|
| DRAFT | 제출 | IN_REVIEW | 대상 데이터·자산 유효성 |
| IN_REVIEW | 반려 | REJECTED | 반려 사유 |
| REJECTED | 재요청 | IN_REVIEW | 새 revision 생성 |
| IN_REVIEW | 승인 | APPROVED | 현재 검수 대상 revision 일치 |
fun approve(expectedRevision: Long) {
requireState(InspectionState.IN_REVIEW)
if (revisionNo != expectedRevision) {
throw StaleInspectionException(revisionNo, expectedRevision)
}
state = InspectionState.APPROVED
processedAt = clock.instant()
}
이 메서드는 검수 상태와 요청한 revision을 확인한다. 프로그램이 더 최신 검수를 기다리는지까지는 별도 검사해야 한다. 특정 상태 머신 라이브러리를 사용하는 것보다 허용 전이와 검수 대상을 한곳에 정의하는 것이 먼저다.
제출한 내용을 고정하고 편집본과 나눈다
제출 뒤 다른 화면에서 초안이 바뀔 수 있다면 검수 화면이 현재 행을 직접 읽는 구조는 대상을 흔들리게 한다. 제출 시점에 revision을 만들고 재요청에서는 다음 revision을 만든다.
CREATE TABLE program_inspection (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
program_id bigint NOT NULL REFERENCES program(id),
revision_no bigint NOT NULL,
state varchar(30) NOT NULL,
source_version bigint NOT NULL,
payload jsonb NOT NULL,
payload_hash varchar(64) NOT NULL,
submitted_at timestamptz NOT NULL,
processed_at timestamptz,
version bigint NOT NULL DEFAULT 0,
UNIQUE (program_id, revision_no)
);
위 DDL은 검토할 모델이다. JSON payload는 제출 내용, 상태는 처리 과정을 담는다. payload가 바뀌지 않게 하는 쓰기 경로·권한은 추가로 필요하며 hash 컬럼만으로 불변성이 생기지는 않는다. 정규화된 snapshot 테이블을 선택할 수도 있다.
편집 중인 revision과 공개 revision을 별도 포인터로 관리하면 화면의 읽기 계약도 명확해진다. 반려 기록은 보존하고 새 제출을 만들어 어느 내용을 승인했는지 추적한다.
그림의 승인 대상은 최신 편집 행이 아니라 검수한 revision 2다. CMS·관리자·사용자 화면이 서로 다른 버전을 읽는다면 그 차이가 의도한 계약인지 확인한다.
이미지 참조와 정책값도 제출 내용이다
텍스트만 복사하고 이미지 ID·순서·섹션을 빠뜨리면 같은 제출본을 복원할 수 없다.
CREATE TABLE program_inspection_asset (
inspection_id bigint NOT NULL REFERENCES program_inspection(id),
section_type varchar(40) NOT NULL,
order_no integer NOT NULL,
asset_id bigint NOT NULL,
object_key text NOT NULL,
metadata jsonb NOT NULL DEFAULT '{}',
PRIMARY KEY (inspection_id, section_type, order_no)
);
자산 관계를 함께 저장하되 같은 object key의 파일 내용이 바뀌면 DB snapshot만으로 승인 이미지를 보존하지 못한다. 불변 키나 객체 버전도 참조한다. 저장소의 파일 작업과 DB 커밋은 별도이므로 준비된 파일의 확인과 실패 재시도를 설계한다.
초안과 검수본의 mapper가 다르면 한쪽에 추가한 이미지 필드가 다른 쪽에서 빠질 수 있다. 다음은 왕복 변환에서 이미지 관계를 확인할 테스트 예시다.
@Test
fun snapshot_round_trip_preserves_assets() {
val original = fixtureWithHighlightAndEquipmentImages()
val snapshot = snapshotMapper.from(original)
val restored = snapshotMapper.toDraft(snapshot)
assertThat(restored.highlightImages).isEqualTo(original.highlightImages)
assertThat(restored.equipmentImages).isEqualTo(original.equipmentImages)
}
두 이미지 목록을 비교해 변환에서 빠진 관계를 찾는다. fixture와 mapper는 실제 모델에 맞게 구현해야 한다. 파일 참조의 동일성, 순서와 정책값도 검증 대상으로 확장한다.
부가세도 업무에 세 가지 의미가 있다면 boolean 두 값에 억지로 넣지 않는다.
enum class VatPolicy {
INCLUDED, EXCLUDED, NOT_APPLICABLE
}
포함·별도·해당 없음이 필요한 경우의 모델이다. 실제 정책의 상태 집합을 UI·API·DB가 함께 표현해야 한다. 필수 값이면 CHECK와 NOT NULL을 각각 검토한다. PostgreSQL에서 CHECK 식이 null인 경우 통과할 수 있으므로 허용 값 검사만으로 누락이 막히지 않는다. 제약조건 문서를 참고한다.
승인 상태와 공개본을 같은 트랜잭션에서 바꾼다
제출할 때 고정하는 내용과 승인할 때 함께 저장하는 결과를 나눠 본다. 아래 흐름에서 승인 대상은 편집 중인 행이 아닌 검수 revision이다.
편집본·검수본·공개본과 승인 트랜잭션의 경계
승인 상태만 커밋되거나 공개 포인터만 바뀌지 않도록 같은 DB 트랜잭션에 묶는다. 제출 payload와 자산 참조를 계속 고정하는 쓰기 정책은 별도로 필요하다.
UPDATE program_inspection
SET state = 'APPROVED', processed_at = now(), version = version + 1
WHERE id = :inspectionId
AND revision_no = :expectedRevision
AND state = 'IN_REVIEW'
AND version = :expectedVersion;
영향 행이 0이면 미존재·상태·버전 충돌을 확인한다. 이 SQL만으로는 오래된 검수가 최신 공개본을 덮는 일을 막지 못한다. 같은 트랜잭션에서 프로그램의 현재 검수 revision을 확인하고 공개 포인터와 적용 데이터를 변경한다. 어느 단계든 실패하면 승인 상태 변경까지 롤백한다.
낙관적 잠금은 동시 변경을 감지하지만 mapper가 이미지 필드를 누락한 문제를 고치지는 않는다. Jakarta Persistence Version의 역할과 snapshot 완전성 검사를 나눈다. 외부 색인·알림은 outbox와 소비자의 중복 처리 정책을 통해 뒤따르게 할 수 있다.
순환 경로에서 바꾸지 않은 값을 비교한다
검증은 한 번의 제출·승인에서 끝내지 않는다. 초안 저장을 반복하고 반려 후 텍스트만 고쳐 재제출한 뒤, 이미지 ID·순서·정책값이 유지되는지 확인한다. 오래된 revision 승인과 동일 승인 재시도는 별도 경우다. snapshot으로 변환했다가 복원하는 왕복 검사도 각 필드와 자산을 비교해야 한다.
이미 손실된 데이터의 보정은 상태 수정과 분리한다. 변경 이력과 자산 참조로 어떤 revision이 대상이었는지 확정한 뒤 복구 후보를 고른다. 다음 판단은 승인 상태가 성공인지보다 검수 대상과 공개 결과가 같은 내용을 가리키는지에 둔다.

