부동소수점 실수 탐구 in JavaScript

윤슬기·2019년 9월 15일
12
post-thumbnail

1. 0.1 + 0.2


// 0.3을 기대했지만..
console.log(0.1 + 0.2);
// 0.30000000000000004

자바스크립트 기초를 배우면서 연산 문제를 풀다 보면 맞닥뜨리는 현상이다. 0.1 더하기 0.2는 0.3이 아니다. 자바스크립트에서 숫자는 '64 비트 IEEE 754 형식'으로 다뤄지는데, 그 형식으로 표현된 0.1은 정확히 0.1과 같은 수가 아닌 0.1에 가장 가까운 2진법으로 표현 가능한 수이다.

컴퓨터가 2진법을 사용하기 때문에, 다른 언어에서도 같은 현상이 일어난다. 단지 다른 언어에는 여러 가지 숫자 데이터 타입이 있거나 이 현상을 해결하는 연산 메서드가 있다. 하지만 자바스크립트에서 숫자는 모두 '64 비트 IEEE 754 형식'으로 다뤄진다. 우리가 콘솔에 0.1을 입력하면, 자바스크립트는 이를 '64 비트 IEEE 754 형식'에 따라 2진법으로 바꾸고 그 결과를 다시 10진법으로 바꿔 화면에 표시한다.

검색 도중 아예 예시 속 수가 도메인 이름인 사이트까지 만났다. 각종 프로그래밍 언어에서 0.1 + 0.2 연산이 어떤 결과를 표시하는지 볼 수 있다. https://0.30000000000000004.com/

유한소수와 무한소수

분수를 소수로 바꿀 땐 분자를 분모로 나눈다. 이때 1/2 = 0.5처럼 딱 떨어지는 소수는 유한소수, 2/3 = 0.6666...처럼 딱 떨어지지 않는 수는 무한소수라고 부른다.
10진법으로는 분모의 소인수가 2 또는 5로만 이루어진 분수만 유한소수로 표현할 수 있다. 그리고 2진법으로는 분모가 2의 거듭제곱인 분수만 유한소수로 표현할 수 있다. 그래서 0.1(=1/10), 0.2(=1/5)처럼 분모가 2의 거듭제곱이 아닌 수는 2진법으로 나타낼 때 무한소수가 된다.

0.1 + 0.2 !== 0.3

'64 비트 IEEE 754 형식'은 숫자를 표현할 때 사용하는 정해진 비트 수가 있다(부호 1, 지수부 11, 가수부 52). 그래서 0.1(=1/10), 0.2(=1/5) 같이 2진법으로 나타낼 때 소수점 아래가 무한히 이어지는 숫자들을 '64 비트 IEEE 754 형식'으로 나타내려면, 비트 안에 수가 담길 수 있도록 가수(mantissa)부의 정해진 자리에서 수를 반올림해야 한다.
자바스크립트에서 0.3(=3/10)을 입력한다면 수는 한 번 반올림된다. 하지만 0.1 + 0.2를 연산하면 0.1과 0.2에서, 그리고 0.1과 0.2를 더한 값에서 반올림되기 때문에 0.1 + 0.2와 0.3은 다른 값이다.

0.1 + 0.2 = 0.30000000000000004
0.3 = 0.29999999999999998

2. 정수는 어디까지 정확할까


'64 비트 IEEE 754 형식'에서는 -(2^53 - 1)부터 2^53 - 1 사이의 수가 안전하다(여기서 안전함이란 정수를 정확하고 올바르게 비교할 수 있음을 의미한다). 즉 안전한 가장 큰 정수는 9007199254740991, 가장 작은 정수는 -9007199254740991이다.

안전한 숫자 범위를 넘어서면 부정확한 연산 결과가 나온다.

const x = 9007199254740991;
console.log(x + 2);
// 9007199254740992
console.log(x + 4);
// 9007199254740996
console.log(x + 6);
// 9007199254740996

const y = -9007199254740991;
console.log(y - 2);
// -9007199254740992
console.log(y - 4);
// -9007199254740996
console.log(y - 6);
// -9007199254740996

ES6에는 자바스크립트에서 안전한 최대/최소 함수를 나타내는 상수가 있다.
[ Number.MAX_SAFE_INTEGER | MDN ]
[ Number.MIN_SAFE_INTEGER | MDN ]

console.log(Number.MAX_SAFE_INTEGER);
// 9007199254740991
console.log(Number.MIN_SAFE_INTEGER);
// -9007199254740991

3. 정확한 연산 결과를 얻는 방법


