📁 File7. 파이프라인 이론04. Idempotency — 중복 메시지의 필연성, 멱등 키, exactly-once의 환상

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/keyO같은 key는 덮어쓰기 (versioning OFF)
SET x = 5 (Redis)O결과는 항상 x=5
INSERT ... ON DUPLICATE KEY UPDATEOupsert
INCR x (Redis)X호출마다 +1
INSERT ... (no constraint)X중복 row
POST /api/transcodeX새 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 CreateJobClientRequestToken약 24h
Stripe POST /chargesIdempotency-Key 헤더24h
AWS DynamoDB TransactWriteItemsClientRequestToken10min
GCP Pub/Sub publishmessageOrderingKey-

활용:

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 내장 dedupe5분(자체)
Kafka (긴 retention)수 주retention 이상

→ “5분 안에 같은 메시지 안 옴” 같은 가정은 위험. 실제 redrive(수동 회수)까지 고려해야 함.

3) 미디어 파이프라인의 단계별 멱등 전략

단계작업멱등 전략
probemetadata 추출 → S3 PUT자연 멱등 (PUT 덮어쓰기)
transcodeMediaConvert CreateJobClientRequestToken = pipelineId
sprite다수 S3 PUT자연 멱등 (덮어쓰기)
waveformS3 PUT자연 멱등
DB updateUPDATE + UPSERTON 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)는 멱등이 안 통하는 진짜 실패를 어떻게 분류하고 회수하는지 본다.