<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Payment on jyukki's Blog</title><link>https://jyukki.com/tags/payment/</link><description>Recent content in Payment on jyukki's Blog</description><generator>Hugo -- 0.147.0</generator><language>ko-kr</language><lastBuildDate>Wed, 05 Aug 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://jyukki.com/tags/payment/index.xml" rel="self" type="application/rss+xml"/><item><title>백엔드 커리큘럼 심화: 결제 승인·캡처 상태 머신, 중복 차감과 유령 주문을 막는 법</title><link>https://jyukki.com/learning/deep-dive/deep-dive-payment-authorization-capture-state-machine-playbook/</link><pubDate>Wed, 05 Aug 2026 00:00:00 +0000</pubDate><guid>https://jyukki.com/learning/deep-dive/deep-dive-payment-authorization-capture-state-machine-playbook/</guid><description>결제 authorize/capture/cancel/refund 흐름을 상태 머신, 원장, 멱등 키, reconciliation 기준으로 설계하는 실무 플레이북입니다.</description><content:encoded><![CDATA[<p>결제 시스템에서 가장 무서운 장애는 &ldquo;실패처럼 보였는데 돈은 빠져나간 상태&quot;입니다. 반대로 &ldquo;성공처럼 보였는데 매입이 되지 않아 매출이 사라진 상태&quot;도 있습니다. 둘 다 단순 API 오류가 아닙니다. 주문, 결제 대행사, 카드사, 재고, 쿠폰, 정산 시스템이 서로 다른 시점에 상태를 바꾸기 때문에 생기는 분산 상태 문제입니다.</p>
<p>그래서 결제 흐름은 <code>status</code> 컬럼 몇 개로 처리하면 금방 한계가 옵니다. 승인, 매입, 승인 취소, 환불, 부분 환불, 타임아웃, 웹훅 지연, 수동 보정이 모두 같은 테이블을 건드리기 시작하면 &ldquo;이 상태에서 이 액션이 가능한가&quot;를 코드만 보고 판단하기 어렵습니다. 필요한 것은 <strong>결제 상태 머신 + 불변 원장 + 멱등 실행 계약 + 대사 파이프라인</strong>입니다.</p>
<p>이 글은 <a href="/learning/deep-dive/deep-dive-operational-state-machine-design/">운영용 상태 머신 설계</a>, <a href="/learning/deep-dive/deep-dive-idempotency/">멱등성 설계</a>, <a href="/learning/deep-dive/deep-dive-reconciliation-ledger-pipeline/">Reconciliation 파이프라인</a>과 이어집니다. 결제 도메인을 예시로 들지만, 재고 선점, 쿠폰 차감, 포인트 사용, 예약 확정처럼 되돌리기 어려운 쓰기 경로에도 같은 사고방식을 적용할 수 있습니다.</p>
<h2 id="이-글에서-얻는-것">이 글에서 얻는 것</h2>
<ul>
<li>결제 승인(authorize), 매입(capture), 취소(cancel), 환불(refund)을 하나의 명시적 상태 머신으로 설계하는 기준을 얻습니다.</li>
<li>PG/API 타임아웃, 웹훅 지연, 중복 요청, 부분 환불 같은 현실적인 실패를 상태 전이와 원장으로 흡수하는 방법을 정리합니다.</li>
<li>자동 재시도, 사용자 재시도, 운영자 보정, reconciliation job의 권한 경계를 숫자와 조건으로 나눌 수 있습니다.</li>
<li>주문 성공률만 보는 결제 운영에서 벗어나, 유령 주문·중복 차감·미매입 매출을 조기에 찾는 체크리스트를 가져갈 수 있습니다.</li>
</ul>
<h2 id="핵심-개념이슈">핵심 개념/이슈</h2>
<h3 id="1-승인과-매입은-같은-성공이-아니다">1) 승인과 매입은 같은 성공이 아니다</h3>
<p>많은 초급 구현은 결제 API가 성공하면 바로 <code>PAID</code>로 바꿉니다. 하지만 카드 결제 흐름에서는 승인과 매입을 분리해서 봐야 합니다.</p>
<ul>
<li><strong>Authorize</strong>: 한도를 확보하고 결제 가능성을 확인한다.</li>
<li><strong>Capture</strong>: 실제 매출로 확정한다.</li>
<li><strong>Cancel/Void</strong>: 아직 매입 전인 승인을 취소한다.</li>
<li><strong>Refund</strong>: 이미 매입된 금액을 돌려준다.</li>
</ul>
<p>쇼핑몰에서는 주문 생성 직후 승인만 하고, 재고 확정·배송 준비 단계에서 매입할 수 있습니다. 디지털 상품처럼 즉시 제공되는 서비스는 authorize와 capture를 붙여 처리할 수도 있습니다. 중요한 것은 &ldquo;우리 서비스가 어떤 모델인지&quot;를 먼저 정하는 것입니다.</p>
<p>의사결정 기준은 다음처럼 잡을 수 있습니다.</p>
<table>
  <thead>
      <tr>
          <th>서비스 유형</th>
          <th>권장 흐름</th>
          <th>이유</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>즉시 제공 디지털 상품</td>
          <td>authorize + capture 동시</td>
          <td>재고/배송 지연이 없어 매입 지연 이득이 작다</td>
      </tr>
      <tr>
          <td>물류/예약/재고 확인 필요</td>
          <td>authorize 후 capture 지연</td>
          <td>재고 실패 시 승인 취소로 비용과 CS를 줄인다</td>
      </tr>
      <tr>
          <td>고액 B2B 주문</td>
          <td>authorize 후 사람/정책 승인 뒤 capture</td>
          <td>fraud, 한도, 계약 조건 검증이 필요하다</td>
      </tr>
      <tr>
          <td>부분 배송 가능 주문</td>
          <td>line item별 partial capture</td>
          <td>전체 주문보다 품목 단위 정산이 안전하다</td>
      </tr>
  </tbody>
