[Daily morning study] 멱등성(Idempotency) 개념과 분산 시스템에서의 적용

#daily morning study

Image


멱등성(Idempotency)이란

멱등성이란 동일한 요청을 한 번 보내든 여러 번 보내든 결과가 같은 성질을 말한다. 수학에서 온 개념으로, f(f(x)) = f(x)가 성립하는 연산을 의미한다.

분산 시스템, 특히 REST API나 메시지 큐 기반 아키텍처에서 네트워크 장애나 타임아웃으로 인해 같은 요청이 두 번 이상 처리될 수 있는 상황이 빈번히 발생한다. 이 때 멱등성을 보장하면 중복 처리로 인한 데이터 불일치나 부작용을 방지할 수 있다.


HTTP 메서드별 멱등성

HTTP 메서드멱등성안전성(Safe)설명
GETOO조회만 하므로 항상 안전하고 멱등
HEADOOGET과 동일하나 본문 없음
PUTOX동일 데이터로 여러 번 갱신해도 결과 동일
DELETEOX두 번 삭제해도 첫 번째 이후 상태 동일
POSTXX호출마다 새 리소스 생성 — 기본적으로 비멱등
PATCHX (조건부)X연산 방식에 따라 달라짐

PUT은 "나이를 30으로 설정" 방식이라 여러 번 호출해도 동일하지만, PATCH는 "나이를 1 증가" 방식이면 호출마다 결과가 달라지므로 비멱등이 된다.


왜 POST가 문제가 되는가

결제, 주문 생성 같은 POST 요청에서 클라이언트가 타임아웃을 받고 재시도를 하면 서버는 동일 요청을 두 번 처리할 수 있다.

클라이언트 → [결제 요청] → 서버
클라이언트 ← [타임아웃]  ← 서버 (처리는 됐지만 응답 유실)
클라이언트 → [결제 재시도] → 서버  ← 중복 결제 발생!

이 문제를 해결하는 가장 일반적인 방법이 Idempotency Key(멱등성 키) 패턴이다.


Idempotency Key 패턴

클라이언트가 요청 시 고유 키를 함께 전송하고, 서버는 이 키를 기반으로 중복 요청을 감지해 이미 처리된 결과를 반환한다.

POST /payments HTTP/1.1
Content-Type: application/json
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000

{
  "amount": 10000,
  "currency": "KRW",
  "target_account": "1234-5678"
}

서버 처리 흐름

1. Idempotency-Key를 DB/캐시에서 조회
2. 이미 존재하면 → 저장된 이전 응답 그대로 반환
3. 존재하지 않으면 → 요청 처리 후 결과를 키와 함께 저장

구현 예시 (Node.js + Redis)

async function handlePayment(idempotencyKey, paymentData) {
  const cached = await redis.get(`idempotency:${idempotencyKey}`);
  
  if (cached) {
    // 이미 처리된 요청 — 저장된 결과 반환
    return JSON.parse(cached);
  }

  // 처리 중 상태를 먼저 기록 (다른 인스턴스의 동시 처리 방지)
  await redis.set(
    `idempotency:${idempotencyKey}`,
    JSON.stringify({ status: 'processing' }),
    'EX', 86400  // 24시간 TTL
  );

  try {
    const result = await processPayment(paymentData);
    // 최종 결과로 업데이트
    await redis.set(
      `idempotency:${idempotencyKey}`,
      JSON.stringify(result),
      'EX', 86400
    );
    return result;
  } catch (err) {
    // 실패 시 키 삭제하여 재시도 허용
    await redis.del(`idempotency:${idempotencyKey}`);
    throw err;
  }
}

실제 Stripe, PayPal 같은 결제 시스템이 이 방식을 공식 지원한다.


데이터베이스 레벨의 멱등성

UPSERT 활용

-- PostgreSQL: INSERT 충돌 시 UPDATE로 처리
INSERT INTO orders (order_id, user_id, amount, status)
VALUES ('order-uuid-001', 42, 10000, 'pending')
ON CONFLICT (order_id) DO UPDATE
  SET status = EXCLUDED.status,
      updated_at = NOW();

order_id에 UNIQUE 제약이 있어서 같은 주문을 두 번 삽입해도 두 번째는 충돌 처리되므로 멱등하다.

