실제로 작동하는 분산 시스템의 멱등성

중복된 side effect 방지

Page content

분산 시스템에서 멱등성(Idempotency)은 네트워크 오류, 큐 재시도, 클라이언트 패닉, 그리고 운영자가 재생(Replay) 버튼을 누른 후에도 시스템을 구해 주는 속성입니다. 프로덕션 시스템에서는 중복 전송이 정상적인 현상입니다. 반면 중복된 부수 효과(Side Effects)는 버그입니다.

HTTP는 동일한 요청을 여러 번 보냈을 때 서버에 단일 요청과 동일한 의도된 효과가 발생하는 메서드를 멱등적 메서드로 정의합니다. 이것이 PUT, DELETE 및 안전(Safe) 메서드가 프로토콜 세맨틱상 멱등적이며 통신 실패 후 자동으로 재시도할 수 있는 이유입니다.

integration message flow: idempotency

이 정의는 유용하지만 충분하지 않습니다. 실제 아키텍처에서 멱등성은 HTTP의 상식 퀴즈 답변이 아닙니다. 이는 비즈니스 보장(Business Guarantee)입니다. 고객이 ‘결제’ 버튼을 한 번 눌렀다면, 커밋과 응답 사이에 타임아웃이 발생했다고 해서 두 번 청구해서는 안 됩니다. 워커가 인벤토리를 업데이트한 후 메시지 확인(Ack) 전에 크래시가 발생했다면, 브로커가 메시지를 재전송했다고 해서 재고를 두 번 차감해서는 안 됩니다. 이것이 기준입니다.

저가 반복적으로 보는 실수는 멱등성을 전송 계층의 기능으로 처리하는 것입니다. 큐 중복 제거, HTTP 동사, 클라이언트 재시도는 도움이 되지만, 동일한 비즈니스 의도가 두 번째 부수 효과를 생성하도록 허용하는 설계는 구제하지 못합니다. 이러한 통합 결정이 서비스 경계와 지속성(Persistence) 트레이드오프에 어떻게 부합하는지에 대한 더 넓은 프레임워크를 원한다면 프로덕션용 앱 아키텍처: 통합 패턴, 코드 설계, 데이터 액세스부터 시작하십시오.

프로덕션에서 중복이 발생하는 원인

중복은 팀이 부주의하기 때문에 발생하는 것이 아닙니다. 분산 시스템이 재시도하고, 순서를 재배치하며, 재생하기 때문에 발생합니다.

클라이언트는 생성 요청을 보낼 수 있고, 서버는 이를 커밋할 수 있지만, 응답은 여전히 네트워크 선상에서 사라질 수 있습니다. 이것이 바로 HTTP가 멱등적 메서드를 구분하는 이유이며, Stripe와 PayPal 같은 결제 API가 POST와 같은 안전하지 않은 메서드에 대해 명시적인 멱등성 메커니즘을 노출하는 이유입니다.

메시지 브로커는 이 문제를 더욱 명확하게 만듭니다. 최소 한 번(At-least-once) 전달은 동일한 메시지에 대해 소비자가 반복적으로 호출될 수 있음을 의미하며, 핸들러는 데이터베이스를 성공적으로 업데이트했지만 확인(Acknowledgment) 전에 실패하여 브로커가 동일한 메시지를 다시 전달하도록 할 수 있습니다.

웹훅(Webhook)도 다르지 않습니다. GitHub는 웹훅 전달이 순서 없이 도착할 수 있으며, 실패한 전달은 자동으로 재전송되지 않는다고 명시합니다. 또한 각 전달에는 재생 공격을 방지할 때 사용해야 하는 고유한 X-GitHub-Delivery GUID가 포함되어 있습니다. 채팅 엔드포인트를 상호작용 경계로 보는 실용적인 아키텍처 관점은 현대 시스템의 시스템 인터페이스로서의 채팅 플랫폼를 참조하십시오.

