금액은 JavaScript Number 에 담기지 않습니다 — DECIMAL 과 BigInt
Go 로 짜인 서버를 TypeScript 로 다시 짜며 다시 공부한 정석을 적는 연재의 다섯 번째 글입니다. 이번 요구는 한 줄입니다. 금액은 저장하고, 꺼내고, 더하는 동안 한 푼도 달라지면 안 됩니다.
당연한 말 같지만, 이 요구는 데이터베이스 안에서는 지켜지다가 프로그램으로 꺼내는 순간 깨지기 쉽습니다. 그리고 그 깨지는 지점은 Go 와 TypeScript 에서 모양이 조금 달랐습니다.
컴퓨터가 다루는 소수는 대부분 근삿값입니다
1 을 3 으로 나누면 0.333… 이 끝없이 이어집니다. 10진수로는 1/3 을 정확히 적을 수 없습니다. 컴퓨터는 숫자를 2진수로 저장하는데, 2진수에서는 0.1 이 바로 그런 수입니다. 끝없이 이어지는 자리를 적당한 곳에서 잘라 근삿값으로 저장합니다.
이렇게 소수를 2진수 근삿값으로 저장하는 방식을 부동소수점이라고 부릅니다. JavaScript 의 숫자(Number)는 모두 이 방식입니다. 그래서 유명한 결과가 나옵니다.
JavaScript: 0.1 + 0.2 = 0.30000000000000004
엑셀 파일을 읽다가 이 근삿값 때문에 부품 번호가 바뀐 일은 예전 글에 적은 적이 있습니다. 이번에는 파일이 아니라 데이터베이스에서 꺼낸 금액 이야기입니다.
데이터베이스의 DECIMAL 은 정확합니다. 문제는 꺼내는 순간입니다
데이터베이스에는 근삿값이 아닌 숫자 타입이 따로 있습니다. DECIMAL(18,2) 는 "전체 18자리, 그중 소수 둘째 자리까지를 10진수 그대로 정확히 저장한다"는 뜻입니다. 데이터베이스 안에서 더하면 정확한 결과가 나옵니다.
MySQL: SUM(0.10, 0.20) = 0.30
문제는 이 값을 프로그램으로 가져올 때입니다. Node.js 에서 MySQL 을 쓰는 드라이버 mysql2 는 기본적으로 DECIMAL 을 문자열로 건네줍니다. 처음에는 불편해 보였는데, 재 보니 이것이 맞는 선택이었습니다.
DECIMAL(18,2) 값을 mysql2 기본 설정으로 읽었을 때
id 1: typeof = string, 값 = "9999999999999999.99", Number(값) = 10000000000000000
id 2: typeof = string, 값 = "0.10", Number(값) = 0.1
id 4: typeof = string, 값 = "12345678901234.56", Number(값) = 12345678901234.56
문자열일 때는 9999999999999999.99 가 정확히 들어 있습니다. 그런데 이것을 Number() 로 바꾸는 순간 10000000000000000 이 됩니다. 0.01 이 사라진 것이 아니라, 16자리를 넘는 숫자를 정확히 담지 못해 가장 가까운 근삿값으로 반올림된 것입니다.
JavaScript 숫자가 정확히 담는 정수에는 끝이 있습니다
JavaScript 숫자가 빈틈없이 정확하게 표현하는 가장 큰 정수는 Number.MAX_SAFE_INTEGER, 즉 9007199254740991 입니다. 16자리입니다. 그보다 큰 정수부터는 이웃한 두 정수를 구분하지 못하는 구간이 생깁니다.
Number.MAX_SAFE_INTEGER = 9007199254740991 (16자리)
2 ** 53 === 2 ** 53 + 1 ? true
DECIMAL(18,2) 는 유효숫자가 18자리까지 갑니다. 즉 이 타입이 담을 수 있는 값의 일부는 애초에 JavaScript 숫자로 옮길 수 없습니다. 드라이버 설정에 decimalNumbers: true 를 주면 DECIMAL 을 숫자로 바로 받을 수 있지만, 같은 실험에서 id 1 은 역시 10000000000000000 으로 왔습니다. 편리해지는 대신 정밀도를 드라이버 안에서 조용히 잃는 셈입니다.
| 받는 방식 | 9999999999999999.99 가 | 평가 |
|---|---|---|
| 문자열(기본값) | "9999999999999999.99" | 정확함. 계산할 때 주의가 필요함 |
| Number(decimalNumbers: true) | 10000000000000000 | 편하지만 큰 값에서 틀림 |
Go 도 같습니다. 어디에 담느냐가 결과를 정합니다
Go 에서도 같은 값을 두 가지로 읽어 봤습니다. 문자열로 읽으면 정확하고, 부동소수점 타입인 float64 로 읽으면 JavaScript 와 똑같이 반올림됩니다.
Go: string 으로 읽기 = 9999999999999999.99
Go: float64 로 읽기 = 10000000000000000.00
Go 변수 계산: 0.1 + 0.2 = 0.30000000000000004
Go 상수 계산: 0.1 + 0.2 = 0.29999999999999999
마지막 두 줄은 Go 를 다시 공부하다 알게 된 사실입니다. Go 는 코드에 직접 적은 상수끼리의 계산을 컴파일할 때 정확한 값으로 먼저 계산하고, 마지막에 한 번만 float64 로 반올림합니다. 그래서 0.1 + 0.2 를 상수로 적으면 0.3 에 가장 가까운 float64 가 나오고, 변수에 담아 더하면 실행 중에 근삿값끼리 더해 0.30000000000000004 가 나옵니다. 같은 식이라도 언제 계산하느냐에 따라 결과가 다를 수 있다는 점이 흥미로웠습니다.
계산은 가장 작은 단위의 정수로 합니다
금액을 정확하게 계산하는 정석은 소수를 없애는 것입니다. 1.25 를 1.25 로 다루지 않고, 소수 둘째 자리까지를 한 단위로 보고 125 라는 정수로 다룹니다. 정수는 근삿값이 아니므로 더하고 빼도 오차가 생기지 않습니다.
다만 정수도 JavaScript 숫자에 담으면 16자리 한계를 그대로 갖습니다. 그래서 크기에 한계가 없는 정수 타입인 BigInt 를 썼습니다.
// "12.34" 같은 문자열을 소수 둘째 자리 단위의 정수로 바꾼다
function toMinorUnits(amount: string): bigint {
const [whole, fraction = ''] = amount.split('.');
return BigInt(whole) * 100n + BigInt((fraction + '00').slice(0, 2));
}
// 다시 "12.34" 모양의 문자열로 돌린다(양수 기준 예시)
function fromMinorUnits(value: bigint): string {
return `${value / 100n}.${String(value % 100n).padStart(2, '0')}`;
}
BigInt: 0.10 + 0.20 = 0.30
BigInt: 9999999999999999.99 + 0.01 = 10000000000000000.00
이 예시는 양수만 다루는 최소한의 모양입니다. 음수·반올림 규칙·통화마다 다른 소수 자리수까지 다뤄야 한다면, 직접 만들기보다 검증된 10진수 라이브러리를 쓰는 편이 안전합니다.
상한을 넘는 값은 데이터베이스가 거절하게 둡니다
위의 두 번째 결과 10000000000000000.00 은 계산으로는 맞지만 DECIMAL(18,2) 에는 들어가지 않는 값입니다. 넣어 보면 MySQL 이 거절합니다.
ER_WARN_DATA_OUT_OF_RANGE: Out of range value for column 'amount' at row 1
이 거절은 MySQL 의 엄격 모드가 켜져 있을 때의 동작입니다. 엄격 모드가 꺼진 서버는 경고만 남기고 값을 잘라 넣을 수 있으니, 서버 설정도 함께 확인해야 합니다. 저는 데이터베이스에 닿기 전에 애플리케이션에서 먼저 범위를 검사하고, 데이터베이스의 거절은 마지막 방어선으로 두었습니다.
화면과 API 에서도 금액은 문자열로 주고받습니다
JSON 으로 금액을 주고받을 때 숫자로 보내면, 받는 JavaScript 쪽에서 다시 Number 가 됩니다. 그래서 API 에서도 금액은 "12345.67" 처럼 문자열로 보내고, 받는 쪽에서 형식을 검사했습니다. 화면에 천 단위 쉼표를 찍을 때도 문자열을 그대로 넘기면 정밀도가 지켜진다는 것을 확인했습니다.
Intl.NumberFormat('ko-KR').format('9999999999999999.99') = 9,999,999,999,999,999.99
Intl.NumberFormat('ko-KR').format(Number('9999999999999999.99')) = 10,000,000,000,000,000.00
정리하며
- 저장은 DECIMAL, 운반은 문자열로 합니다. 데이터베이스에서 꺼낸 금액을 곧바로 Number 로 바꾸지 않습니다.
- 계산은 가장 작은 단위의 정수로 합니다. JavaScript 에서는 BigInt 나 검증된 10진수 라이브러리를 씁니다.
- 경계를 테스트로 고정합니다. 타입이 담을 수 있는 가장 큰 값을 넣고 꺼내는 테스트를 두면, 누군가 Number 로 바꾸는 순간 바로 드러납니다.
금액이 틀리는 일은 대개 큰 계산식이 아니라 "잠깐 숫자로 바꾸는" 한 줄에서 일어난다는 생각이 들었습니다. 그 한 줄이 어디에 있는지 찾는 일이, 결국 데이터가 어떤 타입으로 어디를 지나가는지 따라가 보는 일이었습니다.
실험 환경: MySQL 8.4.11(로컬 Docker 컨테이너, 기본 sql_mode) · Node.js v26.5.0 · mysql2 3.24.4 · Go 1.25.6 · go-sql-driver/mysql v1.10.1 · 2026-09-29. 금액 값은 경계를 보여 주기 위해 고른 예시입니다.
관련 글
upsert 가 돌려준 4 는 새로 들어간 건수가 아니었습니다 — MySQL affected rows
같은 데이터를 두 번 받아도 한 번만 남기려고 upsert 를 썼는데, 돌아온 숫자는 신규 건수가 아니었습니다. 같은 SQL 이 Node 의 mysql2 에서는 4, Go 드라이버에서는 3 을 돌려준 이유와, 신규 건수를 올바르게 세는 방법을 정리했습니다.
0 과 빈 문자열과 null 은 다른 말입니다 — Go 의 zero value 에서 TypeScript 로
Go 에 약해서 서버를 TypeScript 로 다시 짜며 가장 먼저 다시 배운 것은 빈 값이었습니다. 0, 빈 문자열, null, 키 없음이 각각 다른 뜻이라는 것과, 설정 파일이 없는 환경 변수를 조용히 빈 문자열로 넣는다는 것을 직접 실험으로 확인했습니다.
엑셀 부동소수점 — 화면은 4253.15, 저장된 값은 4253.1499999999996 이었습니다
화면에는 4253.15 로 보이던 부품 번호가 엑셀 파일 안에는 4253.1499999999996 으로 저장돼 있었습니다. 직접 만든 XLSX 리더가 numFmt 을 보지 않고 그 값을 그대로 읽어 18건이 손상됐는데, 그중 8건만 검색 0건이 됐습니다.