조건부 업데이트로 멱등성 보장

-- 버전 기반 낙관적 락
UPDATE accounts
SET balance = balance - 10000, version = version + 1
WHERE account_id = 'acc-001'
  AND version = 5;  -- 기대하는 버전이 맞을 때만 처리

version이 이미 변경됐다면 영향받은 행이 0이 되어 재처리를 감지할 수 있다.


메시지 큐에서의 멱등성

Kafka, RabbitMQ 등에서 메시지는 at-least-once 전달 방식이기 때문에 컨슈머가 같은 메시지를 두 번 받을 수 있다. 이를 처리하는 방법:

1. 처리 이력 테이블 관리

CREATE TABLE processed_messages (
  message_id VARCHAR(255) PRIMARY KEY,
  processed_at TIMESTAMP DEFAULT NOW()
);
def consume_message(message):
    message_id = message['id']
    
    # 이미 처리했는지 확인
    if db.exists('processed_messages', message_id):
        return  # 중복 — 건너뜀
    
    # 비즈니스 로직 처리
    process_order(message['data'])
    
    # 처리 이력 기록
    db.insert('processed_messages', {'message_id': message_id})

이 패턴은 메시지 처리와 이력 기록을 같은 트랜잭션 안에 묶어야 완전한 보장이 된다.

2. Kafka의 Exactly-Once Semantics

Kafka 0.11부터 트랜잭셔널 API를 통해 exactly-once를 지원한다.

producer.initTransactions();

try {
    producer.beginTransaction();
    producer.send(new ProducerRecord<>("output-topic", key, value));
    producer.sendOffsetsToTransaction(offsets, consumerGroupMetadata);
    producer.commitTransaction();
} catch (Exception e) {
    producer.abortTransaction();
}

멱등성과 트랜잭션의 관계

멱등성 키 저장과 실제 비즈니스 로직을 동일 트랜잭션에서 처리해야 완전한 보장이 된다.

나쁜 예:
1. 비즈니스 로직 처리 (트랜잭션 A)
2. 멱등성 키 저장 (트랜잭션 B) ← 1과 2 사이에 장애 나면 키 없이 처리 완료됨
좋은 예:
단일 트랜잭션에서:
  1. 멱등성 키 INSERT (중복이면 즉시 반환)
  2. 비즈니스 로직 처리
  3. COMMIT

실제 적용 시 고려 사항

Idempotency Key 생성: 클라이언트에서 UUID v4를 생성해 사용하는 게 일반적이다. 서버 측에서는 키를 검증만 하고 생성하지 않는다.

키의 유효 기간(TTL): 멱등성 키를 영구 보관하면 스토리지 낭비가 생긴다. 보통 24시간~7일 사이의 TTL을 설정한다. 클라이언트가 그 이후에 재시도하면 새 요청으로 처리된다.

요청 본문(Payload)의 변경: 동일 키에 다른 본문을 보내는 경우를 어떻게 처리할지 정책이 필요하다. Stripe는 키가 동일하면 최초 요청 결과를 항상 반환하고 본문 불일치 시 에러를 내보낸다.

분산 환경에서의 동시성: 같은 키로 동시에 두 요청이 오면 Race Condition이 발생할 수 있다. Redis의 SET NX(SET if Not Exists) 또는 DB의 UNIQUE 제약을 활용한 락으로 해결한다.

SET idempotency:key-001 "processing" NX EX 30
# NX: 키가 없을 때만 설정
# 성공하면 처리 진행, 실패하면 이미 다른 인스턴스가 처리 중

정리

멱등성은 분산 시스템의 신뢰성을 높이는 핵심 설계 원칙이다. 네트워크 장애, 재시도, 중복 메시지는 피할 수 없기 때문에 시스템이 그 상황을 자연스럽게 흡수할 수 있어야 한다.

  • GET, PUT, DELETE: 기본적으로 멱등
  • POST: Idempotency Key 패턴으로 멱등성 부여
  • 메시지 큐: 처리 이력 테이블 또는 exactly-once semantics 활용
  • DB: UPSERT, 조건부 업데이트 활용
  • 핵심: 멱등성 키 저장과 비즈니스 로직은 같은 트랜잭션 안에서 처리