숫자 표시와 입력: 천 단위 구분·소수점 쉼표·만/억 로캘별 처리
숫자 강좌 5. .NET·Java 는 "1,5" 를 15 로, PHP 는 전각 123 을 0 으로 읽었다. 만·억 표시, ₩ 반올림 차이, 입력값을 안전하게 정규화하는 법을 실행 결과로 확인.
시리즈: 숫자·금액 계산 강좌 5/6 (고급). 금액이나 수량을 화면에 보여 주거나, 폼에 입력된 숫자를 읽는 개발자를 위한 글입니다. 다 읽으면 로캘별로 어떤 포매터를 믿어도 되는지, 어떤 파서가 "1,5" 를 말없이 15 로 바꾸는지, 해외 사용자의 "1.234,56" 과 전각 "123" 을 어떻게 정규화해야 의도한 숫자가 되는지 알 수 있습니다.
국내 서비스만 만들 때는 number_format 이나 toLocaleString() 한 줄로 끝납니다. 문제는 해외 결제·정산을 붙이거나, 스페인·남미 거래처가 보낸 CSV 의 금액을 읽거나, 일본어 IME 에서 넘어온 전각 숫자를 받을 때 터집니다. 1강은 0.1 + 0.2, 4강은 원을 정수로 들고 가는 이야기였고, 이번에는 숫자 양쪽의 문자열을 다룹니다. Apple Silicon Mac (M1 Pro) 에서 Python 3.9.6(비교용 3.12.14), intl 이 들어 있는 PHP 8.5.6(ICU 78.3), macOS 에 들어 있는 JavaScriptCore 셸 jsc, Java 26, .NET 10.0.14, Swift 6.4.2 를 실제로 돌렸습니다. Node.js 와 브라우저는 돌리지 않았습니다.
같은 숫자, 로캘별 출력
로캘(locale)은 언어와 지역 관습의 묶음으로 ko-KR, en-US, es-ES 처럼 씁니다. 소수점 기호, 천 단위 구분 기호, 통화 기호 위치, 통화의 소수 자릿수를 로캘이 정합니다. jsc 의 Intl.NumberFormat 으로 1234567.891 을 출력했습니다.
const x = 1234567.891;
for (const [l, c] of [["ko-KR","KRW"],["en-US","USD"],["de-DE","EUR"],["es-ES","EUR"],["ja-JP","JPY"],["en-IN","INR"]])
print(l, new Intl.NumberFormat(l).format(x), new Intl.NumberFormat(l, {style: "currency", currency: c}).format(x));
| 로캘 | 숫자 | 통화 |
|---|---|---|
| ko-KR | 1,234,567.891 | ₩1,234,568 |
| en-US | 1,234,567.891 | $1,234,567.89 |
| de-DE | 1.234.567,891 | 1.234.567,89 € |
| es-ES | 1.234.567,891 | 1.234.567,89 € |
| ja-JP | 1,234,567.891 | ¥1,234,568 |
| en-IN | 12,34,567.891 | ₹12,34,567.89 |
원화(KRW)는 소수 단위가 없어서 통화 형식에서 .891 이 반올림돼 ₩1,234,568 이 됐습니다. 유로는 독일·스페인에서 숫자 뒤에, 줄바꿈 없는 공백 U+00A0 을 사이에 두고 붙습니다. 인도는 첫 천 단위 위로 두 자리씩 끊습니다. toFixed(2) 와 정규식으로 직접 만들면 전부 틀립니다. PHP NumberFormatter, Java NumberFormat, .NET ToString("C", culture), Swift NumberFormatter, Python locale.currency 도 같은 모양을 냈습니다.
Python 의 locale 모듈은 ICU 가 아니라 운영체제의 C 로캘을 씁니다. 이 Mac 에는 locale -a 에 ko_KR.UTF-8, es_ES.UTF-8 이 있어 동작했지만, 가벼운 리눅스 컨테이너에는 로캘이 없는 경우가 많아 locale.Error: unsupported locale setting 이 납니다. 서버에서 ICU 계열 라이브러리로 포맷하는 이유 중 하나입니다.
처리계마다 다른 결과
같은 로캘이어도 결과가 다릅니다. 이번 실행에서 나온 차이입니다.
| 경우 | 출력 | 낸 처리계 |
|---|---|---|
| KRW 2.5 | ₩3 |
jsc (0에서 먼 쪽으로 반올림) |
| KRW 2.5 | ₩2 |
.NET (짝수 반올림) |
| JPY 1234.5 | ¥1,235 |
jsc |
| JPY 1234.5 | 1,234 |
PHP, Python, Java, .NET |
| 엔 기호 | ¥ (U+00A5) |
jsc, .NET, Swift |
| 엔 기호 | ¥ (U+FFE5, 전각) |
PHP intl, Java, Python ja_JP |
es-ES 의 1234 |
1234 |
jsc, Swift |
es-ES 의 1234 |
1.234 |
PHP intl, Java, .NET, Python |
반올림 차이는 3강 내용 그대로입니다. 공공요금이나 급여 명세처럼 10원·100원 단위 절사 규칙이 있는 계산이라면, 그 처리는 계산 단계에서 정수로 끝내고 포매터에는 이미 정리된 정수만 넘겨야 합니다. 포매터 기본 반올림은 처리계마다 다르기 때문입니다.
교훈은 두 가지입니다. 포맷한 문자열은 저장하지 않습니다(저장은 숫자, 포맷은 표시 직전). 테스트에서 포맷 결과를 문자열로 비교하려면 처리계와 ICU 버전을 고정하거나, formatToParts 처럼 부품 단위로 비교합니다.
만·억 단위 표시
한국어와 일본어는 큰 수를 네 자리씩 읽습니다. compact 표기가 이 용도입니다.
PHP DECIMAL_COMPACT_SHORT / jsc notation:"compact"
ko-KR 1.2만 | 123만 | 1.2억 | 1.2조
ja-JP 1.2万 | 123万 | 1.2億 | 1.2兆
en-US 12K | 1.2M | 123M | 1.2T
(입력은 12345, 1234567, 123456789, 1234567890123). PHP 와 jsc 는 정확히 같았습니다. Java 26 의 getCompactNumberInstance(SHORT) 는 소수를 버려 1만, 1억 을 냈고, es-MX 에서는 1235 k | 123457 k | 1234567890 k 라는 대시보드에 올리기 싫은 결과였습니다. Swift .notation(.compactName) 은 1.2억 이었습니다.
compact 는 반올림한 값이라 그래프 축이나 "누적 1.2억 건" 같은 요약에 씁니다. 부동산 가격이나 견적서처럼 정확한 금액은 직접 조립합니다. 한국어는 단위 사이를 띄우는 표기가 흔해서 함수를 하나 더 둡니다.
def man_ko(n): # 1억 2,345만 6,789
parts, i, u = [], 0, ("", "만", "억", "조")
while n:
n, r = divmod(n, 10000)
if r: parts.append(f"{r:,}{u[i]}")
i += 1
return " ".join(reversed(parts)) or "0"
print(man_ko(123456789), man_ko(350000000), man_ko(1234567890123))
1억 2,345만 6,789 3억 5,000만 1조 2,345억 6,789만 123
계약서나 영수증의 한글 금액 표기에는 ICU 의 스펠아웃이 있습니다. PHP NumberFormatter::SPELLOUT 은 ko_KR 에서 123456789 를 일억 이천삼백사십오만 육천칠백팔십구 로 냈습니다. "금 일억이천…원정" 같은 서식 관례(띄어쓰기, "원정")는 따로 맞춰야 합니다.
파싱: "1,5" 는 대부분 15 가 된다
포맷 실수는 화면이 어색해질 뿐이지만, 파싱 실수는 금액이 바뀝니다. 같은 문자열을 언어별 기본 변환 함수에 넣었습니다.
| 입력 | Python float |
jsc parseFloat |
jsc Number |
PHP (float) |
Java parseDouble |
.NET TryParse (Number, Invariant) |
|---|---|---|---|---|---|---|
1,5 |
ValueError | 1 | NaN | 1.0 | NumberFormatException | true, 15 |
1.234,56 |
ValueError | 1.234 | NaN | 1.234 | NumberFormatException | false |
12abc |
ValueError | 12 | NaN | 12.0 | NumberFormatException | false |
"" |
ValueError | NaN | 0 | 0.0 | NumberFormatException | false |
123 |
123.0 | NaN | NaN | 0.0 | NumberFormatException | false |
위험한 것은 조용히 지나가는 쪽입니다. parseFloat("1,5") 는 1, PHP (float)"1.234,56" 은 1.234, Number("") 는 0 이라 빈 가격 칸이 0원이 됩니다. PHP 8.5.6 에서 '12abc' + 1 은 Warning: A non-numeric value encountered 를 내면서도 13 을 돌려줬고, 'abc' + 1 은 TypeError: Unsupported operand types: string + int 였습니다.
로캘을 아는 파서도 방심할 수 없습니다.
Java NumberFormat es-MX parse("1,5") -> 15 (pos 3 of 3)
.NET TryParse ja-JP "1,5" -> True 15
.NET double.Parse("1.5") (CurrentCulture = es-ES) -> 15
Python locale.atof("1.5") (es_ES) -> 15.0
Python locale.atof("12.34.56") (es_ES) -> 123456.0
Java NumberFormat es-ES parse("1,234.56") -> 1.234 (pos 5 of 8)
해외 서버의 문화권이 es-ES 로 잡혀 있으면 "1.5" 가 15 가 됩니다. Python locale.atof 는 천 단위 기호를 위치와 상관없이 전부 지워 버려서 12.34.56 도 숫자가 됩니다. Java parse 는 못 읽는 문자에서 멈추고 위치를 돌려주므로, ParsePosition 이 문자열 끝까지 갔는지 확인하지 않으면 숫자 절반만 받아들입니다.
전각 숫자와 다른 문자권 숫자
일본어 IME 를 거친 데이터나 PDF 에서 복사한 텍스트에는 전각 숫자 123(U+FF11~)이 섞입니다. 아랍 문자권의 ٣(U+0663) 같은 숫자도 있습니다.
| 호출 | 결과 |
|---|---|
Python int("123"), int("١٢٣") |
123, 123 |
Python int("²") ("²".isdigit() 는 True) |
ValueError: invalid literal for int() with base 10: '²' |
Java Integer.parseInt("123") |
123 |
Java Double.parseDouble("123") |
NumberFormatException: For input string: "123" |
jsc parseInt("123") |
NaN |
.NET int.Parse("١٢٣") |
FormatException: The input string '١٢٣' was not in a correct format. |
Swift Int("123") |
nil |
PHP (int)"123", is_numeric("123") |
0, false |
PHP 가 가장 위험합니다. 수량 칸에 전각으로 "123" 을 넣은 주문이 0개가 되고, 로그에도 아무것도 남지 않습니다. Python 은 받아 주지만 정규식 \d 도 전각 숫자와 ٣ 에 일치하므로, Python 에서 검증을 통과한 값을 JavaScript 서비스가 NaN 으로 만드는 엇갈림이 생깁니다. isdigit() 은 ² 에서 True 라 검증용으로 쓸 수 없고, int() 가 받는 범위와 맞은 것은 isdecimal() 이었습니다. 전각 문자와 글자 수 이야기는 문자열 길이와 이모지 강좌를 참고하세요.
흔한 실수 → 실제 결과 → 고치는 법
| 실수 | 실제 결과 | 고치는 법 |
|---|---|---|
PHP 에서 (int)$_POST['qty'] |
"123" → 0, "12abc" → 12 |
NFKC 뒤 filter_var(..., FILTER_VALIDATE_INT) |
가격 칸에 parseFloat(input) |
"1,5" → 1, "12abc" → 12 |
엄격한 패턴 먼저, 변환은 그다음 |
빈칸 검사 없는 Number(input) |
"" → 0 |
빈 입력은 명시적으로 거부 |
서버 문화권으로 double.Parse |
es-ES 에서 "1.5" → 15 |
문화권 지정, 기계용 데이터는 Invariant |
사용자 입력에 locale.atof |
es_ES 에서 "1.5" → 15.0 |
로캘별 엄격한 패턴 |
| 테스트에서 포맷 문자열 비교 | ¥ 와 ¥, 1234 와 1.234 |
숫자로 비교하거나 ICU 고정 |
| 값마다 포매터 새로 생성 | 15~66배 느림 (아래 측정) | 한 번 만들어 재사용 |
입력을 안전하게 정규화하기
입력 로캘을 정하고(서버가 아니라 사용자 설정이나 폼에서), 문자를 정규화하고, 그 로캘용 엄격한 패턴 하나와 맞춰 본 다음에야 Decimal 이나 최소 단위 정수(4강)로 바꿉니다.
import re, unicodedata
from decimal import Decimal
SEPS = {"es-ES": (".", ","), "es-MX": (",", "."), "en-US": (",", "."), "ja-JP": (",", "."), "ko-KR": (",", ".")}
PAT = {k: re.compile(r"-?\d{1,3}(?:" + re.escape(g) + r"\d{3})*(?:" + re.escape(d) + r"\d+)?$|-?\d+(?:" + re.escape(d) + r"\d+)?$")
for k, (g, d) in SEPS.items()}
def parse_amount(text, loc):
s = unicodedata.normalize("NFKC", text).strip() # 123 -> 123, 전각 쉼표 -> ","
s = s.replace("−", "-").replace(" ", "").replace(" ", "").replace(" ", "")
s = re.sub(r"^[¥¥₩$€]|[円원€]$", "", s) # 앞이나 뒤의 통화 기호 하나
if not s.isascii():
raise ValueError(f"non-ASCII digits: {text!r}")
if not PAT[loc].match(s):
raise ValueError(f"not a {loc} number: {text!r}")
g, d = SEPS[loc]
return Decimal(s.replace(g, "").replace(d, "."))
ko-KR '12,000원' -> Decimal('12000')
ko-KR '₩3,500' -> Decimal('3500')
ko-KR '−500' -> Decimal('-500')
ko-KR '1,2,3' -> ValueError: not a ko-KR number: '1,2,3'
ja-JP '1,234円' -> Decimal('1234')
ja-JP '١٢٣' -> ValueError: non-ASCII digits: '١٢٣'
es-ES '1.234,56' -> Decimal('1234.56')
es-ES '1.5' -> ValueError: not a es-ES number: '1.5'
es-MX '1,5' -> ValueError: not a es-MX number: '1,5'
중요한 것은 거부된 줄입니다. 스페인식 입력 칸의 1.5 는 "1,5 오타"인지 "1.500 에서 자릿수 빠짐"인지 알 수 없으니 추측하지 않고 오류를 냅니다. −500 의 − 는 하이픈이 아니라 마이너스 기호(U+2212)로, 문서나 엑셀에서 복사하면 자주 섞여 들어옵니다. NFKC(유니코드 정규화 강좌)는 전각 숫자와 전각 쉼표를 반각으로 바꾸지만 ٣ 은 그대로 두므로, isascii() 로 받을지 말지를 정합니다. API 나 CSV 같은 기계끼리의 데이터에는 로캘을 쓰지 말고 점 소수점, 구분 기호 없이 쓰고 Invariant 파서로 읽습니다.
측정: 포맷 비용
보고서에서 10만 건짜리 열을 포맷하면 방법 차이가 드러납니다. 0~10,000,000.00 사이 난수 10만 개(시드 42)를 es-ES 형식으로 포맷하고 5회 중앙값을 냈습니다(Apple Silicon Mac, M1 Pro).
| 방법 | 1건당 |
|---|---|
PHP number_format($x, 2, ',', '.') |
211 ns |
Python 3.9 f"{x:,.2f}" (영어식만) |
309 ns |
PHP NumberFormatter 한 번 생성 |
344 ns |
jsc Intl.NumberFormat 한 번 생성 |
357 ns |
PHP 매번 new NumberFormatter |
5,214 ns |
Python 3.9 locale.format_string (es_ES) |
7,137 ns |
jsc x.toLocaleString("es-ES", {...}) |
23,659 ns |
옵션을 준 toLocaleString 은 매번 포매터를 만드는 것과 같은 속도로, Intl.NumberFormat 재사용보다 66배 느렸습니다. PHP 도 루프 안의 new NumberFormatter 가 재사용보다 15배 느렸습니다. 파싱도 비슷해서 Python 3.9 float() 77 ns, Decimal() 133 ns, 위의 parse_amount 1,584 ns, locale.atof 2,135 ns 였습니다(3.12.14: 51, 113, 1,103, 1,959 ns). 엄격한 파싱은 float() 의 약 20배지만 10만 건에 0.16초입니다.
솔직한 한계와 버전 메모
- 로캘별 출력은 ICU 안의 CLDR 데이터(PHP intl 은 ICU 78.3), JDK 자체 CLDR, Apple Foundation, macOS C 라이브러리에서 옵니다. es-ES 네 자리 처리 같은 세부는 버전마다 바뀔 수 있고, 확인한 것은 위 버전뿐입니다.
- jsc 는 macOS 의 JavaScriptCore 입니다. V8(Node.js, Chrome)과 SpiderMonkey 는 다른 ICU 빌드를 쓰므로 "다른 결과" 표가 달라질 수 있습니다. 실행하지 않았습니다.
Intl.NumberFormat의notation: "compact"는 ECMA-402 2020년판부터입니다. Java es-MX compact 결과는 Java 26 에서 이렇게 나왔다는 기록이지 문서화된 동작이 아닙니다.- Python
locale.setlocale은 프로세스 전체 상태를 바꾸므로 스레드를 쓰는 웹 서버 안에서 바꾸면 위험합니다. - 부가세 계산이나 절사 단위 같은 세무·요금 규칙은 이 글의 범위가 아닙니다. 실무에서는 관련 기관 안내나 세무사에게 확인하세요.
연습 문제
1. 멕시코 사용자가 수량 칸에 2,500 을 입력했는데, .NET 앱의 CultureInfo.CurrentCulture 가 es-ES 입니다. decimal.Parse(input) 와 decimal.Parse(input, CultureInfo.GetCultureInfo("es-MX")) 는 각각 무엇을 돌려줄까요?
2. 위 parse_amount(…, "ko-KR") 에 1,200원 과 1.200 을 넣으면 어떻게 될까요?
정답
1. es-ES 에서는 쉼표가 소수점이라 decimal.Parse("2,500") 이 2.500, 즉 2.5 를 돌려줬습니다. es-MX 를 주면 쉼표가 구분 기호라서 2500 입니다. 서버가 아니라 사용자 로캘을 써야 합니다.
2. 1,200원 은 NFKC 로 1,200원 이 되고 원 을 떼어 Decimal('1200') 입니다. 1.200 은 ko-KR 의 소수점 패턴에 맞아 Decimal('1.200'), 즉 1.2 가 됩니다. 수량이 정수라면 그 칸만 정수 전용 패턴을 써서 점을 거부하세요.
이어서
- 이전: 숫자·금액 4/6: 원을 정수로, Decimal 로
- 다음: 숫자·금액 6/6: 합계 오차, NaN, -0.0
- 시리즈 처음: 1/6: 0.1 + 0.2 가 0.3 이 아닌 이유, 반올림과 부가세: 3/6
- 날짜에도 같은 로캘 문제가 있습니다: 날짜 파싱과 포맷
- 소수점이 쉼표인 CSV 를 엑셀로 열 때: CSV 인코딩·BOM·엑셀
- PHP 형 변환 기초: PHP 타입·문자열·배열
- 전체 강좌: 프로그래밍 학습 로드맵
자주 묻는 질문
"1,5" 가 왜 1.5 가 아니라 15 가 되나요?
영어식 문화권 설정을 쓰는 파서는 쉼표를 천 단위 구분 기호로 보고 건너뜁니다. .NET double.TryParse 는 InvariantCulture, ko-KR, ja-JP, es-MX 에서 15 를 돌려줬고, Java NumberFormat 의 es-MX 도 15 였습니다. 서버 문화권이 es-ES 이면 double.Parse("1.5") 가 15 가 됩니다. 입력 로캘을 명시하고 엄격한 패턴부터 맞춰 보세요.
1억 2,345만 원처럼 만·억 단위로 정확히 표시하려면?
compact 표기(PHP DECIMAL_COMPACT_SHORT, jsc notation: "compact")는 1.2억처럼 반올림하므로 그래프 축이나 요약용입니다. 정확한 금액은 10000 으로 나누면서 직접 조립합니다. 본문의 함수가 123456789 를 1억 2,345만 6,789 로 출력했습니다.
₩2.5 를 통화 형식으로 출력하면 몇 원이 되나요?
처리계마다 달랐습니다. jsc 의 Intl.NumberFormat 은 0에서 먼 쪽으로 반올림해 ₩3, .NET 은 짝수 반올림으로 ₩2 였습니다. 엔화도 1234.5 가 jsc 에서는 ¥1,235, PHP·Python·Java·.NET 에서는 1,234 였습니다. 표시용 포매터에 반올림을 맡기지 말고, 원 단위 절사나 반올림은 계산 단계에서 끝내 두세요.