멱등성과 중복 처리 방지 패턴

네트워크 재시도와 중복 요청으로 인한 데이터 이중 처리 문제를 DB 설계로 해결하는 멱등성 패턴을 설명합니다. Idempotency-Key 헤더, 중복 방지 테이블, ON CONFLICT DO NOTHING, 분산 환경에서의 고려사항을 코드와 함께 다룹니다.

· 6 min read · PALDYN Team

지난 글에서 페이지네이션 전략을 살펴봤습니다. 이번 글의 주제는 **멱등성(Idempotency)**입니다. 특히 결제, 포인트 적립, 재고 감소처럼 “한 번만 실행되어야 하는” 작업에서 네트워크 오류나 클라이언트 재시도로 인한 이중 처리를 방지하는 DB 패턴을 살펴봅니다.

멱등성이란

수학에서 멱등(idempotent)이란 같은 연산을 여러 번 적용해도 결과가 같은 성질입니다. f(f(x)) = f(x). 시스템에서는 같은 요청을 여러 번 보내도 결과가 한 번과 동일한 것을 의미합니다.

HTTP 메서드로 보면 GET, PUT, DELETE는 멱등이지만 POST는 멱등이 아닙니다. “결제” API를 POST /payments로 설계했을 때 클라이언트가 응답을 받지 못해 재시도하면 결제가 두 번 처리될 수 있습니다.

클라이언트: POST /payments { amount: 10000 }
서버: 결제 처리 완료
네트워크 단절 → 클라이언트 응답 못 받음
클라이언트: POST /payments { amount: 10000 } 재시도
서버: 또 결제 처리 → 이중 결제!

Idempotency-Key 패턴

Stripe가 대중화한 패턴으로, 클라이언트가 요청 시 고유한 키를 헤더에 포함합니다.

POST /payments HTTP/1.1
Content-Type: application/json
Idempotency-Key: payment-user123-order456-attempt1

{ "amount": 10000, "currency": "KRW" }

서버는 이 키를 저장해두고, 같은 키로 재요청이 오면 새로 처리하지 않고 이전 결과를 그대로 반환합니다.

멱등성 처리 흐름

DB 스키마 설계

멱등성 키를 저장하는 테이블을 별도로 관리합니다.

-- 멱등성 키 저장 테이블
CREATE TABLE idempotency_keys (
  idempotency_key  VARCHAR(255) PRIMARY KEY,
  request_hash     VARCHAR(64),    -- 요청 본문 해시 (다른 요청인지 검증)
  response_body    JSONB,          -- 캐시된 응답
  response_status  INT,            -- HTTP 상태 코드
  status           VARCHAR(20) DEFAULT 'processing',
  created_at       TIMESTAMPTZ DEFAULT NOW(),
  expires_at       TIMESTAMPTZ NOT NULL  -- TTL (예: 24시간)
);

CREATE INDEX ON idempotency_keys (expires_at)
  WHERE status = 'completed';  -- 만료된 키 정리용

처리 로직

트랜잭션 안에서 “키 삽입 시도 → 이미 있으면 캐시 반환” 흐름으로 처리합니다.

async def process_payment(idempotency_key: str, payload: dict):
    request_hash = sha256(json.dumps(payload, sort_keys=True))

    async with db.transaction():
        # 1. 멱등성 키 삽입 시도 (이미 존재하면 실패)
        result = await db.execute("""
            INSERT INTO idempotency_keys
              (idempotency_key, request_hash, status, expires_at)
            VALUES ($1, $2, 'processing', NOW() + INTERVAL '24 hours')
            ON CONFLICT (idempotency_key) DO NOTHING
            RETURNING status, response_body
        """, [idempotency_key, request_hash])

        if not result:
            # 2. 이미 처리된 경우: 기존 결과 조회
            existing = await db.fetchone(
                "SELECT status, response_body FROM idempotency_keys WHERE idempotency_key = $1",
                [idempotency_key]
            )
            if existing['status'] == 'processing':
                raise ConflictError("요청이 처리 중입니다")
            return existing['response_body']

        # 3. 신규: 비즈니스 로직 실행
        payment_result = await execute_payment(payload)

        # 4. 결과 저장
        await db.execute("""
            UPDATE idempotency_keys
            SET status = 'completed', response_body = $1
            WHERE idempotency_key = $2
        """, [json.dumps(payment_result), idempotency_key])

        return payment_result

DB 레벨 중복 방지: ON CONFLICT

애플리케이션 레이어 없이 DB 레벨에서 직접 중복을 방지할 수 있습니다. PostgreSQL의 ON CONFLICT DO NOTHING을 사용합니다.

DB 레벨 중복 방지 — INSERT ON CONFLICT

-- MySQL 동일 패턴
INSERT IGNORE INTO payments (idempotency_key, user_id, amount)
VALUES ('key-abc', 1, 10000);
-- 중복 시 에러 없이 무시

-- 또는 REPLACE INTO (기존 행 삭제 후 재삽입 — 사용 주의)
-- REPLACE INTO payments ... -- PK가 바뀌므로 FK 이슈 발생 가능

클라이언트 키 생성 전략

멱등성 키는 클라이언트가 생성해야 합니다. 일반적인 전략입니다.

// 결제 전 UUID 생성 (로컬 저장)
const idempotencyKey = crypto.randomUUID();
localStorage.setItem('pendingPaymentKey', idempotencyKey);

// 결제 API 호출
try {
  const result = await fetch('/payments', {
    method: 'POST',
    headers: { 'Idempotency-Key': idempotencyKey },
    body: JSON.stringify({ amount: 10000 })
  });
  localStorage.removeItem('pendingPaymentKey');
  return result.json();
} catch (error) {
  // 재시도 시 동일한 키 사용
  return retryWithSameKey(idempotencyKey);
}

주의사항

  • 요청 본문 해시 검증: 같은 키로 다른 내용의 요청이 오면 에러 반환 (다른 결제 금액 등)
  • TTL 설정: 멱등성 키는 영구 보관하지 않습니다. 일반적으로 24시간 ~ 7일
  • 분산 환경: Redis를 사용한 분산 락과 조합하면 동시 중복 요청도 방지 가능
  • 상태 확인: processing 상태인 키로 재요청이 오면 “처리 중” 응답 반환

다음 글에서는 소프트 딜리트(Soft Delete)와 하드 딜리트(Hard Delete)의 트레이드오프와 올바른 선택 기준을 살펴봅니다.


지난 글: 커서 vs 오프셋 — 페이지네이션 전략

다음 글: Soft Delete vs Hard Delete


읽어주셔서 감사합니다. 😊