<?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>Rounding on jyukki's Blog</title><link>https://jyukki.com/tags/rounding/</link><description>Recent content in Rounding on jyukki's Blog</description><generator>Hugo -- 0.147.0</generator><language>ko-KR</language><lastBuildDate>Sat, 15 Aug 2026 10:06:00 +0900</lastBuildDate><atom:link href="https://jyukki.com/tags/rounding/index.xml" rel="self" type="application/rss+xml"/><item><title>백엔드 커리큘럼 심화: 금액·통화·반올림 경계, Money 값을 정산 사고 없이 다루는 법</title><link>https://jyukki.com/learning/deep-dive/deep-dive-money-currency-rounding-boundaries-playbook/</link><pubDate>Sat, 15 Aug 2026 10:06:00 +0900</pubDate><guid>https://jyukki.com/learning/deep-dive/deep-dive-money-currency-rounding-boundaries-playbook/</guid><description>금액을 부동소수점이나 단순 DECIMAL 컬럼으로만 다루지 않고, 통화 단위·반올림 시점·배분 규칙·원본 산식·정산 검증을 하나의 Money 계약으로 설계하는 실무 플레이북입니다.</description><content:encoded><![CDATA[<p>결제, 포인트, 쿠폰, 사용량 과금에서 금액은 흔히 <code>amount decimal(19,2)</code> 하나로 시작합니다. 초기에는 잘 동작합니다. 그러나 통화가 하나 더 붙고, 부가세 포함·별도 표시가 갈리고, 쿠폰을 여러 상품에 나누고, 부분 환불과 외부 PG 정산까지 들어오면 숫자 하나로는 질문에 답할 수 없습니다. “이 주문이 왜 10,001원인가?”, “할인 1원이 어느 상품에 붙었는가?”, “승인 금액과 환불 잔액이 왜 다른가?”가 남습니다.</p>
<p>금액 사고의 핵심은 덧셈 실수가 아니라 <strong>의미가 사라지는 것</strong>입니다. 금액을 재현하려면 값뿐 아니라 통화, 적용 자릿수, 반올림 방식, 반올림한 경계, 가격·세금 정책 버전, 원본 산식이 필요합니다. 이 글은 <a href="/learning/deep-dive/deep-dive-payment-authorization-capture-state-machine-playbook/">결제 Authorization·Capture 상태 머신</a>, <a href="/learning/deep-dive/deep-dive-reconciliation-ledger-pipeline/">Reconciliation 파이프라인</a>, <a href="/learning/deep-dive/deep-dive-bitemporal-effective-dated-records-playbook/">Bitemporal·유효기간 데이터 설계</a>, <a href="/learning/deep-dive/deep-dive-domain-invariant-registry-data-quality-playbook/">도메인 불변식 Registry와 데이터 품질</a>을 금액이라는 공통 경계로 연결합니다.</p>
<h2 id="이-글에서-얻는-것">이 글에서 얻는 것</h2>
<ul>
<li><code>1000</code>이라는 숫자가 왜 금액으로는 불완전한지, Money 값에 무엇을 붙여야 하는지 설명할 수 있습니다.</li>
<li>정수 minor unit, <code>DECIMAL</code>, <code>BigDecimal</code>을 저장·중간 계산·확정 금액의 역할로 나눌 수 있습니다.</li>
<li>할인과 세금에서 반올림을 어느 경계에서 적용할지, 남은 1원을 어떤 규칙으로 배분할지 설계합니다.</li>
<li>결제 provider, 내부 원장, 고객 화면의 값을 비교할 때 자동 보정하면 안 되는 경우를 구분합니다.</li>
<li>금액 불변식, 예외 격리, 정정 이력을 운영 기준과 숫자로 관리할 수 있습니다.</li>
</ul>
<h2 id="핵심-개념이슈">핵심 개념/이슈</h2>
<h3 id="1-money는-숫자가-아니라-해석-가능한-값이다">1) Money는 숫자가 아니라 해석 가능한 값이다</h3>
<p><code>amount = 1000</code>만 저장하면 두 가지 문제가 바로 생깁니다. 첫째, 통화가 없습니다. KRW라면 1,000원일 수 있고, USD라면 10.00달러일 수도 있습니다. 둘째, 그 금액이 확정 금액인지, 세금 전 금액인지, 환율 적용 전 중간값인지 알 수 없습니다. 나중에 정책이 바뀌면 같은 숫자를 다시 계산해도 같은 결과를 얻을 수 없습니다.</p>
<p>최소 Money 계약은 다음 질문에 답해야 합니다.</p>
<table>
  <thead>
      <tr>
          <th>필드 또는 규칙</th>
          <th>답하는 질문</th>
          <th>예시</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>amount_minor</code> 또는 <code>decimal_amount</code></td>
          <td>얼마인가</td>
          <td><code>1000</code>, <code>10.00</code></td>
      </tr>
      <tr>
          <td><code>currency</code></td>
          <td>어느 통화인가</td>
          <td><code>KRW</code>, <code>USD</code>, <code>JPY</code></td>
      </tr>
      <tr>
          <td><code>scale</code> 또는 통화 minor-unit 표</td>
          <td>몇 자리까지 유효한가</td>
          <td>KRW 0, USD 2</td>
      </tr>
      <tr>
          <td><code>rounding_mode</code></td>
          <td>중간값을 어떻게 확정했는가</td>
          <td>HALF_UP, HALF_EVEN, floor</td>
      </tr>
      <tr>
          <td><code>pricing_policy_version</code></td>
          <td>어떤 가격·세금 규칙을 썼는가</td>
          <td><code>price-2026-08-v3</code></td>
      </tr>
      <tr>
          <td>calculation snapshot</td>
          <td>왜 그 결과가 나왔는가</td>
          <td>세율, 쿠폰, 환율 기준시각</td>
      </tr>
  </tbody>
