본문으로 건너뛰기
기술스택 · stack/javascript/숫자 다루기: 소수 오차, 반올림, 금액 표시

JavaScript · 5장 자료구조와 자료형

숫자 다루기: 소수 오차, 반올림, 금액 표시

장바구니 합계, 할인율, 별점 평균처럼 숫자 계산은 어디에나 있어요. 그런데 자바스크립트의 숫자는 소수를 정확히 담지 못하고, 반올림 함수마다 음수를 다루는 방식이 달라요. 화면에 찍힌 14190.000000000002원 같은 버그를 막는 방법을 정리해요.

#5.1

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%를 더했을 뿐인데
①0.1을 2진수로 바꾸면 0011이 끝없이 되풀이돼요. 저장할 자리(소수 52자리)에서 잘라 반올림하니, 실제로 담기는 값은 0.1보다 아주 조금 커요. 이런 오차가 더해지면 0.1 + 0.2가 0.30000000000000004가 돼요.

이건 자바스크립트만의 버그가 아니라 대부분의 언어가 같은 방식으로 소수를 저장해서 생기는 일이에요. 실무에서는 두 가지로 대처해요.

  • 금액은 정수로 계산해요. 원화는 원래 정수라 괜찮고, 달러처럼 소수점이 있는 통화는 센트(× 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 쪽)
②수직선 위의 2.5와 -2.5가 함수마다 어디로 가는지 봐요. floor는 늘 왼쪽(작은 쪽), ceil은 늘 오른쪽(큰 쪽), trunc는 0 쪽으로 가요. round는 .5에서 오른쪽으로 가서 2.5는 3이 되고 -2.5는 -2가 돼요.

소수 둘째 자리까지 보여 줄 때 흔히 쓰는 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()로 바꾸는 편이 안전해요.

SIMPLE · 기술스택 / JavaScript — 5장 자료구조와 자료형