--- 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({ 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]]