04. Idempotency — 중복 메시지의 필연성, 멱등 키, exactly-once의 환상
이 문서가 답하는 질문: 같은 메시지가 두 번 와도 무사하려면 어떻게 짜는가? 멱등 키는 어떻게 설계하는가?
한 줄 답
at-least-once 큐 + Lambda retry + DLQ 회수가 합쳐지면 중복 메시지는 필연이다. “exactly-once delivery”는 분산 시스템에서 환상에 가깝다 — 진짜 답은 “at-least-once delivery + idempotent processing = effectively exactly-once”. 멱등 키는 콘텐츠 해시(자연키) 와 비즈니스 키(생성된 ID) 두 부류로 갈리고, dedupe 윈도우 안에서만 의미가 있다.
Why — 왜 중복이 필연인가
미디어 파이프라인이 메시지를 두 번 처리하게 되는 시나리오는 무수히 많다.
이 6가지 중 어느 하나만 일어나도 같은 작업이 두 번 실행된다.
미디어 파이프라인에서 중복이 만들어내는 사고:
| 작업 | 중복 시 결과 |
|---|---|
| MediaConvert Job 생성 | 같은 영상에 대해 같은 transcode 두 번 = 비용 2배 |
| S3 PUT (sprite 업로드) | 같은 키로 덮어쓰기 — 보통 OK이지만 versioning 켜면 비용 |
| DB INSERT (artifact row) | 중복 row 또는 unique constraint 위반 |
| 결제 처리 | 두 번 청구 (가장 위험) |
| 알림 발송 | 사용자가 2개 알림 받음 |
해결책: 모든 Filter는 멱등이어야 한다. 같은 입력이 N번 들어와도 결과는 1번 처리한 것과 같아야 한다.
How — 멱등성을 구현하는 4가지 패턴
1) Natural idempotency — 작업 자체가 멱등
가장 깨끗한 케이스 — 작업이 본질적으로 멱등이라 추가 코드 불필요.
| 작업 | 멱등인가? | 이유 |
|---|---|---|
S3 PUT s3://bucket/key | O | 같은 key는 덮어쓰기 (versioning OFF) |
SET x = 5 (Redis) | O | 결과는 항상 x=5 |
INSERT ... ON DUPLICATE KEY UPDATE | O | upsert |
INCR x (Redis) | X | 호출마다 +1 |
INSERT ... (no constraint) | X | 중복 row |
POST /api/transcode | X | 새 Job 생성 |
→ 가능하면 PUT/UPSERT/SET 같은 자연 멱등 연산을 쓴다.
2) Idempotency key — 처리 결과를 키로 캐싱
작업이 본질적으로 멱등이 아니면, 처리 시작 전에 키를 보고 이미 처리했는지 확인.
핵심:
- SETNX (set if not exists) 원자성 — 두 worker 동시 진입 시 한 명만 성공
- TTL — 무한히 쌓이면 store 비용. 보통 작업시간 × 2 이상.
- 두 단계 상태 — “processing” → “done” — 진행 중인 것을 알 수 있음
3) Idempotency key 설계 — 자연키 vs 비즈니스키
콘텐츠 해시 (자연키)
- 장점: 같은 파일 다른 메시지 ID로 두 번 와도 dedupe됨
- 단점: 해시 계산 비용. 큰 파일은 무거움.
- 언제: 파일 자체에 대한 작업 (transcode, OCR 등)
비즈니스 ID (인공키)
- 장점: 즉시 사용 가능, 의미 있는 식별자
- 단점: 같은 파일에 대한 다른 시점 메시지는 dedupe 안 됨 (의도일 수 있음)
- 언제: 비즈니스 단위로 한 번 처리 (예:
media-123:transcode)
복합 키가 흔하다:
idempotencyKey = `${fileId}:${stage}:${contentVersion}`fileId— 어떤 파일stage— 어떤 단계 (transcode, sprite, …)contentVersion— 파일이 재업로드되면 바뀌는 버전 (옵션)
4) 외부 시스템의 멱등 토큰 활용
많은 SDK가 idempotency를 client side에서 받는다.
| 서비스 | 멱등 토큰 | TTL |
|---|---|---|
MediaConvert CreateJob | ClientRequestToken | 약 24h |
Stripe POST /charges | Idempotency-Key 헤더 | 24h |
AWS DynamoDB TransactWriteItems | ClientRequestToken | 10min |
| GCP Pub/Sub publish | messageOrderingKey 등 | - |
활용:
await mc.createJob({
ClientRequestToken: idempotencyKey, // 24시간 안에 같은 토큰이면 같은 Job 반환
Settings: { /* ... */ }
});이 토큰을 잘 쓰면 외부 서비스 비용 중복도 막힘.
What — 구체 구현 패턴
1) 메시지 핸들러의 멱등 골격
async function handler(event: SQSEvent) {
for (const record of event.Records) {
const msg = parseMessage(record);
const idempotencyKey = `${msg.fileId}:${msg.stage}`;
// 1. 멱등 체크 — atomic
const setResult = await ddb.put({
TableName: 'idempotency',
Item: { key: idempotencyKey, status: 'processing', ttl: now + 3600 },
ConditionExpression: 'attribute_not_exists(key)',
}).catch(e => {
if (e.name === 'ConditionalCheckFailedException') return null;
throw e;
});
if (!setResult) {
console.log(`Skip duplicate: ${idempotencyKey}`);
continue;
}
// 2. 실제 작업
try {
const result = await doWork(msg);
// 3. 완료 마킹
await ddb.update({
TableName: 'idempotency',
Key: { key: idempotencyKey },
UpdateExpression: 'SET #s = :s, result = :r, ttl = :ttl',
ExpressionAttributeValues: {
':s': 'done', ':r': result, ':ttl': now + 86400 * 7,
},
});
} catch (e) {
// 4. 실패 시 idempotency 키 삭제 (다음 retry 허용)
await ddb.delete({ /* ... */ });
throw e;
}
}
}핵심 디테일:
- ConditionExpression 으로 atomic SETNX
- TTL 분리 — processing(1시간) vs done(7일)
- 실패 시 키 삭제 — 다음 retry가 다시 시도 가능
2) Dedupe 윈도우의 의미
- TTL 안에 들어온 중복은 dedupe됨
- TTL 지난 후 들어오면 신규 메시지로 처리됨
- window 길이는 retry policy 최대 수명보다 길게
| 큐 | 메시지 최대 수명 | 권장 dedupe TTL |
|---|---|---|
| SQS standard (4일 retention × 5 retry) | 약 20일 | 30일 |
| SQS FIFO 내장 dedupe | 5분 | (자체) |
| Kafka (긴 retention) | 수 주 | retention 이상 |
→ “5분 안에 같은 메시지 안 옴” 같은 가정은 위험. 실제 redrive(수동 회수)까지 고려해야 함.
3) 미디어 파이프라인의 단계별 멱등 전략
| 단계 | 작업 | 멱등 전략 |
|---|---|---|
| probe | metadata 추출 → S3 PUT | 자연 멱등 (PUT 덮어쓰기) |
| transcode | MediaConvert CreateJob | ClientRequestToken = pipelineId |
| sprite | 다수 S3 PUT | 자연 멱등 (덮어쓰기) |
| waveform | S3 PUT | 자연 멱등 |
| DB update | UPDATE + UPSERT | ON DUPLICATE KEY UPDATE |
→ 미디어 파이프라인은 자연 멱등이 잘 맞는다 — 산출물이 결정적이라(같은 입력 → 같은 출력) 덮어쓰기가 안전.
4) 비결정적 작업의 함정
// 안티패턴 — 멱등 보이지만 아님
async function generateThumbnail(s3Key: string) {
const buffer = await s3.getObject(s3Key);
const thumb = await ffmpeg.screenshot({ time: Date.now() % buffer.duration }); // 시점이 매번 다름!
await s3.putObject({ key: `${s3Key}.thumb.jpg`, body: thumb });
}- 같은 입력이라도 결과 thumbnail 시점이 다름
- → 명시적 시점 인자 받기 (
{ time: 5.0 })
미디어 파이프라인의 비결정적 함정:
- 시간 기반 (
Date.now()) - 랜덤 ID 생성 (
uuid()결과) - 외부 API의 비결정적 응답
- floating point ordering
→ 결정적(deterministic)하지 않은 작업은 별도 결과 캐싱 필요.
What-if — 잘못 쓰면 어떻게 깨지는가
1) “MediaConvert에 ClientRequestToken 안 넘김”
가장 비싼 사고. SQS retry로 같은 메시지 두 번 → 두 개 Job 생성 → MC 비용 2배.
// 안티패턴
await mc.createJob({ Settings: { /* ... */ } });
// 정답
await mc.createJob({
ClientRequestToken: idempotencyKey, // 24h 안에 같은 토큰이면 기존 Job 반환
Settings: { /* ... */ }
});2) “DB INSERT를 ON DUPLICATE 없이”
-- 안티패턴
INSERT INTO artifacts (media_id, type, s3_key) VALUES (?, ?, ?);
-- 두 번째 호출 → unique constraint 위반 또는 중복 row→ unique key + UPSERT:
INSERT INTO artifacts (media_id, type, s3_key) VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE s3_key = VALUES(s3_key);3) “Idempotency key를 메시지 receive 직후가 아니라 처리 직전에 체크”
// 안티패턴
const result = await heavyWork(msg); // 5분 소요
await checkIdempotency(key); // 그제야 체크 → 중복 처리됨→ 첫 줄에서 체크. 5분 동안 다른 worker도 같은 메시지 받을 수 있음.
4) “Race condition — SETNX 없이 read-then-write”
// 안티패턴
const exists = await ddb.get({ key });
if (exists) return;
await ddb.put({ key, ... }); // 두 worker 사이에 시간 차이
// 정답 — atomic conditional put
await ddb.put({ key, ConditionExpression: 'attribute_not_exists(key)' });5) “처리 완료 마킹을 DB 트랜잭션 밖에서”
// 안티패턴
await db.transaction(async tx => {
await tx.update(/* business write */);
});
await ddb.put({ key, status: 'done' }); // 이 사이에 람다 죽으면?
// → DB는 갱신됐는데 멱등키는 "processing" 그대로 → 다음 retry가 또 갱신→ 비즈니스 write와 멱등 키 갱신을 같은 트랜잭션에 (가능하다면), 또는 자연 멱등 연산으로 만들어 중복 갱신이 안전하게.
6) “TTL 너무 짧음”
retry 정책 최대 수명보다 짧으면 dedupe 의미 없음. → TTL = SQS retention × max retry × 안전마진 (보통 7-30일).
7) “dedupe key가 너무 광범위”
// 안티패턴
const key = msg.fileId; // stage 구분 없음
// → transcode와 sprite가 같은 키 → 한 단계만 실행됨→ key는 (파일 + 단계) 또는 더 세밀.
Insight — 흥미로운 이야기
“exactly-once delivery는 수학적으로 불가능하다”
Two Generals Problem (1975) — 통신 채널이 신뢰할 수 없을 때 두 장군이 동시에 공격을 약속하는 것은 불가능하다는 것이 증명됐다. 분산 시스템에서 “메시지를 정확히 한 번 전달했다”는 사실을 producer와 consumer가 동시에 합의하는 것이 같은 문제다. 그래서 모든 큐의 본질은 at-least-once + dedupe. “exactly-once”라고 광고하는 것들도 실제로는 at-least-once + 내장 dedupe다 (SQS FIFO의 5분 dedupe window가 그 예).
“Stripe의 Idempotency-Key 헤더는 결제 산업의 표준이 됐다”
2014년 Stripe API에 Idempotency-Key 헤더가 도입됐다. 결제 같은 critical 작업에서 클라이언트가 retry해도 안전하게. 이후 사실상 모든 결제 SDK가 이 패턴을 따른다. 미디어 처리도 본질이 같다 — MediaConvert minute당 비용은 결제만큼 비싸므로 같은 패턴이 필요. ClientRequestToken을 빠뜨리는 건 신용카드 두 번 긁는 것과 같다.
“Kafka의 ‘exactly-once semantics’는 좁은 정의다”
Kafka 0.11에서 추가된 EOS는 (a) producer → broker (idempotent producer) + (b) Kafka transactions (across topics) + (c) consumer offset commit이 transaction 안에 묶일 때만 성립. 외부 시스템(DB, 외부 API)으로 나가면 그 보장은 깨진다 — Kafka 안에서는 정확히 한 번이지만 DB write는 두 번 갈 수 있음. 그래서 결국 application-level idempotency가 필요.
“멱등성은 사실 함수형 프로그래밍의 사촌”
Pure function — 같은 입력 → 같은 출력, side effect 없음. 멱등 함수 — 같은 입력 → 같은 효과, 여러 번 실행해도 한 번 실행한 것과 같음. 함수형 사고가 분산 시스템 안정성에 직결된다 — “side effect를 어떻게 제어하느냐”의 다른 표현.
요약 + Mermaid
요점 — exactly-once delivery는 환상. 진짜 답은 at-least-once + idempotent processing. 멱등성은 (a) 자연 멱등 연산을 쓰거나, (b) 멱등 키 + 외부 store, (c) 외부 SDK의 ClientRequestToken을 활용해 만든다. 미디어 파이프라인은 산출물이 결정적이라 자연 멱등이 잘 맞고, 가장 비싼 사고는 MediaConvert에 ClientRequestToken을 빠뜨리는 것 — 비용이 2배가 된다. 다음 문서(
05)는 멱등이 안 통하는 진짜 실패를 어떻게 분류하고 회수하는지 본다.