숫자 표시와 입력: 천 단위 구분·소수점 쉼표·만/억 로캘별 처리

숫자 강좌 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 파서로 읽습니다.

흐름도. 사용자 입력을 NFKC 로 정규화하고 공백과 통화 기호 하나를 지운다. 다음으로 입력 로캘을 아는지 판단한다. 모르면 사용자에게 묻거나 고정 형식을 쓴다. 알면 그 로캘용 엄격한 패턴과 맞춰 본다. 맞으면 Decimal 이나 최소 단위 정수로 바꿔 저장하고, 안 맞으면 메시지와 함께 거부한다. 표시할 때는 저장한 값을 재사용하는 로캘 포매터에 넘긴다.
사람이 입력한 숫자를 안전하게 읽는 흐름. 모든 단계를 본문에서 실행했다. NFKC 가 123 을 123 으로 바꾸고, 로캘별 엄격한 패턴이 es-ES 의 1.5 와 es-MX 의 1,5 를 15 로 읽지 않고 거부했으며, Decimal 이 자릿수를 그대로 지켰다.

측정: 포맷 비용

보고서에서 10만 건짜리 열을 포맷하면 방법 차이가 드러납니다. 0~10,000,000.00 사이 난수 10만 개(시드 42)를 es-ES 형식으로 포맷하고 5회 중앙값을 냈습니다(Apple Silicon Mac, M1 Pro).

1건당 포맷 시간(나노초) 막대그래프. PHP number_format 211, Python f-문자열(쉼표) 309, PHP NumberFormatter 재사용 344, jsc Intl.NumberFormat 재사용 357, PHP 매번 new NumberFormatter 5214, Python locale.format_string 7137, jsc toLocaleString(옵션 포함) 23659.
6.865.793,03 같은 금액 1건을 포맷하는 비용(시드 42 난수 10만 건, 5회 중앙값, Apple Silicon Mac M1 Pro). 비싼 것은 매번 포매터를 만드는 부분으로, PHP 에서 15배, jsc 에서 66배 느렸다.
방법 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 가 됩니다. 수량이 정수라면 그 칸만 정수 전용 패턴을 써서 점을 거부하세요.

이어서

자주 묻는 질문

"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 였습니다. 표시용 포매터에 반올림을 맡기지 말고, 원 단위 절사나 반올림은 계산 단계에서 끝내 두세요.

  • #숫자 계산
  • #고급
  • #로캘
  • #국제화
  • #입력 검증
  • #Python
  • #PHP
  • #JavaScript
  • #Java
  • #.NET

t.laboratory

직접 만들고 운영하는 앱·서버·서비스에서 겪은 문제와, 그 해결에 쓴 코드를 기록하는 기술 블로그입니다.

운영자 소개 · 앱

↑