병원 관리 폼에서 첫 저장은 POST /hospitals, 이후 저장은 PATCH /hospitals/{id}를 호출한다고 하자. 서버 응답이 { "id": 101 }에서 { "data": { "id": 101 } }로 바뀌었는데 화면은 여전히 response.id를 읽으면 첫 생성은 커밋됐어도 ID를 저장하지 못한다. 다시 저장할 때 신규 폼으로 판단해 POST를 한 번 더 보낼 수 있다.
질문은 생성 응답을 제대로 읽지 못한 화면이 같은 대상을 다시 만들지 않게 하려면 어디서 막아야 하는가다. 응답 계약, 화면 상태, 생성 명령의 재시도 계약을 나눠 설명한다.
타입 단언은 도착한 JSON을 검사하지 않는다
type CreateHospitalResponse = {
id: number;
};
const body = (await response.json()) as CreateHospitalResponse;
setHospitalId(body.id);
as CreateHospitalResponse는 컴파일러의 타입 해석을 바꾸지만 JSON의 필드를 검사하지 않는다. TypeScript 타입 단언 문서는 런타임 검사 없이 제거되는 동작을 설명한다. 서버가 envelope를 바꾸면 body.id가 없어도 이 단언은 예외를 만들지 않는다.
경계에서 응답을 파싱하고 필요한 ID를 확인한 뒤 성공 상태로 전환한다.
import { z } from "zod";
const CreateHospitalResponse = z.object({
data: z.object({
id: z.number().int().positive(),
}),
});
async function createHospital(input: CreateHospitalInput) {
const response = await fetch("/api/hospitals", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(input),
});
if (!response.ok) throw new Error(`create failed: ${response.status}`);
const parsed = CreateHospitalResponse.safeParse(await response.json());
if (!parsed.success) {
throw new Error("create response contract mismatch");
}
return parsed.data.data.id;
}
Zod의 safeParse는 파싱 성공 여부와 결과를 분리한다. 이 예제는 data.id가 양의 정수인지 확인한다. CreateHospitalInput은 별도 정의할 입력 타입이다. HTTP 상태가 성공이어도 파싱이 실패하면 생성 결과를 인식한 상태로 넘기지 않는다. optional chaining이나 기본값으로 오류를 정상 신규 상태에 숨기지 않는다.
서버 계약도 필수값을 명시한다. OpenAPI 3.0.4의 응답 스키마에서 data와 그 안의 id를 각각 필수로 둔다. JSON Schema의 required 설명처럼 속성을 나열하는 것과 존재를 요구하는 것은 별개다.
CreateHospitalResponse:
type: object
required: [data]
properties:
data:
type: object
required: [id]
properties:
id:
type: integer
format: int64
이 예제는 nullable을 허용하지 않는 정수 ID 계약이다. validator와 코드 생성기가 사용하는 스키마 버전을 일치시킨다.
생성 커밋과 화면의 생성 결과 인식 사이에서 두 번째 POST가 발생하는 경로.
첫 POST는 커밋됐지만 응답의 ID 위치가 달라 클라이언트가 생성 결과를 인식하지 못한다. 화면이 신규 상태에 머물면 다음 저장도 POST가 되어 중복 생성 위험이 생긴다.
ID 없음과 생성 전 상태를 구분한다
if (id) update else create는 결과를 아직 모르는 순간을 표현하지 못한다. 다음 상태를 따로 둔다.
DRAFT 아직 생성 요청 전
CREATING POST 처리 중
PERSISTED 서버 ID를 확인한 상태
UNKNOWN 요청은 전송됐지만 결과를 확정하지 못한 상태
요청 중에는 추가 생성을 막고, 유효 ID를 확인한 경우만 PERSISTED로 전환한다. 전송 뒤 계약 파싱 실패나 타임아웃이 발생하면 서버 생성 여부를 모를 수 있으므로 UNKNOWN으로 둔다. 이 상태를 무조건 DRAFT로 되돌려 POST를 허용하지 않는다.
type SaveState =
| { type: "draft"; clientDraftId: string }
| { type: "creating"; clientDraftId: string }
| { type: "persisted"; id: number; clientDraftId: string }
| { type: "unknown"; clientDraftId: string };
안정적인 clientDraftId는 같은 폼의 시도를 연결한다. 미확정 상태에서는 저장을 잠그거나 서버 결과를 조회해 조정한다. 네트워크 오류가 서버의 미커밋을 뜻한다고 가정하지 않는다.
ID가 없는 두 순간을 상태로 나누면 다음 저장의 종류도 달라진다. 아래 그림은 생성 요청을 이미 전송한 뒤 결과를 알 수 없는 경우를 별도로 둔다.
생성 전 상태와 결과 불명 상태를 나누는 화면 상태 전이
UNKNOWN에서 새 POST로 바로 돌아가는 화살표가 없다. 같은 요청 키로 생성 결과를 확인하고 유효 ID를 얻은 뒤 수정 요청을 허용한다.
서버와 소비자가 같은 응답 계약을 확인한다
계약 문서를 사람이 읽는 절차만으로 변경을 감지하기 어렵다. 서버 통합 테스트는 실제 생성 응답을 스키마와 비교하고, 클라이언트는 ID 누락·잘못된 envelope를 성공으로 처리하지 않는지 확인한다.
Pact의 소비자·제공자 검증은 소비자가 사용하는 요청·응답 예시를 만들고 제공자의 실제 응답과 비교한다. 전체 인터페이스 스키마와 특정 화면의 의존 조건을 함께 검증하는 선택이다.
호환성이 깨지는 변경에는 새 필드를 추가하고 클라이언트를 전환한 뒤 과거 필드를 제거하는 순서를 검토한다. 여러 endpoint의 envelope 정책이 다르면 코드 생성과 런타임 검증이 어떤 계약을 쓰는지 더 분명히 관리해야 한다.
같은 생성 명령을 서버도 식별한다
응답 검증만으로 더블 클릭과 응답 유실을 모두 막지는 못한다. 폼을 처음 만들 때 생성한 키를 재시도에서도 유지한다고 하자.
POST /hospitals
Idempotency-Key: 13fd...9a
Content-Type: application/json
서버는 주체·작업·키 조합을 유일하게 저장한다. payload hash는 같은 키로 다른 입력을 보냈는지 확인하고, resource ID와 상태는 첫 실행의 결과를 나타낸다.
CREATE TABLE idempotency_record (
actor_id bigint NOT NULL,
operation varchar(80) NOT NULL,
request_key varchar(100) NOT NULL,
request_hash varchar(64) NOT NULL,
resource_id bigint NULL,
status varchar(20) NOT NULL,
created_at timestamptz NOT NULL,
UNIQUE (actor_id, operation, request_key)
);
PostgreSQL의 복합 UNIQUE는 키 조합의 중복을 제한한다. 같은 키·같은 입력은 기존 결과를 반환하고, 같은 키·다른 입력은 409 등 정한 계약으로 거절한다. 동시 실행에서는 기록 획득을 UNIQUE와 행 잠금 또는 조건부 전이로 제어한다.
DB 내부 생성이라면 멱등 기록 획득, 병원 생성, 완료 기록을 같은 트랜잭션에 묶는다. 병원만 먼저 커밋하고 나중에 멱등 기록을 저장하면 그 사이 종료 시 중복 창이 생긴다. 미확정 화면은 다음처럼 키로 결과를 조회할 수 있다.
GET /hospital-creation-requests/13fd...9a
200 OK
{
"status": "SUCCEEDED",
"resourceId": 101
}
결과 조회도 원 요청의 actor와 tenant 인가를 적용한다. 키를 아는 것만으로 다른 주체의 생성 결과를 읽게 하지 않는다.
RFC 9110의 멱등 메서드는 비멱등 요청의 자동 재시도 조건을 제한한다. POST라는 메서드만으로 반복 실행이 안전해지지는 않는다. 같은 업무 명령을 식별하는 위 계약이 있어야 응답 유실 후에도 결과를 조정할 수 있다.
업무상 사업자 번호·지점 코드가 유일하다면 별도 자연키 제약도 방어선이다. 그러나 동명 병원처럼 유일하지 않은 속성을 키로 쓰거나 삭제 후 재등록 규칙을 빼면 정상 생성을 막을 수 있다. 명령 중복과 업무 객체의 중복은 각자 정의한다.
생성·응답·다음 저장을 연결해 확인한다
중복 행만 보면 원인을 알 수 없다. clientDraftId, request/trace ID, 멱등 키, resource ID, 파싱한 계약 버전, create/update 판단 당시 상태를 연결한다. 첫 POST는 성공인데 같은 draft의 ID 인식이 실패하고 두 번째 POST가 이어졌는지 본다. 서로 다른 draft라면 별도 생성 행위일 수도 있다.
정상 응답 외에 ID 누락, envelope 변경, 손상된 본문, 커밋 후 연결 종료, 더블 클릭, 같은 키·다른 입력, 구버전 화면과 신버전 서버를 확인한다. 기대 동작은 신규 생성으로 되돌아가는 대신 결과를 조회하거나 같은 명령의 결과를 받는 것이다.
DB 생성 완료와 화면의 ID 인식은 서로 다른 경계다. 두 경계를 계약 검증으로 연결하고 미확정 상태를 표현하며 서버의 재실행까지 제어해야 응답 필드 하나의 변경이 중복 생성으로 번지는 것을 막을 수 있다.

