Claude Code로 통화 포맷 구현하기: Intl.NumberFormat 실전 가이드
Intl.NumberFormat으로 다중 통화 표시, 반올림, 회계 표기, minor unit, JPY/USD/EUR 테스트를 구현합니다.
통화 포맷은 화면 장식이 아니라 과금 경계입니다
SaaS 요금제, 전자상거래, 인보이스, 관리자 리포트에서 금액은 단순한 문자열이 아닙니다. JPY는 보통 소수 자리가 없고, USD와 EUR은 두 자리 소수를 표시하며, 인도식 영어는 큰 수를12,34,567처럼 묶습니다. 브라질 포르투갈어는 쉼표와 마침표의 의미가 미국식 영어와 다르고, 환불 금액은($10.00) 같은 회계 표기가 필요할 수 있습니다.
안전한 설계는 DB에 minor unit 정수와 통화 코드를 저장하고, 계산도 정수로 처리한 뒤, 사용자에게 보여주기 직전에만Intl.NumberFormat을 사용하는 것입니다. minor unit은 통화의 최소 계산 단위입니다. USD라면 cent, JPY라면 1엔입니다.
이 글은 Claude Code가 구현하고 사람이 리뷰하기 쉬운 방식으로 다중 통화 SaaS 가격, 반올림, 회계 표기, 포맷된 문자열을 저장하지 않는 규칙, JPY/USD/EUR/BRL/INR/IDR 테스트를 정리합니다. 공식 문서는 MDN의Intl.NumberFormat 생성자, formatToParts, ECMA-402 NumberFormat 사양을 기준으로 삼으세요.
flowchart LR
A[DB: minor unit integer] --> B[Domain math]
B --> C[Tax, discount, refund]
C --> D[Intl.NumberFormat]
D --> E[UI, invoice, email]
저장 값과 표시 값을 분리하기
"$19.99"나"¥1,980"을 그대로 저장하면 처음에는 편해 보이지만 정렬, 합계, 환불, 감사, 번역에서 문제가 생깁니다. 대신amountMinor와currency를 저장하세요. 예를 들어1999 USD, 1980 JPY, 123456 IDR처럼 다룹니다.
| 방식 | 예 | 장점 | 위험 |
|---|---|---|---|
| 포맷된 문자열 저장 | "$19.99" | 정적 화면에는 빠름 | 리포트와 환불, 현지화가 깨짐 |
| 소수 number 저장 | 19.99 | 처음에는 쉬움 | 부동소수점 오차와 통화별 자릿수가 섞임 |
| minor unit 정수 저장 | 1999 | 계산, 집계, 테스트가 안정적 | 입출력 경계에서 변환이 필요 |
Intl.NumberFormat은 표시 도구입니다. 환율, 세율, 결제사 minor unit 정책, 인보이스 반올림 위치는 애플리케이션 요구사항으로 따로 정해야 합니다.
그대로 실행할 수 있는 구현
아래 코드를currency-format-demo.mjs로 저장하고node currency-format-demo.mjs를 실행하세요. 외부 라이브러리는 필요 없습니다.
// currency-format-demo.mjs
import assert from "node:assert/strict";
const minorUnitDigits = Object.freeze({
JPY: 0,
USD: 2,
EUR: 2,
BRL: 2,
INR: 2,
IDR: 0,
});
function assertCurrency(currency) {
if (!(currency in minorUnitDigits)) {
throw new Error(`Unsupported currency: ${currency}`);
}
}
function roundHalfAwayFromZero(value) {
return value < 0 ? -Math.round(Math.abs(value)) : Math.round(value);
}
export function moneyFromMajor(amount, currency) {
assertCurrency(currency);
if (!Number.isFinite(amount)) {
throw new Error(`Invalid amount: ${amount}`);
}
const digits = minorUnitDigits[currency];
return {
minor: roundHalfAwayFromZero(amount * 10 ** digits),
currency,
};
}
export function toMajor(money) {
assertCurrency(money.currency);
return money.minor / 10 ** minorUnitDigits[money.currency];
}
export function addMoney(left, right) {
if (left.currency !== right.currency) {
throw new Error(`Currency mismatch: ${left.currency} vs ${right.currency}`);
}
return { minor: left.minor + right.minor, currency: left.currency };
}
export function multiplyMoney(money, factor) {
if (!Number.isFinite(factor)) {
throw new Error(`Invalid factor: ${factor}`);
}
return {
minor: roundHalfAwayFromZero(money.minor * factor),
currency: money.currency,
};
}
export function formatMoney(
money,
{
locale = "en-US",
accounting = false,
currencyDisplay = "symbol",
roundingMode = "halfExpand",
} = {},
) {
assertCurrency(money.currency);
return new Intl.NumberFormat(locale, {
style: "currency",
currency: money.currency,
currencyDisplay,
currencySign: accounting ? "accounting" : "standard",
roundingMode,
}).format(toMajor(money));
}
const samples = [
["ja-JP", { minor: 123456, currency: "JPY" }],
["en-US", { minor: 123456, currency: "USD" }],
["de-DE", { minor: 123456, currency: "EUR" }],
["pt-BR", { minor: 123456, currency: "BRL" }],
["en-IN", { minor: 123456789, currency: "INR" }],
["id-ID", { minor: 123456, currency: "IDR" }],
];
for (const [locale, money] of samples) {
const formatter = new Intl.NumberFormat(locale, {
style: "currency",
currency: money.currency,
});
const options = formatter.resolvedOptions();
const parts = formatter.formatToParts(toMajor(money));
assert.equal(options.maximumFractionDigits, minorUnitDigits[money.currency]);
assert.ok(parts.some((part) => part.type === "currency"));
console.log(`${locale} ${money.currency}: ${formatMoney(money, { locale })}`);
}
assert.equal(
addMoney(moneyFromMajor(19.99, "USD"), moneyFromMajor(5, "USD")).minor,
2499,
);
assert.equal(multiplyMoney(moneyFromMajor(1980, "JPY"), 1.1).minor, 2178);
assert.match(
formatMoney({ minor: -129900, currency: "USD" }, { locale: "en-US", accounting: true }),
/^\(\$/,
);
assert.throws(
() => addMoney(moneyFromMajor(10, "USD"), moneyFromMajor(10, "JPY")),
/Currency mismatch/,
);
console.log("currency formatting checks passed");
실제 사용 사례
첫 번째는 다중 통화 SaaS 가격표입니다. 화면 컴포넌트에는 미리 번역된 가격 문자열이 아니라 구조화된 데이터를 넘기세요.
type CurrencyCode = "JPY" | "USD" | "EUR" | "BRL" | "INR" | "IDR";
type PlanPrice = {
planId: "starter" | "pro" | "team";
currency: CurrencyCode;
amountMinor: number;
};
const prices: PlanPrice[] = [
{ planId: "pro", currency: "JPY", amountMinor: 1980 },
{ planId: "pro", currency: "USD", amountMinor: 1999 },
{ planId: "pro", currency: "EUR", amountMinor: 1899 },
];
두 번째는 인보이스와 환불입니다. 미국식 인보이스에서는 음수를 마이너스 대신 괄호로 보여주는 회계 표기가 자연스러울 수 있습니다. 단, 모든 locale이 괄호를 쓰는 것은 아니므로 PDF처럼 고정 출력이 필요하면 스냅샷 테스트를 두세요.
세 번째는 세금, 할인, 일할 계산입니다. 1999 * 10 / 31처럼 나누어떨어지지 않는 값은 줄 단위로 반올림할지, 합계 후 반올림할지 정책이 필요합니다. 테스트 이름에 정책을 드러내면 Claude Code 리뷰가 훨씬 쉬워집니다.
네 번째는 관리자 화면과 CSV입니다. amount_minor, currency, amount_display를 분리해 내보내면 사람이 읽기 쉬우면서 MRR, LTV, 환불률 같은 수익 지표도 정확히 계산할 수 있습니다.
흔한 실패
포맷된 통화 문자열을 DB에 저장하지 마세요. locale과 currency를 같은 것으로 보지 마세요. 한국어 UI에서도 USD 결제를 보여줄 수 있고, 미국 팀이 EUR 인보이스를 확인할 수도 있습니다. 모든 통화가 소수 두 자리라고 가정하지 마세요. roundingMode는 표시 단계의 옵션이지 청구 정책 전체가 아닙니다. 통화 기호를 정규식으로 파싱하지 말고, 필요한 경우formatToParts를 사용하세요.
Claude Code 리뷰 Prompt
Review this repository's money formatting and billing math.
Requirements:
- Check that DB/API models do not store formatted currency strings
- Make JPY/USD/EUR/BRL/INR/IDR minor units explicit
- Use Intl.NumberFormat while keeping locale and currency separate
- Decide whether refunds and discounts need currencySign: "accounting"
- Make rounding policy visible in test names
- Add Node-runnable tests for currency formatting behavior
관련 글과 수익화 체크
다국어 URL과 번역 파일은Claude Code i18n 구현, 날짜와 시간대는날짜/시간 처리, 유료 구독 흐름은Stripe 구독 구현을 함께 보세요.
ClaudeCodeLab은 과금, 인증, 보안처럼 AI가 대충 처리하기 쉬운 경계에 대한 체크리스트와 프롬프트를 제공합니다. 가격 페이지를 Claude Code에 맡기기 전에 먼저 금액 데이터가amountMinor + currency + locale로 분리되어 있는지 확인하세요.
이 글의 예제는 로컬 Node.js 24에서 실행해 JPY/USD/EUR/BRL/INR/IDR의 소수 자리, 통화 part, USD 회계 표기, 정수 계산, 통화 불일치 오류를 확인했습니다. ICU 데이터에 따라 문자열의 공백이나 기호가 달라질 수 있으므로 운영 테스트는 스냅샷 하나보다 정책 검증에 집중하는 편이 안전합니다.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.
관련 글
Obsidian 메모를 CLAUDE.md로 바꾸는 Claude Code 워크플로
Obsidian 작업 메모를 CLAUDE.md 운영 노트로 정리해 Claude Code 세션의 문맥 반복을 줄입니다.
Claude Code Revenue CTA Routing: 글에서 PDF, Gumroad, 상담으로 보내기
독자 의도에 따라 무료 PDF, Gumroad 상품, 상담으로 나누는 Claude Code CTA 설계입니다.
Claude Code 팀 인계 규칙: 리뷰 증거, 권한, 롤백, 수익 경로까지 넘기는 법
Claude Code 작업을 팀에 넘길 때 필요한 증거, 권한 규칙, 롤백, 무료 PDF, Gumroad, 상담 경로 체크리스트.