← 프리뷰 목록으로

Postgres에서 Stripe 스타일의 Idempotency Keys 구현하기

게시일: 2025년 11월 22일 | 원문 작성일: 2017년 10월 27일 | 저자: Brandur Leach | 원문 보기

요약

  • 문제: 외부 시스템(Stripe 같은)을 호출하는 API는 재시도 시 중복 부작용(이중 청구, 데이터 손상)을 방지해야 해요. 로컬 데이터베이스만 변경하면 트랜잭션으로 충분하지만, 외부 호출은 롤백할 수 없어서 특별한 처리가 필요하죠.
  • 해결책: Idempotency keys와 atomic phases를 조합하면 부분 실패에도 안전한 재시도가 가능해요. 각 요청을 식별 가능한 단계로 나누고, 실패 지점을 기록하고, 재시도 시 그 지점부터 재개해요.
  • 핵심 통찰: 외부 시스템 경계를 넘어가면 롤백할 수 없으므로, 실패를 전제하고 복구 가능하도록 설계해야 해요. SERIALIZABLE 격리, recovery points, staged jobs를 사용하면 어떤 실패 시나리오에서도 안전한 시스템을 만들 수 있어요.

문제 정의: 멱등성이 중요한 이유

분산 시스템에서 실패는 예외가 아니라 일상이에요. 클라이언트의 네트워크 연결이 요청 중간에 끊길 수 있어요. Stripe API 호출이 타임아웃될 수 있고, 결제가 성공했는지 불확실할 수 있죠. 서버 프로세스가 외부 응답을 기다리다가 충돌할 수도 있고요. 나쁜 배포가 요청을 절반쯤 실패시킬 수도 있어요.

멱등성이 있는 엔드포인트는 몇 번을 호출하든 실제 부작용은 정확히 한 번만 일어나요. 요청이 성공했는지 실패했는지 불확실한 클라이언트는 명확한 응답을 얻을 때까지 안심하고 재시도할 수 있죠.

로컬 ACID 데이터베이스 내에서만 상태를 변경하는 엔드포인트라면, 트랜잭션만으로도 충분해요. 전체 요청을 serializable 트랜잭션으로 감싸면, 데이터베이스가 모든 변경을 커밋하거나 모두 롤백하거든요.

복잡해지는 건 외부 시스템을 동기적으로 호출해야 할 때예요. 사용자의 신용카드로 Stripe에서 결제하는 결제 API를 생각해보세요. 요청 흐름은 로컬 변경(데이터베이스 업데이트)과 외부 변경(Stripe에서 결제 생성)을 포함해요. 이 둘을 트랜잭션으로 묶을 수 없고, Stripe에서는 성공했지만 로컬 데이터베이스가 실패하면 결제를 롤백할 수 없죠.

이것이 idempotency keys가 필수적인 이유예요.

Foreign State Mutations: 중요한 경계

Foreign state mutation은 시스템 외부의 데이터를 변경하는 모든 작업이에요. 외부 API 호출, 다른 시스템의 데이터베이스 변경, 이메일 발송, Kafka로 메시지 발행, DNS 레코드 생성 등이죠.

이 구분이 중요한 이유는:

특성 로컬 Mutations 외부 Mutations
롤백 가능성 ✅ 가능 - ACID 트랜잭션이 모든 변경을 롤백 ❌ 불가능 - 호출이 성공하면 되돌릴 수 없음
제어 범위 완전히 제어 가능 외부 시스템이 제어하며, 우리는 결과를 추적하고 받아들일 수밖에 없어요
예시 로컬 DB 업데이트, 인덱스 생성 Stripe API, 이메일 발송, Kafka 발행
네트워크 의존성 없음 (로컬 프로세스) 있음 (네트워크 호출로 본질적으로 불안정)

중요한 점: 같은 인프라 내의 내부 API 호출도 외부 mutation이에요. 네트워크 호출로 통신하는 순간, 그 시스템은 본질적으로 불안정하고 외부 경계로 취급해야 하거든요.

구현 아키텍처: Atomic Phases와 Recovery Points

해결책은 요청 생명주기를 두 가지 핵심 개념으로 분해하는 거예요:

Atomic Phases

Atomic phase는 단일 ACID 트랜잭션 안에서 함께 실행되는 로컬 상태 변경들을 묶은 것이에요. 이 변경들은 모두 커밋되거나 모두 롤백돼요. Postgres가 보증하죠.

핵심 규칙:

  • Atomic phases는 외부 상태 mutation을 시작하기 전에 완료되고 커밋되어야 해요
  • 외부 호출이 실패하면, 로컬 상태에 그 기록이 남아 복구가 가능해요
  • 두 외부 mutations 사이의 모든 데이터베이스 작업은 같은 atomic phase에 속할 수 있어요