강력한 보장을 광고하는 시스템조차도 사용자에게 작업을 남깁니다. Kafka는 멱등적 프로듀서를 사용하여 Kafka 로그의 중복 항목을 방지할 수 있으며, 트랜잭션과 read_committed 소비자를 사용하여 Kafka 내부에 머무는 읽기-처리-쓰기 흐름에 대해 정확 한 번(Exactly-once) 전달을 제공할 수 있습니다. 그러나 Kafka 자체 설계 문서도 외부 시스템은 여전히 오프셋과 출력과 조정(coordination)이 필요하다고 명확히 밝히고 있습니다. Google Cloud Pub/Sub의 정확 한 번 전달은 풀(Pull) 구독으로 제한되며, 클라우드 리전 내에서만 적용되며, 여전히 클라이언트가 확인이 성공할 때까지 처리 진행 상황을 추적해야 합니다.

제 의견에 따른 요약은 간단합니다. 전송 계층이 재시도할 것이라고 가정하십시오. 운영자가 재생할 것이라고 가정하십시오. 웹훅이 늦게 도착할 것이라고 가정하십시오. 반복된 의도가 두 번째 비즈니스 효과를 생성할 수 없도록 쓰기 경로를 설계하십시오.

제가 실제로 신뢰하는 API 계약

멱등성 키가 중복 API 요청을 방지하는 방법

변경 작업(Mutating operations)에 대해 제가 신뢰하는 유일한 API 계약은 호출자가 제공한 의도와 서버 측 지속성의 조합입니다.

AWS는 호출자가 제공한 요청 식별자를 권장하며, 서비스가 멱등성 토큰을 변경 작업과 원자적으로(Atomically) 기록해야 한다고 경고합니다. Stripe는 키에 대한 첫 번째 상태 코드와 응답 본문을 저장하고, 나중에 온 요청을 원래 요청의 매개변수와 비교하여 재시도에 동일한 결과를 반환합니다. PayPal은 지원되는 POST API에서 PayPal-Request-Id를 사용하며, 동일한 헤더가 포함된 이전 요청의 최신 상태를 반환합니다.

이로 인해 다음과 같은 실용적인 계약이 도출됩니다:

  1. 클라이언트는 비즈니스 작업을 위해 멱등성 키를 생성합니다.
  2. 서버는 해당 키를 테넌트(Tenant)와 작업 이름으로 범위화(Scope)합니다.
  3. 서버는 동일한 키가 다른 페이로드에 재사용되지 않도록 요청 해시(Request Hash)를 저장합니다.
  4. 서버는 pending(대기 중), completed(완료), failed(실패) 등의 상태를 기록합니다.
  5. 동일한 키로 인한 재시도는 저장된 결과를 반환하거나 이에 대한 안정적인 포인터를 반환합니다.
  6. 동일한 키로 다른 페이로드를 사용한 재시도는 오류를 명확하게 발생시킵니다(Loudly fail).

IETF에는 Idempotency-Key 헤더 초안이 있지만, 2026-05-09 기준 IETF Datatracker에는 발행된 RFC가 아닌 만료된 인터넷 초안(Internet-Draft)으로 나열되어 있습니다. 실무적으로 헤더 이름은 여전히 사실상의 관례로 널리 유용하지만, 표준이 완료된 것처럼 행세하기보다 자체 API에서 계약을 문서화해야 합니다.

이 키는 무엇을 대표해야 할까요? 의도(Intent)입니다. HTTP 시도나 TCP 연결, 재시도 카운터가 아닙니다. 사용자가 “주문 123을 한 번 생성"하는 것을 의미한다면, 해당 명령에 대한 모든 재시도는 동일한 키를 재사용해야 합니다. 사용자가 “두 번째 주문을 하겠다"는 의미라면, 다른 키를 사용해야 합니다.

요청 ID(Request ID)는 추적(Tracing)을 위한 것입니다. 멱등성 키는 정확성(Correctness)을 위한 것입니다. 이 둘을 혼동하면 대시보드는 깔끔해 보이지만 돈은 두 번 이동하게 됩니다.

PUT만으로는 충분하지 않은 이유

아니요, HTTP PUT만으로는 작업을 멱등적으로 만들 수 없습니다.

네, RFC 9110는 PUT에 멱등적 세맨틱을 부여합니다. 그러나 PUT 핸들러가 새로운 다운스트림 이벤트를 방출하거나, 매번 재시도마다 이메일을 보내거나, 외부 제공자에게 다시 청구한다면, 라우트 이름이 존경받을 만해 보일지라도 구현이 비즈니스 계약을 위반한 것입니다.

