---
id: messaging-exactly-once
title: Exactly-once 의미 — Idempotency + Outbox + Dedup
category: Coding
status: draft
source_trust_level: B
verification_status: conceptual
created_at: 2026-05-09
updated_at: 2026-05-09
tags: [messaging, exactly-once, idempotency, vibe-coding]
tech_stack: { language: "TS / Kafka / SQL", applicable_to: ["Backend"] }
applied_in: []
aliases: [exactly once, EOS, transactional producer, dedupe, effectively-once]
---

# Exactly-once

> 분산 시스템에서 진정한 exactly-once 는 거의 없다. 실용적 = **at-least-once + idempotent consumer = effectively-once**. Kafka transactional 은 read-process-write 안에서만.

## 📖 핵심 개념
- At-most-once: 잃을 수 있음.
- At-least-once: 중복 가능.
- Exactly-once: 정확히 1번 — 분산 boundary 까지는 어렵.
- Effectively-once = at-least-once + idempotent.

## 💻 코드 패턴

### Idempotent consumer (가장 표준)
```sql
CREATE TABLE processed (
  event_id TEXT PRIMARY KEY,
  processed_at TIMESTAMPTZ DEFAULT NOW()
);
```

```ts
async function consume(msg: Message) {
  const id = msg.headers['event-id'];
  return db.transaction(async (tx) => {
    try {
      await tx.processed.insert({ eventId: id }); // unique violation = 이미 처리
    } catch (e) {
      if (isUniqueViolation(e)) return; // skip
      throw e;
    }
    await handleInTx(tx, msg.body);
  });
}
```

### Producer idempotency (Kafka)
```ts
const producer = kafka.producer({
  idempotent: true,
  maxInFlightRequests: 5, // <= 5 권장
  retry: { retries: 10 },
});
// Kafka 가 자동 PID + sequence number 로 중복 제거 (single producer)
```

### Transactional producer (Kafka, read-process-write)
```ts
const producer = kafka.producer({ transactionalId: 'projector-1', idempotent: true });
const consumer = kafka.consumer({ groupId: 'projector', readUncommitted: false });

await producer.connect();
const tx = await producer.transaction();
try {
  await tx.send({ topic: 'output', messages: [...] });
  await tx.sendOffsets({ consumerGroupId: 'projector', topics: [{ topic: 'input', partitions: [{ partition: 0, offset: '42' }] }] });
  await tx.commit();
} catch {
  await tx.abort();
}
```

EOS 안 = consumer offset 과 produced messages 가 한 트랜잭션.

### Outbox + processed-table 조합
```
1. Producer: outbox 안에 같이 commit (atomic)
2. Publisher: outbox → broker (at-least-once)
3. Consumer: processed table 검사 + handle in tx
```

이게 분산 시스템에서 정직한 exactly-once.

### 외부 부수효과 — TX 후
```ts
// ❌ TX 안 외부 API: TX 롤백 시 부수효과만 남음
await db.transaction(async (tx) => {
  await tx.orders.insert(o);
  await stripe.charges.create(...); // 안 됨
});

// ✅ outbox + 후속 처리
await db.transaction(async (tx) => {
  await tx.orders.insert(o);
  await tx.outbox.insert({ type: 'ChargeRequested', ... });
});
// publisher 가 별도로 stripe 호출 + idempotencyKey
```

### Dedupe 시간 윈도우 (가벼운)
```ts
const recent = new LRU<string, true>({ max: 100_000, ttl: 5 * 60_000 });

async function consume(msg) {
  if (recent.has(msg.id)) return;
  recent.set(msg.id, true);
  await handle(msg);
}
```

분산 환경에선 Redis 같은 외부 store 필요.

### Once-and-only-once 가짜 / 진짜
가짜 (claim 만): SQS FIFO `MessageDeduplicationId` 5분 / Kafka idempotent producer (single producer).
진짜에 가까움: Kafka transactional EOS + processed table.
완벽 X: 외부 시스템 boundary (HTTP, email).

## 🤔 의사결정 기준
| 상황 | 보장 |
|---|---|
| Read → process → write Kafka 안 | Kafka EOS transactional |
| DB write + 외부 API | Outbox + idempotency key |
| 단순 큐 처리 | Idempotent consumer (processed table) |
| Stream aggregations | Kafka Streams EOS |
| 일회성 알림 | At-least-once + idempotent |
| 정확히 한 번 진짜 | 보통 불가 — effectively-once 디자인 |

## ❌ 안티패턴
- **EOS 라고 가정 + 외부 부수효과**: 깨짐.
- **Idempotency key 없는 dedupe**: 같은 의미 다른 hash 통과.
- **Processed table 없음**: 중복 처리.
- **Producer idempotent 만 + multiple producer**: producer 간 중복.
- **Manual offset commit + before processing**: 메시지 잃음.
- **SQS visibility timeout < 처리 시간**: 같은 메시지 두 consumer.
- **Distributed lock 으로 dedupe**: 복잡 + 실패 모드.

## 🤖 LLM 활용 힌트
- 진짜 exactly-once 거의 없음 — effectively-once 디자인.
- Idempotent consumer + outbox 가 표준.
- Kafka EOS 는 read-process-write 안에서만.

## 🔗 관련 문서
- [[Backend_Idempotency_Keys]]
- [[Backend_Outbox_Pattern]]
- [[Messaging_Kafka_Patterns]]
