10 KiB
title, source_type, status, confidence, tags, related_projects, last_reviewed, canonical_sources, audience, target_publish, status_label
| title | source_type | status | confidence | tags | related_projects | last_reviewed | canonical_sources | audience | target_publish | status_label | ||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Idempotency Key를 API 표면이 아니라 실행 계약으로 보기 | blog | verified | high |
|
|
2026-07-02 |
|
backend-engineer | ready |
Idempotency Key를 API 표면이 아니라 실행 계약으로 보기
Parent / 부모 (필수)
- 핵심 canonical: wiki/projects/ca-tmpl/idempotency-key-design
- 관련 개념 문서: wiki/concepts/idempotency-key-design — 일반 idempotency key 개념. 현재 이 글의 구현 사실 근거는 verified project canonical에 둔다.
타깃 독자 / Target reader
- 독자 profile: POST 중복 요청과 retry를 안전하게 처리하려는 백엔드 엔지니어.
- 이미 안다고 가정하는 것: HTTP retry, unique key, transaction.
- 처음 듣는다고 가정하는 것: scope, body digest, replay/in-flight/mismatch 분류를 API 계약으로 고정하는 방식.
도입 / Hook
- 문제 / 궁금증:
Idempotency-Keyheader만 받는다고 idempotency가 구현되는 것은 아니다. - 이 글이 답하는 것: ca-tmpl이 key scope, request digest, status classification, persistence/executor 경계를 어떻게 나눴는지.
- 이 글이 답하지 않는 것: production retry traffic과 duplicate suppression metric.
본문 outline / Body outline
- header surface와 실제 executor의 차이.
- triple scope와 request digest — 같은 key가 무엇을 의미하는지 고정한다.
- in-flight/replay/mismatch 분류 — client가 무엇을 해야 하는지 알려준다.
- transaction boundary와 persistence adapter — local verification 범위.
- 아직 운영 검증은 없다.
본문 / Body
Idempotency-Key header를 받는 것만으로 idempotency가 구현되지는 않습니다. header는 단지 client가 “이 요청은 같은 의도로 다시 보낼 수 있다”고 알려주는 표면입니다. 서버가 실제로 해야 할 일은 더 많습니다. 같은 요청인지 판단해야 하고, 이미 처리 중인지 구분해야 하며, 완료된 결과를 replay할 수 있어야 하고, 같은 key로 다른 body가 들어오면 client bug로 돌려줘야 합니다.
ca-tmpl은 이 문제를 web filter 하나로 처리하지 않았습니다. 핵심 실행 계약은 application layer의 IdempotencyExecutor에 둡니다. web adapter는 header, principal, use case name, request fingerprint를 모아 context를 만들고, executor는 store port를 통해 claim/replay/mismatch/in-flight를 판정합니다. persistence adapter는 DB table과 unique constraint로 scope 충돌을 실제로 막습니다.
scope는 (authenticatedPrincipal, idempotencyKey, useCaseName) triple입니다. tenant isolation이 활성화되면 tenant가 앞에 붙어 4-tuple이 됩니다. 여기서 useCaseName을 넣는 이유가 중요합니다. 같은 principal이 같은 idempotency key를 두 다른 use case에 보냈을 때 충돌하면 안 됩니다. URL path를 scope에 넣지 않는 것도 의도입니다. path version이 바뀌어도 같은 application use case의 실행 의미가 유지될 수 있기 때문입니다.
request fingerprint는 같은 key가 같은 body를 뜻하는지 확인하는 장치입니다. ca-tmpl의 executor는 live record를 찾으면 fingerprint를 먼저 비교합니다. 같으면 상태에 따라 replay 또는 in-flight 처리로 갑니다. 다르면 IdempotencyRequestMismatchException을 던지고, web boundary에서 422로 매핑합니다. 같은 key를 재사용했지만 body가 다르다는 것은 보통 client가 idempotency key를 잘못 관리한다는 신호입니다.
동시 도착은 409로 분리합니다. executor는 record가 IN_FLIGHT이면 바로 실패시키지 않고 최대 200ms 동안 짧게 기다립니다. 그 안에 선행 요청이 완료되면 저장된 response를 replay할 수 있습니다. 그래도 완료되지 않으면 IdempotencyInFlightException이 나고 409로 응답합니다. 이 200ms는 부하 테스트로 튜닝된 수치가 아니라 ca-tmpl 기본 정책값입니다.
완료된 요청은 저장된 response를 replay합니다. action이 성공하면 codec이 response를 직렬화해 store에 저장하고, 같은 scope의 후속 요청은 action을 다시 실행하지 않고 그 payload를 역직렬화합니다. action이 예외를 던지면 executor는 record를 discard합니다. 실패한 실행을 영구적으로 replay하지 않기 위해서입니다. 즉 idempotency는 성공 응답 replay와 실행 중 충돌 제어를 다루며, 모든 실패를 캐시하는 장치가 아닙니다.
저장소는 DB table입니다. Redis나 in-memory cache만으로 두지 않은 이유는 skeleton에서 transaction boundary와 운영 복구 가능성을 우선했기 때문입니다. PostgreSQL migration에는 tenant, principal, idempotency_key, use_case_name unique constraint가 있습니다. TTL은 기본 24h이고, executor는 per-use-case override가 있더라도 72h cap을 넘지 못하게 합니다.
응답 코드도 의도적으로 나뉩니다. in-flight 충돌은 409 Conflict, 같은 key와 다른 body fingerprint는 422 Unprocessable Entity입니다. 두 상황은 client가 해야 할 일이 다릅니다. 409은 조금 뒤 다시 시도할 수 있지만, 422는 key/body 조합을 고쳐야 합니다. ca-tmpl은 이 차이를 error envelope mapping까지 이어갑니다.
검증 범위는 local/dev입니다. IdempotencyExecutor, web helper/codec, RDBMS store, PostgreSQL unique scope contract가 존재하고 ./gradlew check로 검증됐습니다. 하지만 운영 duplicate suppression metric, 실제 retry traffic 비율, 200ms wait의 부하 기반 튜닝은 없습니다. 따라서 이 글에서 말할 수 있는 것은 구현과 로컬 검증이지 운영 효과 측정이 아닙니다.
코드 예제 / Code samples (있다면)
// 출처: [[wiki/projects/ca-tmpl/idempotency-key-design]]
// 실제 파일: application-core/.../IdempotencyScope.java, ca-tmpl @f6fbd4e196b4
public record IdempotencyScope(
String tenant, String principal, String idempotencyKey, String useCaseName) {
public static IdempotencyScope of(String principal, String idempotencyKey, String useCaseName) {
return of(null, principal, idempotencyKey, useCaseName);
}
}
// 출처: [[wiki/projects/ca-tmpl/idempotency-key-design]]
// 실제 파일: application-core/.../IdempotencyExecutor.java, ca-tmpl @f6fbd4e196b4
public final class IdempotencyExecutor {
public static final Duration IN_FLIGHT_WAIT = Duration.ofMillis(200);
public static final Duration MAX_TTL = Duration.ofHours(72);
public <R> R execute(
IdempotencyContext context, Supplier<R> action, IdempotentResponseCodec<R> codec) {
// claim -> fingerprint mismatch -> replay -> in-flight wait -> 409
}
}
// 출처: [[wiki/projects/ca-tmpl/idempotency-key-design]]
// 실제 파일: application-core/.../IdempotencyExecutor.java
if (!record.fingerprint().equals(fingerprint)) {
throw new IdempotencyRequestMismatchException(scope);
}
if (record.status() == IdempotencyStatus.COMPLETED) {
return codec.deserialize(record.response().payload());
}
if (!now.isBefore(deadline)) {
throw new IdempotencyInFlightException(scope);
}
-- 출처: [[wiki/projects/ca-tmpl/idempotency-key-design]]
-- 실제 파일: adapter-persistence-postgresql/.../V1__idempotency_record.sql
CREATE TABLE idempotency_record (
id uuid NOT NULL,
tenant varchar(128) NOT NULL DEFAULT '',
principal varchar(256) NOT NULL,
idempotency_key varchar(256) NOT NULL,
use_case_name varchar(256) NOT NULL,
request_hash char(64) NOT NULL,
status varchar(16) NOT NULL,
response_payload text NULL,
expires_at timestamptz NOT NULL,
CONSTRAINT uq_idempotency_scope
UNIQUE (tenant, principal, idempotency_key, use_case_name)
);
Sources / 근거 (canonical 인용 필수, derived layer 의무)
- wiki/projects/ca-tmpl/idempotency-key-design — 이 글의 1차 canonical. triple scope, 24h TTL, 200ms wait, 409/422 mapping, 구현/로컬 검증 범위와 과장 금지 경계를 따른다.
- wiki/concepts/idempotency-key-design — 관련 개념 문서. 구현 사실 출처로 쓰지 않는다.
사실 vs 의견 / Fact vs opinion 구분
- 사실: ca-tmpl에는
IdempotencyExecutor,IdempotencyStorePort,IdempotencyScope,RequestFingerprint, web helper/codec, RDBMS store, PostgreSQL unique scope migration이 존재한다. 근거: wiki/projects/ca-tmpl/idempotency-key-design - 사실:
./gradlew check, executor/store/web mapping/unique scope contract test가 로컬 검증 범위에 포함된다. 근거: wiki/projects/ca-tmpl/idempotency-key-design - 사실: 운영 배포, duplicate suppression metric, 200ms wait 부하 튜닝은 없다. 근거: wiki/projects/ca-tmpl/idempotency-key-design
- 의견: idempotency는 API header보다 application execution contract로 설명할 때 설계가 더 잘 보인다.
- 알지 못하는 것: 운영 retry traffic에서 replay/in-flight/mismatch 비율이 어떻게 나오는지.
답할 수 있는 범위 / Answer boundary
- 자신 있게 답할 수 있는 후속 질문:
- replay, in-flight, mismatch를 왜 나눴는가?
- triple scope에
useCaseName을 넣은 이유는 무엇인가? - 같은 key + 다른 body를 왜
422로 보는가? - DB table unique constraint가 idempotency executor와 어떻게 맞물리는가?
- 다음 글로 넘길 부분:
- 운영 duplicate suppression metric.
- multi-node production race 부하 테스트.
- long-term retention policy와 비용 모델.
게시 체크리스트 / Publish checklist
- 모든 사실 주장에 canonical 링크 있음
- 사실 vs 의견 분리 명시됨
- 금지 마케팅 표현 없음
- 코드 예제 출처 명시
- 타깃 독자 가정과 톤 일치
/lint통과- 게시 URL 기록 (게시 후):