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 하위단위.
USD → $10.00 → amount: 1000
EUR → €25.50 → amount: 2550
GBP → £7.99 → amount: 799
변환 공식: amount = 표시 금액 × 100
2. Zero-Decimal Currency (영소수점 통화)
하위 단위가 없는 통화. 기본 단위 자체가 최소 단위다.
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 프랑 | 서아프리카 |
| XPF | CFP 프랑 | 프랑스령 태평양 |
3. Three-Decimal Currency (3자리 소수점 통화)
일부 통화는 1 기본단위 = 1000 하위단위다. Stripe에서 지원하는 경우는 드물지만, BHD(바레인 디나르), KWD(쿠웨이트 디나르) 등이 있다.
BHD → 1.500 BHD → amount: 1500
KWD → 2.750 KWD → amount: 2750
변환 공식: amount = 표시 금액 × 1000
실제 구현
다중 통화를 지원하는 서비스에서는 금액 변환 함수를 만들어서 통화 종류에 따라 분기해야 한다.
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 * 100이 1998.9999...가 될 수 있기 때문에, 반올림으로 정수를 보장해야 한다.
역변환: Stripe → 표시 금액
Stripe에서 받은 금액을 사용자에게 보여줄 때는 반대로 변환한다.
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을 활용하면 통화 기호, 천단위 구분자, 소수점 자릿수를 로케일에 맞게 자동 처리할 수 있다.
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배 금액이 결제된다.
// ❌ 잘못된 코드: KRW 10,000원 결제 의도
const amount = 10000 * 100; // 1,000,000 → 백만 원 청구!
// ✅ 올바른 코드
const amount = convertToStripeAmount(10000, 'KRW'); // 10000
2. 소수점 잘림
Zero-Decimal 통화에 소수점이 들어오면 Stripe가 에러를 반환한다.
// ❌ 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에서 금액을 검증할 때도 같은 변환 로직을 적용해야 한다.
// 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에 저장할 때는 두 가지 전략이 있다.
- 표시 금액 저장: 사람이 읽기 쉽고 쿼리가 직관적이지만, Stripe에 보낼 때마다 변환 필요
- Stripe 단위(최소 단위) 저장: Stripe와의 통신에서 변환이 불필요하지만, UI 표시 시 역변환 필요
어느 쪽이든 통화 코드를 반드시 함께 저장해야 한다. 10000이라는 숫자만으로는 10,000원인지 100달러인지 알 수 없다.
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 통화와 일반 통화를 모두 커버해야 한다.
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()로 부동소수점 오차 방지- 통화 코드를 금액과 항상 함께 저장·검증
- 변환 로직은 유틸리티 함수로 한 곳에 모아서 관리