도쿄의 고객이 19.99달러 구독을 결제합니다. 코드가 USD/JPY 환율을 곱해 2942.82785를 얻고, 이를 데이터베이스에 기록한 뒤 결제 대행사로 전송합니다. 대행사는 이를 거부합니다 — 혹은 더 나쁘게, 받아들여 100배를 청구합니다. 엔에는 소수 자릿수가 없는데, 코드는 한 번도 묻지 않았습니다.
통화 반올림은 프로덕션에 도달하기 전까지는 사소해 보이는 문제 중 하나입니다. 이는 서식의 문제가 아니라 정확성의 문제입니다. 통화마다 소수 자릿수가 다르고, 부동소수점 연산은 금액을 조용히 망가뜨리며, 통화를 환산하는 순간 의도적으로 내려야 하는 반올림 결정이 생깁니다. 이 가이드는 실제로 중요한 규칙을 다룹니다. 각 통화의 소수 자릿수, 금액을 정수로 저장해야 하는 이유, 어떤 반올림 모드를 선택할지, 그리고 월말에 원장이 맞아떨어지도록 환전을 어떻게 반올림할지입니다.
보조단위: 통화별 소수 자릿수는 몇 자리인가?
통화의 보조단위는 거래 가능한 최소 세분 단위입니다. 미국 달러는 센트이므로 USD는 소수 두 자리이고 19.99달러는 1999센트입니다. 이것이 결제 대행사가 기대하는 표현이며, 여러분의 데이터베이스가 사용해야 할 표현입니다.
ISO 4217 — USD, JPY 같은 세 글자 코드를 제공하는 바로 그 표준 — 은 각 통화에 보조단위 지수도 부여합니다. 대부분의 개발자는 이 지수가 항상 2라고 가정합니다. 그렇지 않으며, 이 가정이 이 분야에서 가장 비싼 버그입니다.
소수 자릿수가 없는 통화
이 통화들은 유통되는 보조단위가 없으므로, 여러분이 보내는 금액이 곧 정수 단위 수입니다.
- JPY — 일본 엔
- KRW — 대한민국 원
- VND — 베트남 동
- CLP — 칠레 페소
- ISK — 아이슬란드 크로나
- XAF / XOF / XPF — CFA 프랑 및 CFP 프랑
- UGX — 우간다 실링
- PYG — 파라과이 과라니
- RWF, GNF, KMF, DJF, VUV — 그 외 소액권 통화 몇 가지
JPY를 소수 두 자리 통화로 다루고 대행사로 보내기 전에 100을 곱했다면, 고객에게 의도한 금액의 100배를 청구한 것입니다.
소수 세 자리 통화
일곱 개 통화는 100분의 1이 아니라 1000분의 1로 세분됩니다.
- KWD — 쿠웨이트 디나르 (1000 필스)
- BHD — 바레인 디나르 (1000 필스)
- OMR — 오만 리알 (1000 바이사)
- JOD — 요르단 디나르 (1000 필스)
- TND — 튀니지 디나르 (1000 밀림)
- IQD — 이라크 디나르 (1000 필스)
- LYD — 리비아 디나르 (1000 디르함)
여기서는 실패가 반대 방향으로 갑니다. KWD를 소수 두 자리로 다루면 의도한 금액의 10분의 1만 청구하게 됩니다. KWD 12.500 청구서가 KWD 1.250이 되어버립니다.
ISO 4217에는 소수 네 자리 항목도 있습니다 — 칠레의 unidad de fomento(CLF)와 우루과이의 unidad previsional(UYW)입니다. 현금이라기보다 지수 연동 회계 단위지만, 시스템이 임의의 ISO 코드를 받아들인다면 이것들도 견뎌야 합니다.
표준과 대행사가 어긋날 때
이것은 나머지를 모두 제대로 한 팀들이 걸려드는 함정입니다. 결제 대행사는 때때로 운영상의 이유로 ISO 4217에서 벗어납니다. 예컨대 Adyen은 CLP, CVE, IDR, ISK가 자사 API에서 표준과 다른 소수 자릿수를 갖는다고 문서화합니다 — ISK는 ISO 4217에서 소수 0자리지만 Adyen에는 두 자리로 제출해야 합니다.
원칙은 이렇습니다. 반올림 테이블은 여러분이 대화하는 시스템의 속성이지 보편 상수가 아닙니다. 연동마다 테이블을 하나씩 두고, ISO 4217을 기본값으로 삼되, 상대 문서가 지시하는 곳에서는 대행사별로 덮어쓰세요. 절대 100을 하드코딩하지 마세요.
금액을 float로 저장하지 마세요
반올림 논의에 앞서 기초부터. 이진 부동소수점은 대부분의 십진 소수를 정확히 표현하지 못합니다.
0.1 + 0.2 // 0.30000000000000004
1.005 * 100 // 100.49999999999999
19.99 * 147.2150 // 2942.8278499999997이 꼬리 자릿수들은 겉치레가 아닙니다. 잘못된 시점에 Math.round()에 통과시키면 보조단위 하나만큼 어긋난 금액이 나오고, 그것만으로 정산 대사가 실패합니다.
두 가지 규칙이 거의 모든 경우를 덮습니다.
- 금액은 보조단위 정수로 저장하세요.
amount_minor BIGINT컬럼과currency CHAR(3)컬럼.{ amount_minor: 1999, currency: "USD" }는 모호함이 없고 Stripe, Adyen을 비롯한 대부분의 대행사가 이미 기대하는 형태입니다. - 연산은 정수나 십진 타입으로 하세요. Python의
decimal.Decimal, Java의BigDecimal, PostgreSQL의NUMERIC, 또는 정수 연산을 감싼 JavaScript 금액 라이브러리. float는 환율 자체에만 남겨두고, 그마저도 곱셈 시점까지만 씁니다.
이 계층을 처음부터 설계한다면, 다중 통화 원장 설계 가이드가 스키마 결정을 더 깊이 다룹니다.
환산 파이프라인: 보조단위로 들어와 보조단위로 나간다
통화 환산은 정확히 네 단계이고, 반올림은 3단계에 속합니다 — 마지막에 딱 한 번.
- 원본 금액을 보조단위에서 십진값으로 변환합니다.
- 전체 정밀도의 환율을 곱합니다.
- 대상 통화의 보조단위 지수로 반올림합니다.
- 다시 정수 보조단위로 변환합니다.
JavaScript로는 이렇게 되며, 통화별 테이블이 일을 합니다.
// Minor unit exponents. Seed from ISO 4217, override per payment provider.
const MINOR_UNITS = {
USD: 2, EUR: 2, GBP: 2, CHF: 2, CAD: 2, AUD: 2, CNY: 2, INR: 2,
JPY: 0, KRW: 0, VND: 0, CLP: 0, ISK: 0, XAF: 0, XOF: 0, XPF: 0,
KWD: 3, BHD: 3, OMR: 3, JOD: 3, TND: 3, IQD: 3, LYD: 3,
};
function exponentFor(currency) {
const e = MINOR_UNITS[currency];
if (e === undefined) throw new Error(`Unknown minor unit for ${currency}`);
return e;
}
/**
* Convert an integer minor-unit amount from one currency to another.
* Returns an integer in the target currency's minor units.
*/
function convertMinor(amountMinor, from, to, rate) {
const fromExp = exponentFor(from);
const toExp = exponentFor(to);
const decimalAmount = amountMinor / 10 ** fromExp; // 1999 -> 19.99
const converted = decimalAmount * rate; // full precision, no rounding yet
return Math.round(converted * 10 ** toExp); // single rounding step
}
convertMinor(1999, "USD", "JPY", 147.2150); // 2943 (¥2,943)
convertMinor(1999, "USD", "KWD", 0.30590); // 6115 (KWD 6.115)
convertMinor(1999, "USD", "EUR", 0.9241); // 1847 (€18.47)이 함수가 하지 않는 일에 주목하세요. 환율을 반올림하지 않고, 중간값을 반올림하지 않으며, 소수 두 자리를 가정하지 않습니다. 여기서 Math.round는 양수에 대한 반올림(half-up)입니다 — 체크아웃에는 괜찮지만, 규제 대상 용도에 쓰기 전에 다음 절을 읽으세요.
반올림 모드 고르기
"소수 두 자리로 반올림"은 명세가 아닙니다. 동점을 처리하는 방법은 적어도 다섯 가지가 있고, 금융 시스템은 여러분이 무엇을 고르는지 신경 씁니다.
| 모드 | 2.5 → | 3.5 → | −2.5 → | 전형적 용도 |
|---|---|---|---|---|
| 올림 반올림 (half up) | 3 | 4 | −3 | 소비자 가격, 체크아웃 합계 |
| 짝수 반올림 (은행가 반올림) | 2 | 4 | −2 | 회계, 이자, 세금, 리포팅 |
| half down | 2 | 3 | −2 | 드묾. 레거시 금융 코드에서 가끔 |
| 올림 (ceiling) | 3 | 4 | −2 | 절대 과소 징수하면 안 되는 수수료 |
| 내림 (floor / 절사) | 2 | 3 | −3 | 절대 과다 지급하면 안 되는 정산금 |
Math.round()의 동작입니다. 직관적이며 고객이 곧 보게 될 가격에 적합합니다.Half even, 곧 은행가 반올림은 정확히 절반인 값을 가장 가까운 짝수 자릿수로 보냅니다. 많은 거래에 걸쳐 half-up이 만드는 체계적 상향 편향을 상쇄하며, 그래서 회계 시스템, Python decimal 모듈, 그리고 IEEE 754 자체의 기본값입니다. 수천 건의 환산 금액을 매출 리포트로 집계한다면 half-up은 합계를 조용히 부풀립니다. half-even은 그러지 않습니다.
올림과 내림은 비대칭 위험을 위해 존재합니다. 판매자에게 정산하는 마켓플레이스는 보유액보다 많이 분배하지 않도록 모든 정산을 내림 처리할 수 있습니다. 차액은 반올림 계정으로 갑니다.
Python은 이 선택을 명시적으로 만듭니다. 그것이 올바른 설계입니다.
from decimal import Decimal, ROUND_HALF_EVEN, ROUND_HALF_UP
MINOR_UNITS = {"USD": 2, "EUR": 2, "JPY": 0, "KWD": 3}
def convert_minor(amount_minor: int, src: str, dst: str,
rate: str, mode=ROUND_HALF_EVEN) -> int:
"""Convert integer minor units to integer minor units, exactly once."""
src_exp, dst_exp = MINOR_UNITS[src], MINOR_UNITS[dst]
amount = Decimal(amount_minor) / (Decimal(10) ** src_exp)
converted = amount * Decimal(rate) # rate passed as a string, not a float
quantum = Decimal(1).scaleb(-dst_exp) # 0.01, 1, or 0.001
rounded = converted.quantize(quantum, rounding=mode)
return int(rounded.scaleb(dst_exp))
convert_minor(1999, "USD", "JPY", "147.2150") # 2943
convert_minor(1999, "USD", "KWD", "0.30590") # 6115환율을 Decimal에 문자열로 넘기는 것이 중요합니다. Decimal(0.9241)은 float의 오차를 물려받지만 Decimal("0.9241")은 그렇지 않습니다.
실제로 돈이 새는 세 가지 반올림 버그
1. 곱하기 전에 환율을 반올림하기
환율은 보통 유효 소수 네다섯 자리에서 여섯 자리를 갖고, 이를 잘라내는 것은 무해하지 않습니다. USD/JPY 147.2150에 10,000달러 송금을 봅시다.
- 전체 환율:
10000 × 147.2150 = ¥1,472,150 - 소수 두 자리로 반올림한 환율(147.21):
10000 × 147.21 = ¥1,472,100
단 한 건의 거래에서 ¥50 차이가 납니다. 순전히 환율을 쓰기 전에 서식화했기 때문입니다. 공급자가 반환하는 정밀도 그대로 환율을 저장하고, 결과 금액만 반올림하며, 감사에 대비해 실제 사용한 환율을 거래와 함께 보존하세요. 환율 API는 데이터를 어디서 얻는가 가이드가 애초에 그 정밀도가 왜 의미 있는지 설명합니다.
2. 다단계 환산에서 두 번 반올림하기
USD → EUR → JPY로 경유하면서 EUR 단계에서 반올림하면, 두 번째 곱셈이 증폭시킬 정밀도를 버린 셈입니다. USD/EUR 0.9241, EUR/JPY 159.3063으로 12.34달러를 환산하면:
- 직접:
12.34 × 147.2150 = 1816.63→ ¥1,817 - 반올림된 EUR 구간 경유:
12.34 × 0.9241 = 11.4034→ €11.40으로 반올림 →11.40 × 159.3063 = 1816.09→ ¥1,816
불필요한 반올림 한 번 때문에 1엔. 5만 건짜리 정산 배치라면 그것은 대사 티켓이 됩니다. 직접 통화쌍을 쓸 수 있으면 쓰고, 삼각 환산이 필요하면 중간값을 전체 정밀도로 유지하세요. 원리는 교차 환율 설명을 참고하세요.
3. 품목 합이 총액과 맞지 않음
청구서의 각 줄을 독립적으로 반올림하면 부분의 합이 항상 반올림된 총액이 되지는 않습니다. 고전적인 사례는 분할입니다.
$10.00 split three ways
10.00 / 3 = 3.3333...
→ 3.33 + 3.33 + 3.33 = 9.99 ✗ one cent missing해법은 반올림이 아니라 배분입니다. 총액을 한 번 반올림한 다음 각 부분에 나누되, 나머지를 보조단위 하나씩 배분하세요.
/**
* Split an integer minor-unit total into `n` parts whose sum is exactly the total.
* Remainder units are distributed to the earliest parts (largest-remainder method).
*/
function allocate(totalMinor, ratios) {
const sum = ratios.reduce((a, b) => a + b, 0);
const shares = ratios.map(r => Math.floor((totalMinor * r) / sum));
let remainder = totalMinor - shares.reduce((a, b) => a + b, 0);
for (let i = 0; remainder > 0; i = (i + 1) % shares.length, remainder--) {
shares[i] += 1;
}
return shares;
}
allocate(1000, [1, 1, 1]); // [334, 333, 333] → sums to exactly 1000
allocate(9247, [3, 2, 1]); // [4624, 3082, 1541] → sums to exactly 9247환전 후에도 같은 패턴을 쓰세요. 청구서 총액을 환산해 반올림한 다음, 그 총액을 각 줄에 배분합니다. 각 줄은 총액에서 파생되었으므로 항상 맞아떨어집니다. 이는 다중 통화 청구와 일할 계산이 있는 SaaS 빌링에서 특히 중요합니다. 1센트의 어긋남이 고객이 보는 PDF에 그대로 드러나기 때문입니다.
현금 반올림은 별개의 규칙
통화의 보조단위는 기록될 수 있는 최소 금액을 알려줍니다. 현금으로 지불될 수 있는 최소 금액을 항상 알려주지는 않습니다. 여러 나라가 최소 동전을 회수하고 계산대에서 현금 결제를 반올림합니다.
- 스위스 — 현금은 가장 가까운 0.05 CHF로 반올림
- 캐나다 — 1센트 동전이 2013년에 회수됨. 현금은 가장 가까운 5센트로 반올림
- 스웨덴 — 현금은 가장 가까운 1크로나로 반올림
- 네덜란드 — 현금은 가장 가까운 5센트로 반올림
결정적으로 이는 청구서가 아니라 현금 수수에 적용됩니다. CHF 12.32짜리 스위스 청구서는 여전히 12.32로 기록되고, 현금 결제만 12.30으로 반올림되며, 0.02 차이는 반올림 조정으로 계상됩니다. POS 소프트웨어를 만든다면 현금 반올림을 결제에 적용되는 별도의 후속 단계로 모델링하세요. 저장 금액에 섞어 넣으면 전자 거래와 현금 거래가 어긋납니다.
서식화는 계산이 아니라 마지막 단계
연산이 끝나면 표시는 로케일을 아는 포매터에 맡기세요. Intl.NumberFormat은 이미 모든 통화의 소수 자릿수, 기호 위치, 구분자를 알고 있습니다.
function formatMinor(amountMinor, currency, locale = "en-US") {
const exp = exponentFor(currency);
return new Intl.NumberFormat(locale, {
style: "currency",
currency,
}).format(amountMinor / 10 ** exp);
}
formatMinor(1999, "USD"); // "$19.99"
formatMinor(2943, "JPY", "ja-JP"); // "¥2,943"
formatMinor(6115, "KWD"); // "KWD 6.115"
formatMinor(1847, "EUR", "de-DE"); // "18,47 €"실무적으로 두 가지. 첫째, Intl.NumberFormat 인스턴스는 생성 비용이 큽니다 — 행마다 만들지 말고 로케일-통화 쌍마다 하나씩 캐시하세요. 둘째, 마지막 줄의 10 ** exp 나눗셈은 float가 금액 값에 닿아도 되는 유일한 지점이며, 그것도 결과가 즉시 문자열이 되기 때문입니다.
Finexly API로 한데 묶기
환율을 전체 정밀도로 가져와 한 번 환산하고, 한 번 반올림하고, 사용한 환율을 저장하세요.
curl "https://api.finexly.com/v1/latest?base=USD&symbols=JPY,KWD,EUR" \
-H "Authorization: Bearer YOUR_API_KEY"{
"success": true,
"base": "USD",
"timestamp": 1755244800,
"rates": {
"JPY": 147.2150,
"KWD": 0.30590,
"EUR": 0.9241
}
}async function quote(amountMinor, from, to) {
const res = await fetch(
`https://api.finexly.com/v1/latest?base=${from}&symbols=${to}`,
{ headers: { Authorization: `Bearer ${process.env.FINEXLY_API_KEY}` } }
);
const data = await res.json();
const rate = data.rates[to];
return {
amount_minor: convertMinor(amountMinor, from, to, rate),
currency: to,
rate, // persist the exact rate used
rate_timestamp: data.timestamp, // and when it was captured
};
}
await quote(1999, "USD", "JPY");
// { amount_minor: 2943, currency: "JPY", rate: 147.215, rate_timestamp: 1755244800 }거래 행에 rate와 rate_timestamp를 저장해 두는 것이 6개월 뒤의 분쟁에 답할 수 있게 해줍니다. 엔드포인트와 파라미터 전체 내용은 Finexly API 문서에 있고, 요청 사이에 환율을 캐시한다면 캐싱과 오류 처리에 대한 정리가 신선도 트레이드오프를 다룹니다.
테스트 체크리스트
금액 버그는 아무도 테스트를 쓰지 않는 곳에 숨습니다. 최소한 다음을 다루세요.
- 소수 0자리 대상 통화 — JPY나 KRW로 환산해 결과에 소수부가 없음을 검증합니다.
- 소수 3자리 대상 통화 — KWD나 BHD로 환산해 세 자리가 살아남는지 검증합니다.
- 정확히 절반인 값 — 선택한 반올림 모드를 음수를 포함해 양방향으로 검증합니다.
- 왕복 오차 — USD → EUR → USD로 환산해 결과가 보조단위 하나 이내인지(같은지가 아니라) 검증합니다.
- 배분 불변식 — 나눈 부분의 합이 1개부터 100개까지 항상 정확히 총액이 되는지 검증합니다.
- 알 수 없는 통화 코드 — 조용히 소수 두 자리로 넘어가지 않고 예외를 던지는지 검증합니다.
- 매우 큰 금액 — JavaScript에서
Number.MAX_SAFE_INTEGER를 넘어 정밀도가 손실되지 않는지 검증합니다. IDR이나 VND를 대규모로 다룬다면BigInt를 쓰세요.
자주 묻는 질문
통화마다 소수 자릿수는 몇 자리인가요?
대부분 두 자리입니다. 스무 종 남짓은 아예 없고(JPY, KRW, VND, CLP, ISK 포함), 일곱 종은 세 자리입니다(KWD, BHD, OMR, JOD, TND, IQD, LYD). ISO 4217이 권위 있는 출처지만, 운영상의 이유로 벗어나는 곳이 있으니 결제 대행사의 표도 확인하세요.
환율을 반올림해야 하나요, 환산된 금액을 반올림해야 하나요?
환산된 금액만입니다. 환율은 공급자가 반환하는 전체 정밀도로 유지하고, 곱한 다음, 결과를 대상 통화의 보조단위로 딱 한 번 반올림하세요. 곱하기 전에 환율을 반올림하면 거래 규모에 비례하는 오차가 생깁니다.
half-up과 은행가 반올림의 차이는 무엇인가요?
half-up은 정확히 절반인 값을 항상 0에서 멀어지게 보냅니다(2.5 → 3). 은행가 반올림(짝수 반올림)은 가장 가까운 짝수 자릿수로 보내며(2.5 → 2, 3.5 → 4), 많은 금액을 집계할 때 생기는 체계적 상향 편향을 없앱니다. 고객에게 보이는 가격에는 half-up을, 회계와 리포팅에는 half-even을 쓰세요.
환산된 품목이 환산된 총액과 왜 맞지 않나요?
각 줄을 독립적으로 반올림해 오차가 누적되기 때문입니다. 총액을 한 번 반올림한 뒤 최대잉여법으로 각 줄에 배분하세요. 그러면 부분의 합이 구조적으로 전체와 일치합니다.
금액을 그냥 소수 두 자리 float로 저장하면 안 되나요?
안 됩니다. 이진 부동소수점은 0.1 같은 값을 정확히 표현하지 못해 덧셈과 곱셈에서 오차가 쌓이고 결국 반올림 판정을 뒤집습니다. 보조단위 정수로 저장하거나 정확한 십진 타입을 쓰세요. 이는 이론적 우려가 아니라 1센트 차이 대사 실패의 가장 흔한 근본 원인입니다.
전체 정밀도의 환율을 받아보세요
올바른 반올림은 신뢰할 수 있는 환율과, 버리지 않은 정밀도에서 시작합니다. 무료 Finexly API 키 받기 — 신용카드가 필요 없습니다. 170개 이상 통화에 대해 월 1,000회 무료 요청으로 시작하고, 통화 변환기로 계산을 점검하고, 사용량이 늘면 요금제를 확인하세요.
Explore More
Vlado Grigirov
Senior Currency Markets Analyst & Financial Strategist
Vlado Grigirov is a senior currency markets analyst and financial strategist with over 14 years of experience in foreign exchange markets, cross-border finance, and currency risk management. He has wo...
View full profile →