--- title: Idempotency Key를 API 표면이 아니라 실행 계약으로 보기 source_type: blog status: verified confidence: high tags: [blog, ca-tmpl, idempotency, api-design] related_projects: [ca-tmpl] last_reviewed: 2026-07-02 canonical_sources: - wiki/projects/ca-tmpl/idempotency-key-design audience: backend-engineer target_publish: status_label: 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-Key` header만 받는다고 idempotency가 구현되는 것은 아니다. - 이 글이 답하는 것: ca-tmpl이 key scope, request digest, status classification, persistence/executor 경계를 어떻게 나눴는지. - 이 글이 답하지 않는 것: production retry traffic과 duplicate suppression metric. ## 본문 outline / Body outline 1. header surface와 실제 executor의 차이. 2. triple scope와 request digest — 같은 key가 무엇을 의미하는지 고정한다. 3. in-flight/replay/mismatch 분류 — client가 무엇을 해야 하는지 알려준다. 4. transaction boundary와 persistence adapter — local verification 범위. 5. 아직 운영 검증은 없다. ## 본문 / 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 (있다면) ```java // 출처: [[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); } } ``` ```java // 출처: [[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 execute( IdempotencyContext context, Supplier action, IdempotentResponseCodec codec) { // claim -> fingerprint mismatch -> replay -> in-flight wait -> 409 } } ``` ```java // 출처: [[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); } ``` ```sql -- 출처: [[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 - [x] 모든 사실 주장에 canonical 링크 있음 - [x] 사실 vs 의견 분리 명시됨 - [x] 금지 마케팅 표현 없음 - [x] 코드 예제 출처 명시 - [x] 타깃 독자 가정과 톤 일치 - [x] `/lint` 통과 - [ ] 게시 URL 기록 (게시 후): ## Related / 관련 - 후속 글 후보: [[wiki/blog/ca-tmpl-api-evolution-and-schema-2026-07-02]]