결제 인프라를 통해 배운, 무너지지 않는 시스템 구축법
요약
결제 인프라를 구축하며 얻은 시스템 설계의 핵심 교훈을 다룹니다. 멱등성 확보를 위한 데이터베이스 제약 조건 활용법과 외부 벤더 장애에 대비한 아키텍처 설계 전략을 설명합니다.
핵심 포인트
- 멱등성은 기능이 아닌 토대이며, 반드시 원자적(Atomic)으로 설계해야 함
- 레이스 컨디션 방지를 위해 애플리케이션 로직보다 DB 유니크 제약 조건을 활용할 것
- 외부 벤더의 장애는 발생 여부가 아닌 '언제 발생하는가'의 문제로 접근해야 함
- 벤더 의존성을 격리하여 도메인 언어로 통신하는 인터페이스 구축 필요
멱등성 (Idempotency), 벤더 장애, 보이지 않는 장애를 포착하는 모니터링, 그리고 아무도 경고해주지 않는 트레이드오프(tradeoffs) — 결제 인프라를 확장하며 얻은 교훈들.
대부분의 소프트웨어는 조용히 실패합니다. 페이지가 느리게 렌더링되거나, 추천 결과가 약간 어긋나거나, 보고서가 한 시간 정도 최신 상태가 아닐 때 말이죠. 사용자들은 어깨를 으쓱하며 그냥 넘어갑니다.
결제는 그렇게 작동하지 않습니다. 결제가 깨지면, 누군가의 돈이 양측 모두가 파악할 수 없는 곳에 머물게 되며, 그들의 인내심에는 시계가 돌아가기 시작합니다. 우아한 성능 저하 (graceful degradation) 같은 건 없습니다. 돈이 이동했거나, 이동하지 않았거나, 그리고 누군가는 어느 쪽인지 반드시 알아야 합니다.
저는 커리어의 상당 부분을 결제 인프라를 구축하고 확장하는 데 보냈으며, 이는 제가 엔지니어링 전반을 생각하는 방식을 조용히 재설계했습니다. 여기서 제가 얻은 교훈들을 공유합니다.
📋 요약 버전
| # | 교훈 | 한 줄 요약 |
|---|---|---|
| 1 | 멱등성 (Idempotency) | 동일한 요청을 두 번 받게 될 것입니다. 이를 위해 설계하세요. |
| ... |
1. 🔁 멱등성 (Idempotency)은 기능이 아닙니다. 그것은 토대입니다.
첫 번째 혹독한 교훈은 이것입니다: 당신은 동일한 요청을 두 번 받게 될 것입니다.
해결책은 개념적으로는 단순하지만 운영 측면에서는 까다롭습니다. 돈을 이동시키는 모든 작업은 고유하게 식별 가능해야 하며 안전하게 반복 가능해야 합니다. 호출자(Caller)가 멱등성 키 (Idempotency key)를 제공합니다. 당신은 다른 어떤 것을 하기 '전에' 이를 저장해야 합니다. 만약 해당 키를 다시 보게 된다면, 새로운 시도나 에러를 반환하는 것이 아니라, 처음에 주었던 것과 동일한 답변인 원래의 결과를 반환해야 합니다.
모두가 처음에 작성하는 순진한 버전:
# ❌ 눈에 잘 띄지 않는 곳에 숨어 있는 레이스 컨디션 (Race condition)
existing = db.find_by_idempotency_key(key)
if existing:
...
5ms 간격으로 도착하는 두 개의 동일한 요청은 모두 if 문을 통과하게 됩니다. 대신 데이터베이스가 이를 강제하도록 하세요:
# ✅ 유니크 제약 조건 (Unique constraint)이 실제 작업을 수행합니다
try:
with db.transaction():
...
실제로 노력이 필요한 부분들:
- 멱등성 (Idempotency)은 작업 자체와 원자적 (Atomic)이어야 합니다. 만약 하나의 트랜잭션에서 키를 기록하고 다른 트랜잭션에서 결제를 진행한다면, 당신은 레이스 컨디션 (Race condition)을 단지 덜 명확한 곳으로 옮겼을 뿐입니다.
- 동시성 중복 (Concurrent duplicates)이 가장 어려운 케이스입니다. 순진한
결제 게이트웨이 (Payment gateway)를 통합한다는 것은 단순히 라이브러리를 추가하는 것이 아닙니다. 여러분은 코드를 읽을 수도 없고, 배포 일정을 조율할 수도 없으며, 장애 발생 소식을 Twitter를 통해 접하게 되고, SLA (Service Level Agreement)가 보장이 아닌 문서에 불과한 의존성 (Dependency)을 떠안는 것입니다.
저는 아주 오래전에 "만약 이 제공업체가 다운되면 어떻게 하지?"라는 질문을 그만두었습니다. 올바른 질문은 **"이 제공업체가 다운되었을 때 우리는 무엇을 하는가?"**이며, 이는 일정이 포함된 질문입니다. 왜냐하면 이번 분기 중에 반드시 일어날 일이기 때문입니다.
이러한 프레임워크의 전환은 아키텍처를 변화시킵니다:
🧱 격리 (Isolate)
모든 제공업체는 여러분만의 인터페이스 뒤에 위치하며, 여러분의 도메인 언어 (Domain language)로 대화해야 합니다. 여러분의 핵심 시스템은 제공업체 A가 이를 transaction_reference라고 부르고, 제공업체 B가 paymentId라고 부른다는 사실을 결코 알아서는 안 됩니다.
┌──────────────────────┐
│ Payment Service │ ← 여러분의 도메인 언어로 대화함
└──────────┬───────────┘
...
이 번역 계층 (Translation layer)을 구축하는 것은 번거로운 일이지만, 이것이 있어야만 2개월간의 마이그레이션 기간 대신 부하가 걸려 있는 상태에서도 (under load) 제공업체를 교체할 수 있습니다.
⏱️ 에러가 아닌 타임아웃 (Timeout)을 가정하라
깔끔한 에러는 무엇이 일어났는지 알려주는 선물입니다. 진정으로 위험한 응답은 응답이 없는 것 (no response) 입니다.
여러분의 요청이 처리되었을 수도 있고, 처리되지 않았을 수도 있습니다. 여러분은 알 수 없으며, 그저 맹목적으로 재시도 (Retry)할 수도 없습니다. 여기서 대조 (Reconciliation)와 상태 폴링 (Status polling)은 '있으면 좋은 것'이 아니라 필수적인 것이 됩니다. 네트워크가 모호함을 줄 때, 여러분은 **"이것이 실제로 일어났는가?"**라고 묻고 권위 있는 답변을 얻을 수 있는 방법이 필요합니다.
🔌 서킷 브레이커 (Circuit breaker) 추가
제공업체에 장애가 발생하기 시작할 때, 재시도로 계속 몰아붙이는 것은 그들의 복구를 더 느리게 만들고 여러분의 큐 (Queue)를 더 길게 만듭니다. 빠르게 실패하고 (Fail fast), 물러나고 (Back off), 다른 곳으로 라우팅하며, 나중에 다시 돌아오십시오.
🎲 지터 (Jitter)와 함께 재시도하되, 안전한 경우에만 수행하라
delay = min(base * (2 ** attempt), max_delay)
delay = random.uniform(0, delay) # ← 사람들이 건너뛰는 부분
지터 (jitter) 없는 지수 백오프 (Exponential backoff)는 모든 재시도(retry)가 다시 동기화된 파도처럼 한꺼번에 충돌한다는 것을 의미할 뿐입니다. 그리고 "재시도해도 안전하다"는 말은 여러분이 첫 번째 레슨에서 수행한 작업 덕분에 성립하는 것입니다.
🔀 손상된 경로를 우회합니다
여러 공급업체 (providers)를 사용하고 있다면, 그 목적은 단순히 가격 협상력을 갖기 위함만이 아닙니다. 한 곳의 성능이 저하되었을 때 트래픽 볼륨을 전환할 수 있다는 점이 핵심입니다.
하지만 자동 장애 조치 (automatic failover)는 **"공급업체가 다운된 상태"**와 **"공급업체가 이 트랜잭션들을 정상적으로 거절(decline)하고 있는 상태"**를 구분할 수 있을 때에만 신뢰할 수 있습니다. 정상적인 거절 상황에서 장애 조치를 수행하는 것은 작은 문제를 사기 사고 (fraud incident)로 키우는 아주 좋은 방법입니다.
🧪 샌드박스 (sandbox)를 절대 믿지 마세요
테스트 환경은 깨끗하고, 빠르며, 항상 사용 가능합니다. 운영 환경 (Production)은 이 중 어느 것도 아닙니다. 여러분을 괴롭히는 동작들, 즉 부분적 실패 (partial failures), 정산 지연 (delayed settlement), 순서가 뒤바뀐 웹훅 (out-of-order webhooks), 문서화되지 않은 상태 코드 (undocumented status codes) 등은 실제 트래픽이 들어오기 전까지는 거의 나타나지 않습니다.
3. 📡 장애 상황을 고객으로부터 듣게 되어서는 안 됩니다
고객이 무언가 고장 났다고 말하고 있다면, 여러분은 이미 두 번 패배한 것입니다. 한 번은 장애 자체로 인해, 또 한 번은 그 사실을 먼저 알지 못했기 때문에 패배한 것입니다.
결제 분야의 모니터링 (Monitoring)은 두 가지 수준에서 작동해야 하지만, 대부분의 팀은 한 가지 수준만 구축합니다.
| 수준 | 측정 항목 | 알려주는 내용 |
|---|---|---|
| 기술적 (Technical) | 지연 시간 (Latency), 에러율 (error rates), 큐 깊이 (queue depth), 포화도 (saturation), 공급업체 응답 시간, 재시도 횟수 | 시스템이 건강하지 않음 |
| 비즈니스 (Business) | 공급업체 / 채널 / 카드 유형 / 국가별 성공률, 지난주 대비 볼륨, 대기(pending) 상태로 멈춘 트랜잭션, 정산 대비 원장 (settlement vs. ledger) | 돈이 제대로 움직이지 않고 있음 |
두 가지 모두가 필요한 이유는 최악의 결제 사고는 장애 (outage)처럼 보이지 않기 때문입니다.
모든 서비스가 가동 중입니다. 지연 시간도 정상입니다. 에러율도 평탄합니다. 그런데 공급업체가 조용히 무언가를 변경하는 바람에 특정 은행의 카드들이 40분 동안 소리 없이 실패하고 있습니다.
인프라 지표 (Infrastructure metrics)로는 이를 절대 잡아낼 수 없습니다. 발행사 (issuer)별로 세분화된 성공률 알림 (success-rate alert)은 이를 5분 안에 잡아냅니다.
제가 이제 타협할 수 없다고 생각하는 몇 가지 사항은 다음과 같습니다:
- 임계값(threshold)이 아닌 추세(trends)에 대한 경고. 71%가 0%는 아니더라도, 94%에서 71%로의 하락은 비상사태입니다.
- 모든 경고에는 실행 매뉴얼(runbook)이 필요합니다.
최소한 매일 실행되는, 귀사의 원장(ledger)과 각 제공업체의 정산 보고서(settlement reports) 간의 자동화된 비교가 필요합니다. 불일치는 복리로 쌓입니다. 다음 날 아침에 발견된 불일치는 수정 사항이지만, 분기 말에 발견된 동일한 불일치는 조사 대상입니다.
돈을 다룰 때 부동 소수점(floating point)을 절대 사용하지 마세요
0.1 + 0.2 === 0.3 // false. 이것이 귀사의 매출입니다.
최소 단위(minor units)를 정수(integer)로 저장하세요. 항상 통화(currency)를 함께 붙여야 합니다. 반올림 규칙을 명시적으로 결정하고 문서화하세요. 귀사와 제공업체 간의 반올림 방식 차이는 결국 설명할 수 없는 실제 격차로 나타나게 됩니다.
결제 상태를 명시적인 상태 머신(state machine)으로 모델링하세요
불리언(boolean) 값이 아닙니다. 누구나 아무 값이나 설정할 수 있는 상태 문자열(status string)도 아닙니다.
initiated ──► pending ──┬─► succeeded ──► refunded
│
├─► failed
...
결제 시스템에서 발생하는 기괴한 버그의 절반은 아무도 방지할 생각을 하지 못한 불법적인 상태 전이(illegal state transitions) 때문입니다.
웹훅(Webhooks)은 순서가 바뀌거나, 늦게 오거나, 두 번 도착합니다
때로는 "성공(success)" 이벤트가 "처리 중(processing)" 이벤트보다 먼저 도착하기도 합니다. 각 이벤트를 상태를 진행시키라는 명령이 아니라, **특정 시점에 대한 사실(fact about a point in time)**로 취급하여 처리하세요.
예외 경로(unhappy paths)를 먼저 설계하세요
환불(refunds), 부분 환불(partial refunds), 취소(reversals), 차지백(chargebacks), 분쟁(disputes), 만료된 승인(expired authorizations) 등이 있습니다. 팀들은 해피 패스(happy path)를 구축하여 출시한 뒤, 환불이 데이터 모델에 전혀 맞지 않는다는 사실을 깨닫곤 합니다. 실제 복잡성이 존재하는 곳은 바로 이 예외 경로(unhappy paths)이며, 이를 나중에 끼워 맞추는 것은 처음부터 설계하는 것보다 훨씬 더 많은 비용이 듭니다.
컴플라이언스(Compliance)는 체크리스트가 아니라 아키텍처적 제약 조건입니다
무엇을 저장할 수 있는지, 어디에 저장할 수 있는지, 누가 볼 수 있는지, 얼마나 오래 보관할 수 있는지에 대한 문제입니다. 시스템을 구축한 후에 이러한 요구사항을 발견하게 된다면, 그것은 재작성(rewrite)을 의미합니다.
5. ⚖️ 위의 모든 사항은 서로 충돌합니다
제가 받아들이는 데 가장 오래 걸렸던 부분은 이것입니다. 이 교훈들 중 어느 것도 공짜가 없으며, 그중 몇몇은 서로 적극적으로 충돌한다는 사실입니다.
| 얻는 것 | 치러야 할 대가 |
|---|---|
| 공격적인 재시도 (Aggressive retries) → 더 높은 성공률 | 더 높은 중복 위험 |
| ... |
모든 다이얼을 최대로 돌릴 수 있는 설정은 존재하지 않습니다. 엔지니어링이란 각 항목에서 자신이 어디에 위치할지를 의도적으로, 그리고 눈을 똑바로 뜨고 결정하는 것이며, 모든 것을 다 가졌다고 가장하기보다는 그것이 대가가 따르는 선택임을 정직하게 인정하는 과정에 있습니다.
🎯 오늘 이 작업을 시작한다면
확장성 (Scale)을 위해 구축하기 전에, 실패 사례 (Failure cases)를 위해 구축하세요. 확장성 문제는 요란하게 자신을 드러내기에 밝은 낮에 수정할 수 있습니다. 하지만 정확성 (Correctness) 문제는 숨어 있다가 조용히 복합적으로 쌓이며, 보통 계산이 맞지 않는 숫자와 자신의 돈이 어디로 갔는지 알고 싶어 하는 고객의 모습으로 최악의 타이밍에 나타납니다.
지루한 부분들을 제대로 처리하세요:
- 데이터베이스 수준에서 강제되는 멱등성 키 (Idempotency keys)
- 추가만 가능한 (Append-only) 복식부기 원장 (Double-entry ledger)
- 인프라뿐만 아니라 비즈니스 지표에 대한 모니터링 (Monitoring)
- 결제 상태를 위한 명시적인 상태 머신 (State machine)
- 자동화된 일일 대조 (Daily reconciliation)
- 첫날부터 데이터 모델에 포함된 환불 (Refunds) 및 지급 거절 (Chargebacks)
그 외의 모든 것은 나중에 덧붙이는 것보다, 위 사항들을 기반으로 구축하는 것이 훨씬 쉽습니다.
여러분도 이 중 하나를 혹독하게 경험한 적이 있나요? 어떤 것이 여러분을 괴롭혔는지 댓글로 들려주세요. 👇
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기