JavaScript · 5장 자료구조와 자료형
숫자 다루기: 소수 오차, 반올림, 금액 표시
장바구니 합계, 할인율, 별점 평균처럼 숫자 계산은 어디에나 있어요. 그런데 자바스크립트의 숫자는 소수를 정확히 담지 못하고, 반올림 함수마다 음수를 다루는 방식이 달라요. 화면에 찍힌 14190.000000000002원 같은 버그를 막는 방법을 정리해요.
01. 0.1 + 0.2가 0.3이 아닌 이유
자바스크립트의 숫자는 모두 2진수 소수로 저장돼요(IEEE 754 배정밀도). 10진수의 1/3이 0.333…으로 끝나지 않듯, 10진수 0.1은 2진수로 바꾸면 끝없이 이어져요. 저장할 수 있는 자리는 정해져 있어서 끝을 잘라 내고, 그만큼 아주 작은 오차가 생겨요.
console.log(0.1 + 0.2); // 0.30000000000000004
console.log(0.1 + 0.2 === 0.3); // false
console.log(1.1 * 3); // 3.3000000000000003
console.log(12900 * 1.1); // 14190.000000000002 — 부가세 10%를 더했을 뿐인데
이건 자바스크립트만의 버그가 아니라 대부분의 언어가 같은 방식으로 소수를 저장해서 생기는 일이에요. 실무에서는 두 가지로 대처해요.
- 금액은 정수로 계산해요. 원화는 원래 정수라 괜찮고, 달러처럼 소수점이 있는 통화는 센트(× 100) 단위 정수로 계산한 뒤 표시할 때만 나눠요. 비율을 곱한 결과는 계산 직후
Math.round로 정수로 만들어요. - 소수끼리는
===로 비교하지 않아요. 차이가 아주 작은지(Math.abs(a - b) < Number.EPSILON)로 비교하거나, 정해진 자리에서 반올림한 뒤 비교해요.
const pricesInCents = [1999, 501, 10]; // $19.99, $5.01, $0.10
let total = 0;
for (const p of pricesInCents) total += p;
console.log(total, total / 100); // 2510 25.1 — 정수끼리 더하고 마지막에 나눠요
console.log(Math.abs((0.1 + 0.2) - 0.3) < Number.EPSILON); // true
console.log(Math.round(12900 * 1.1)); // 14190
02. 반올림하고 보기 좋게 표시하기
소수점을 없애는 함수는 네 가지예요. 양수에서는 비슷해 보이지만 음수에서 결과가 갈려요. 할인액이나 온도 변화처럼 음수가 나올 수 있는 값이면 어떤 함수를 쓰는지가 중요해요.
const n = -2.5;
console.log(Math.round(n)); // -2 — 가장 가까운 정수, .5는 큰 쪽(+ 쪽)으로
console.log(Math.floor(n)); // -3 — 항상 아래로(작은 쪽)
console.log(Math.ceil(n)); // -2 — 항상 위로(큰 쪽)
console.log(Math.trunc(n)); // -2 — 소수 부분을 잘라 내요(0 쪽)
소수 둘째 자리까지 보여 줄 때 흔히 쓰는 toFixed(2)는 두 가지를 조심해요. 결과가 문자열이라 다시 계산하려면 숫자로 바꿔야 하고, 2진수 오차 때문에 (1.005).toFixed(2)가 '1.01'이 아니라 '1.00'이 돼요.
화면에 표시할 때는 Intl.NumberFormat을 쓰는 게 가장 깔끔해요. 천 단위 쉼표, 통화 기호, 퍼센트, "1.2만" 같은 짧은 표기를 나라별 규칙에 맞게 만들어 줘요. 같은 설정을 여러 번 쓸 거라면 한 번 만들어 두고 format만 불러요.
console.log((1.005).toFixed(2), typeof (1.5).toFixed(1)); // 1.00 string
const won = new Intl.NumberFormat('ko-KR', { style: 'currency', currency: 'KRW' });
const usd = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
const pct = new Intl.NumberFormat('ko-KR', { style: 'percent', maximumFractionDigits: 1 });
const short = new Intl.NumberFormat('ko-KR', { notation: 'compact' });
console.log(won.format(12345)); // ₩12,345
console.log(usd.format(12.5)); // $12.50
console.log(pct.format(0.1234)); // 12.3%
console.log(short.format(1234567)); // 123만
console.log((1234567.5).toLocaleString('ko-KR')); // 1,234,567.5
03. 문자열에서 숫자 꺼내기: parseInt와 Number
Number()는 문자열 전체가 숫자여야 해요(형 변환). parseInt · parseFloat는 앞에서부터 읽을 수 있는 데까지 읽고 멈춰요. 그래서 '12px'처럼 단위가 붙은 값에서 숫자를 꺼낼 때 써요. 대신 앞에서 멈춘다는 성질 때문에 엉뚱한 값을 조용히 돌려주기도 해요.
console.log(Number('12px'), parseInt('12px')); // NaN 12
console.log(parseFloat('3.5kg')); // 3.5
console.log(parseInt('1,200')); // 1 — 쉼표에서 멈춰요
console.log(parseInt('px12')); // NaN — 첫 글자부터 못 읽어요
console.log(Number(''), parseInt('')); // 0 NaN
console.log(parseInt('ff', 16)); // 255 — 두 번째 인자는 진법
console.log(Number.isNaN(parseInt('px12'))); // true
console.log(Number.isInteger(5.0), Number.isInteger(5.5)); // true false
정리하면, 사용자 입력이나 API 값처럼 숫자여야 하는 값은 Number()로 바꾸고 Number.isNaN으로 확인해요. 틀린 값이 NaN으로 드러나니 안전해요. 단위가 붙은 값(CSS의 '12px')을 읽을 때만 parseInt · parseFloat를 써요. 숫자가 아닌지 확인할 때 전역 isNaN('abc')은 먼저 숫자로 바꿔 보고 판단해서 헷갈리니, Number.isNaN을 써요.
04. 아주 큰 정수: 안전한 범위와 BigInt
number는 Number.MAX_SAFE_INTEGER(9007199254740991)까지의 정수만 정확히 다뤄요(자료형). 이보다 큰 정수는 이웃한 값과 구분이 안 돼요. 실무에서 만나는 곳은 주로 서버가 주는 큰 ID(트위터 · 디스코드 ID 같은 64비트 정수)예요. 이런 ID는 서버에서 문자열로 받아요. 정말 큰 정수로 계산해야 하면 끝에 n을 붙인 BigInt를 써요.
console.log(2 ** 53 === 2 ** 53 + 1); // true — 구분이 안 돼요
console.log(Number.isSafeInteger(2 ** 53)); // false
const big = BigInt('9007199254740993'); // 문자열에서 만들면 정확해요
console.log(big + 1n); // 9007199254740994n
console.log(7n / 2n); // 3n — 소수점은 버려요
console.log(big + 1); // number와 섞으면?
BigInt는 number와 섞어 계산할 수 없고(TypeError), Math 함수와 JSON.stringify에서도 쓸 수 없어요. 그래서 "큰 ID를 다룬다"는 이유만으로 BigInt를 쓰기보다는, ID는 문자열로 두고 계산이 필요한 곳에서만 BigInt로 바꾸는 편이 편해요.
상품 가격에 부가세 10%를 더해 표시하려는데, 몇 개가 14190.000000000002원처럼 나와요. 원 단위 정수로 반올림하고 천 단위 쉼표를 넣어서 아래처럼 나오게 고쳐 보세요.
14,190원
3,630원
8,470원
const prices = [12900, 3300, 7700];
for (const price of prices) {
const withTax = price * 1.1;
console.log(withTax + '원');
}
해답 보기
곱한 직후 Math.round로 정수로 만들고, 표시는 Intl.NumberFormat에 맡겨요. 포맷 객체는 반복문 밖에서 한 번만 만들어요. toFixed(0)도 정수 문자열을 만들지만 쉼표는 넣어 주지 않아요.
const prices = [12900, 3300, 7700];
const krw = new Intl.NumberFormat('ko-KR');
for (const price of prices) {
const withTax = Math.round(price * 1.1);
console.log(krw.format(withTax) + '원');
}
05. 실전 포인트
- 언제 쓰나요? 금액은 가장 작은 단위의 정수로 계산하고 마지막에만 나눠요. 화면 표시는
Intl.NumberFormat, 입력값 변환은Number()+Number.isNaN, 단위가 붙은 값은parseFloat를 써요. - 대신 쓰는 것: 이자 · 환율처럼 소수 정밀도가 중요한 계산은 서버에서 하거나, 10진 소수 계산 라이브러리(decimal.js 같은 것)를 써요. 64비트 ID는 number가 아니라 문자열로 받아요.
- 흔한 실수: 소수를
===로 비교하는 것,toFixed결과(문자열)에 다시+를 해서 이어 붙이는 것, 음수에Math.floor를 써서 "버림"이 한 칸 더 내려가는 것,parseInt('1,200')이 1이 되는 걸 모르는 것이에요.
정리
- 숫자는 2진수 소수로 저장돼서 0.1 같은 값에 작은 오차가 있어요. 금액은 정수로 계산해요.
round·floor·ceil·trunc는 음수에서 결과가 달라요.- 표시는
Intl.NumberFormat,toFixed는 문자열을 돌려줘요. Number()는 전체를,parseInt는 앞에서 읽을 수 있는 데까지 읽어요.- 9007199254740991보다 큰 정수는 정확하지 않아요. ID는 문자열로, 계산은 BigInt로 해요.
달러 가격 19.99, 5.01을 더할 때 오차를 피하려면 어떻게 계산할까요?
센트 단위 정수(1999, 501)로 바꿔 더하고, 화면에 보여 줄 때만 100으로 나눠요. 정수끼리의 덧셈은 오차가 없어요.
Math.floor(-2.5)와 Math.trunc(-2.5)는 각각 무엇일까요?
floor는 늘 작은 쪽으로 가서 -3, trunc는 소수 부분만 잘라 0 쪽으로 가서 -2예요.
입력 칸의 값 '1,200'을 parseInt로 바꾸면 무엇이 나올까요?
1이에요. parseInt는 앞에서부터 읽다가 쉼표에서 멈춰요. 쉼표를 먼저 지우고 Number()로 바꾸는 편이 안전해요.