</table>
<p>승인과 매입을 섞으면 장애 대응이 어려워집니다. 예를 들어 승인 성공 후 재고 확정이 실패했는데 이미 매입했다면 환불 프로세스가 필요합니다. 반대로 승인만 된 주문을 <code>PAID</code>처럼 보여주면 사용자는 상품 제공을 기대하지만 실제 매출은 아직 확정되지 않았습니다.</p>
<h3 id="2-결제-상태는-현재-상태와-사실-기록을-분리해야-한다">2) 결제 상태는 &ldquo;현재 상태&quot;와 &ldquo;사실 기록&quot;을 분리해야 한다</h3>
<p>운영 화면에는 현재 결제 상태가 필요합니다. 하지만 정산과 복구에는 상태 변경 이력이 필요합니다. 그래서 최소한 두 계층으로 나누는 것이 안전합니다.</p>
<ul>
<li><code>payment_attempt</code>: 현재 시도 상태, provider id, 마지막 에러, 다음 조치</li>
<li><code>payment_ledger</code>: 승인/매입/취소/환불/보정 이벤트의 불변 기록</li>
</ul>
<p>예시 상태는 아래처럼 시작할 수 있습니다.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>INITIATED
</span></span><span style="display:flex;"><span>  -&gt; AUTHORIZING
</span></span><span style="display:flex;"><span>  -&gt; AUTHORIZED
</span></span><span style="display:flex;"><span>  -&gt; CAPTURING
</span></span><span style="display:flex;"><span>  -&gt; CAPTURED
</span></span><span style="display:flex;"><span>  -&gt; CANCELING
</span></span><span style="display:flex;"><span>  -&gt; CANCELED
</span></span><span style="display:flex;"><span>  -&gt; REFUNDING
</span></span><span style="display:flex;"><span>  -&gt; REFUNDED
</span></span><span style="display:flex;"><span>  -&gt; FAILED
</span></span><span style="display:flex;"><span>  -&gt; UNKNOWN_REQUIRES_RECONCILIATION
</span></span></code></pre></div><p>여기서 <code>UNKNOWN_REQUIRES_RECONCILIATION</code>이 중요합니다. 외부 PG 호출이 타임아웃되면 성공인지 실패인지 모를 수 있습니다. 이 상태를 <code>FAILED</code>로 닫고 사용자에게 다시 결제하게 만들면 중복 승인 가능성이 생깁니다. 반대로 성공으로 가정하면 상품 제공과 정산이 어긋날 수 있습니다. 모르는 상태는 모른다고 표시하고, provider 조회와 대사 job으로 닫아야 합니다.</p>
<p>이 구조는 <a href="/learning/deep-dive/deep-dive-api-error-semantics-retryability-contract/">API Error Semantics</a>와도 맞닿아 있습니다. 결제 실패 메시지는 &ldquo;재시도하세요&rdquo; 한 줄이 아니라, 재시도 가능한 실패인지, 확인 중인지, 운영자 조치가 필요한지 구분해야 합니다.</p>
<h3 id="3-멱등-키는-주문-id-하나로-끝나지-않는다">3) 멱등 키는 주문 ID 하나로 끝나지 않는다</h3>
<p>결제에서 멱등 키를 <code>order_id</code> 하나로 잡으면 곧 막힙니다. 한 주문에 여러 결제 시도가 있을 수 있고, 같은 결제 시도 안에서도 승인, 매입, 환불은 서로 다른 부작용입니다.</p>
<p>권장 키는 액션 단위로 나눕니다.</p>
<table>
  <thead>
      <tr>
          <th>액션</th>
          <th>멱등 키 예시</th>
          <th>중복 방지 대상</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>authorize</td>
          <td><code>payment_attempt_id + authorize</code></td>
          <td>같은 결제 시도 중복 승인</td>
      </tr>
      <tr>
          <td>capture</td>
          <td><code>payment_attempt_id + capture + amount</code></td>
          <td>같은 금액 중복 매입</td>
      </tr>
      <tr>
          <td>cancel</td>
          <td><code>payment_attempt_id + cancel</code></td>
          <td>승인 취소 중복 호출</td>
      </tr>
      <tr>
          <td>refund</td>
          <td><code>refund_id</code> 또는 <code>payment_attempt_id + refund_seq</code></td>
          <td>환불 중복 지급</td>
      </tr>
      <tr>
          <td>webhook ingest</td>
          <td><code>provider_event_id</code></td>
          <td>외부 이벤트 중복 소비</td>
      </tr>
  </tbody>