동사 선택은 클라이언트가 의도를 이해하는 데 도움이 됩니다. 하지만 의도를 구현해 주지는 않습니다.

리소스 모델이 진정한 전체 교체 또는 업서트(Upsert) 스타일 작업에 부합할 때 PUT를 사용하십시오. 명령 또는 작업을 생성할 때 POST를 사용하십시오. 그러나 네트워크 경계를 넘어 재시도될 가능성이 있는 모든 변경 작업에 대해 명시적인 멱등성 계약을 문서화하십시오. 변경 작업이 채팅 워크플로우에서 트리거되는 경우, 동일한 계약이 알림 및 워크플로우를 위한 Slack 통합 패턴알림 및 제어 루프를 위한 Discord 통합 패턴에도 적용됩니다. 숨겨진 부수 효과가 아키텍처가 죽는 곳입니다.

멱등성 키를 얼마나 오래 저장해야 하는가

전송 팀이 원하는 시간보다 더 오래 저장해야 합니다.

Stripe는 키가 최소 24시간 후에 정리될 수 있다고 말합니다. PayPal은 유지 기간이 API에 따라 다르며 최대 45일까지 지속될 수 있는 사례를 제시합니다. Amazon SQS FIFO는 5분 창(Windows) 내에서만 중복을 제거합니다. GitHub는 수동 재전송을 위해 최근 전달을 3일 동안 유지합니다. 이러한 숫자는 크게 다른데, 올바른 유지 기간은 프로토콜 기본값이 아닌 비즈니스 결정이기 때문입니다.

큐가 5분만 유지하기 때문에 키를 5분만 유지한다면, 멱등성을 설계하는 것이 아닙니다. 전송 계층의 한계를 비즈니스 계층으로 복사하는 것입니다.

다음 창(Window) 중 최대 기간 동안 최소한 멱등성 기록을 유지하십시오:

  • 클라이언트 재시도 지평선(Horizon)
  • 큐 재드라이브(Redrive) 지평선
  • 웹훅 재생 지평선
  • 운영자 재생 지평선
  • 자금 이동 작업의 정산 또는 보상 지평선

결제, 예약, 프로비저닝의 경우 이는 종종 분而不是 시간이 아닌 시간이나 일일을 의미합니다.

AWS는 제가 완전히 동의하는 두 가지 반패턴(Anti-pattern)도 지적합니다. 시계 왜곡과 충돌로 인해 신뢰할 수 없으므로 타임스탬프를 키로 사용하지 마십시오. 모든 요청에 대해 중복 제거 기록으로 전체 요청 페이로드를 맹목적으로 저장하지 마십시오. 이는 성능과 확장성에 해를 끼칩니다. 안전하게 재생할 수 있는 최소한의 응답 상태와 함께 정규화된 요청 해시를 저장하십시오. Stripe처럼 첫 번째 응답 바이트를 그대로 재현해야 한다면 정규화된 응답 본문을 저장하십시오.

멱등성을 현실로 만드는 데이터베이스 패턴

지속성 계층이 경쟁(Race)에서 정확히 한 번 승리할 수 있을 때 멱등성이 현실이 됩니다.

PostgreSQL은 여기서 두 가지 중요한 원시(Primitive)를 제공합니다. 고유 제약(Unique Constraints)은 하나 이상의 열에서 고유성을 강제하며, INSERT ... ON CONFLICT는 고유성 위반 시 실패하는 대신 대체 작업을 정의할 수 있게 합니다. PostgreSQL은 또한 ON CONFLICT DO UPDATE가 동시성 하에서 원자적인 삽입 또는 업데이트 결과를 보장한다고 문서화하고 있습니다.

이는 즉, 멱등성 계층은 일반적으로 다음과 같은 테이블로 시작해야 한다는 의미입니다:

create table api_idempotency (
    tenant_id text not null,
    operation text not null,
    idempotency_key text not null,
    request_hash text not null,
    state text not null,
    status_code integer,
    response_body jsonb,
    resource_type text,
    resource_id text,
    created_at timestamptz not null default now(),
    expires_at timestamptz not null,
    primary key (tenant_id, operation, idempotency_key)
);