Recovery Points

Recovery point는 요청 생명주기에 표시하는 체크포인트예요. 마지막 실패 전에 실행이 어디까지 도달했는지 추적하죠.

재시도 요청이 들어오면, idempotency key를 조회해서 recovery point를 찾고, 중단된 지점부터 다시 시작해요. Recovery point는 로컬 상태 변경과 함께 atomic phase 안에 저장되므로 일관성이 보증돼요.

“Recovery point는 요청이 어디까지 완료되었는지 나타내는 이정표예요. 실패 후 재시도 시 정확히 어디서 재개해야 하는지 알려주죠.”

Recovery point 예시:

  • __CODE_UNDER_0__ – idempotency key 생성 후 초기 상태
  • __CODE_UNDER_1__ – 로컬 ride 레코드 삽입, 감사 기록 저장
  • __CODE_UNDER_2__ – Stripe 결제 성공, ride 업데이트
  • __CODE_UNDER_3__ – 전체 요청 완료

기술 상세: 스키마와 동시성 제어

Idempotency Key 테이블

각 idempotency key의 메타데이터를 저장하는 스키마예요:

__FENCED_UNDER_0__

설계 결정:

  • 유니크 제약: __CODE_UNDER_4__는 다른 사용자가 같은 idempotency key를 충돌 없이 사용할 수 있게 해요
  • locked_at 필드: 요청이 현재 처리 중인지 나타내요. 동시 실행 시 충돌을 방지하죠
  • request_params: 같은 idempotency key인데 다른 파라미터로 재시도하는 버그를 감지하기 위해 저장돼요
  • response_code와 response_body: 이미 완료된 요청의 캐시된 결과를 재시도 시 반환해요

동시성 제어: SERIALIZABLE 트랜잭션

모든 atomic phases는 SERIALIZABLE 격리 트랜잭션 내에서 실행돼요. Postgres의 최고 수준 격리죠:

  • 두 요청이 같은 idempotency key를 동시에 잠그려고 하면, 하나는 serialization 에러로 중단돼요
  • 락 없는 check-and-set 패턴을 안전하게 구현할 수 있어요 (예: 키 존재 확인 후 없으면 생성)
  • 데이터베이스가 같은 idempotency key의 동시 재시도 간 race condition을 방지해요

Serialization 에러가 발생해도 괜찮아요. 클라이언트가 재시도하면 충돌하는 트랜잭션이 완료된 후 결국 성공하거든요.

“SERIALIZABLE 격리는 성능 비용이 있지만, 멱등성 보증 시스템에서는 필수적이에요. 락 획득이나 상태 검증 시 발생할 수 있는 race condition을 완전히 방지하죠.”

운영 패턴: Phases, Staging, Background Work

Atomic Phases 설계하기

체계적으로 요청을 phases로 분해하세요:

  1. Phase 1: Idempotency key upsert – key 레코드 생성 또는 검색
  2. Phase 2: 첫 번째 외부 호출 전 모든 로컬 변경
  3. Phase 3: 첫 번째 외부 mutation (그리고 결과로 로컬 상태 업데이트)
  4. Phase 4: 다음 외부 호출 전 모든 로컬 변경
  5. 각 외부 mutation마다 반복

외부 mutations 사이에 100개의 로컬 작업도 안전하게 같은 atomic phase에 속할 수 있어요.

Background Job Staging

클라이언트 연결을 유지한 채 외부 호출을 하면 느리고 실패 위험도 커져요. Background job으로 처리하는 게 좋아요.

Transactional staged job drain 패턴을 사용하세요: job 레코드를 staging 테이블에 삽입하되, 다른 로컬 변경과 같은 트랜잭션 내에서 해요. 별도의 워커 프로세스가 staged job을 모니터링하고 트랜잭션 커밋 후에만 큐로 옮겨요. 이렇게 하면:

  • Job이 요청의 로컬 변경과 원자적으로 실행돼요
  • 요청이 실패하면, job은 절대 실행되지 않아요 (트랜잭션 롤백)
  • Job이 atomic phase의 일부가 되어 운영 보증이 단순해져요

예시: 요청 중에 동기적으로 영수증 이메일을 보내는 대신, staged job을 삽입하세요. Background enqueuer가 트랜잭션 커밋 후 이를 job queue로 옮겨요. 이메일 발송은 background 작업이 되어 API 응답을 차단하지 않죠.

요청 생명주기: Rocket Rides 예시

사용자를 Stripe로 청구하는 라이드 셰어링 API를 생각해봐요:

__MERMAID_UNDER_0__