</table>
<p>모든 화면 응답에 이 값을 전부 내보내라는 뜻은 아닙니다. 고객에게는 <code>10,000원</code>이라는 표시값이면 충분할 수 있습니다. 하지만 order line, invoice, payment attempt, ledger entry처럼 나중에 재현·정산해야 하는 레코드에서는 숫자만 남기면 안 됩니다. 특히 <strong>가격 규칙의 현재값을 다시 읽어 과거 주문을 계산하는 방식</strong>은 위험합니다. 과거를 다시 해석해야 한다면 정책 버전과 입력 snapshot을 남겨야 합니다.</p>
<h3 id="2-double의-문제와-bigdecimal의-한계는-다르다">2) <code>double</code>의 문제와 <code>BigDecimal</code>의 한계는 다르다</h3>
<p>Java <code>double</code>이나 JavaScript <code>Number</code>는 많은 소수를 이진 부동소수점으로 표현합니다. 따라서 <code>0.1 + 0.2</code>가 사람이 기대한 0.3과 정확히 같지 않을 수 있습니다. 금액의 기준 저장·비교·합계에 쓰면 안 되는 이유입니다.</p>
<p>그렇다고 <code>BigDecimal</code>을 쓰면 설계가 끝나는 것은 아닙니다. <code>new BigDecimal(0.1)</code>처럼 binary float에서 만들면 이미 오차를 들고 들어옵니다. 문자열 또는 정수에서 만들고, scale과 rounding mode를 명시해야 합니다.</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-java" data-lang="java"><span style="display:flex;"><span><span style="color:#6272a4">// 금지: binary float의 근사값을 BigDecimal로 옮긴다.</span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">new</span> BigDecimal(0.<span style="color:#50fa7b">1</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4">// 권장: 문자열 또는 minor unit에서 만든다.</span>
</span></span><span style="display:flex;"><span>BigDecimal rate <span style="color:#ff79c6">=</span> <span style="color:#ff79c6">new</span> BigDecimal(<span style="color:#f1fa8c">&#34;0.1&#34;</span>);
</span></span><span style="display:flex;"><span>BigDecimal price <span style="color:#ff79c6">=</span> BigDecimal.<span style="color:#50fa7b">valueOf</span>(10_000L);
</span></span><span style="display:flex;"><span>BigDecimal tax <span style="color:#ff79c6">=</span> price.<span style="color:#50fa7b">multiply</span>(rate).<span style="color:#50fa7b">setScale</span>(0, RoundingMode.<span style="color:#50fa7b">HALF_UP</span>);
</span></span></code></pre></div><p>하지만 위 코드의 <code>HALF_UP</code>도 정책입니다. 어느 나라의 세금 규정, 계약, 회계 기준이든 반올림 방식과 시점이 다를 수 있습니다. 기술팀이 라이브러리 기본값으로 정해서는 안 되고, 도메인 정책으로 명시한 뒤 재사용해야 합니다.</p>
<h3 id="3-저장-단위와-계산-단위를-분리한다">3) 저장 단위와 계산 단위를 분리한다</h3>
<p>확정된 금액은 보통 통화의 minor unit 정수로 저장하는 편이 단순합니다. KRW처럼 소수 단위가 없는 통화는 <code>amount_minor = 1000</code>이 곧 1,000원입니다. USD가 2자리 소수 단위를 쓴다는 계약이라면 <code>amount_minor = 1000</code>, <code>currency = 'USD'</code>는 10.00달러를 뜻합니다. 정수 합계와 비교는 정확하고 index·aggregation도 단순합니다.</p>
<p>다만 세율, 환율, 사용량 단가, 비례 할인처럼 중간값이 소수를 만드는 계산은 더 높은 정밀도로 해야 합니다. 핵심은 <strong>중간 계산을 정밀하게 하고, 도메인이 정한 확정 경계에서 한 번 반올림한다</strong>는 것입니다. 매 연산 뒤 습관적으로 scale을 잘라 내면 작은 오차가 누적됩니다.</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>중간 계산: usage × unit_rate × exchange_rate  -&gt; 높은 정밀도 유지
</span></span><span style="display:flex;"><span>청구 확정: invoice line 또는 invoice total    -&gt; 정책 scale로 반올림
</span></span><span style="display:flex;"><span>저장/정산: canonical minor unit               -&gt; 정수로 확정
</span></span></code></pre></div><p>예외도 있습니다. 법·계약이 “각 주문 행의 세금을 원 단위로 반올림한 뒤 합산”하도록 정했다면 line이 확정 경계입니다. 반대로 invoice 총액에만 세금을 부과한다면 invoice가 경계입니다. 둘 중 무엇이 옳은지는 프로그래밍 취향이 아니라 <strong>상품·세무·결제 계약</strong>의 문제입니다.</p>
<h3 id="4-할인-배분의-잔여-1원은-반드시-결정적으로-처리한다">4) 할인 배분의 잔여 1원은 반드시 결정적으로 처리한다</h3>
<p>상품 A 1,990원, 상품 B 2,010원인 주문에 주문 쿠폰 1,000원을 비율로 배분한다고 해 봅시다. 이론적인 할인은 A 497.5원, B 502.5원입니다. 하지만 KRW 확정 금액은 반 원을 저장할 수 없습니다. A와 B를 각각 반올림하면 합계가 999원 또는 1,001원이 될 수 있습니다.</p>
<p>안전한 방식은 세 단계입니다.</p>
<ol>
<li>각 line의 이론 배분액을 높은 정밀도로 계산합니다.</li>
<li>모든 line에 floor 또는 정책상 기본 반올림을 적용합니다.</li>
<li>목표 할인 총액과 현재 합계의 차이(residual)를 fractional remainder가 큰 순서, 동률이면 stable line id 순서로 한 단위씩 배분합니다.</li>
</ol>
<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>coupon_total = 1,000 KRW
</span></span><span style="display:flex;"><span>line A raw = 497.5 -&gt; base 497, remainder 0.5
</span></span><span style="display:flex;"><span>line B raw = 502.5 -&gt; base 502, remainder 0.5
</span></span><span style="display:flex;"><span>base sum = 999, residual = 1
</span></span><span style="display:flex;"><span>stable line id가 작은 A에 +1 -&gt; A 498, B 502, 합계 1,000
</span></span></code></pre></div><p>여기서 중요한 것은 A에 1원을 주는 정책 자체가 아니라, <strong>언제나 같은 입력이면 같은 line이 그 1원을 받는다는 것</strong>입니다. DB 조회 순서, hash map 순회 순서, 워커 실행 순서에 따라 결과가 바뀌면 고객 문의와 재처리에서 설명할 수 없습니다. 잔여 배분의 기준과 tie-breaker를 <code>pricing_policy_version</code>에 포함하고 fixture로 고정하세요.</p>
<h3 id="5-원장-표시값-provider-금액은-같은-숫자여도-같은-역할이-아니다">5) 원장, 표시값, provider 금액은 같은 숫자여도 같은 역할이 아니다</h3>
<p>금액을 한 테이블에 덮어쓰면 감사와 재현이 무너집니다. 최소한 아래 역할을 분리합니다.</p>
<table>
  <thead>
      <tr>
          <th>레이어</th>
          <th>역할</th>
          <th>수정 원칙</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>가격 계산 snapshot</td>
          <td>상품가·쿠폰·세율·환율로 계산한 근거</td>
          <td>확정 후 append 또는 versioned</td>
      </tr>
      <tr>
          <td>주문/청구서 합계</td>
          <td>고객에게 청구하기로 한 금액</td>
          <td>변경은 명시적 재계산 명령으로</td>
      </tr>
      <tr>
          <td>payment attempt</td>
          <td>PG에 보낸 amount·currency·idempotency key</td>
          <td>provider 원문과 함께 보관</td>
      </tr>
      <tr>
          <td>ledger entry</td>
          <td>승인·환불·조정의 회계적 효과</td>
          <td>원칙적으로 append-only</td>
      </tr>
      <tr>
          <td>display projection</td>
          <td>UI용 합계·포맷</td>
          <td>원장에서 재구축 가능해야 함</td>
      </tr>
  </tbody>