가장 간단한 방법은 잘 만들어진 자바스크립트용 수학 라이브러리를 사용하는 것이다.
다음은 라이브러리 mathjsmath.fraction() 사용 예다.

print(math.add(math.fraction(0.1), math.fraction(0.2))) // Fraction, 0.3
print(math.divide(math.fraction(0.3), math.fraction(0.2))) // Fraction, 1.5

라이브러리 외에는, toFixed()를 이용할 수 있다. toFixed()는 문자열을 반환한다.
[ Number.prototype.toFixed() | MDN ]

const x = (0.1 * 0.2).toFixed(2);
console.log(x);
// "0.02"

필요한 만큼 10의 거듭제곱을 실수에 곱해서 정수로 만들어 연산한 뒤, 또 그만큼을 나눠서 해결할 수도 있다.

const y = ((0.1 * 10) * (0.2 * 10)) / 100;
console.log(y);
// 0.02

4. 함수 multiplyNums() 작성하기

물론 라이브러리를 사용하겠다. 하지만..

수를 곱해서 반환하는 코드를 적다가 의문이 들어 여기까지 자료를 찾아보게 되었다. 그래서 부동소수점 실수가 매개변수로 들어와도 위와 같은 현상이 발생하지 않는 곱셈 함수를 만들고 싶어졌다.
부동소수점 실수를 정수로 만든 후 연산하는 방법을 사용해 작성했다.

전제

  1. 함수에는 항상 전달인자로 문자열형 숫자가 입력된다.
  2. 함수의 매개변수는 2개이며, 함수에는 항상 전달인자가 2개 입력된다.
  3. 안전한 범위를 넘어가는 숫자를 연산하거나 반환하는 경우는 고려하지 않았다.

순서

매개변수로 받은 수를 각각 1~3의 과정을 통해 정수 형태로 바꾸어 서로 곱한 후, 필요한 만큼 다시 10의 거듭제곱으로 나누어 최종 답을 반환한다.
1. 매개변수의 소수점이 위치한 인덱스값을 얻는다.
2. 소수점의 앞쪽 숫자들과 뒤쪽 숫자들을 얻어, 둘을 이어 붙여 정수 형태로 만든다.
2-1. 뒤쪽 숫자의 길이(=소수부의 자릿수)를 얻는다.
3. 2-1의 반환 값을 지수로 사용해 10의 거듭제곱을 얻는다.

// 1.
function getIndexOfDot(num) {
  for (let i = 0; i < num.length; i++) {
    if (num[i] === '.') {
      return i;
    }
  }

  return -1;
};

// 2.
function getIntNumber(num) {
  const demicalPoint = getIndexOfDot(num);

  if (getIndexOfDot(num) > 0) {
    const leftSideOfDot = num.slice(0, demicalPoint);
    const rightSideOfDot = num.slice(demicalPoint + 1, num.length);
    return leftSideOfDot + rightSideOfDot;
  }

  return num;
};

// 2-1.
function getExponent(num) {
  const demicalPoint = getIndexOfDot(num);

  if (demicalPoint > 0) {
    const exponent = num.slice(demicalPoint + 1, num.length);
    return exponent.length;
  }

  return 0;
};

// 3.
function getNumToDivision(num1, num2) {
  return Math.pow(10, getExponent(num1)) * Math.pow(10, getExponent(num2));
};

multiplyNums()

function multiplyNums (num1, num2) {
  const integer1 = getIntNumber(num1);
  const integer2 = getIntNumber(num2);

  if (integer1 === '0' || integer2 === '0') {
    return 0;
  }

  return (integer1 * integer2) / getNumToDivision(num1, num2);
};

multiplyNums() 테스트

  • 콘솔에서 테스트했을 때 오류가 발생하는 연산을 multiplyNums()로 연산했다. 테스트 횟수와 범위가 적어 정확도를 알기는 어렵다.
console.log(34.2 * 22)
// 752.4000000000001
console.log(0.03 * 45)
// 1.3499999999999999
console.log(1.3 * 16.6);
// 21.580000000000002
console.log(-1.32 * 10);
// -13.200000000000001
console.log(-1.3 * 16.6);
// -21.580000000000002


multiplyNums('34.2', '22')
// 752.4
multiplyNums('0.03', '45')
// 1.35
multiplyNums('1.3', '16.6')
// 21.58
multiplyNums('-1.32', '10')
// -13.2
multiplyNums('-1.3', '16.6')
// -21.58
profile
👩🏻‍💻

1개의 댓글

comment-user-thumbnail
2020년 8월 17일

좋은 정리글 감사합니다!

답글 달기