junyeokk
Blog
Stripe·2026. 02. 04

Stripe Zero-Decimal Currency

Stripe API에 결제 금액을 보낼 때, 금액을 "그냥 숫자"로 보내면 안 된다. amount 필드는 해당 통화의 최소 단위(smallest currency unit)로 표현해야 하기 때문이다. 미국 달러라면 센트 단위, 즉 $10.00을 보내려면 1000을 전달해야 한다.

여기서 문제가 생긴다. 모든 통화가 소수점 이하 단위를 갖는 건 아니다.

왜 이런 구분이 필요한가

통화는 국가마다 하위 단위(subunit) 체계가 다르다.

  • USD: 1달러 = 100센트 → 소수점 2자리
  • BHD: 1디나르 = 1000필스 → 소수점 3자리
  • KRW: 원 단위가 최소 단위 → 소수점 없음
  • JPY: 엔 단위가 최소 단위 → 소수점 없음

Stripe는 통화 금액을 정수로 표현하기 위해 최소 단위 기준을 사용한다. 부동소수점 오차를 피하기 위한 설계다. 금융 시스템에서 0.1 + 0.2 !== 0.3 같은 문제가 발생하면 돈이 사라지거나 늘어날 수 있으니, 정수 연산으로 강제하는 것이다.

그런데 이 규칙을 일률적으로 적용하면 KRW나 JPY 같은 통화에서 문제가 발생한다. 10,000원을 결제하려는데 10000 * 100 = 1000000을 보내면? Stripe는 이걸 100만 원으로 인식한다. 이게 Zero-Decimal Currency 개념이 필요한 이유다.

통화 분류 체계

Stripe는 통화를 세 가지로 분류한다.

1. Standard Currency (일반 통화)

대부분의 통화가 여기에 해당한다. 1 기본단위 = 100 하위단위.

text
USD → $10.00 → amount: 1000
EUR → €25.50 → amount: 2550
GBP → £7.99 → amount: 799

변환 공식: amount = 표시 금액 × 100

2. Zero-Decimal Currency (영소수점 통화)

하위 단위가 없는 통화. 기본 단위 자체가 최소 단위다.

text
KRW → ₩10,000 → amount: 10000
JPY → ¥500 → amount: 500
VND → ₫50,000 → amount: 50000

변환 공식: amount = 표시 금액 (그대로)

Stripe가 정의한 Zero-Decimal 통화 전체 목록은 다음과 같다:

통화 코드통화명국가
BIF부룬디 프랑부룬디
CLP칠레 페소칠레
DJF지부티 프랑지부티
GNF기니 프랑기니
JPY일본 엔일본
KMF코모로 프랑코모로
KRW대한민국 원대한민국
MGA마다가스카르 아리아리마다가스카르
PYG파라과이 과라니파라과이
RWF르완다 프랑르완다
UGX우간다 실링우간다
VND베트남 동베트남
VUV바누아투 바투바누아투
XAF중앙아프리카 CFA 프랑중앙아프리카
XOF서아프리카 CFA 프랑서아프리카
XPFCFP 프랑프랑스령 태평양

3. Three-Decimal Currency (3자리 소수점 통화)

일부 통화는 1 기본단위 = 1000 하위단위다. Stripe에서 지원하는 경우는 드물지만, BHD(바레인 디나르), KWD(쿠웨이트 디나르) 등이 있다.

text
BHD → 1.500 BHD → amount: 1500
KWD → 2.750 KWD → amount: 2750

변환 공식: amount = 표시 금액 × 1000

실제 구현

다중 통화를 지원하는 서비스에서는 금액 변환 함수를 만들어서 통화 종류에 따라 분기해야 한다.

typescript
function convertToStripeAmount(amount: number, currency: string): number {
  const zeroDecimalCurrencies = [
    'BIF', 'CLP', 'DJF', 'GNF', 'JPY', 'KMF',
    'KRW', 'MGA', 'PYG', 'RWF', 'UGX', 'VND',
    'VUV', 'XAF', 'XOF', 'XPF',
  ];

  if (zeroDecimalCurrencies.includes(currency.toUpperCase())) {
    return Math.round(amount);
  }

  // 일반 통화: 센트 단위로 변환
  return Math.round(amount * 100);
}

핵심은 Math.round()를 사용하는 것이다. 부동소수점 연산에서 19.99 * 1001998.9999...가 될 수 있기 때문에, 반올림으로 정수를 보장해야 한다.

역변환: Stripe → 표시 금액

Stripe에서 받은 금액을 사용자에게 보여줄 때는 반대로 변환한다.

typescript
function convertFromStripeAmount(amount: number, currency: string): number {
  const zeroDecimalCurrencies = [
    'BIF', 'CLP', 'DJF', 'GNF', 'JPY', 'KMF',
    'KRW', 'MGA', 'PYG', 'RWF', 'UGX', 'VND',
    'VUV', 'XAF', 'XOF', 'XPF',
  ];

  if (zeroDecimalCurrencies.includes(currency.toUpperCase())) {
    return amount;
  }

  return amount / 100;
}

포맷팅까지 포함한 완전한 유틸리티

실무에서는 변환뿐 아니라 사용자에게 보여줄 포맷팅까지 필요하다. Intl.NumberFormat을 활용하면 통화 기호, 천단위 구분자, 소수점 자릿수를 로케일에 맞게 자동 처리할 수 있다.

typescript
function formatCurrency(
  stripeAmount: number,
  currency: string,
  locale: string = 'en-US'
): string {
  const displayAmount = convertFromStripeAmount(stripeAmount, currency);

  return new Intl.NumberFormat(locale, {
    style: 'currency',
    currency: currency.toUpperCase(),
  }).format(displayAmount);
}