</table>
<p>사용자 브라우저가 새로고침하거나 모바일 앱이 네트워크 오류 후 같은 요청을 다시 보내도, 서버는 같은 멱등 키면 기존 결과를 반환해야 합니다. 단, payload hash가 달라졌다면 409로 막는 편이 안전합니다. 같은 키로 10,000원 승인 후 12,000원 승인 요청이 들어오면 &ldquo;같은 요청 재시도&quot;가 아니라 충돌입니다.</p>
<p>멱등 처리는 API 계층만으로 부족합니다. DB에는 unique 제약이 있어야 하고, 외부 PG 호출 전후의 상태 전이도 원자적으로 보호해야 합니다. 이 부분은 <a href="/learning/deep-dive/deep-dive-upsert-unique-idempotency-write-path-playbook/">UPSERT와 UNIQUE 제약</a>, <a href="/learning/deep-dive/deep-dive-database-locking-contention-playbook/">DB 락 경합 대응</a>과 같이 설계해야 합니다.</p>
<h3 id="4-웹훅은-정답이-아니라-늦게-도착하는-증거다">4) 웹훅은 정답이 아니라 늦게 도착하는 증거다</h3>
<p>PG 웹훅은 중요하지만 웹훅만 믿으면 안 됩니다. 웹훅은 중복될 수 있고, 순서가 바뀔 수 있고, 지연될 수 있고, 운영 설정 오류로 누락될 수 있습니다. 따라서 웹훅 처리는 &ldquo;상태를 바로 덮어쓰기&quot;가 아니라 &ldquo;provider event를 원장에 적재하고 허용 전이를 평가&quot;하는 흐름이어야 합니다.</p>
<p>예를 들어 <code>CAPTURED</code> 상태에 늦은 <code>AUTHORIZED</code> 웹훅이 도착했다면 상태를 되돌리면 안 됩니다. 이미 capture 원장이 있다면 authorized 웹훅은 관측 이력으로만 남깁니다. 반대로 <code>AUTHORIZING</code> 상태에서 provider의 authorization success 웹훅이 도착하면 <code>AUTHORIZED</code>로 전이할 수 있습니다.</p>
<p>웹훅 처리 기준:</p>
<ul>
<li>provider event id는 unique로 저장한다.</li>
<li>같은 event가 2번 오면 두 번째는 no-op으로 ack한다.</li>
<li>상태 전이는 허용표를 통과할 때만 수행한다.</li>
<li>금액, 통화, merchant account, order id가 내부 기록과 다르면 quarantine한다.</li>
<li>고위험 이벤트는 provider 조회 API로 재확인한다.</li>
</ul>
<p>이 규칙은 <a href="/learning/deep-dive/deep-dive-inbound-webhook-receiver-playbook/">Inbound Webhook Receiver</a>의 결제 버전입니다. 결제·권한·구독 종료 같은 고위험 웹훅은 &ldquo;받았다&quot;와 &ldquo;믿는다&rdquo; 사이에 검증 단계를 둬야 합니다.</p>
<h2 id="실무-적용">실무 적용</h2>
<h3 id="1-최소-데이터-모델">1) 최소 데이터 모델</h3>
<p>처음부터 거대한 결제 플랫폼을 만들 필요는 없습니다. 하지만 아래 필드는 초기에 넣는 편이 좋습니다.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#ff79c6">create</span> <span style="color:#ff79c6">table</span> payment_attempt (
</span></span><span style="display:flex;"><span>  payment_attempt_id <span style="color:#8be9fd;font-style:italic">varchar</span>(<span style="color:#bd93f9">64</span>) <span style="color:#ff79c6">primary</span> <span style="color:#ff79c6">key</span>,
</span></span><span style="display:flex;"><span>  order_id <span style="color:#8be9fd;font-style:italic">varchar</span>(<span style="color:#bd93f9">64</span>) <span style="color:#ff79c6">not</span> <span style="color:#ff79c6">null</span>,
</span></span><span style="display:flex;"><span>  customer_id <span style="color:#8be9fd;font-style:italic">varchar</span>(<span style="color:#bd93f9">64</span>) <span style="color:#ff79c6">not</span> <span style="color:#ff79c6">null</span>,
</span></span><span style="display:flex;"><span>  provider <span style="color:#8be9fd;font-style:italic">varchar</span>(<span style="color:#bd93f9">32</span>) <span style="color:#ff79c6">not</span> <span style="color:#ff79c6">null</span>,
</span></span><span style="display:flex;"><span>  provider_payment_id <span style="color:#8be9fd;font-style:italic">varchar</span>(<span style="color:#bd93f9">128</span>),
</span></span><span style="display:flex;"><span>  status <span style="color:#8be9fd;font-style:italic">varchar</span>(<span style="color:#bd93f9">48</span>) <span style="color:#ff79c6">not</span> <span style="color:#ff79c6">null</span>,
</span></span><span style="display:flex;"><span>  amount <span style="color:#8be9fd;font-style:italic">numeric</span>(<span style="color:#bd93f9">20</span>, <span style="color:#bd93f9">2</span>) <span style="color:#ff79c6">not</span> <span style="color:#ff79c6">null</span>,
</span></span><span style="display:flex;"><span>  currency <span style="color:#8be9fd;font-style:italic">char</span>(<span style="color:#bd93f9">3</span>) <span style="color:#ff79c6">not</span> <span style="color:#ff79c6">null</span>,
</span></span><span style="display:flex;"><span>  idempotency_key <span style="color:#8be9fd;font-style:italic">varchar</span>(<span style="color:#bd93f9">128</span>) <span style="color:#ff79c6">not</span> <span style="color:#ff79c6">null</span>,
</span></span><span style="display:flex;"><span>  payload_hash <span style="color:#8be9fd;font-style:italic">varchar</span>(<span style="color:#bd93f9">128</span>) <span style="color:#ff79c6">not</span> <span style="color:#ff79c6">null</span>,
</span></span><span style="display:flex;"><span>  last_error_code <span style="color:#8be9fd;font-style:italic">varchar</span>(<span style="color:#bd93f9">64</span>),
</span></span><span style="display:flex;"><span>  next_reconcile_at timestamptz,
</span></span><span style="display:flex;"><span>  created_at timestamptz <span style="color:#ff79c6">not</span> <span style="color:#ff79c6">null</span>,
</span></span><span style="display:flex;"><span>  updated_at timestamptz <span style="color:#ff79c6">not</span> <span style="color:#ff79c6">null</span>
</span></span><span style="display:flex;"><span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">create</span> <span style="color:#ff79c6">unique</span> <span style="color:#ff79c6">index</span> ux_payment_attempt_idempotency
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">on</span> payment_attempt (provider, idempotency_key);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">create</span> <span style="color:#ff79c6">table</span> payment_ledger (
</span></span><span style="display:flex;"><span>  ledger_id <span style="color:#8be9fd;font-style:italic">varchar</span>(<span style="color:#bd93f9">64</span>) <span style="color:#ff79c6">primary</span> <span style="color:#ff79c6">key</span>,
</span></span><span style="display:flex;"><span>  payment_attempt_id <span style="color:#8be9fd;font-style:italic">varchar</span>(<span style="color:#bd93f9">64</span>) <span style="color:#ff79c6">not</span> <span style="color:#ff79c6">null</span>,
</span></span><span style="display:flex;"><span>  event_type <span style="color:#8be9fd;font-style:italic">varchar</span>(<span style="color:#bd93f9">48</span>) <span style="color:#ff79c6">not</span> <span style="color:#ff79c6">null</span>,
</span></span><span style="display:flex;"><span>  amount <span style="color:#8be9fd;font-style:italic">numeric</span>(<span style="color:#bd93f9">20</span>, <span style="color:#bd93f9">2</span>) <span style="color:#ff79c6">not</span> <span style="color:#ff79c6">null</span>,
</span></span><span style="display:flex;"><span>  currency <span style="color:#8be9fd;font-style:italic">char</span>(<span style="color:#bd93f9">3</span>) <span style="color:#ff79c6">not</span> <span style="color:#ff79c6">null</span>,
</span></span><span style="display:flex;"><span>  provider_event_id <span style="color:#8be9fd;font-style:italic">varchar</span>(<span style="color:#bd93f9">128</span>),
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">source</span> <span style="color:#8be9fd;font-style:italic">varchar</span>(<span style="color:#bd93f9">32</span>) <span style="color:#ff79c6">not</span> <span style="color:#ff79c6">null</span>,
</span></span><span style="display:flex;"><span>  occurred_at timestamptz <span style="color:#ff79c6">not</span> <span style="color:#ff79c6">null</span>,
</span></span><span style="display:flex;"><span>  recorded_at timestamptz <span style="color:#ff79c6">not</span> <span style="color:#ff79c6">null</span>
</span></span><span style="display:flex;"><span>);
</span></span></code></pre></div><p><code>payment_attempt</code>는 빠른 조회와 운영 화면을 위한 현재 상태입니다. <code>payment_ledger</code>는 정산과 감사, 대사를 위한 사실 기록입니다. status를 고칠 수는 있어도 ledger를 지우면 안 됩니다. 잘못된 이벤트는 삭제가 아니라 correction event로 보정합니다.</p>
<h3 id="2-상태-전이표를-코드와-문서-양쪽에-둔다">2) 상태 전이표를 코드와 문서 양쪽에 둔다</h3>
<p>상태 전이는 리뷰 가능한 표로 관리해야 합니다.</p>
<table>
  <thead>
      <tr>
          <th>현재 상태</th>
          <th>액션</th>
          <th>다음 상태</th>
          <th>조건</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>INITIATED</code></td>
          <td>authorize 요청</td>
          <td><code>AUTHORIZING</code></td>
          <td>idempotency key 신규</td>
      </tr>
      <tr>
          <td><code>AUTHORIZING</code></td>
          <td>provider 승인 성공</td>
          <td><code>AUTHORIZED</code></td>
          <td>amount/currency 일치</td>
      </tr>
      <tr>
          <td><code>AUTHORIZING</code></td>
          <td>provider 타임아웃</td>
          <td><code>UNKNOWN_REQUIRES_RECONCILIATION</code></td>
          <td>provider_payment_id 있거나 확인 불가</td>
      </tr>
      <tr>
          <td><code>AUTHORIZED</code></td>
          <td>capture 요청</td>
          <td><code>CAPTURING</code></td>
          <td>재고/주문 확정 완료</td>
      </tr>
      <tr>
          <td><code>CAPTURING</code></td>
          <td>capture 성공</td>
          <td><code>CAPTURED</code></td>
          <td>provider capture id 저장</td>
      </tr>
      <tr>
          <td><code>AUTHORIZED</code></td>
          <td>cancel 요청</td>
          <td><code>CANCELING</code></td>
          <td>capture 전</td>
      </tr>
      <tr>
          <td><code>CAPTURED</code></td>
          <td>refund 요청</td>
          <td><code>REFUNDING</code></td>
          <td>refund amount &lt;= captured balance</td>
      </tr>
      <tr>
          <td><code>UNKNOWN_REQUIRES_RECONCILIATION</code></td>
          <td>provider 조회 성공</td>
          <td><code>AUTHORIZED</code> 또는 <code>CAPTURED</code></td>
          <td>외부 상태 기준</td>
      </tr>
  </tbody>