그리고 처리 흐름은 다음과 같아야 합니다:

begin transaction

try insert (tenant_id, operation, idempotency_key, request_hash, state='pending')
on conflict do nothing

load row for (tenant_id, operation, idempotency_key) for update

if row.request_hash != incoming_request_hash
    fail with conflict or validation error

if row.state = 'completed'
    return stored response

if row.state = 'pending' and row was created by another live request
    either wait briefly, or fail fast with a retryable response

perform local business mutation

store stable result in idempotency row
set state = 'completed'

commit
return result

중요한 부분은 문법이 아닙니다. 중요한 부분은 원자성(Atomicity)입니다. 키를 기록하고 변경 작업을 수행하는 것은 함께 성공하거나 실패해야 합니다. AWS는 API 멱등성에 대해 이를 명시적으로 언급하며, SQL 기반 서비스에도 동일한 규칙이 적용됩니다.

“키를 선택; 누락 시 주문 삽입"과 같은 단순한 확인-후-실행(Check-then-act) 시퀀스를 수행하지 마십시오. 동시성 하에서 두 요청이 확인을 통과하고 둘 다 부수 효과를 생성할 수 있습니다. 고유 제약은 선택 사항이 아닙니다. 이는 아키텍처를 낙관적인 민담에서 부하 하에 증명할 수 있는什么东西(무언가)로 전환하는 메커니즘입니다.

리뷰에서 제가 사용하는 규칙은 이렇습니다. 중복 제거 결정이 변경 작업과 동일한 트랜잭션 경계로 보호되지 않으면, 멱등성을 가지고 있는 것이 아닙니다. 그저 희망을 가지고 있는 것입니다.

메시지, 이벤트, 웹훅은 자체 경계가 필요합니다

소비자가 중복 이벤트 및 메시지를 처리하는 방법

메시지 소비자의 경우, 고전적인 패턴이 여전히 올바른 선택입니다. 비즈니스 업데이트와 동일한 데이터베이스 트랜잭션 내에서 처리된 메시지 ID를 기록하십시오. Chris Richardson은 구독자와 메시지 ID를 기본 키로 사용하여 중복이 깔끔하게 실패하고 무시될 수 있도록 하는 PROCESSED_MESSAGES 테이블 접근 방식을 직접 설명합니다.

많은 팀이 명시적인 processed_messages 스토어를 인박스(Inbox) 테이블이라고 부릅니다. 라벨보다 규칙이 중요합니다. 수신자는 재시도가 안전하게 아무것도 하지 않도록 하기 전에 이미 메시지를 처리했다는 증명을 지속해야 합니다.

최소한의 형태는 다음과 같습니다:

create table processed_messages (
    subscriber_id text not null,
    message_id text not null,
    processed_at timestamptz not null default now(),
    primary key (subscriber_id, message_id)
);

그리고 소비자 흐름은 HTTP 흐름만큼 엄격합니다:

begin transaction

insert into processed_messages (subscriber_id, message_id)
values (?, ?)
on conflict do nothing

if no row inserted
    rollback
    ack and ignore duplicate

apply business mutation

commit
ack message

이 패턴은 지루합니다. 좋습니다. 멱등성은 지루해야 합니다.

또한 브로커 마케팅 용어에 의존하려는 시도보다 일반적으로 더 좋습니다. Kafka의 정확 한 번 지원은 Kafka 자체 트랜잭션 모델 내에 머무를 때 훌륭하지만, Kafka 문서도 여전히 외부 목적지는 협력이 필요하다고 경고합니다. SQS FIFO는 5분 중복 제거 창 내에서만 중복 전송을 줄입니다. Pub/Sub의 정확 한 번도 구독자가 진행 상황을 추적하고 확인이 실패할 때 중복 작업을 피하기를 기대합니다.

정확 한 번(Exactly-once)은 일반적으로 로컬 최적화입니다. 멱등적 부수 효과가 시스템 보장입니다.

아웃박스 패턴과 중복 제거를 결합

서비스가 로컬 상태를 업데이트하고 이벤트를 게시하는 경우, 멱등적 소비만으로는 충분하지 않습니다. 로컬 트랜잭션 커밋 후 이벤트를 안전하게 외부로 보내는 방법도 필요합니다.