상세 단계:

  1. Phase 1 (tx1): Idempotency key upsert – key 생성 또는 검색, 락 획득
    • Recovery point: __CODE_UNDER_5__
  2. Phase 2 (tx2): Ride 레코드와 감사 기록 생성
    • Recovery point: __CODE_UNDER_6__
  3. Phase 3 (tx3): Stripe API 호출해서 고객 청구, ride를 charge ID로 업데이트
    • 외부 mutation이에요. Stripe 다운, 네트워크 타임아웃, 카드 거부로 실패할 수 있어요
    • Recovery point: __CODE_UNDER_7__
  4. Phase 4 (tx4): 이메일 영수증 background job 생성, recovery point를 __CODE_UNDER_8__로 설정
    • Recovery point: __CODE_UNDER_9__

Phase 3에서 실패한 재시도는 같은 idempotency key로 Stripe 호출을 다시 실행해요. Stripe의 자체 멱등성 보증이 중복 청구를 방지하죠.

실패 시나리오와 안전장치

같은 Idempotency Key로 동시 요청

동시에 같은 idempotency key로 두 요청이 도착해요. 둘 다 SERIALIZABLE 트랜잭션 내에서 락을 획득하려고 해요. Postgres가 하나를 serialization 에러로 중단해요. 클라이언트가 재시도하고 두 번째 시도가 성공해요. 단 하나의 요청만 상태를 변경하죠.

외부 서비스 실패

Stripe이 일시적으로 다운돼요. Stripe 호출 phase가 실패해요. Atomic phase가 롤백되고 idempotency key가 잠금 해제돼요. 클라이언트가 재시도해요. 결국 Stripe이 복구되고 결제가 성공해요.

부분 요청 (연결 끊김)

클라이언트가 요청을 시작하지만 응답을 받기 전에 연결이 끊겨요. 서버는 idempotency key를 생성하고 작업을 시작한 상태예요. 클라이언트가 다시 연결해서 같은 key로 재시도하면, 서버는 key를 조회해서 중간 recovery point를 찾고 거기서부터 계속해요.

잘못된 재시도

클라이언트가 같은 idempotency key로 재시도하지만 다른 요청 파라미터를 보내요. 서버가 불일치를 감지하고 409 Conflict로 요청을 거부하며 다른 idempotency key를 사용하라고 해요.

Stripe 호출 중 서버 충돌

서버 프로세스가 Stripe 응답을 기다리다가 죽어요. 결제는 Stripe에서 성공적으로 생성되었어요. 클라이언트가 재시도하면, 서버는 같은 내부 idempotency key (로컬 idempotency key에서 파생)로 Stripe 호출을 다시 실행해요. Stripe의 멱등성이 중복 청구를 방지하고 기존 결제를 반환하죠.

지원 프로세스

메인 API 요청 핸들러 외에, 세 가지 보조 프로세스가 시스템을 유지해요:

Enqueuer

Atomic phase에서 삽입된 __CODE_UNDER_10__ 테이블을 모니터링해요. 삽입 트랜잭션이 커밋된 후, enqueuer가 이들을 가져와서 background job queue에 발행해요.

이 두 단계 접근법은 job이 관련 요청 트랜잭션 성공 후에만 워커에게 보이도록 보증해요.

Completer

중간 recovery point에 도달했지만 완료되지 않은 요청을 처리해요. 클라이언트가 요청을 시작하고 부분 성공 후 재시도하지 않으면 이런 일이 발생할 수 있어요 (연결 끊김, 앱 종료 등).

Completer는 “stale” idempotency key를 스캔해요. 완료되지 않고 락이 오래 유지된 것들이죠. 내부 인증을 사용해서 이들을 재시도해요. 부분 완료된 작업을 완료로 밀어붙여서 고아 결제나 불완전한 감사를 방지해요.

Reaper

Idempotency keys는 영구 아카이브가 아니라 일시적 운영 기록이에요. Reaper 프로세스는 임계값보다 오래된 keys를 삭제해요 (권장: 72시간).

Reaper는 주기적으로 실행되고 만료된 keys를 제거해요. 72시간 임계값은 금요일 나쁜 배포가 요청을 깨뜨려도 주말 내내 기록을 유지하도록 선택되었어요. 그래서 개발자가 월요일 아침에 수정을 커밋하고 completer로 요청을 완료할 수 있죠.

실무자를 위한 핵심 통찰

Passive Safety Design

시스템은 passive하게 안전해야 해요. 어떤 실패가 닥쳐도 안정적 상태에 도달해야 하죠. 사용자는 절대 broken된 상태로 남지 않아요. 그 다음 active 메커니즘 (background workers, completers)이 완전한 일관성으로 이끌어가고요.