</table>
<p>전이표 없이 if 문으로만 처리하면 운영자 보정, 웹훅, 재시도 job이 서로 다른 규칙을 가질 가능성이 큽니다. 결제 같은 고위험 도메인에서는 상태 전이 함수를 하나로 모으고, 허용되지 않은 전이는 409 또는 quarantine으로 닫는 편이 안전합니다.</p>
<h3 id="3-타임아웃은-실패가-아니라-확인-필요로-분류한다">3) 타임아웃은 실패가 아니라 확인 필요로 분류한다</h3>
<p>결제 provider 호출에서 가장 위험한 응답은 500보다 timeout입니다. 500은 provider가 실패를 명시했을 가능성이 있지만, timeout은 네트워크가 끊긴 것인지 provider가 처리 후 응답만 못 준 것인지 알 수 없습니다.</p>
<p>운영 기준:</p>
<ul>
<li>authorize/capture write timeout은 즉시 <code>FAILED</code>로 닫지 않는다.</li>
<li>provider payment id가 있으면 1차 조회를 5~30초 안에 예약한다.</li>
<li>3회 조회 실패 또는 5분 이상 미확정이면 <code>UNKNOWN_REQUIRES_RECONCILIATION</code>으로 운영 알림을 올린다.</li>
<li>같은 order에서 새 결제 시도는 기존 attempt가 확정될 때까지 기본 차단한다.</li>
<li>사용자에게는 &ldquo;결제 확인 중&rdquo; 상태를 보여주고, 중복 결제 버튼은 비활성화한다.</li>
</ul>
<p>숫자는 서비스에 맞게 조정할 수 있습니다. 다만 고액 결제나 재고 소진 상품에서는 확인 지연을 줄이기보다 중복 차감 방지를 우선해야 합니다. 결제 확인 중 화면에서 30초를 기다리게 하는 것이 중복 결제 환불보다 싸고 안전한 경우가 많습니다.</p>
<h3 id="4-자동-재시도와-사람-재시도-경계를-나눈다">4) 자동 재시도와 사람 재시도 경계를 나눈다</h3>
<p>모든 실패를 자동 재시도하면 provider 장애 때 폭주가 납니다. 반대로 아무것도 재시도하지 않으면 사용자가 반복 클릭하게 됩니다.</p>
<p>권장 기준:</p>
<table>
  <thead>
      <tr>
          <th>실패 유형</th>
          <th>재시도 정책</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>네트워크 read timeout</td>
          <td>provider 조회 후 상태 확정, write 재호출은 보수적</td>
      </tr>
      <tr>
          <td>429/rate limit</td>
          <td>exponential backoff + jitter, 사용자 재시도 제한</td>
      </tr>
      <tr>
          <td>4xx 카드 거절</td>
          <td>자동 재시도 금지, 결제수단 변경 안내</td>
      </tr>
      <tr>
          <td>provider 5xx</td>
          <td>멱등 키 유지, 최대 2~3회, 총 2분 이내</td>
      </tr>
      <tr>
          <td>amount/currency mismatch</td>
          <td>즉시 quarantine, 자동 복구 금지</td>
      </tr>
  </tbody>
