--- id: backend-idempotent-consumer title: Idempotent Consumer — At-least-once → Exactly-once category: Coding status: draft source_trust_level: B verification_status: conceptual created_at: 2026-05-09 updated_at: 2026-05-09 tags: [backend, idempotent, consumer, vibe-coding] tech_stack: { language: "TS / SQL", applicable_to: ["Backend"] } applied_in: [] aliases: [idempotent consumer, dedupe table, processed events, exactly-once delivery] --- # Idempotent Consumer > Message broker = at-least-once. **같은 메시지 두 번 처리 = 중복 사고**. Idempotent consumer = effectively-once. Dedupe table + transactional handling. ## 📖 핵심 개념 - Event ID: producer 가 생성, 고유. - Processed table: 이미 처리한 ID list. - Atomic: handle + insert ID 한 트랜잭션. - TTL: 옛 ID 정리. ## 💻 코드 패턴 ### Dedupe table ```sql CREATE TABLE processed_events ( event_id TEXT PRIMARY KEY, processed_at TIMESTAMPTZ DEFAULT NOW(), consumer TEXT NOT NULL ); CREATE INDEX processed_events_processed_at ON processed_events(processed_at); ``` ### Consume + atomic ```ts async function consume(msg: Message) { const eventId = msg.headers['x-event-id']; if (!eventId) throw new Error('event-id required'); return await db.transaction(async (tx) => { // 1. Try insert event id (unique constraint = 중복 검출) try { await tx.processedEvents.insert({ eventId, consumer: 'order-projector', }); } catch (e) { if (isUniqueViolation(e)) { // 이미 처리 — skip return { skipped: true }; } throw e; } // 2. 실제 작업 await handleInTx(tx, msg); return { processed: true }; }); } ``` → DB 트랜잭션 = atomic. Crash 시 둘 다 X. ### Per-consumer dedupe (다른 consumer 가 같은 event 처리) ```sql ALTER TABLE processed_events ADD CONSTRAINT processed_events_pk PRIMARY KEY (event_id, consumer); ``` → Order-projector + Email-sender 가 둘 다 같은 OrderPlaced 처리. ### TTL cleanup ```sql -- 30일 이상 된 events 삭제 DELETE FROM processed_events WHERE processed_at < NOW() - INTERVAL '30 days'; ``` ```ts // Cron async function cleanupProcessedEvents() { await db.processedEvents.delete({ where: { processedAt: { lt: subDays(new Date(), 30) } }, }); } ``` → Producer 재시도 윈도우보다 길게. ### Redis-based (빠른) ```ts async function consume(msg: Message) { const eventId = msg.headers['x-event-id']; // SETNX — 중복 시 false const ok = await redis.set(`processed:${eventId}`, '1', 'EX', 30 * 24 * 3600, 'NX'); if (!ok) return { skipped: true }; try { await handle(msg); return { processed: true }; } catch (e) { // 처리 실패 — Redis 에서 제거 (재시도 가능) await redis.del(`processed:${eventId}`); throw e; } } ``` ⚠️ Redis 와 DB 의 atomicity X — DB 트랜잭션 패턴 권장. ### Outbox + idempotent consumer = effectively exactly-once ``` Producer: DB write + outbox → broker (at-least-once) Consumer: dedupe + handle (idempotent) → 결과: exactly-once ``` → [[Backend_Outbox_Pattern]] + 이 문서. ### Kafka exactly-once semantics (EOS) ```ts // 같은 transaction 안 consume + produce + commit const producer = kafka.producer({ transactionalId: 'projector', idempotent: true }); 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(); } ``` → Kafka 안 EOS. 외부 system 까지는 X. ### App-level idempotency key (외부 API) ```ts // Stripe / Square / 외부 API 호출 async function chargeCustomer(eventId: string, amount: number) { return await stripe.charges.create( { amount, currency: 'usd', source: '...' }, { idempotencyKey: eventId } ); } ``` → Stripe 가 같은 key = 한 번만 charge. ### Retry-safe DB write ```sql -- ON CONFLICT (UPSERT) = idempotent INSERT INTO orders (id, status, amount) VALUES ($1, $2, $3) ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status; ``` → 같은 order id 재시도 OK. ### Increment vs SET (counter) ```sql -- ❌ Increment 가 두 번 실행 = 중복 UPDATE products SET likes = likes + 1 WHERE id = $1; -- ✅ Idempotent — UNIQUE 한 like 별 row INSERT INTO product_likes (product_id, user_id, event_id) VALUES (...) ON CONFLICT (event_id) DO NOTHING; -- 그 후 count SELECT count(*) FROM product_likes WHERE product_id = $1; ``` ### State machine (transition idempotent) ```ts async function handleOrderShipped(orderId: string) { const order = await db.orders.find(orderId); if (!order) throw new Error('not found'); if (order.status === 'shipped' || order.status === 'delivered') { return; // 이미 처리 — skip } if (order.status !== 'paid') { throw new Error(`cannot ship from ${order.status}`); } await db.orders.update(orderId, { status: 'shipped' }); } ``` → State 만 변경 — 두 번 호출 OK. ### 외부 부수효과 추적 ```ts // 이메일 보냄 — 두 번 보내면 안 됨 async function sendOrderEmail(orderId: string, eventId: string) { return await db.transaction(async (tx) => { // 이미 보냄? const sent = await tx.emails.find({ eventId }); if (sent) return { skipped: true }; // 보내기 (transactional 외부 API 가 best) await emailService.send({ ..., idempotencyKey: eventId }); // 기록 await tx.emails.insert({ eventId, sentAt: new Date() }); return { sent: true }; }); } ``` ⚠️ Email service 가 idempotencyKey 지원 안 하면 — 보내기 후 commit 사이 crash 가능. 보상 / monitoring. ### 시간 윈도우 dedupe (가벼운) ```ts // 5분 안 같은 event 처리 X const recent = new LRU({ max: 100_000, ttl: 5 * 60_000 }); async function consume(msg: Message) { const id = msg.headers['x-event-id']; if (recent.has(id)) return; recent.set(id, true); await handle(msg); } ``` ⚠️ Process restart = recent 사라짐. 분산 환경 = 다른 consumer 안 dedupe. ### Test ```ts test('consume same event twice = handled once', async () => { const msg = makeMessage({ orderId: 'o1' }); await consumer.consume(msg); await consumer.consume(msg); // duplicate const count = await db.orders.count({ where: { id: 'o1' } }); expect(count).toBe(1); }); ``` ### Monitoring ```ts metrics.counter('events.processed', { consumer, type }); metrics.counter('events.duplicates', { consumer }); metrics.histogram('events.processing_ms', ms); // Alarm: duplicates 률 너무 높음 = producer issue ``` ## 🤔 의사결정 기준 | 상황 | 추천 | |---|---| | DB 가 truth | Processed table + transaction | | 가벼운 / 작은 throughput | Redis SETNX | | Kafka 안 transform | EOS transactional | | 외부 API 호출 | App idempotency key | | Counter 증가 | UNIQUE row + COUNT | | State machine | State guard | ## ❌ 안티패턴 - **Dedupe 안 함 + at-least-once**: 중복 처리. - **Dedupe + 외부 부수효과 (atomic X)**: 중복 가능. - **TTL 없음**: 영원 자라남. - **Process memory dedupe + 분산**: 다른 process 안 dedupe. - **`COUNT + 1` 같은 non-idempotent**: 중복 시 잘못된 count. - **Retry 무한**: 영원 stuck. DLQ. - **Producer 가 다른 ID 매번 (UUID 매 retry)**: dedupe 의미 없음. ## 🤖 LLM 활용 힌트 - Processed table + transaction = 표준. - 외부 API = idempotencyKey. - TTL 30일 + cleanup. - Test 가 중복 처리 검증. ## 🔗 관련 문서 - [[Backend_Idempotency_Keys]] - [[Backend_Outbox_Pattern]] - [[Messaging_Exactly_Once]]