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

중복된 사이드 이펙트 방지

Page content

분산 시스템에서의 멱등성(Idempotency)은 네트워크 오류, 큐의 재시도, 클라이언트의 패닉, 그리고 운영자의 재생(Replay) 작업이 발생했을 때 시스템을 구해 주는 속성입니다. 프로덕션 시스템에서 중복 전달(Duplicate delivery)은 정상적인 현상입니다. 그러나 중복된 부수 효과(Duplicate side effects)는 버그입니다.

HTTP는 멱등적인 메서드를 서버에 대해 하나의 요청과 동일한 의도된 효과를 가지는 다중의 동일한 요청으로 정의합니다. 따라서 PUT, DELETE 및 안전(Safe) 메서드는 프로토콜 시맨틱상 멱등적이며, 통신 실패 후 자동으로 재시도할 수 있습니다.

integration message flow: idempotency

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

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

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

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

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

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

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

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

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

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

멱등성 키는 어떻게 중복 API 요청을 방지합니까

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

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

이는 다음과 같은 실용적인 계약으로 이어집니다:

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

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

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

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

왜 PUT만으로는 부족합니까

아니요, HTTP PUT만으로는 작업이 멱등적이 되지 않습니다.

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

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

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

멱등성 키를 얼마나 오래 저장해야 합니까

전송 팀이 원하는 것보다 더 오래입니다.

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

큐가 5분만 유지한다고 해서 키를 5분만 유지한다면, 당신은 멱등성을 설계하는 것이 아닙니다. 전송 계층의 제한을 비즈니스 계층으로 복사하는 것입니다.

최소한 다음 창들의 최대값만큼 멱등성 기록을 유지하세요:

  • 클라이언트 재시도 지평선
  • 큐 재전송(Dead-letter/Redrive) 지평선
  • 웹훅 재생 지평선
  • 운영자 재생 지평선
  • 자금 이체 작업의 정산 또는 보상 지평선

결제, 예약 및 프로비저닝의 경우, 이는 종종 분而不是가 아닌 시간 또는 일 단위를 의미합니다.

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

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

지속성 계층이 경주를 정확히 한 번 이길 수 있을 때 멱등성이 현실이 됩니다.

PostgreSQL은 여기서 두 가지 중요한 원시 요소를 제공합니다. 고유 제약 조건(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를 사용합니다
  • 부수 효과 전에 수신 영수를 지속합니다
  • 도착 순서를 가정하기보다 순서에 인지된 핸들러를 만듭니다
  • 무거운 작업을 큐에 넣고 빠르게 반환합니다

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

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

사가(Sagas) 및 내구성 워크플로우 엔진은 문제를 삭제하지 않습니다. 그들은 문제를 가시화합니다.

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

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

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

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

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

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

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

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

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

저는 하나의 실패 드릴(Failure drill)을 더 추가하겠습니다. 재생된 응답이 첫 번째 결과와 시맨틱하게 동등함을 검증하세요. AWS는 늦게 도착하는 재시도를 논의하고 기본 상태가 변경된 후에도 원래 의미를 보존하는 응답을 옹호합니다. 이는 “추가 부수 효과가 발생하지 않음"과 “호출자가 여전히 일관된 계약을 가짐” 사이의 차이입니다.

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

아키텍처 리뷰에서 강제할 규칙들은 다음과 같습니다.

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

둘째, 모든 키를 테넌트와 작업으로 스코핑합니다. 전역 키 공간은 관련 없는 요청이 충돌하는 방법입니다.

셋째, 중복 제거 결정을 변형과 원자적으로 지속합니다. 이것이 사실이 아니라면, 설계가 잘못되었습니다.

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

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

여섯째, 프로듀서는 아웃박스와, 소비자는 메시지 ID 추적과 쌍을 이루어야 합니다. 한쪽만 있는 것은 반쪽짜리 설계입니다.

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

여덟째, 정확 한 번(Exactly-once) 마케팅이 멱등적 부수 효과의 필요성을 제거한다고 결코 가정하지 마세요.

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

유용한 링크

구독하기

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