멱등성 엔진: 트랜잭션 무결성 보장하기
SchemaBridge Team · 2025-12-22 · Idempotency, Consistency, Transactions
at-least-once 실행, 인바운드 중복 제거, 목적지 측 키: 분산 워크플로에서 중복이 실제로 어디서 막히고 어느 부분이 여러분의 몫인지 살펴봅니다.
"이중 청구"의 악몽: 분산 시스템이 재시도를 싫어하는 이유
불안정한 네트워크와 일시적인 클라우드 리소스로 이루어진 우리의 세계에서 장애는 단순히 잦은 것이 아니라 상시적인 상태입니다. 시니어 엔지니어라면 누구나 "고아 액션"의 악몽을 겪어 봤을 것입니다. 사소한 네트워크 문제로 시작해 데이터 손상이나 재정적 책임으로 끝나는 시나리오입니다. 전형적인 분산 시스템 장애 시나리오는 다음과 같습니다.
1. 요청: 오케스트레이터가 결제 API에 "청구" 요청을, 또는 운송업체에 "배송" 요청을 보냅니다.
2. 성공: 서드파티 API가 요청을 성공적으로 처리하여 고객의 카드에 청구하거나 배송 라벨을 출력합니다.
3. 파티션: 응답 경로에서 짧은 네트워크 장애가 발생합니다. API의 응답, 즉 핵심적인 확인 응답이 워커 머신에 끝내 도달하지 못합니다.
4. 재시도: 워커는 타임아웃을 보고 표준 관행에 따라 처리가 실패했다고 가정합니다. 재시도 정책에 따라 요청을 다시 보냅니다.
5. 중복: API는 이 특정한 의도에 대한 고유한 아이덴티티를 받지 못했기 때문에 요청을 다시 처리합니다. 새로운 트랜잭션으로 간주하는 것입니다. 고객은 두 번 청구되거나, 같은 주문에 대해 배송 라벨이 두 장 생성됩니다.
이것은 코드 로직의 실패가 아니라 아이덴티티의 실패입니다. 특정 의도를 시간과 공간을 가로질러 고유하게 식별할 방법이 없다면, 시스템은 연결을 재시도할 때마다 고객의 데이터와 돈을 걸고 도박을 하는 셈입니다.
분산 트랜잭션은 죽었다: 멱등성이여 영원하라
모놀리식 아키텍처에서는 일관성을 보장하기 위해 Two-Phase Commit (2PC) 이나 전역 분산 락에 의존합니다. 이런 도구 덕분에 여러 작업을 하나의 진실 단위로 다룰 수 있습니다. 하지만 SaaS API, 서버리스 워커, 폴리글랏 마이크로서비스로 파편화된 세계에서 전역 트랜잭션은 환상입니다. 확장되지 않고, 막대한 지연 시간을 유발하며, 대부분의 서드파티 제공자는 이를 지원하지 않습니다. 조직 경계를 넘어서는 불가능한 리소스 "잠금"을 요구합니다.
분산 일관성을 위한 유일하게 실현 가능한 길은 멱등성입니다. 수학적으로, 어떤 작업을 여러 번 적용해도 첫 번째 적용 이후 결과가 바뀌지 않으면 그 작업은 멱등입니다. 대수적으로는 f(x) = f(f(x))입니다. 엔지니어링 관점에서는 시스템이 몇 번이고 실패하고 재시도해도 최종 결과가 항상 올바르다는 뜻입니다.
정직한 계약: 가드가 있는 at-least-once
오케스트레이터가 약속할 수 있는 것과 없는 것을 정확히 짚어 봅시다. SchemaBridge는 작업을 at-least-once로 전달합니다. HTTP 호출이 성공한 뒤 결과를 기록하기 전에 워커가 크래시할 수 있고, 메시지가 재전달될 수 있으며, claim 리스가 만료되어 다른 워커에게 넘어갈 수 있습니다. 이 모든 경우에 안전한 동작은 스텝을 잃어버리는 대신 다시 실행하는 것입니다. 즉, 스텝이 드물게 두 번 실행될 수 있습니다.
플랫폼이 하는 일은 그 "드물게"를 가능한 한 드물게 만드는 것이며, 이를 두 개의 레이어에서 수행합니다.
레이어 1: 인바운드 트리거 중복 제거
엔트리 포인트의 모든 트리거에는 실행 ID가 부여되며, 기본적으로 이 ID는 콘텐츠에서 도출됩니다. 워크스페이스, 엔트리 포인트 vertex, 요청 페이로드에 대한 결정론적 SHA-256입니다. 같은 웹훅 전달이 두 번 도착하면 두 번째 실행을 시작하는 대신 같은 실행으로 해석됩니다. 발신자가 재시도할 때 정확히 원하는 동작입니다. 아이덴티티는 세 가지 손잡이로 제어합니다.
| 손잡이 | 위치 | 사용 시점 |
| :--- | :--- | :--- |
| idempotencyFields | 엔트리 포인트 vertex 설정 | 본문 전체가 아니라 비즈니스 식별자(예: order.id)를 기준으로 중복 제거하고 싶을 때 |
| clientIdempotencyId | 트리거 호출의 쿼리 파라미터 | 호출자가 중복 제거 키를 소유할 때 |
| forceNewExecution | 테스트 실행과 스트리밍 실행 호출에서만(그곳에서는 기본 활성) | 프로덕션 트리거에서는 사용할 수 없습니다. 프로덕션에서는 idempotencyFields 또는 호출마다 고유한 clientIdempotencyId를 사용하세요 |
{
"type": "ENTRY_POINT",
"config": {
"idempotencyFields": "order.id"
}
}
이면에는 실제 함정이 있습니다. 두 개의 정당한 실행이 바이트 단위로 동일한 페이로드를 가질 수 있다면("빈 본문으로 야간 리포트 실행"), 옵트아웃하지 않는 한 하나로 합쳐집니다. 예약(cron) 트리거는 예외입니다. 스케줄은 결코 중복 제거 대상이 아니므로, 실행될 때마다 새로운 무작위 ID를 받습니다. 또한 명시적 중복 제거는 읽은 뒤 쓰는 조회 방식이지 데이터베이스 고유성 제약이 아니므로, 같은 키를 가진 진정으로 동시적인 두 트리거가 모두 통과할 수 있다는 점에 유의하세요.
레이어 2: 스텝 수준 가드
하나의 실행 안에서 이미 COMPLETED인 스텝은 다시 실행되지 않고, 워커는 스텝을 실행하기 전에 원자적으로 claim(DISPATCHED → EXECUTING)해야 하며, 종료 전이는 가드되고, claim 에포크는 리스를 빼앗긴 워커를 차단합니다. 이러한 가드는 흔한 중복 경로를 막아 주지만, 어느 것도 서드파티 API 내부로 들어가 이미 일어난 호출을 되돌릴 수는 없습니다.
레이어 3은 여러분의 몫: 목적지 측 키
이 마지막 공백 때문에 플랫폼 자체의 동작 계약은 분명하게 말합니다. 부수 효과를 목적지에서 멱등하게 만들고, 오케스트레이터가 그것을 정확히 한 번만 실행할 것이라고 기대하지 마세요.
다행히 대부분의 본격적인 API(결제, 배송, 메시징)는 멱등성 키를 받아들이며, Gateway 스텝은 실행 데이터에서 도출한 키를 보낼 수 있습니다. SchemaBridge에서 헤더 값은 스텝 입력에 대한 표현식이 될 수 있으므로, 키는 타임스탬프나 무작위 값이 아니라 안정적인 비즈니스 식별자로부터 계산됩니다.
{
"type": "GATEWAY",
"config": {
"method": "POST",
"url": "https://api.payments.example/v1/charges",
"headers": {
"Idempotency-Key": "= 'charge-' & payload.order.id",
"Authorization": "Bearer {{ secrets.PAYMENTS_KEY }}"
}
}
}
키가 주문에만 의존하므로, 이 스텝이 다시 실행되더라도(재전달 후, 운영자의 Retry, 또는 과부하 시 자동 재시도 후) 같은 키가 전송되고, 목적지는 다시 청구하는 대신 첫 번째 결과를 반환합니다. 한 가지 주의할 점이 있습니다. Start over(워크플로 전체 재실행)는 새 실행을 생성하지만, 키를 실행 ID가 아닌 비즈니스 데이터에서 도출했다면 그 경우에도 키는 안정적으로 유지됩니다. 바로 이것이 엔진 식별자보다 비즈니스 식별자가 더 나은 키인 이유입니다.
레거시 API 다루기: "Read-Verify-Write" 패턴
안타깝게도 많은 레거시 시스템과 틈새 SaaS 제공자는 멱등성 키를 지원하지 않습니다. Idempotency-Key 헤더가 없습니다. 이러한 "멱등하지 않은" 엔드포인트에 대해서는 일반 스텝들로 Read-Verify-Write 시퀀스를 구성할 수 있습니다.
1. 검증 스텝(Read): Gateway 스텝이 먼저 다운스트림 시스템에 레코드가 이미 존재하는지 조회합니다(예: GET /orders?external_id=123, 키로 사용했을 것과 같은 비즈니스 식별자를 사용).
2. 조건 분기: 조건부 edge가 응답을 확인합니다. 주문이 존재하면 실행은 "이미 처리됨" 경로를 따릅니다. 없으면 계속 진행합니다.
3. 액션 스텝(Write): 검증 스텝이 아무것도 찾지 못한 경우에만 실행이 실제 쓰기(POST /orders)로 진행합니다.
윈도우를 파악하라
Read-Verify-Write는 중복 윈도우를 좁힐 뿐 닫지는 않습니다. 쓰기 이후 성공을 기록하기 전에 워커가 크래시하면, 쓰기 스텝만 단독으로 다시 실행됩니다. 돌아가서 먼저 재검증하지 않습니다. 따라서 멱등성을 전혀 지원하지 않는 목적지라면, 목적지 측의 자연스러운 고유성 제약(충돌 시 거부하는 external_id 등)도 찾아보고, 충돌 응답을 "이미 처리됨"으로 취급하세요. 기본적으로 4xx는 Gateway 스텝을 실패시킵니다. clientErrorMode를 complete로 설정하면 클라이언트 오류 응답이 다음 스텝이 라우팅에 사용할 수 있는 데이터가 됩니다.
"키 관리"의 함정: 직접 만든 멱등성이 규모에서 실패하는 이유
많은 엔지니어링 팀이 주 데이터베이스에 "멱등성 테이블"을 만들려고 시도합니다. 이는 결국 개발 속도와 안정성을 무너뜨리는 세 가지 치명적인 문제를 낳습니다.
1. 쓰기 병목: 이제 모든 API 호출마다 토큰을 기록하기 위한 데이터베이스 쓰기가 필요합니다. 높은 부하에서는 멱등성 테이블이 경합 지점이 됩니다.
2. 정리의 복잡성, 쓰레기 문제: 멱등성 키가 영원히 필요한 것은 아닙니다. 오래된 키를 제거할 백그라운드 프로세스나 TTL(Time-To-Live)이 필요합니다. 정리가 너무 공격적이면 느리게 재시도되는 작업에서 이중 청구 위험이 생깁니다. 너무 느리면 테이블이 끝없이 커집니다.
3. 분산 상태 불일치: 데이터베이스 쓰기는 성공했는데 API 호출이 실패하면 어떻게 될까요? 혹은 API 호출 후 데이터베이스를 "완료"로 갱신하기 전에 워커가 크래시하면요? 해결하려면 수동 개입이 필요한 불일치가 남게 됩니다.
해법은 여러분 쪽에 더 똑똑한 테이블을 만드는 것이 아니라, 부수 효과를 수행하는 시스템이 중복 제거를 책임지게 하는 것입니다. 멱등성 키를 존중하는 목적지는 자신의 작업에 대해 저장, 만료, 크래시 윈도우 문제를 이미 해결했습니다. 여러분이 할 일은 안정적인 키를 보내는 것입니다.
클라이언트 측 vs. 서버 측 토큰 생성
토큰은 어디서 생성해야 할까요?
- 클라이언트 측(의도의 출처): 호출자가 첫 시도 전에 토큰을 도출하고 모든 재시도에서 재사용합니다. 이 글 서두에서 설명한 "응답 경로 장애"로부터 보호하는 유일한 위치입니다.
- 서버 측: 수신자가 ID(보통 DB ID)를 생성합니다. 내부 일관성에는 유용하지만, 확인 응답을 잃어버린 호출자에게는 도움이 되지 않습니다.
SchemaBridge 워크플로에서 의도의 출처는 실행 자체의 데이터입니다. 그곳, 즉 Gateway 스텝의 헤더에서 키를 도출하면 엔진이 택할 수 있는 모든 재시도 경로에서 키가 안정적으로 유지됩니다.
비교표: 중복은 어디서 막히는가
| 중복의 출처 | 막는 수단 | 설정 주체 |
| :--- | :--- | :--- |
| 발신자가 같은 웹훅을 재시도 | 콘텐츠 기반 실행 ID / idempotencyFields / clientIdempotencyId | SchemaBridge(키는 여러분이 선택) |
| 실행 내에서 스텝이 재전달됨 | 스텝 수준 가드(완료 시 단락, 원자적 claim, claim 에포크) | SchemaBridge |
| 호출이 이미 성공한 뒤 스텝이 재실행됨 | 목적지 측 멱등성 키 | 여러분(Gateway 스텝에서) |
| 목적지가 멱등성을 지원하지 않음 | Read-Verify-Write와 목적지 측 고유성 제약 | 여러분 |
전문가 팁: 멱등성 체크리스트
1. 타임스탬프를 절대 사용하지 마세요: 키는 시간이 아니라 데이터에 기반해야 합니다.
2. 키의 범위를 지정하세요: 작업별로 키에 접두사(charge-, ship-)를 붙여, 같은 주문 ID를 공유하더라도 "배송" 키가 "청구" 키와 충돌하지 않도록 하세요.
3. 409 Conflict를 처리하세요: API가 이미 본 키나 식별자에 대해 409(Conflict)를 반환하면, 입력이 일치하는 경우 워크플로는 대개 이를 "이미 처리됨"으로 취급해야 합니다.
4. 반복 페이로드에 대해 미리 결정하세요: 두 개의 실제 실행이 동일한 본문을 가질 수 있다면 idempotencyFields를 설정하거나 호출마다 고유한 clientIdempotencyId를 전달하세요. 그렇지 않으면 두 번째 실행이 중복 제거로 사라집니다. (forceNewExecution은 테스트 실행과 스트리밍 실행 호출에만 있습니다.)
5. 키를 템플릿의 일부로 만드세요: 개발자가 멱등성 키 추가를 기억해야 한다면 언젠가는 잊어버립니다. 해당 API에 재사용하는 Gateway 설정에 키를 넣어 두세요.
결론: 아이덴티티는 진실의 중추다
분산 시스템에서는 네트워크도, 시계도, 응답도 신뢰할 수 없습니다. 진정으로 신뢰할 수 있는 유일한 것은 아이덴티티입니다.
SchemaBridge는 자신이 제어하는 경계에서 아이덴티티를 제공하고(콘텐츠 기반 실행 ID, 명시적 중복 제거 키, 스텝 수준 가드), 제어하지 못하는 경계, 즉 서드파티 부수 효과에 대해서는 정직하게 밝힙니다. 비즈니스 데이터에서 안정적인 키를 도출해 모든 호출에 함께 보내면, "이중 청구의 악몽"은 나중에 발견하는 인시던트가 아니라 한 번 내린 설계 결정이 됩니다.
Building the Bridge 시리즈의 일부입니다. 함께 보기: 병렬 브랜치 간 상태 동기화를 다룬 머지 마스터하기.