</table>
<p>PG webhook이 <code>approved_amount = 10,000</code>, <code>currency = KRW</code>를 보냈을 때 내부 payment attempt가 9,900원이면 “1% 정도 차이”로 넘어가면 안 됩니다. 금액, 통화, merchant account, provider transaction id 중 하나라도 맞지 않으면 상태 전이를 멈추고 <code>AMOUNT_OR_CURRENCY_MISMATCH</code>로 격리하는 편이 안전합니다. 네트워크 timeout 뒤 provider가 실제로 승인했는지 모르는 경우도 마찬가지입니다. <a href="/learning/deep-dive/deep-dive-payment-authorization-capture-state-machine-playbook/">결제 Authorization·Capture 상태 머신</a>처럼 조회와 수동 확인으로 효과를 확정합니다.</p>
<h3 id="6-금액-불변식은-db코드운영에서-세-번-확인한다">6) 금액 불변식은 DB·코드·운영에서 세 번 확인한다</h3>
<p>금액은 테스트만으로 충분하지 않습니다. 아래 불변식은 한 계층에만 두지 말고, 가능한 것은 DB 제약과 원장 집계로, 업무 규칙은 서비스 코드로, 사후 탐지는 배치·대시보드로 중복 확인합니다.</p>
<ul>
<li><code>invoice_total = line_total + tax_total - discount_total</code>이 통화 단위까지 일치한다.</li>
<li>같은 currency가 아닌 금액은 합산하지 않는다. 환산했다면 exchange rate와 기준시각이 있다.</li>
<li><code>authorized &gt;= captured &gt;= refunded</code> 또는 도메인별 허용 상태 관계가 항상 성립한다.</li>
<li>한 <code>provider_event_id</code>와 한 idempotency key가 두 개의 금전 효과를 만들지 않는다.</li>
<li>line 할인 합계와 order 할인 총액의 차이는 정확히 0이다.</li>
</ul>
<p>운영 기준은 보수적으로 둡니다. 원장·PG·청구 금액 불일치는 <strong>0건</strong>이 목표입니다. 표시용 통계의 반올림 오차와 섞지 마세요. 금액 mismatch가 1건이라도 발생하면 자동 재시도만 반복하지 말고 order id, invoice id, provider event id, policy version, 최초 불일치 시각을 한 사건으로 묶어 조사합니다. 이 관점은 <a href="/learning/deep-dive/deep-dive-domain-invariant-registry-data-quality-playbook/">도메인 불변식 Registry와 데이터 품질</a>의 P0 불변식 운영과 같습니다.</p>
<h2 id="실무-적용">실무 적용</h2>
<h3 id="1-money-값-객체와-확정-api를-먼저-만든다">1) Money 값 객체와 확정 API를 먼저 만든다</h3>
<p>애플리케이션 전체에 <code>BigDecimal</code>을 흩뿌리면 scale과 rounding mode가 호출자마다 달라집니다. 금액 생성·더하기·환산·확정을 한 값 객체 또는 좁은 domain service로 모으세요. 아래는 개념적 인터페이스입니다.</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-java" data-lang="java"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">public</span> <span style="color:#8be9fd;font-style:italic">record</span> <span style="color:#50fa7b">Money</span>(<span style="color:#8be9fd">long</span> amountMinor, Currency currency) {
</span></span><span style="display:flex;"><span>    <span style="color:#8be9fd;font-style:italic">public</span> Money <span style="color:#50fa7b">plus</span>(Money other) {
</span></span><span style="display:flex;"><span>        requireSameCurrency(other);
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">return</span> <span style="color:#ff79c6">new</span> Money(Math.<span style="color:#50fa7b">addExact</span>(amountMinor, other.<span style="color:#50fa7b">amountMinor</span>), currency);
</span></span><span style="display:flex;"><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:#8be9fd;font-style:italic">public</span> <span style="color:#8be9fd;font-style:italic">interface</span> <span style="color:#50fa7b">PricingPolicy</span> {
</span></span><span style="display:flex;"><span>    Money <span style="color:#50fa7b">finalizeInvoice</span>(PriceSnapshot snapshot);
</span></span><span style="display:flex;"><span>    List<span style="color:#ff79c6">&lt;</span>AllocatedDiscount<span style="color:#ff79c6">&gt;</span> <span style="color:#50fa7b">allocateDiscount</span>(Money coupon, List<span style="color:#ff79c6">&lt;</span>LineAmount<span style="color:#ff79c6">&gt;</span> lines);
</span></span><span style="display:flex;"><span>    String <span style="color:#50fa7b">version</span>();
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>이 예제는 모든 통화의 중간 계산을 <code>long</code>으로 하라는 뜻이 아닙니다. <code>Money</code>는 확정 금액, <code>PreciseAmount</code> 또는 <code>BigDecimal</code>은 계산 중간값처럼 역할을 나누면 좋습니다. 또한 <code>currency</code>가 다르면 <code>plus</code>를 실패시켜야 합니다. 환산은 <code>convert(exchangeRate, asOf, policy)</code>처럼 의도적인 명령으로만 일어나야 합니다.</p>
<h3 id="2-db-스키마에-해석-단서를-남긴다">2) DB 스키마에 해석 단서를 남긴다</h3>
<p>확정 payment·ledger에는 다음과 같은 형태가 출발점이 될 수 있습니다.</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_attempts (
</span></span><span style="display:flex;"><span>  id                    uuid <span style="color:#ff79c6">PRIMARY</span> <span style="color:#ff79c6">KEY</span>,
</span></span><span style="display:flex;"><span>  order_id              uuid <span style="color:#ff79c6">NOT</span> <span style="color:#ff79c6">NULL</span>,
</span></span><span style="display:flex;"><span>  amount_minor          <span style="color:#8be9fd;font-style:italic">bigint</span> <span style="color:#ff79c6">NOT</span> <span style="color:#ff79c6">NULL</span> <span style="color:#ff79c6">CHECK</span> (amount_minor <span style="color:#ff79c6">&gt;=</span> <span style="color:#bd93f9">0</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>  pricing_policy_version <span style="color:#8be9fd;font-style:italic">text</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">text</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">text</span>,
</span></span><span style="display:flex;"><span>  idempotency_key       <span style="color:#8be9fd;font-style:italic">text</span> <span style="color:#ff79c6">NOT</span> <span style="color:#ff79c6">NULL</span>,
</span></span><span style="display:flex;"><span>  status                <span style="color:#8be9fd;font-style:italic">text</span> <span style="color:#ff79c6">NOT</span> <span style="color:#ff79c6">NULL</span>,
</span></span><span style="display:flex;"><span>  created_at            timestamptz <span style="color:#ff79c6">NOT</span> <span style="color:#ff79c6">NULL</span> <span style="color:#ff79c6">DEFAULT</span> now(),
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">UNIQUE</span> (provider, provider_payment_id),
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">UNIQUE</span> (idempotency_key)
</span></span><span style="display:flex;"><span>);
</span></span></code></pre></div><p>이 테이블에 환율·세율·쿠폰 세부 산식까지 모두 넣을 필요는 없습니다. 그러나 이를 조회할 수 있는 immutable snapshot id, invoice id 또는 pricing calculation id는 있어야 합니다. <code>amount_minor</code>의 해석을 정하는 통화·정책 버전까지 없으면 원인 분석 시점에 현재 코드로 과거 결과를 추측하게 됩니다.</p>
<h3 id="3-반올림배분을-예제-기반-테스트로-잠근다">3) 반올림·배분을 예제 기반 테스트로 잠근다</h3>
<p>정상값만 테스트하면 residual 버그를 놓칩니다. 최소 fixture는 다음을 포함합니다.</p>
<table>
  <thead>
      <tr>
          <th>경우</th>
          <th>반드시 확인할 결과</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1원·1cent 쿠폰</td>
          <td>할인 합계가 목표 금액과 정확히 같은가</td>
      </tr>
      <tr>
          <td>세 상품에 1원 배분</td>
          <td>tie-breaker가 stable id 기준으로 고정되는가</td>
      </tr>
      <tr>
          <td>동일 가격 line 순서 변경</td>
          <td>결과가 입력 순서가 아닌 정책 기준으로 같은가</td>
      </tr>
      <tr>
          <td>부분 환불</td>
          <td>원 할인·세금 배분과 환불 금액의 관계가 보존되는가</td>
      </tr>
      <tr>
          <td>통화 불일치</td>
          <td>합산·환불·capture가 즉시 거부되는가</td>
      </tr>
      <tr>
          <td>provider timeout 후 webhook</td>
          <td>중복 승인 또는 이중 ledger entry가 없는가</td>
      </tr>
  </tbody>
</table>
<p>새 가격 정책을 배포할 때는 최근 30일 주문 snapshot을 재계산해 기존 확정값과 비교하는 shadow run이 도움이 됩니다. 정책 변경이 의도된 주문을 분리한 뒤, 의도하지 않은 금액 차이는 0건이어야 합니다. 차이가 있다면 <code>어떤 주문이 몇 원 달라졌는가</code>만 보지 말고 rounding boundary, input snapshot, residual rule, currency scale 중 어디가 달라졌는지 분류하세요.</p>
<h3 id="4-수정은-overwrite가-아니라-보정-효과로-남긴다">4) 수정은 overwrite가 아니라 보정 효과로 남긴다</h3>
<p>정산 오류가 났을 때 <code>orders.total_amount</code>를 직접 update하면 현재 화면은 맞아 보일 수 있습니다. 그러나 이전 청구, 승인, 환불, 고객 안내와의 관계가 끊깁니다. 금전 효과는 가능한 한 compensation entry 또는 adjustment entry로 남기고, 원인을 <code>reason_code</code>, 승인자, policy version, 연결된 incident로 기록하세요.</p>
<p>자동 보정은 좁게 시작합니다. 예를 들어 display projection 재생성처럼 원장이 바뀌지 않는 작업은 자동으로 해도 됩니다. 반면 승인·환불·포인트 잔액·세금 금액이 바뀌는 작업은 차이가 1원이어도 수동 승인을 기본으로 두는 편이 안전합니다. <a href="/learning/deep-dive/deep-dive-reconciliation-ledger-pipeline/">Reconciliation 파이프라인</a>과 <a href="/learning/deep-dive/deep-dive-correction-job-audit-guardrails-playbook/">정정 Job 감사 가드레일</a>의 dry-run·영향 범위·rollback 기준을 그대로 적용할 수 있습니다.</p>
<h2 id="트레이드오프주의점">트레이드오프/주의점</h2>
<ol>
<li><strong>정수 저장이 모든 계산을 단순화하지는 않습니다.</strong> 확정 금액에는 강하지만, 환율·세율·비례 배분은 고정 정밀도 중간 계산이 필요합니다. 정수로 일찍 자르면 오차가 커집니다.</li>
<li><strong>통화 minor unit을 하드코딩하지 마세요.</strong> 현재 지원 통화가 KRW 하나여도 provider가 반환하는 scale, 향후 다중 통화, 통화 코드 변경을 고려해 중앙 policy로 관리해야 합니다.</li>
<li><strong>반올림 방식은 법무·재무와 확인해야 합니다.</strong> HALF_UP이 익숙하다는 이유로 선택하지 말고, 청구서·세금·계약의 확정 경계를 문서로 합의해야 합니다.</li>
<li><strong>잔여 배분은 공정성 문제도 됩니다.</strong> 항상 첫 상품에 1원을 몰아주면 대량 주문에서 편향이 생길 수 있습니다. stable id, remainder, 고객 혜택 우선순위 중 어떤 기준을 쓸지 제품 정책으로 결정합니다.</li>
<li><strong>외부 provider 금액을 조용히 변환하지 마세요.</strong> amount와 currency mismatch는 단순 파싱 오류일 수도 있지만, 잘못된 merchant 설정·이벤트 연결·중복 처리의 신호일 수도 있습니다. 자동 보정이 아니라 quarantine이 기본입니다.</li>
<li><strong>Money 모델을 과도하게 일반화하지 마세요.</strong> 단일 통화의 내부 포인트를 처리하는 작은 기능에 FX ledger, 다중 tax engine, 범용 allocation framework를 먼저 넣을 필요는 없습니다. 현재 도메인에 필요한 통화·반올림 경계부터 작게 고정하고, 지원 범위가 늘 때 정책 버전을 올리면 됩니다.</li>
</ol>
<h2 id="체크리스트-또는-연습">체크리스트 또는 연습</h2>
<h3 id="적용-체크리스트">적용 체크리스트</h3>
<ul>
<li><input disabled="" type="checkbox"> 금액 기준 저장소에 <code>double</code> 또는 JavaScript <code>Number</code>를 쓰지 않는다.</li>
<li><input disabled="" type="checkbox"> 확정 금액에는 amount, currency, 해석 가능한 scale/minor-unit 규칙이 있다.</li>
<li><input disabled="" type="checkbox"> 통화가 다른 Money는 명시적인 환산 명령 없이는 합산할 수 없다.</li>
<li><input disabled="" type="checkbox"> 세금·할인·환불별 반올림 mode와 확정 경계가 문서화되어 있다.</li>
<li><input disabled="" type="checkbox"> residual 1원/1cent 배분의 순서와 tie-breaker가 결정적이다.</li>
<li><input disabled="" type="checkbox"> pricing policy version과 계산 snapshot을 과거 주문에서 조회할 수 있다.</li>
<li><input disabled="" type="checkbox"> payment webhook은 amount, currency, provider transaction id, idempotency key를 함께 검증한다.</li>
<li><input disabled="" type="checkbox"> ledger·invoice·provider 정산의 금액 mismatch는 0건 기준으로 관측한다.</li>
<li><input disabled="" type="checkbox"> 금전 effect의 정정은 overwrite가 아니라 adjustment/compensation으로 남긴다.</li>
<li><input disabled="" type="checkbox"> 가격 정책 변경 전 최근 주문 snapshot shadow run과 차이 분류를 수행한다.</li>
</ul>
<h3 id="연습">연습</h3>
<ol>
<li>현재 서비스의 금액 필드 5개를 골라 “확정 금액 / 중간 계산 / 화면 projection / 외부 provider 원문” 중 어디에 속하는지 분류해 보세요.</li>
<li>세 line에 1,000원 쿠폰을 비례 배분하는 fixture를 만들고, 합계가 정확히 1,000원인지와 line 순서가 바뀌어도 결과가 같은지 테스트하세요.</li>
<li><code>amount = 1000</code>만 가진 payment 레코드에 어떤 정보가 더 있어야 1년 뒤 승인 근거를 재현할 수 있는지 schema diff를 작성해 보세요.</li>
<li>PG webhook에서 amount 또는 currency가 한 단위라도 다를 때 어떤 상태로 격리하고, 누가 어떤 증거를 보고 해제할지 runbook으로 정리해 보세요.</li>
</ol>
<p>좋은 Money 모델의 목표는 모든 금액 계산을 복잡하게 만드는 것이 아닙니다. <strong>같은 입력과 정책이면 언제나 같은 결과가 나오고, 다른 결과가 나오면 이유를 추적할 수 있게 만드는 것</strong>입니다. amount, currency, rounding boundary, allocation rule, provider evidence를 한 계약으로 관리하면 작은 1원 차이가 큰 정산 사고로 번지는 경로를 일찍 차단할 수 있습니다.</p>
]]></content:encoded></item></channel></rss>