멱등성은 단순한 재시도 캐시를 넘어, 동일 키의 다른 요청, 동시 재시도, 부분 성공, 다운스트림 불확실성까지 다뤄야 하는 API 계약이다.
2026년 5월 7일
25분 읽기
apihttpidempotencybackenddistributed-systemsdatabasesmicroservicesarchitecturepayments
사람들은 멱등성을 이미 해결된 문제처럼 이야기한다:
요청에
Idempotency-Key를 넣어라. 응답을 저장하라. 재시도 시 재생하라.
물론, 그렇게 만드는 것은 가능하다. 순조로운 경로에 대해서는 구현도 꽤 작다.
클라이언트는 다음을 보낸다:
POST /payments
Idempotency-Key: abc-123
Content-Type: application/json
{
"accountId": "acc_1",
"amount": "10.00",
"currency": "EUR",
"merchantReference": "invoice-7781"
}
서버는 abc-123을 본 적이 있는지 확인한다. 없으면 결제를 생성한다. 있으면 이전 응답을 반환한다.
이 버전은 데모에서는 버틴다.
내가 이견을 제기하는 부분은 이것이 어려운 부분이라는 주장이다. 그렇지 않다. 어려운 부분은 두 번째 요청에서 시작된다. 왜냐하면 두 번째 요청이 항상 첫 번째 요청의 깔끔한 재생은 아니기 때문이다.
완료된 재생일 수도 있다. 좋다. 저장된 결과를 반환하면 된다.
첫 번째 요청이 아직 실행 중일 때 도착할 수도 있다. 이제 당신의 멱등성 레이어는 동시성 제어의 일부가 된다.
첫 번째 요청이 로컬 결제는 만들었지만 이벤트를 발행하기 전에 크래시가 날 수도 있다. 그러면 로컬 행과 외부 부작용이 어긋난다.
또는 첫 번째 요청이 결제 프로바이더를 호출했고, 프로바이더는 이를 수락했지만 당신의 프로세스가 결과를 기록하기 전에 죽었을 수도 있다. 이제 데이터베이스는 돈이 실제로 이동했는지 추론할 수 없다.
혹은 두 번째 요청이 같은 키를 가지지만 내용은 다를 수도 있다:
{
"accountId": "acc_1",
"amount": "100.00",
"currency": "EUR",
"merchantReference": "invoice-7781"
}
같은 키. 다른 금액.
이 경우가 멱등성을 흥미롭게 만든다. 이것은 재시도인가? 클라이언트 버그인가? 새로운 작업인가? 서버는 이전 응답을 재생해야 하는가, 요청을 거부해야 하는가, 아니면 (key + content)를 새로운 식별성으로 취급해야 하는가?
이 정책들 중 무엇이든 고를 수 있다. 단, 명확하게 문서화해야 한다. 하지만 서버는 입장을 가져야 한다. 꼭 내 입장일 필요는 없지만, 분명한 입장은 있어야 한다.
부작용이 있는 API에 대한 내 편향은 이렇다: 같은 범위의 키에 다른 정규화된 명령이 결합되면 강한 오류가 되어야 한다. 이것은 클라이언트 버그를 일찍 잡아낸다. 10 EUR 결제를 안전하게 재시도한다고 믿는 클라이언트를 서버가 조용히 다른 것으로 해석해서는 안 된다.
중요한 경우는 재생 캐시만으로는 설명되지 않는 것들이다:
당신의 설계가 완료된 동일 명령 재시도만 처리한다면, 그것은 재생 캐시다. 어떤 엔드포인트에는 그것으로 충분할 수 있다. 하지만 그것이 문제의 전부는 아니다.
한 작업이 한 번 적용되든 여러 번 적용되든 의도된 효과가 같다면 그 작업은 멱등적이다.
이 정의는 단순하다. 여기서 모든 일을 하는 단어는 “효과”다.
HTTP는 메서드 수준의 의미를 제공한다. 같은 표현을 반복해서 보내도 리소스가 같은 상태로 남는다면 PUT /users/123/email은 멱등적일 수 있다. 이미 삭제된 세션을 다시 삭제해도 여전히 “세션이 존재하지 않는다”를 의미한다면 DELETE /sessions/456도 멱등적일 수 있다. DELETE를 반복하면 404를 반환할 수도 있다. 그래도 효과는 멱등적일 수 있다.
하지만 당신의 핸들러는 여전히 비즈니스가 중요하게 여기는 중복 부작용을 만들 수 있다. 중복 감사 레코드, 중복 도메인 이벤트, 중복 이메일, 중복 프로바이더 호출, 또는 과금이나 사기 탐지 로직에 영향을 주는 중복 메트릭 같은 것들이다.
POST는 보통 기본적으로 멱등적이지 않지만, 서버가 적절한 동작을 저장하고 강제하면 멱등적으로 만들 수 있다. 키는 주장된 작업을 식별한다. 하지만 그것이 요청 동등성, 재생 정책, 또는 다운스트림 중복 제거를 정의해주지는 않는다.
유니크 제약은 한 종류의 중복을 막을 수 있다. 하지만 그것만으로는 클라이언트에게 올바른 재시도 결과를 주지 못한다.
예를 들어 unique(account_id, merchant_reference)는 두 개의 결제 행 생성을 막을 수 있다. 그러나 재시도가 일반적인 500을 받는다면, 클라이언트는 여전히 결제가 성공했는지 모른다. 행이 존재하지만 응답이 다르거나, 이벤트가 두 번 발행되거나, 원장 항목이 중복된다면, 그 작업은 호출자가 신경 쓰는 방식으로 멱등적이지 않다.
POST /payments에서 지속적인 멱등성 레코드는 세 가지 질문에 답할 수 있어야 한다:
PostgreSQL 스타일 SQL로 최소 테이블은 다음과 같을 수 있다:
create table idempotency_requests
(
tenant_id text not null,
operation_name text not null,
idempotency_key text not null,
request_hash text not null,
status text not null,
response_status int,
response_body jsonb,
resource_type text,
resource_id text,
error_code text,
created_at timestamptz not null,
updated_at timestamptz not null,
expires_at timestamptz not null,
locked_until timestamptz,
primary key (tenant_id, operation_name, idempotency_key)
);
전역적으로 고유하게 만들 의도가 없는 한 이 키는 전역 고유 키가 아니다. 보통은 그래서는 안 된다. abc-123을 생성하는 망가진 클라이언트는 자기 자신하고만 충돌해야지, 다른 테넌트와 충돌해서는 안 된다.
범위는 테넌트, 사용자, 계정, 가맹점, API 클라이언트, 또는 그 조합일 수 있다. 의도적으로 선택하라.
작업 이름은 서로 다른 작업 간의 우발적 재사용을 막는다. create_payment에 사용된 키가 자동으로 create_refund에서도 같은 의미가 되어서는 안 된다.
request_hash는 첫 번째 명령에 대한 서버의 기억이다. 이것이 없으면 같은 키에 다른 본문이 붙은 상황이 애매해진다. 첫 번째 응답을 다른 명령에 재생하게 되거나, 오래된 키 아래에서 새로운 작업을 실행하게 된다. 클라이언트가 재시도한다고 생각하고 있다면 둘 다 나쁘다.
IN_PROGRESS는 내부 구현 디테일이 아니다. 첫 번째 요청이 아직 실행 소유권을 가지고 있을 때 재시도가 도착할 수 있다.
동작은 명시적이어야 한다:
| Existing record | Same canonical command? | Suggested behavior |
|---|---|---|
| none | yes | insert IN_PROGRESS and execute |
COMPLETED | yes | replay stored response or documented equivalent |
| any existing record | no | reject with idempotency conflict |
IN_PROGRESS, fresh | yes | wait, return 202, or return 409 + Retry-After |
IN_PROGRESS, stale | yes | recover ownership; do not blindly execute again |
FAILED_REPLAYABLE | yes | replay stored failure |
FAILED_RETRYABLE | yes | allow retry according to policy |
UNKNOWN_REQUIRES_RECOVERY | yes | trigger reconciliation or return pending/recovery status |
| expired/deleted | unknown | follow documented expiry behavior |
응답 필드가 존재하는 이유는 멱등성이 단지 중복 쓰기를 막는 것만이 아니기 때문이다. 클라이언트는 답을 필요로 한다.
전체 응답 본문을 저장할 수도 있고, 생성된 리소스에 대한 참조만 저장한 뒤 응답을 재구성할 수도 있다. 두 선택 모두 서로 다른 방식으로 귀찮다.
전체 응답을 저장하면 충실한 재생이 가능하다. 하지만 PII, 서명된 URL, 일회용 토큰, 카드 소지자 관련 데이터, 또는 원래는 재시도 테이블에 보관할 생각이 없던 필드를 유지하게 될 수도 있다.
리소스 참조로부터 재구성하면 공간은 절약되지만, 생성 이후 리소스가 바뀌었다면 다른 표현을 반환할 수 있다.
이것은 계약 결정이다. “생성 응답을 재생한다”와 “현재 결제를 반환한다”는 둘 다 유효한 API 설계다. 하지만 같은 설계는 아니다.
이것이 멱등성 레이어가 크게 잡아내야 하는 버그다.
첫 번째 요청:
{
"accountId": "acc_1",
"amount": "10.00",
"currency": "EUR",
"merchantReference": "invoice-7781"
}
두 번째 요청:
{
"accountId": "acc_1",
"amount": "100.00",
"currency": "EUR",
"merchantReference": "invoice-7781"
}
같은 Idempotency-Key: abc-123. 다른 금액.
그래도 원래 응답을 반환하는 것은 단순하다. 하지만 그것은 심각한 클라이언트 버그를 숨긴다. 클라이언트는 100 EUR 결제를 요청했는데 10 EUR 결제 응답을 받는다. 호출자가 응답을 주의 깊게 비교하지 않으면, 100 EUR 결제가 성공했다고 믿을 수 있다.
이것은 멱등성이 아니다. 이것은 재해석이다.
부작용이 있는 API에서는 다른 정규화된 명령과 함께 재사용된 범위 키는, 첫 번째 작업이 완료되었든 실패했든 아직 실행 중이든 상관없이 강한 오류가 되어야 한다.
HTTP/1.1 409 Conflict
Content-Type: application/json
{
"errorCode": "IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_REQUEST",
"message": "이 멱등성 키는 이미 다른 요청과 함께 사용되었습니다."
}
409 Conflict는 서버가 그 범위 키에 대해 기억하고 있는 의미와 요청이 충돌하기 때문에 방어 가능한 기본값이다. 어떤 API는 400이나 422를 사용한다. 중요한 것은 안정적인 기계 판독 가능 오류와, 다른 명령에 대한 조용한 재생이 없어야 한다는 점이다.
흔한 클라이언트 버그는 다음과 같다:
bad:
idempotencyKey = cartId
POST /payments amount=10.00 key=cart_123
POST /payments amount=15.00 key=cart_123
better:
idempotencyKey = paymentAttemptId
서버는 카트 키가 어떤 결제를 나타내려 했는지 추측해서는 안 된다.
(key + content hash)가 작업 식별성을 정의하도록 API를 설계할 수도 있다. 그것도 유효한 정책이다. 하지만 그러면 그 키는 더 이상 일반적인 재시도 의미의 멱등성 키가 아니다. 그것은 복합 작업 식별자의 일부다. 이 점이 클라이언트에게 분명해야 한다.
위험한 버전은 그 중간지대다. 클라이언트는 하나의 작업을 안전하게 재시도하고 있다고 생각하는데, 서버는 두 번째 요청을 조용히 다른 작업으로 해석한다.
JSON API에서는 원시 바이트 비교가 보통 너무 엄격하다. 다음 두 본문은 일반적으로 동등해야 한다:
{
"amount": "10.00",
"currency": "EUR"
}
{
"currency": "EUR",
"amount": "10.00"
}
필드 순서와 공백은 중요하지 않아야 한다.
기본값은 덜 자명하다:
{
"accountId": "acc_1",
"amount": "10.00",
"currency": "EUR"
}
대비:
{
"accountId": "acc_1",
"amount": "10.00",
"currency": "EUR",
"channel": "web"
}
channel: "web"이 서버 기본값이라면, 이 둘은 같은 논리적 명령인가? 아마도 그럴 수 있다. 해시하기 전에 결정하라.
알 수 없는 필드도 또 다른 함정이다. 당신의 API가 알 수 없는 JSON 필드를 무시한다고 해보자. 첫 번째 요청에는 "foo": "bar"가 있고 두 번째에는 없다면, 둘을 같다고 볼 것인가? 알 수 없는 필드가 정말로 무시된다면 아마 그렇다. 배포 후 의미를 갖게 될 수 있다면 아마 아닐 것이다.
실용적인 규칙은 이렇다: 원시 HTTP 본문이 아니라 검증된 명령을 해시하라.
합리적인 흐름은 다음과 같다:
Prefer: return=minimal처럼 응답 형태에만 영향을 주는 헤더는 명령 해시에 포함할지, 재생 계약에 포함할지, 아니면 둘 다 제외할지 결정한다.Authorization과 멱등성 키 자체는 제외한다.결제 예시에서 지문은 다음을 포함할 수 있다:
operation: create_payment
accountId: acc_1
amount: 10.00
currency: EUR
merchantReference: invoice-7781
channel: web
apiVersion: 2026-05-01
금액, 타임스탬프, 생성된 기본값, 로케일에 민감한 포맷팅, 그리고 배포 중 추가되는 필드에 주의하라. 요청 해시는 계약이다. 계산 방식을 바꾸면 오래된 재시도가 갑자기 다른 것처럼 보이기 시작할 수 있다.
거의 같은 시각에 두 개의 동일한 요청이 두 API 인스턴스에 도착한다:
POST /payments
Idempotency-Key: abc-123
같은 정규화된 명령. 같은 테넌트. 같은 엔드포인트.
다음 구현은 단일 스레드 테스트를 모두 통과하더라도 깨져 있다:
existing = find_by_key(key)
if existing does not exist:
create_payment()
insert_idempotency_record()
두 요청 모두 기존 행이 없다고 관찰할 수 있다. 그러면 둘 다 부작용을 실행할 수 있다.
범위 키에 대한 원자적 삽입이나 유니크 제약이 없다면, 두 인스턴스 모두 자신이 실행 소유자라고 판단할 수 있다.
삽입 우선 형태는 다음과 같다:
insert into idempotency_requests (tenant_id,
operation_name,
idempotency_key,
request_hash,
status,
created_at,
updated_at,
expires_at,
locked_until)
values (:tenant_id,
'create_payment',
:idempotency_key,
:request_hash,
'IN_PROGRESS',
now(),
now(),
now() + interval '24 hours',
now() + interval '30 seconds') on conflict do nothing;
정확한 문법은 데이터베이스마다 다르다. 중요한 속성은 (tenant_id, operation_name, idempotency_key)에 대해 원자적으로 소유권을 획득하는 것이다.
그 다음:
if rows_inserted == 1:
this request owns execution
else:
existing = load idempotency row
if existing.request_hash != request_hash:
return 409 IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_REQUEST
if existing.status == COMPLETED:
return replay(existing.response_status, existing.response_body)
if existing.status == IN_PROGRESS and existing.locked_until > now():
return 202 or 409 + Retry-After
if existing.status == IN_PROGRESS and existing.locked_until <= now():
attempt recovery ownership
# this must be atomic too
if existing.status == UNKNOWN_REQUIRES_RECOVERY:
trigger reconciliation or return pending/recovery response
복구 소유권 획득도 원자적이어야 한다. 그렇지 않으면 두 재시도가 모두 이전 소유자가 죽었다고 판단하고 둘 다 복구를 시작할 수 있다.
단순한 로컬 케이스에서는 소유자가 결제를 만들고 멱등성 레코드를 하나의 트랜잭션 안에서 완료할 수 있다:
begin transaction
insert idempotency row as IN_PROGRESS
insert payment row pay_789
insert outbox event PaymentCreated(pay_789)
update idempotency row:
status = COMPLETED
resource_type = payment
resource_id = pay_789
response_status = 201
response_body = {...}
commit
이것이 좋은 버전이다. 하나의 데이터베이스 트랜잭션이 멱등성 행, 비즈니스 행, 아웃박스 이벤트를 모두 덮는다.
외부 부작용이 있으면 형태가 바뀐다. 프로바이더를 호출하면서 데이터베이스 트랜잭션을 열어 두는 것은 보통 나쁜 생각이다. 프로바이더 호출 전에 커밋하면, 실행은 트랜잭션 밖에서 계속되는 동안 로컬 상태는 IN_PROGRESS라고 말하게 될 수 있다. 그 지점에서 프로세스가 크래시 나면 재시도가 복구해야 한다. 이때 필요한 것은 단순한 요청 테이블이 아니라 작업 상태 머신과 복구 워커다.
Redis SET NX EX는 종종 전체 해결책처럼 제안된다. 잘해야 실행 가드일 뿐이다:
SET idempotency:tenant_1:create_payment:abc-123 value NX EX 30
이것은 중복 동시 실행을 줄일 수 있다. 하지만 작업 결과에 대한 지속적인 기억은 아니다. 프로바이더 호출이 아직 실행 중일 때 Redis 락이 만료되면 다른 요청이 진입할 수 있다. 프로세스가 프로바이더 성공 후 응답을 저장하기 전에 죽는다면, 그 락은 재시도에게 무슨 일이 일어났는지 알려주지 못한다. Redis 락이 다운스트림 리소스를 보호한다면 펜싱이나 지속적인 소유권도 필요하다.
Redis는 유용할 수 있다. 하지만 작업 결과를 기억하는 것을 대체하지는 못한다.
중요한 실패 경로는 이색적인 것이 아니다:
POST /payments를 받는다.IN_PROGRESS로 삽입한다.pay_789를 만든다.프로바이더가 당신의 요청을 받았고 결과를 기록하기 전에 당신의 프로세스가 죽었다면, 데이터베이스는 돈이 실제로 이동했는지 추론할 수 없다.
로컬 상태 머신은 다음과 같을 수 있다:
RECEIVED
LOCAL_PAYMENT_CREATED
PROVIDER_REQUEST_SENT
PROVIDER_CONFIRMED
COMPLETED
UNKNOWN_REQUIRES_RECOVERY
재시도 동작은 상태에 달려 있다.
재시도가 COMPLETED를 찾으면 재생한다.
새로운 PROVIDER_REQUEST_SENT를 찾으면 202 Accepted, 409 Conflict와 Retry-After, 또는 짧게 블록하고 완료를 기다리는 동작 중 하나를 선택하라. 하나를 정하고 문서화하라. 클라이언트는 재시도해야 하는지, 폴링해야 하는지, 기다려야 하는지 알아야 한다.
오래된 PROVIDER_REQUEST_SENT를 찾았다면 pay_790를 만들지 마라. 새로운 식별성으로 프로바이더를 호출하지 마라. 안정적인 다운스트림 작업 ID를 사용해 복구하라:
payment id: pay_789
provider idempotency key: provider_payment_pay_789
그 다음 복구 워커나 재시도 요청은 다음을 할 수 있다:
pay_789에 대한 복구 소유권을 획득한다provider_payment_pay_789로 조회한다COMPLETED로 표시한다UNKNOWN_REQUIRES_RECOVERY로 표시한다프로바이더에 멱등성 키도 없고 조회 API도 없다면, 시스템에 운영상 공백이 있는 것이다. 여전히 이를 수용하기로 결정할 수는 있다. 하지만 로컬 멱등성 테이블이 외부 효과를 보호하고 있는 것은 아니다. 그것은 중복 로컬 요청 처리만 막는다.
결제와 유사한 작업에서 클라이언트의 멱등성 키는 종종 다운스트림으로 보내는 정확한 키가 아니다. 다운스트림 호출에는 재시도, 크래시, 정합성 복구를 견디는 안정적인 식별성이 필요하다. 그렇지 않으면 두 번째 로컬 시도는 그냥 두 번째 프로바이더 시도가 된다.
당신의 API에 이미 그것을 써야 할 특별한 이유가 없다면 425 Too Early는 피하고 싶다. 대부분의 클라이언트는 그것을 특별하게 처리하지 않는다. 202 Accepted, 409 Conflict와 Retry-After, 또는 작업 상태 엔드포인트가 설명하기 더 쉽다.
완료된 멱등 요청에 대해 같은 상태와 본문을 재생하는 것이 가장 놀랍지 않은 동작이다:
HTTP/1.1 201 Created
Idempotent-Replayed: true
Content-Type: application/json
{
"paymentId": "pay_789",
"status": "PENDING",
"accountId": "acc_1",
"amount": "10.00",
"currency": "EUR",
"merchantReference": "invoice-7781"
}
Idempotent-Replayed: true 같은 커스텀 응답 헤더는 디버깅에 도움이 될 수 있다. 하지만 클라이언트가 그것에 의존하게 만들고 싶지는 않다.
현재 리소스 상태로부터 응답을 재구성하는 것은 매력적이다:
load payment pay_789
return current representation
하지만 첫 번째 응답이 다음과 같았다고 해보자:
{
"paymentId": "pay_789",
"status": "PENDING"
}
그리고 정산 후 10분 뒤에 재시도가 일어난다면:
{
"paymentId": "pay_789",
"status": "SETTLED"
}
그것은 유용할 수 있다. 하지만 재생은 아니다. 그것은 리소스의 새로운 읽기다. 당신의 API 계약이 멱등 재시도는 원래 생성 결과를 반환한다고 말한다면, 그렇게 할 수 있을 만큼 충분히 저장해야 한다.
스키마 변경은 이 문제를 더 악화시킨다.
버전 2 응답:
{
"paymentId": "pay_789",
"status": "PENDING"
}
버전 3 응답:
{
"id": "pay_789",
"state": "PENDING",
"createdAt": "2026-05-07T10:00:00Z"
}
생성된 클라이언트가 배포 후 재시도할 때, 저장된 v2 응답을 받아야 하는가, 아니면 재구성된 v3 응답을 받아야 하는가? 둘 다 방어 가능할 수 있다. 하지만 서로 다른 계약이다.
흔한 절충안은 다음을 저장하는 것이다:
resource_type = payment
resource_id = pay_789
response_status = 201
response_schema_version = v2
그리고 정확한 재생이 중요한 엔드포인트에만 전체 응답 본문을 저장하는 것이다. 본문을 저장한다면 멱등성 테이블을 무해한 캐시가 아니라 민감한 데이터 저장소처럼 다뤄라.
HTTP는 헤더가 눈에 보이기 때문에 가장 많은 주목을 받는다. 하지만 많은 중복 부작용은 그 이후, 즉 컨슈머, 아웃박스 퍼블리셔, 인박스 프로세서, 알림 워커에서 발생한다.
결제 서비스가 다음을 발행한다고 해보자:
{
"eventId": "evt_100",
"type": "PaymentCreated",
"paymentId": "pay_789",
"accountId": "acc_1",
"amount": "10.00",
"currency": "EUR"
}
컨슈머가 이것을 두 번 받는다. 그러면 이메일 두 통을 보내거나, 원장 항목 두 개를 만들거나, 프로바이더에 두 번 알리면 안 된다.
중복 제거 키는 이벤트 ID, 메시지 ID, 작업 ID, aggregate ID와 버전의 조합, 또는 ledger_payment_pay_789 같은 비즈니스 키일 수 있다. 정답은 부작용에 따라 다르다.
컨슈머 인박스 테이블은 다음과 같을 수 있다:
consumer_inbox
- consumer_name
- message_id
- status
- processed_at
- error_code
unique(consumer_name, message_id)
하지만 메시지를 처리 완료로 표시하는 것은 단순하지 않다.
이메일을 보내기 전에 처리 완료로 표시하고 그 뒤에 크래시 나면, 재시도는 그 이메일을 영원히 건너뛴다. 이메일을 보내고 나서 처리 완료로 표시하기 전에 크래시 나면, 재시도는 이메일을 다시 보낼 수 있다. 보통의 해답은 보내기 전에 부작용을 지속화하는 것이다. 고유 키를 가진 이메일 알림 행을 삽입하고, 별도의 송신 프로세스가 그 행을 처리하게 하라.
원장 항목은 종종 자연스러운 멱등성 키를 가진다:
unique(ledger_entry_type, source_payment_id)
PaymentCreated(pay_789)를 두 번 처리하면 같은 원장 항목을 두 번 만들려고 시도하게 되고, 두 번째 시도는 기존 항목으로 해소된다.
많은 프로덕션 큐 통합은 컨슈머 관점에서 사실상 최소 한 번 전달이다. 브로커가 더 강한 전달 의미를 광고하더라도, 비즈니스 부작용은 여전히 중복 제거가 필요하다. 정확히 한 번 전달은 정확히 한 번 비즈니스 효과를 의미하지 않는다. 후자는 보통 지속적인 작업 ID, 유니크 제약, 멱등 쓰기, 복구 경로에서 나온다.
아웃박스/인박스는 보통 다음 형태다:
same database transaction:
insert payment row pay_789
insert outbox event PaymentCreated(pay_789)
publisher:
reads unpublished outbox event
publishes event with eventId
marks outbox event published
consumer:
deduplicates by eventId or business operation key
writes side effect behind a unique constraint
멱등성은 일부 중복을 막는다. 하지만 poison message, 망가진 프로바이더, 데드레터 처리, 복구 작업을 없애주지는 못한다.
멱등성 레코드는 보통 영원히 살 수 없다.
서버가 24시간 멱등성 윈도우를 약속한다면, 25시간 뒤의 재시도는 새로운 작업을 만들 수 있다. 이것은 허용 가능할 수 있다. 하지만 며칠 동안 큐 재시도를 하는 클라이언트에게는 놀라울 수 있다. 재생 윈도우는 단순한 정리 설정이 아니라 제품/API 결정이다.
완료된 레코드는 다음과 같을 수 있다:
created_at: 2026-05-07T10:00:00Z
expires_at: 2026-05-08T10:00:00Z
status: COMPLETED
만료 후에는 응답 본문은 삭제하고 메타데이터는 더 오래 보관할 수도 있다:
idempotency_key
scope
operation_name
request_hash
resource_id
created_at
expires_at
이것은 민감한 응답 페이로드를 유지하지 않으면서도 진단을 지원한다.
오래된 IN_PROGRESS는 별도 처리가 필요하다:
status: IN_PROGRESS
resource_id: pay_789
updated_at: 2026-05-07T10:00:00Z
locked_until: 2026-05-07T10:00:30Z
now: 2026-05-07T10:45:00Z
이것을 보는 재시도는 무턱대고 다시 실행해서는 안 된다. 복구 소유권을 획득하고, pay_789를 검사하고, 필요하다면 다운스트림을 조회하고, 작업을 COMPLETED, FAILED_RETRYABLE, 또는 UNKNOWN_REQUIRES_RECOVERY로 옮겨야 한다.
정리 작업은 단지 오래되었다는 이유만으로 진행 중 레코드를 제거해서는 안 된다. 오래된 진행 중 행은 멈춘 워커, 프로세스 크래시, 또는 정합성 복구를 기다리는 작업을 의미할 수 있다. 그것을 삭제하면 중복 부작용이 허용될 수 있다.
나쁜 정리:
delete
from idempotency_requests
where expires_at < now();
더 나은 선택지로는 작은 배치로 삭제하기, expires_at 기준 파티셔닝, 재생 윈도우가 지난 오래된 시간 파티션 드롭하기, 응답 본문과 메타데이터에 대해 별도 보존 정책 유지하기 등이 있다.
재생 횟수는 주로 용량 계획 문제다. 서로 다른 본문 재사용, 오래된 IN_PROGRESS 행, 만료된 재시도, 미확정 상태가 버그를 찾는 메트릭이다.
idempotency.replay.count
idempotency.conflict.different_request.count
idempotency.in_progress.age.max
idempotency.expired_retry.count
idempotency.unknown_state.count
위험한 실수는 모든 실패를 “재시도해도 안전함” 또는 “완료됨”으로 취급하는 것이다.
순수한 문법 검증 실패는 보통 멱등성 저장이 필요하지 않다. JSON이 잘못되었거나 필수 필드가 빠졌다면, 요청을 반복해도 다시 실패할 것이다.
비즈니스 거부는 다르다. 결정이 잔액, 재고, 계정 상태, 또는 사기 탐지 규칙처럼 변할 수 있는 상태에 의존한다면, 첫 번째 결정이 그 멱등성 키에 대해 구속력을 가지는지, 아니면 클라이언트가 새 키로 재시도해야 하는지 결정하라.
결정론적인 거부는 재생 가능할 수 있다:
{
"errorCode": "INSUFFICIENT_FUNDS",
"message": "이 결제를 처리하기에 계정 잔액이 부족합니다."
}
하지만 5초 뒤 계정 잔액이 바뀐다면, 그 거부를 재생하는 것이 당신의 API 의도에 맞는지는 별개의 문제다.
인증 실패는 멱등성 레코드를 만들면 안 된다. 인가 실패에 대해서는 주의하라. 재시도는 여전히 원래 레코드를 만든 것과 같은 범위/주체로 해석되어야 한다. 한 호출자가 다른 호출자의 멱등성 키를 이용해 어떤 작업이 일어났는지 알아내게 해서는 안 된다. 나중의 권한 변경이 이미 완료된, 원래는 인가되었던 작업의 재생을 막아야 하는지는 제품 및 보안 결정이다.
속도 제한은 보통 완료된 멱등 결과로 기록하면 안 된다. 나중의 재시도는 허용될 수 있다.
부작용 이전의 서버 오류는 종종 재시도를 허용해도 된다. 부작용 이후의 서버 오류는 위험하다. 결제를 생성했지만 응답 직렬화에 실패했다면, 재시도는 또 다른 결제를 만들면 안 된다. 프로바이더를 호출하고 응답을 잃었다면, 재시도에는 낙관이 아니라 복구 상태가 필요하다.
실용적인 내부 상태 집합은 다음과 같을 수 있다:
IN_PROGRESS
COMPLETED
FAILED_REPLAYABLE
FAILED_RETRYABLE
UNKNOWN_REQUIRES_RECOVERY
EXPIRED
모든 내부 상태를 외부에 그대로 노출하지는 마라. 하지만 내부적으로 모든 실패가 “끝남” 또는 “안 끝남” 둘 중 하나라고 가장하면 복구가 더 어려워진다.
유용한 구분은 모놀리스 대 마이크로서비스가 아니다. 하나의 지속적인 트랜잭션이 작업을 덮을 수 있는지 여부다.
하나의 데이터베이스 트랜잭션이 멱등성 행, 결제 행, 아웃박스 레코드를 모두 덮을 수 있다면, 로컬 부분은 단순하다:
insert idempotency row
insert payment row
insert outbox event
mark idempotency completed
commit
퍼블리셔는 아웃박스 전달을 재시도할 수 있다. 컨슈머는 이벤트 ID나 비즈니스 작업 키로 중복을 제거한다. 로컬 쓰기 경로는 추론하기 훨씬 쉽다.
부작용이 경계를 넘으면, 작업을 반복할 수 있는 모든 경계마다 자체적인 중복 억제 규칙이 필요하다.
Idempotency-Key: abc-123를 받는 업스트림 API는 엣지에서 중복 HTTP 결제 생성 요청을 막을 수 있다. 하지만 그것이 자동으로 중복 원장 항목, 중복 알림, 중복 프로바이더 호출, 또는 중복 읽기 모델 업데이트를 막아주지는 않는다.
더 나은 모델은 안정적인 작업 식별성을 유지하는 것이다:
client idempotency key: abc-123
payment operation id: payop_456
payment id: pay_789
ledger entry id: ledger_payment_pay_789
email dedupe key: receipt_payment_pay_789
provider idempotency key: provider_payment_pay_789
이름은 중요하지 않다. 핵심은 각 부작용이 그 부작용에 적합한 지속적인 식별성을 가진다는 점이다.
active-active 멀티 리전 배포에서 리전 로컬 멱등성 테이블은 같은 리전에 도착하는 재시도만 보호한다. 같은 범위 키에 대한 모든 요청을 홈 리전으로 라우팅하거나, 멱등성 레코드에 대해 강한 일관성을 가진 공유 저장소를 사용하거나, 크로스 리전 경쟁을 견디는 다운스트림 비즈니스 제약에 의존해야 한다. 비동기 복제만으로는 두 리전이 서로의 쓰기를 보기 전에 같은 키를 둘 다 수락할 수 있다.
고처리량 API에서는 멱등성 테이블이 핫패스가 될 수 있다. 응답 본문은 비용이 커질 수 있다. 정리는 트래픽과 경쟁할 수 있다. 필요하다면 테넌트, 해시, 시간 기준으로 파티셔닝하라. 재생 윈도우를 파악하라. 중복의 피해가 그만한 가치를 갖지 않는다면 전역 테이블을 병목으로 만들지 마라.
비용은 헤더가 아니다. 그 뒤에 있는 지속적인 기억과 복구 동작이 비용이다.
중복이 무해하고 눈에 잘 띄는 관리자 작업에 대해 결제 수준의 멱등성 레이어를 만들지 마라.
읽기 전용 작업에서는 멱등성 키가 보통 잡음만 더한다.
중복 분석 이벤트의 비용이 거의 없고 다운스트림에서 수정 가능하다면, 무거운 멱등성 테이블은 잘못된 선택일 수 있다.
어떤 작업에서는 랜덤 키보다 비즈니스 키가 더 낫다:
unique(account_id, merchant_reference)
비즈니스 규칙이 “계정별 가맹점 참조마다 결제는 하나만 존재할 수 있다”라면, 그 제약은 클라이언트가 실수로 새로운 랜덤 키로 재시도하더라도 중복을 잡아낸다. 랜덤 멱등성 키는 클라이언트가 재시도 시 같은 키를 재사용할 때만 도움이 된다.
다른 작업에서는 리소스 모델을 바꾸는 편이 낫다:
PUT /accounts/acc_1/settings/default-currency
{
"currency": "EUR"
}
이 요청을 반복하면 설정은 EUR로 남는다. 여전히 부작용을 고민해야 하지만, 작업 형태 자체가 도움을 주고 있다.
클라이언트가 같은 작업의 재시도를 식별할 수 있을 때 클라이언트 생성 키는 유용하다. 제대로 생성된 랜덤 키면 보통 충분하다. 타임스탬프만으로 만든 키, 카운터, 민감한 데이터에서 파생된 키는 그렇지 않다. 예를 들어 (tenant_id, operation_name, idempotency_key)처럼 호출자와 작업에 키의 범위를 두어, 잘못된 클라이언트가 자기 자신과만 충돌하게 하라. 클라이언트가 매 시도마다 새 키를 생성한다면, 비즈니스 키나 서버가 만든 작업 리소스가 필요하다.
중복 부작용이 초래하는 피해의 크기, 재시도의 가능성, 사후에 중복을 감지하기 어려운 정도를 기준으로 얼마나 많은 장치가 필요한지 결정하라.
중복이 돈을 움직이거나, 사람에게 알림을 보내거나, 프로바이더를 호출하거나, 희소한 재고를 소비하거나, 회계를 오염시킨다면 설계 노력을 들여라. 중복이 무해하고, 드물고, 정리하기 쉽다면 더 작은 메커니즘을 사용하라.
행복 경로 유닛 테스트 열두 개보다 내가 더 보고 싶은 테스트들이다.
첫 번째 요청이 결제를 생성한다:
POST /payments
Idempotency-Key: abc-123
반환:
201 Created
그리고 paymentId = pay_789.
같은 정규화된 명령과 키를 가진 두 번째 요청은 같은 저장 결과 또는 문서화된 동등 결과를 반환해야 한다. pay_790를 만들면 안 된다. 두 번째 PaymentCreated 이벤트를 발행해서도 안 된다.
첫 번째 요청:
{
"amount": "10.00",
"currency": "EUR"
}
두 번째 요청:
{
"amount": "100.00",
"currency": "EUR"
}
같은 키.
예상 동작: 안정적인 기계 판독 가능 멱등성 충돌로 거부한다. 로그를 남기고 카운트한다.
같은 키와 같은 명령으로 두 요청을 동시에 시작한다.
예상 동작: 하나만 실행에서 승리한다. 다른 하나는 IN_PROGRESS를 보고 기다렸다가 재생하거나, 나중에 재시도하라는 응답을 반환한다. 부작용은 한 번만 실행된다.
유니크 제약이나 원자적 삽입 없이 이 테스트가 통과한다면, 테스트를 의심하라.
프로바이더 성공을 시뮬레이션한 뒤 클라이언트가 응답을 받기 전에 크래시 나게 하라.
예상 동작: 재시도는 새로운 작업 식별성으로 프로바이더를 호출해서는 안 된다. 로컬 완료 상태를 찾거나, 프로바이더의 멱등 상태를 조회하거나, 복구 상태로 들어가야 한다.
PaymentCreated(pay_789)를 두 번 전달하라.
예상 동작: 원장 항목 하나, 이메일 알림 하나, 프로바이더 알림 하나. 첫 번째 시도가 중간에 절반만 진행되고 실패했다면, 재시도는 완료된 작업을 중복시키지 않으면서 누락된 지속 작업을 마무리해야 한다.
멱등성 레코드가 만료된 뒤 재시도하라. 레코드가 오래된 IN_PROGRESS일 때 재시도하라. 응답 스키마가 바뀐 뒤 재시도하라. 배포가 허용한다면 다른 리전에서 재시도하라.
이것들은 이색적인 경우가 아니다. 네트워크 위에서 재시도할 때의 정상적인 가장자리다.
IN_PROGRESS를 API에 보이는 동작으로 취급하라.IN_PROGRESS, 만료된 재시도, 미확정 상태, 재생률을 모니터링하라.쉬운 버전의 멱등성은 키가 보였다는 사실을 기억한다.
유용한 버전은 그 키의 의미를 기억한다.
POST /payments에서 그것은 범위가 지정된 작업, 정규화된 명령, 실행 상태, 결과 리소스 또는 응답, 만료 윈도우, 그리고 불확실성을 중복 부작용으로 바꾸지 않을 만큼 충분한 실패 상태를 기억하는 것을 뜻한다.
두 번째 요청은 재시도일 수 있다. 같은 키를 쓴 다른 작업일 수도 있다. 첫 번째 요청과 경쟁 중일 수도 있다. 프로바이더는 성공했지만 당신의 프로세스는 실패한 뒤에 도착할 수도 있다. 정리 작업이 무슨 일이 있었는지에 대한 유일한 기억을 지운 뒤에 도착할 수도 있다.
서버는 그것이 어떤 경우인지 증명해야 한다.
키 자체가 보장은 아니다. 보장은 서버가 첫 번째 작업을 충분히 정확하게 기억하여 그것을 재생하거나, 불일치를 거부하거나, 추측하는 대신 복구할 수 있다는 점이다.
[INFO] 2026 Dochia. 복원력을 사랑하는 엔지니어를 위해 만들어졌습니다.
Astro와 Tailwind CSS로 구축되었습니다.