Files
llm-wiki/vault/40-publish/blog/ca-tmpl-idempotency-key-design-2026-07-02.md
T

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
blog
ca-tmpl
idempotency
api-design
ca-tmpl
2026-07-02
wiki/projects/ca-tmpl/idempotency-key-design
backend-engineer ready

Idempotency Key를 API 표면이 아니라 실행 계약으로 보기

Parent / 부모 (필수)

타깃 독자 / 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 (있다면)

// 출처: [[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 의무)

사실 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 기록 (게시 후):