</table>
<p>재시도는 <a href="/learning/deep-dive/deep-dive-timeout-retry-backoff/">Timeout·Retry·Backoff</a>처럼 기술 문제로만 보면 부족합니다. 결제에서는 재시도 1회가 금전 부작용 1회를 의미할 수 있습니다. 그래서 &ldquo;재시도 가능한 실패&quot;의 정의를 결제 액션별로 나눠야 합니다.</p>
<h3 id="5-대사-job은-결제의-안전망이다">5) 대사 job은 결제의 안전망이다</h3>
<p>실시간 경로가 아무리 좋아도 결제에는 대사 job이 필요합니다. 웹훅이 누락될 수 있고, 내부 트랜잭션은 성공했지만 provider 반영은 실패했을 수 있고, 운영자가 수동 처리한 건이 나중에 들어올 수 있습니다.</p>
<p>초기 대사 범위:</p>
<ul>
<li>최근 24시간 <code>UNKNOWN_REQUIRES_RECONCILIATION</code></li>
<li><code>AUTHORIZED</code> 상태로 30분 이상 머문 주문</li>
<li><code>CAPTURING</code> 상태로 5분 이상 머문 결제</li>
<li>내부 <code>CAPTURED</code>인데 provider settlement에는 없는 건</li>
<li>provider에는 captured인데 내부 주문은 미확정인 건</li>
<li>refund 요청 금액과 provider refund balance가 다른 건</li>
</ul>
<p>대사 결과는 세 가지로 나눕니다.</p>
<ul>
<li>자동 전이 가능: provider와 내부 값이 일치하고 허용 전이가 명확함</li>
<li>자동 보정 가능: 금액 차이가 0이고 상태만 늦게 따라온 경우</li>
<li>사람 승인 필요: 금액, 통화, 결제수단, 환불 잔액, 회계 마감 구간이 걸린 경우</li>
</ul>
<p>이때도 목표는 &ldquo;모든 건 자동 처리&quot;가 아닙니다. 금전 도메인의 우선순위는 <strong>오복구 방지 &gt; 중복 차감 방지 &gt; 빠른 확정 &gt; 운영 편의성</strong>입니다.</p>
<h2 id="트레이드오프주의점">트레이드오프/주의점</h2>
<p>첫째, 상태 머신은 코드량을 늘립니다. 단순 CRUD보다 테이블과 전이 함수, 테스트가 늘어납니다. 하지만 결제 흐름은 어차피 복잡합니다. 복잡도를 감추면 운영자가 장애 때 추측하게 되고, 드러내면 테스트와 대사 job이 다룰 수 있습니다.</p>
<p>둘째, 승인과 매입을 분리하면 사용자 경험이 길어질 수 있습니다. 즉시 제공 서비스에서는 authorize/capture를 붙이는 편이 낫습니다. 반대로 배송·예약·재고 검증이 있는 서비스에서는 분리 비용보다 잘못 매입 후 환불하는 비용이 큽니다.</p>
<p>셋째, provider 조회를 자주 하면 비용과 quota 문제가 생깁니다. 모든 결제를 매초 확인하기보다 위험 상태만 좁혀 조회해야 합니다. 예를 들어 <code>UNKNOWN</code>과 장시간 <code>AUTHORIZING/CAPTURING</code>만 조회하고, 정상 <code>CAPTURED</code>는 일 배치 settlement 대사로 충분할 수 있습니다.</p>
<p>넷째, 운영자 보정 권한을 너무 쉽게 열면 내부 사고가 됩니다. 결제 상태를 수동으로 바꾸는 기능에는 <a href="/learning/deep-dive/deep-dive-step-up-authorization-high-risk-actions-playbook/">Step-up Authorization</a>, 실행 영수증, 2인 승인, 사후 리뷰가 필요합니다. 특히 환불과 정산 보정은 감사 로그 없이는 운영 기능으로 두면 안 됩니다.</p>
<p>다섯째, 재고·쿠폰·포인트와 결제 상태를 하나의 분산 트랜잭션처럼 묶으려 하면 시스템이 무거워집니다. 대부분의 서비스에서는 강한 2PC보다 saga, outbox, compensation, reconciliation 조합이 현실적입니다. 이 판단은 <a href="/learning/deep-dive/deep-dive-distributed-transactions/">Distributed Transactions</a>와 함께 봐야 합니다.</p>
<h2 id="체크리스트-또는-연습">체크리스트 또는 연습</h2>
<h3 id="체크리스트">체크리스트</h3>
<ul>
<li><input disabled="" type="checkbox"> authorize, capture, cancel, refund를 각각 별도 액션과 멱등 키로 정의했다.</li>
<li><input disabled="" type="checkbox"> provider write timeout을 <code>FAILED</code>가 아니라 확인 필요 상태로 분류한다.</li>
<li><input disabled="" type="checkbox"> 상태 전이표가 문서와 코드 테스트 양쪽에 존재한다.</li>
<li><input disabled="" type="checkbox"> payment ledger가 불변 이벤트로 남고 correction은 삭제가 아니라 보정 이벤트로 처리된다.</li>
<li><input disabled="" type="checkbox"> 웹훅은 provider event id unique, amount/currency 검증, 허용 전이 검사를 통과해야 상태를 바꾼다.</li>
<li><input disabled="" type="checkbox"> <code>UNKNOWN</code>, 장시간 <code>AUTHORIZED</code>, 장시간 <code>CAPTURING</code> 상태를 찾는 reconciliation job이 있다.</li>
<li><input disabled="" type="checkbox"> 수동 환불·상태 보정에는 2인 승인 또는 사후 리뷰 기준이 있다.</li>
</ul>
<h3 id="연습-과제">연습 과제</h3>
<ol>
<li>현재 서비스의 결제 흐름을 <code>INITIATED -&gt; AUTHORIZED -&gt; CAPTURED</code> 형태의 전이표로 그려 보세요. 실패 상태와 확인 필요 상태를 최소 3개 이상 추가해야 합니다.</li>
<li>같은 사용자가 결제 버튼을 3번 누르고, 첫 번째 provider 호출은 timeout, 두 번째는 success, 세 번째는 duplicate인 시나리오를 테스트 케이스로 작성해 보세요.</li>
<li><code>AUTHORIZED</code> 상태로 30분 이상 남은 결제 100건이 발견됐다고 가정하고, 자동 취소·provider 조회·운영자 승인 중 어떤 기준으로 나눌지 정책표를 만들어 보세요.</li>
</ol>
<h2 id="관련-글">관련 글</h2>
<ul>
<li><a href="/learning/deep-dive/deep-dive-operational-state-machine-design/">운영용 상태 머신 설계</a></li>
<li><a href="/learning/deep-dive/deep-dive-idempotency/">멱등성 설계와 중복 요청 제어</a></li>
<li><a href="/learning/deep-dive/deep-dive-reconciliation-ledger-pipeline/">Reconciliation 파이프라인</a></li>
<li><a href="/learning/deep-dive/deep-dive-inbound-webhook-receiver-playbook/">Inbound Webhook Receiver</a></li>
<li><a href="/learning/deep-dive/deep-dive-api-error-semantics-retryability-contract/">API Error Semantics</a></li>
</ul>
]]></content:encoded></item></channel></rss>