// 사용 예시
formatCurrency(1000, 'USD');        // "$10.00"
formatCurrency(10000, 'KRW', 'ko'); // "₩10,000"
formatCurrency(500, 'JPY', 'ja');   // "¥500"

Intl.NumberFormat은 통화 코드에 따라 소수점 자릿수를 자동으로 결정하므로, KRW는 소수점 없이 표시되고 USD는 소수점 2자리로 표시된다.

흔한 실수와 디버깅

1. 100배 과다 청구

가장 치명적인 버그다. Zero-Decimal 통화인데 * 100을 적용하면 100배 금액이 결제된다.

typescript
// ❌ 잘못된 코드: KRW 10,000원 결제 의도
const amount = 10000 * 100; // 1,000,000 → 백만 원 청구!

// ✅ 올바른 코드
const amount = convertToStripeAmount(10000, 'KRW'); // 10000

2. 소수점 잘림

Zero-Decimal 통화에 소수점이 들어오면 Stripe가 에러를 반환한다.

typescript
// ❌ Stripe는 정수만 허용
stripe.paymentIntents.create({
  amount: 10000.5, // Error!
  currency: 'krw',
});

// ✅ Math.round()로 정수 보장
stripe.paymentIntents.create({
  amount: Math.round(10000.5), // 10001
  currency: 'krw',
});

3. 통화 코드 대소문자

Stripe API는 통화 코드를 소문자로 받지만('krw'), Zero-Decimal 목록과 비교할 때 대소문자를 맞춰야 한다. toUpperCase()로 통일하는 게 안전하다.

4. Webhook 금액 검증

결제 완료 webhook에서 금액을 검증할 때도 같은 변환 로직을 적용해야 한다.

typescript
// webhook handler
function verifyPaymentAmount(
  intent: Stripe.PaymentIntent,
  expectedAmount: number,
  currency: string
): boolean {
  const stripeExpectedAmount = convertToStripeAmount(expectedAmount, currency);

  return (
    intent.amount === stripeExpectedAmount &&
    intent.currency?.toUpperCase() === currency.toUpperCase()
  );
}

금액뿐 아니라 통화 코드도 함께 검증하는 게 중요하다. 공격자가 통화를 바꿔서 더 적은 금액으로 결제를 시도할 수 있기 때문이다. 예를 들어, USD 100달러 상품을 KRW 100원으로 결제 완료 처리되는 걸 방지해야 한다.

다중 통화 서비스 설계 시 고려사항

한국 서비스가 해외 진출하거나, 다중 통화 결제를 지원할 때 설계 시점부터 고려해야 할 것들이 있다.

DB에 금액 저장할 때

금액을 DB에 저장할 때는 두 가지 전략이 있다.

  1. 표시 금액 저장: 사람이 읽기 쉽고 쿼리가 직관적이지만, Stripe에 보낼 때마다 변환 필요
  2. Stripe 단위(최소 단위) 저장: Stripe와의 통신에서 변환이 불필요하지만, UI 표시 시 역변환 필요

어느 쪽이든 통화 코드를 반드시 함께 저장해야 한다. 10000이라는 숫자만으로는 10,000원인지 100달러인지 알 수 없다.

sql
CREATE TABLE payments (
  id UUID PRIMARY KEY,
  amount INTEGER NOT NULL,        -- Stripe 최소 단위 기준
  currency VARCHAR(3) NOT NULL,   -- ISO 4217 통화 코드
  display_amount DECIMAL(12, 4),  -- 표시용 금액 (선택적 중복 저장)
  -- ...
);

테스트 전략

다중 통화를 지원하면 테스트에서 반드시 Zero-Decimal 통화와 일반 통화를 모두 커버해야 한다.

typescript
describe('convertToStripeAmount', () => {
  it('일반 통화는 100을 곱한다', () => {
    expect(convertToStripeAmount(10.00, 'USD')).toBe(1000);
    expect(convertToStripeAmount(25.50, 'EUR')).toBe(2550);
  });

  it('Zero-Decimal 통화는 그대로 반환한다', () => {
    expect(convertToStripeAmount(10000, 'KRW')).toBe(10000);
    expect(convertToStripeAmount(500, 'JPY')).toBe(500);
  });

  it('부동소수점 오차를 반올림으로 처리한다', () => {
    expect(convertToStripeAmount(19.99, 'USD')).toBe(1999);
  });
});

ISO 4217과 Stripe의 관계

Zero-Decimal Currency라는 개념은 Stripe가 만든 게 아니다. ISO 4217 국제 통화 코드 표준에서 각 통화의 minor unit(소수점 자릿수)을 정의하고 있고, Stripe는 이 표준을 따르는 것이다.

  • Minor unit = 0 → Zero-Decimal Currency
  • Minor unit = 2 → Standard Currency (대부분)
  • Minor unit = 3 → Three-Decimal Currency

다만 Stripe가 지원하는 통화 목록이 ISO 4217 전체와 완전히 일치하지는 않으므로, 항상 Stripe 공식 문서의 지원 통화 목록을 기준으로 구현해야 한다.

정리

  • Stripe amount는 통화의 최소 단위 정수로 전달해야 한다
  • Zero-Decimal 통화(KRW, JPY 등)는 곱하지 않고 그대로 전달
  • 일반 통화(USD, EUR 등)는 × 100
  • Math.round()로 부동소수점 오차 방지
  • 통화 코드를 금액과 항상 함께 저장·검증
  • 변환 로직은 유틸리티 함수로 한 곳에 모아서 관리