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");
실제 사용 사례(Use case)
첫 번째는 다중 통화 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 column, API response, 결제 webhook, 관리자 화면, 이메일, PDF, CSV, 분석 event까지 검색하세요. 각 위치에서 minor unit을 저장하는지 표시 문자열을 저장하는지, currency가 함께 있는지, locale을 누가 선택하는지 기록합니다. 어느 한 출구에서 다시 나누거나 기호를 붙이면 같은 주문이 화면과 인보이스에서 다르게 보일 수 있습니다.
두 번째 단계는 구현보다 테스트 표를 먼저 만드는 것입니다. JPY와 IDR의 소수 0자리, USD와 EUR의 2자리, BRL의 구분자, INR의 숫자 묶음, 음수 환불, 다른 통화끼리 더했을 때의 오류를 포함하세요. 완성 문자열만 snapshot으로 비교하면 Node나 브라우저의 ICU 데이터가 바뀔 때 불필요하게 실패합니다. resolvedOptions().maximumFractionDigits, formatToParts()의 currency part, minor unit 정수, 합의한 반올림 결과를 각각 검증하는 편이 안정적입니다.
세 번째 단계는 기존 데이터의 작은 표본으로 migration을 검증하는 것입니다. DB에"₩1,980"이나"$19.99"가 이미 들어 있다면 운영 테이블에서 바로 일괄 변환하지 마세요. 개인정보가 없는 합성 표본으로 통화별 parser를 실행하고, currency 누락, 해석 실패, 원래 합계와의 차이를 별도 목록으로 만듭니다. 사람이 규칙을 승인한 뒤에만 되돌릴 수 있는 migration을 실행하고, 재실행해도 두 번 환산되지 않도록 idempotency를 보장해야 합니다.
네 번째 단계는 배포 후 대조입니다. 고정된 합성 주문 몇 개를 준비해 frontend, API, PDF, email, CSV가 같은 금액 의미를 출력하는지 확인하세요. 운영 로그에는 알 수 없는 currency, 포맷 실패, 합계 불일치만 남기고 고객 이름이나 결제 수단 전체를 기록하지 않습니다. 표시 오류라면 formatter를 롤백할 수 있지만 저장 값이 틀렸다면 쓰기를 먼저 멈추고 사람이 수정 범위를 결정해야 합니다.
이 절차에서 Claude Code는 호출 지점 검색, 공통 helper 작성, 합성 테스트, migration 초안을 맡습니다. 제품·재무·운영 담당자는 가격, 세금, 환불, 반올림 시점, 실제 데이터 변경을 승인합니다. 완료 기준은 “통화처럼 보이는 문자열”이 아니라 하나의 minor unit 값이 모든 출구에서 같은 비즈니스 의미를 유지하고, 차이를 테스트로 설명할 수 있는 상태입니다.
Pitfall: 흔한 실패와 수정 방법
포맷된 통화 문자열을 DB에 저장하지 마세요. locale과 currency를 같은 것으로 보지 마세요. 한국어 UI에서도 USD 결제를 보여줄 수 있고, 미국 팀이 EUR 인보이스를 확인할 수도 있습니다. 모든 통화가 소수 두 자리라고 가정하지 마세요. roundingMode는 표시 단계의 옵션이지 청구 정책 전체가 아닙니다. 통화 기호를 정규식으로 파싱하지 말고, 필요한 경우formatToParts를 사용하세요.
Claude Code에 맡길 범위와 사람이 승인할 범위
Claude Code는 포맷터 helper를 만들고, 흩어진 toLocaleString 호출을 바꾸고, 합성 금액으로 테스트를 추가하고, formattedPrice나 displayAmount 같은 의심 필드를 표시할 수 있습니다. amountMinor, currency, locale을 입력으로 받는 가격 컴포넌트, 인보이스 표시, CSV 내보내기 diff도 먼저 맡기기 좋습니다.
사람이 승인해야 하는 범위는 가격, 세금, 환불 규칙, 실제 고객 데이터 마이그레이션, 고객에게 발송되는 이메일, 결제 서비스 설정입니다. 에이전트가 환율, 법적 인보이스 규칙, 운영 데이터를 추측하게 두지 마세요. 합성 데이터로 Node 스크립트를 먼저 실행하고, 재무나 지원 팀이 diff를 확인한 뒤 운영 반영을 결정해야 합니다.
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 구독 구현을 함께 보세요.
주요 CTA: billing, auth, security, release review를 단계별로 점검하려면Claude Code training 경로를 사용하세요. ClaudeCodeLab은 과금, 인증, 보안처럼 AI가 대충 처리하기 쉬운 경계에 대한 체크리스트와 프롬프트를 제공합니다. 가격 페이지를 Claude Code에 맡기기 전에 먼저 금액 데이터가amountMinor + currency + locale로 분리되어 있는지 확인하세요.
실제 확인
이 글의 예제는 로컬 Node.js 24에서 실행해 JPY/USD/EUR/BRL/INR/IDR의 소수 자리, 통화 part, USD 회계 표기, 정수 계산, 통화 불일치 오류를 확인했습니다. ICU 데이터에 따라 문자열의 공백이나 기호가 달라질 수 있으므로 운영 테스트는 스냅샷 하나보다 정책 검증에 집중하는 편이 안전합니다.
관련 글
Claude Code 날짜와 시간 처리 실전 가이드
Claude Code로 타임존, DST, Intl, DB 저장 정책을 안전하게 구현하고 테스트하는 실무 가이드.
Claude Code로 i18n 구현하기: Next.js 다국어 실전 가이드
Claude Code로 Next.js i18n을 구현하는 방법을 next-intl 설정, 번역 검증, 실전 사례와 함정까지 정리했습니다.
호텔 취소 답변을 Claude Code로 정리하기: 예약번호와 개인정보를 넘기지 않는 확인표
호텔 취소 및 일정 변경 답변에서 예약번호, 이름, 환불 판단을 AI에 넘기지 않는 점검 절차.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.