이것은 성공을 바라는 것의 반대예요. 실패를 전제하고, 그것을 견디고 복구할 메커니즘을 구축하는 거죠.

외부 Mutations 조기 식별

가장 중요한 설계 결정은 어디서 외부 시스템을 호출하는지 식별하는 거예요. 시스템 경계를 명확히 그으세요. 그 경계 밖은 전부 외부 mutation이고 특별한 처리가 필요해요.

많은 장애는 엔지니어가 내부 서비스 호출을 원자적으로 취급하거나 (아니에요. 네트워크 호출이죠) 메시지 큐 발행을 보증으로 취급할 때 (역시 아니에요) 발생해요. 둘 다 외부 mutation이고 실패할 수 있어요.

SERIALIZABLE 격리는 비용 대비 가치가 있어요

성능 비용이 크다며 SERIALIZABLE 격리를 피하는 엔지니어가 있어요. 하지만 멱등성 보증 시스템에서 이 격리 수준은 필수예요. 락 획득이나 상태 검증 시 발생할 수 있는 race condition을 방지하거든요.

최신 Postgres는 이 격리 수준으로 최적화되어 있어서, conflict detection 비용은 안전 보증에 비해 미미해요.

Staged Jobs는 원자적 보증을 가능하게 해요

트랜잭션 안에서 job을 staging하고 커밋 후 큐로 옮기는 것은 강력한 패턴이에요. Background 작업이 로컬 상태 변경과 원자적으로 실행되도록 만들어서 이런 일관성 버그를 제거하죠.

Recovery Points를 First-Class 설계 요소로 취급해요

복구 로직을 요청 핸들러에 하드코딩하는 대신, recovery point를 명시적인 상태로 취급하세요. 이렇게 하면 요청 생명주기가 명확해지고, 테스트하기 쉬워지고, 이해하기 쉬워져요.

지속적 가치 평가

이 패턴은 분산 시스템 엔지니어링의 근본적 과제를 해결해요: 제어할 수 없는 외부 시스템과 상호작용해야 할 때 부분적으로 완료된 요청을 안전하게 처리하는 방법이죠.

이 통찰은 여전히 유효해요:

  1. 결제 시스템은 항상 존재해요: 사용자를 청구하는 서비스는 멱등성 보증이 필요해요. 이 패턴은 결제 API, 구독, 금융 거래에 직접 적용돼요.
  2. 외부 통합은 항상 실패해요: 서드파티 API (Stripe, SendGrid, AWS 등)를 호출하는 모든 서비스는 부분 실패 처리 문제에 직면해요. 이 패턴은 보편적으로 적용 가능하죠.
  3. 근본 제약은 불변이에요: 외부 시스템의 상태를 트랜잭션으로 롤백할 수 없어요. 신뢰할 수 없는 네트워크를 통해 외부 호출이 성공했는지 확실히 알 수 없죠. 이 제약은 불변이므로 설계 패턴도 시대를 초월해요.
  4. 운영 성숙도는 이런 사고방식을 요구해요: 멱등성 보증을 건너뛰는 팀은 중복 청구, 중복 이메일, 불일치 상태, 고객 대면 버그를 경험해요. Mutation을 처리하는 운영 시스템에 이 패턴은 선택사항이 아니죠.
  5. 패턴은 구현에 무관해요: 예시가 Postgres와 Ruby를 사용하지만, 개념은 트랜잭션을 지원하는 모든 관계형 데이터베이스와 모든 프로그래밍 언어에 적용돼요. 소규모 서비스부터 거대 결제 플랫폼까지 확장되죠.
“이 글의 진정한 가치는 그것이 시연하는 surgical thinking이에요: 실제 문제를 식별하고, 실패를 전제로 시스템을 설계하고, 신중한 안전장치를 구축하는 것이죠.”

이것이 craft 지향 엔지니어링의 본질이에요. 기능을 급하게 배포하는 게 아니라, 현실의 혼란을 신뢰성 있게 처리하는 시스템을 구축하는 데 시간을 들이는 거죠.

저자 소개: Brandur Leach는 Stripe의 엔지니어로, 분산 시스템과 API 인프라에 대한 깊이 있는 글을 작성해요. 그의 블로그는 실무에서 검증된 엔지니어링 패턴을 공유하는 것으로 유명하죠.

참고: 이 글은 Brandur Leach가 2017년에 작성한 아티클을 번역하고 요약한 것이에요. 원문은 Stripe의 실제 프로덕션 시스템에서 사용하는 멱등성 보증 패턴을 상세히 설명해요.

원문: Implementing Stripe-like Idempotency Keys in Postgres - Brandur Leach (2017년 10월 27일)

생성: Claude (Anthropic)

총괄: (디노이저denoiser)