이것이 트랜잭션 아웃박스(Transaction Outbox) 패턴이 중요한 이유입니다. Chris Richardson은 기본 아이디어를 비즈니스 업데이트와 동일한 트랜잭션 내에서 아웃박스 테이블에 이벤트를 작성한 다음 비동기로 게시하는 것으로 설명합니다. Debezium은 아웃박스 패턴이 서비스의 내부 상태와 다른 서비스에서 소비하는 이벤트 간의 불일치를 피한다고 말합니다. NServiceBus는 더 나아가 아웃박스 처리가 들어오는 메시지를 중복 제거하고 좀비 기록과 고스트 메시지를 피하는 방법을 보여줍니다.

데이터를 소유하고 통합 이벤트를 게시하는 서비스에 대해 제가 권장하는 아키텍처는 다음과 같습니다:

  1. 멱등성 키 하에서 명령을 검증하고 지속합니다.
  2. 비즈니스 상태와 아웃박스 이벤트를 하나의 로컬 트랜잭션 내에 작성합니다.
  3. CDC 또는 아웃박스 디스패처가 이벤트를 게시하도록 합니다.
  4. 다운스트림 소비자도 멱등적으로 만듭니다.

아웃박스는 멱등적 소비자의 필요성을 제거하지 않습니다. 데이터베이스 커밋과 브로커 게시가 마법 같은 분산 트랜잭션이 될 수 있다고 가장할 필요를 제거합니다. 보통은 불가능하기 때문입니다.

웹훅은 브랜딩이 더 나은 메시지일 뿐입니다

входящие 웹훅을 신뢰할 수 없는 네트워크 가장자리의 메시지와 정확히 동일하게 취급하십시오.

GitHub는 전달이 순서 없이 도착할 수 있다고 문서화하고, 진위를 검증하기 위해 X-Hub-Signature-256를 사용하도록 권장하며, 고유한 전달 식별자로 X-GitHub-Delivery를 제공합니다. 또한 재전송은 동일한 전달 ID를 재사용한다고 명시합니다.

따라서 아키텍처는 직관적입니다:

  • 먼저 서명을 검증합니다
  • 중복 제거 키로 전달 GUID를 사용합니다
  • 부수 효과 전에 수신을 지속합니다
  • 도착 순서를 가정하는 대신 핸들러를 순서 인식(Order-aware)으로 만듭니다
  • 무거운 작업을 큐에 넣고 빠르게 반환합니다

웹훅 핸들러가 수신을 기록하기 전에 비즈니스 테이블에 직접 작성한다면, 이는 프로덕션 준비가 되어 있는 것이 아닙니다. 그저 중복 실수를 더 빠르게 만드는 것일 뿐입니다.

사가(Sagas) 및 워크플로우 엔진도 여전히 멱등성이 필요합니다

Saga 및 내구성 워크플로우 엔진은 문제를 삭제하지 않습니다. 가시화할 뿐입니다.

Temporal은 실패 또는 타임아웃 후 활동(Activity)이 재시도될 수 있으므로 활동을 멱등적으로 작성할 것을 권장합니다. 문서에서는 작업자가 외부 부수 효과를 성공적으로 완료했지만 완료 보고 전에 크래시하여 활동이 다시 실행되는 가장자리 사례(Edge case)도 명시적으로 지적합니다. Temporal은 또한 다운스트림 서비스를 호출할 때 워크플로우 실행 ID와 활동 ID의 조합을 안정적인 멱등성 키로 사용하는 것을 제안합니다. 서비스 오케스트레이션에 이를 적용하고 있다면, AI/ML 오케스트레이션을 위한 Go 마이크로서비스에서 더 넓은 워크플로우 트레이드오프를 다룹니다.

이것이 바로 올바른 정신 모델입니다. 워크플로우 엔진은 실행 기록을 보존하고 재시도를 조정할 수 있습니다. 그러나 애플리케이션이 멱등적 단계와 멱등적 보상을 제공하지 않는 한 카드를 취소하거나 이메일을 취소하는 것은 역행할 수 없습니다.

