---
id: idempotent-operations
title: 멱등성 연산 실전 (Idempotent Operations)
category: Coding
status: draft
canonical_id: idempotent-operations
aliases: [idempotency, retry-safe, exactly-once illusion, 멱등성]
duplicate_of: null
source_trust_level: B
confidence_score: 0.9
verification_status: conceptual
created_at: 2026-05-09
updated_at: 2026-05-09
last_reinforced: 2026-05-09
review_reason: ""
merge_history: []
tags: [coding, distributed, http, message-queue, retry, vibe-coding]
raw_sources: ["P-Reinforce session 2026-05-09 — bulk Coding seed batch 1"]
tech_stack:
  language: "TypeScript / SQL / HTTP"
  applicable_to: ["Backend", "Worker", "API design"]
applied_in: []
---

# 멱등성 연산 실전

> 같은 요청이 N번 와도 부작용은 1번분만. 분산 시스템에서 "exactly-once" 는 불가능에 가깝고, "at-least-once + idempotent" 가 현실적 답이다.

## 📖 핵심 개념

**멱등성(idempotent)**: `f(f(x)) == f(x)`. 연산을 여러 번 적용해도 첫 번째 적용과 결과가 같음.

분산 환경의 모든 네트워크 호출은 **재시도 가능성**이 있다 (ACK 유실 / 타임아웃 / 클라이언트 retry). 따라서 다음을 항상 가정:
- 클라이언트는 같은 요청을 여러 번 보낼 수 있다
- 메시지 큐는 같은 메시지를 여러 번 전달할 수 있다 (at-least-once delivery)
- DB 트랜잭션은 commit 후 응답을 못 받고 재시도될 수 있다

해결: **연산 자체를 멱등하게** 만들거나, **외부 idempotency key**로 중복 감지.

## 💻 코드 패턴

### 1. HTTP 엔드포인트 — Idempotency-Key 헤더

```ts
// Express
app.post('/api/payments', async (req, res) => {
  const key = req.header('Idempotency-Key');
  if (!key) return res.status(400).json({ error: 'Idempotency-Key required' });

  // 1) 같은 키의 이전 응답을 캐시에서 조회
  const cached = await redis.get(`idem:${key}`);
  if (cached) return res.json(JSON.parse(cached));

  // 2) 진짜 결제 수행 (DB 트랜잭션 안에서 키 unique 보장)
  try {
    const payment = await db.transaction(async (tx) => {
      await tx.idempotencyKeys.insert({ key, status: 'processing' }); // UNIQUE
      const result = await chargePayment(req.body);
      await tx.idempotencyKeys.update(key, { status: 'done', response: result });
      return result;
    });
    await redis.setex(`idem:${key}`, 86400, JSON.stringify(payment));
    res.json(payment);
  } catch (e) {
    if (isUniqueViolation(e)) {
      // 이전 호출이 진행 중 — 클라이언트가 잠시 후 재시도하도록
      return res.status(409).json({ error: 'In progress, retry later' });
    }
    throw e;
  }
});
```

### 2. SQL — UPSERT 로 멱등 INSERT

```sql
-- 같은 user_id 가 여러 번 들어와도 한 행
INSERT INTO user_settings (user_id, theme)
VALUES ($1, $2)
ON CONFLICT (user_id) DO UPDATE SET theme = EXCLUDED.theme;
```

### 3. Message Queue Consumer — processed_id 테이블

```ts
async function handleMessage(msg: { id: string; payload: any }) {
  const inserted = await db.processedMessages.insertIfAbsent({ id: msg.id }); // UNIQUE
  if (!inserted) {
    logger.info('Duplicate message ignored', { id: msg.id });
    return; // 이미 처리됨
  }
  await actuallyProcess(msg.payload);
}
```

## 🤔 의사결정 기준

| HTTP 메서드 | 멱등성 기본값 | 강제 필요 |
|---|---|---|
| GET / HEAD / PUT / DELETE | 멱등 (RFC 9110) | 일반적으로 자연스러움 |
| POST | 비멱등 | **반드시 Idempotency-Key 도입** |

| 작업 종류 | 멱등화 전략 |
|---|---|
| 결제 / 외부 API 호출 | Idempotency-Key + 응답 캐시 |
| DB INSERT | UPSERT or UNIQUE 제약 |
| 메시지 큐 consumer | processed_id 테이블 |
| 파일 업로드 | content hash 기반 dedup |
| 카운터 증가 | **이건 멱등화 어려움** — sequence number / event sourcing 고려 |

## ❌ 안티패턴

- **클라이언트가 키를 안 보냈다고 그냥 처리**: "키 없으면 실패" 가 안전. 키를 매번 새로 만드는 클라이언트는 매번 새 결제로 처리되어도 클라이언트 책임.
- **응답 캐시 TTL 너무 짧음**: 24시간 권장. 그보다 짧으면 늦은 retry가 새 처리를 만듦.
- **카운터 증가를 "그냥 두 번 올리면 되지" 식으로 처리**: at-least-once + ++counter 는 정확한 카운트 보장 못 함. 이벤트 소싱 또는 외부 dedup 필요.
- **트랜잭션 밖에서 키 체크**: 키 체크 → 실 처리 사이에 race. 키 INSERT는 같은 트랜잭션 안에서.
- **에러 응답도 캐싱**: 일시 오류(5xx)를 캐시하면 후속 retry가 모두 실패. 4xx만 캐싱, 5xx는 캐시 안 함.

## 🤖 LLM 활용 힌트

- LLM에게 외부 호출 코드 작성을 시킬 때 "**at-least-once delivery 가정 + Idempotency-Key 패턴 적용**" 명시.
- DB INSERT 코드: "**같은 키로 N번 호출되어도 한 행만 남도록**" 라고 요청 → UPSERT or UNIQUE.
- 메시지 핸들러 작성: "**이 핸들러는 같은 메시지가 N번 들어와도 안전해야 함**" 명시.

## 🧪 검증 상태

- verification_status: `conceptual`
- Stripe / AWS / Square 의 실제 결제 API 가 모두 Idempotency-Key 사용 → B급 출처.
- 적용 사례 발견 시 `applied_in` 추가.

## 🔗 관련 문서

- [[Optimistic_Concurrency_Control]]
- [[Backpressure_Patterns]]
- [[Error_Handling_Result_vs_Throw]]