이는 Saga에도 동일하게 적용됩니다. Temporal 자체의 Saga 가이드는 실패 시 실행되는 보상 작업을 설명합니다. 이러한 보상도 멱등적이어야 합니다. “결제 환불"이 두 번 실행되면, 새로운 버그를 생성함으로써 원래 버그를 해결했을 수 있습니다.

여기서 제 규칙은 잔인하고 간단합니다. 외부 세계와 상호작용하는 모든 활동(Activity), 모든 명령 핸들러, 모든 보상은 자연스럽게 멱등적이거나 다운스트림 시스템에 실제 멱등성 키를 전달해야 합니다.

프로덕션 전에 멱등성을 테스트하는 방법

대부분의 팀은 해피 패스(Happy Path)를 테스트한 후 재시도가 발생하면 놀라워합니다. 이는 충분하지 않습니다.

최소한 다음 사례에 대한 자동화된 테스트가 있어야 합니다:

  • 서버가 변경 작업을 커밋하지만 응답이 클라이언트에 도달하지 않음
  • 동일한 멱등성 키로 두 개의 동일한 요청이 경쟁함
  • 동일한 키가 다른 페이로드와 함께 재사용됨
  • 소비자가 데이터베이스 작업을 커밋한 후 확인(Ack) 전에 크래시
  • 동일한 전달 ID로 웹훅이 재생됨
  • 아웃박스 디스패처가 동일한 이벤트를 한 번 이상 게시
  • 워크플로우 활동이 외부 호출을 완료하고 완료 보고 전에 크래시
  • 멱등성 기록이 만료되고 진정한 늦은 재시도가 도착

AWS는 성공적인 요청, 실패한 요청, 중복 요청을 포함하는 포괄적인 테스트 스위트 사용은 명시적으로 권장합니다. 이 조언은 평범하지만 절대적으로 정확합니다.

저는 하나의 더 많은 실패 드릴(Failure Drill)을 추가하겠습니다. 재생된 응답이 첫 번째 결과와 시맨틱하게 동등한지 확인하십시오. AWS는 늦게 도착하는 재시도를 논의하고, 근본적인 상태가 변경된 후에도 원래 의미를 보존하는 응답을 옹호합니다. 이는 “추가 부수 효과가 발생하지 않음"과 “호출자가 여전히 일관된 계약을 가지고 있음"의 차이입니다.

실제 시스템을 구하는 의견 기반 규칙

아키텍처 리뷰에서 강제할 규칙들입니다.

첫째, 멱등성 키는 전송 시도가 아닌 비즈니스 의도에 속합니다.

둘째, 모든 키를 테넌트와 작업으로 범위화하십시오. 전역 키 공간은 관련 없는 요청이 충돌하는 방법입니다.

셋째, 중복 제거 결정을 변경 작업과 원자적으로 지속하십시오. 사실이 아니라면 설계가 잘못되었습니다.

넷째, 동일한 키로 다른 페이로드인 재시도를 거부하십시오. Stripe와 AWS는 모두 좋은 이유로 이를 수행합니다.

다섯째, 가장 짧은 큐 창이 아닌 비즈니스 프로세스의 전체 재생 지평선 동안 키를 유지하십시오.

여섯째, 프로듀서는 아웃박스와, 소비자는 메시지 ID 추적과 결합하십시오. 한쪽 없이 다른 쪽은 절반의 설계입니다.

일곱째, 비즈니스 작업이 동일하면 동일한 작업 식별성을 다운스트림으로 전파하십시오. AWS는 처리 체인 lungo에 멱등성 토큰을 전달할 것을 명시적으로 권장합니다.

여덟째, 정확 한 번 마케팅이 멱등적 부수 효과의 필요성을 제거한다고 결코 가정하지 마십시오.

엄격하게 들린다면, 좋습니다. 멱등성은 낙관적인 아키텍처가 프로덕션 현실을 만나는 곳입니다. 모든 곳에 복잡성이 필요한 것은 아닙니다. 그러나 중복된 부수 효과가 돈, 상태 또는 신뢰에 해를 끼칠 수 있는 곳에서는, 멱등성은 계약의 일류(Class) 부분이어야 합니다.

유용한 링크

구독하기

시스템, 인프라, AI 엔지니어링에 관한 새 글을 받아보세요.