0.1 + 0.2 > 0.3 라고?

Jeongbong Seo
bgpworks
Published in
15 min readMay 6, 2022

컴퓨터에서 123.45 같은 실수를 Floating-point 자료형을 이용해 다룰 때는 오차가 생길 수 있다는건 기초 지식으로서 다들 알고 있다. 그런데 오차는 큰 수를 계산할 때나 발생하는 문제이지, 0.1 + 0.2나 1.005 x 1000 같은 간단한 계산에서는 발생하지 않을 거라고 오해하는 경우가 많다. 그리고 실제 실행해보면 충격을 받는다. 오차가 어느 과정에서 왜 생기는 지, 해결책은 무엇이 있는 지 파보자.

Photo by Volkan Olmez on Unsplash

CS101: Floating point (IEEE-754)

먼저 CS101을 복습해보자. 소수를 Floating-point 자료형으로 저장하면 내부적으로는 무엇이 어떻게 저장될까?

IEEE-754 기본 컨셉

IEEE-754는 숫자의 과학적 기수법(Scientific notation)에 기반하여, 소수의 부호, 가수부, 지수부를 뽑아서 저장하도록 정의하고 있다. 예를 들어 12.25는 +1.225 × 10¹ 로 정규화(normalize)해서 표현할 수 있고, 여기서 부호 (+), 지수부(exponent)인 1, 가수부(significand)인 1225를 뽑아서 {sign: 0, e: 1, s: 1225}로 저장한다.

단, 컴퓨터는 내부적으로 2진수를 사용하기 때문에 위 방법도 2진수에 기반하여 진행된다. 오해하면 안되는게 10진수에 기반하여 계산한 가수부(1225)와 지수부(1)를 2진수로 변환하여 저장하는게 아니라, 소수 자체를 2진수로 변환한 후에 가수부와 지수부를 추출한다.

12.25는 이진수로는 1100.01(2진수) 이고, 이는 +1.10001 × 10¹¹ (2진수)로 정규화할 수 있다. 동일하게 필요한 정보를 추출하면 {sign: 0, e: 11₂(=3), s: 110001₂(=49)} 가 된다.

바이너리 인코딩

IEEE-754는 위 정보를 (과거의) 1워드안에 압축해서 저장할 수 있도록 비트 레이아웃을 정의한다. 32-bit float 형 기준으로 부호(1bit), 지수부(8bit), 가수부 (23bit) 순으로 이어서 저장한다. 64-bit double 형 기준으로는 각각 1bit, 11bit, 52bit 이다. 여기에서 몇가지 변경사항이 있다.

  • 지수부는 음수를 지원하기 위해 bias(float 기준 127)를 더한 값을 저장한다. 예를 들어 -10은 117로, 10은 137로 저장하는 식이다. 2의 보수 방식을 쓰지 않고 bias 방식을 쓰면, 최종적으로 생성된 바이너리 값들을 각각 integer로 해석해서 크기를 비교해도 동일한 순서로 결과가 나오는 장점이 있다.
IEEE-754 인코딩을 이용하면 두 값을 비교할 때 단순히 Integer로 해석해서 비교해도 된다. 덕분에 Floating-point unit이 없는 오래된 칩에서도 해당 연산을 빠르게 처리할 수 있다.
  • 대부분의 경우 2진수의 특성 상 가수부의 첫 자리는 항상 1이다. (2⁻¹²⁶ 이하의 아주 작은 subnormal 경우 제외) 이 경우 첫 자리 1은 생략하고 나머지 숫자를 52bit의 상위 비트부터 순서대로 저장한다.

이를 12.25에 적용해보면,

  • sign bit: 0
  • exponent bit: 3 + 127 = 10000010₂
  • fraction bit: 110001₂ 에서 첫 1빼고 52bit로 확장 = 1000100…0₂

이를 이어 붙이면 0x4144000이 된다.

12.25를 32-bit Float 타입으로 인코딩한 결과

진법 변환 과정에 발생하는 오차

IEEE-754는 일단 10진수 소수를 2진수 소수로 변경하여 저장한다. 그런데 이 변환이 단순하지 않아 예상하기 힘든 오차가 발생하곤 한다.

10진수 소수 -> 2진수 소수: 순환소수 문제

아쉽게도 2진수로는 모든 10진수 소수를 정확히 표현할 수 없다. 2진수가 특별히 나빠서는 아니고 소수 표기법의 한계 때문이다.

문제를 쉽게 이해하기 위해 10진수 기준으로 생각해보자. 3진수 소수 0.1₃ 을 10진수 소수로 변환해보자. 0.1₃는 10진수 분수 표기법으로 쓰면 1/3 이다. 그런데 1/3은 소수 표기법으로 쓰면 딱 맞아 떨어지지 않고 일정 숫자열이 무한히 반복된다.

0.1₃ = 1/3 = 0.333333… (3 반복. 3 위에 점을 찍어 표기.)

하지만 Floating point 자료형에 맞춰 인코딩하기 위해서는 소숫점 아래 일정 자리수만을 선택해야만 한다. 따라서 어쩔 수 없이 오차를 감안하고 일부 숫자(정확도)는 포기해야한다.

1/3 = 0.3333… ≅ 0.3333 (오차는 1/30000)

동일한 일이 10진수 소수를 2진수 소수로 변환할 때도 발생한다. 오차를 감안하고 적당한 숫자를 선택하는데, 이를 rounded value 라고 부른다.

0.1 (10진수, 아래부터는 2진수)
= 0.000110011…₂ (0011 반복)
= 1.1001100… × 10⁻¹⁰⁰ ₂
≅ 1.1001100110011001100110011001100110011001100110011010 × 10⁻¹⁰⁰ ₂ (오차는 0.000000001490116119384765625)

“0.1”에 대응하는 32-bit float형.

이런 근원적인 문제를 재외하더라도 10진수 소수 문자열을 2진수 소수로 파싱하는 문제는 복잡하다고 한다. 정확한 파서는 무려 1990년에 들어서야 나왔다고 한다.

2진수 소수 -> 10진수 소수: 무엇이 정확한 숫자인가?

그럼 출력을 위해 이 숫자를 다시 10진수 소수로 변환할 때는 어떻게 해야할까? 2진수 소수는 10진수로 오차 없이 변환할 수 있으니, 의미상 그대로의 값으로 변환하여 표시해줘야 할까? 0.1에 대응하는 32-bit float형을 변환하면0.100000001490116119384765625 이 된다. 이 값은 너무 긴 것도 문제지만, 애초에 사용자가 입력한 값도 아니기 때문에 단순환 정확도를 고집하는건 의미가 없을 듯 하다. “0.1”을 파싱해서 나온 float 데이터형은 다시 “0.1”로 변환해주는게 가장 이상적인 결과일 것이다.

이 변환도 쉽지 않았다고 하고, 마찬가지로 1990년에 들어서야 구현체가 나왔다고 한다. C코드로 무려 6200줄이나 된다! 살펴보면 코드에 각종 예외사항들에 대한 주석과 처리코드들이 포함되어 있다. 선현들의 고통과 지혜 덕분에 지금은 Float 형 0x3DCCCCCD가 “0.1”로 잘 출력이 된다.

결과적으로 약간 이상한 맵핑 관계

훌륭한 알고리즘들 덕분에 10진수 소수는 최대한 오차가 적은 2진수 값으로 맵핑되지만, 직관적으로 봤을 때는 예측하기 힘든 간격의 숫자들로 맵핑된다. 어떨 때는 실제보다 큰 숫자에, 어떨 때는 실제보다 작은 숫자에 맵핑된다. 우리가 10진수로 인지하는 0.1의 간격 자체가 2진수로 표현했을 때는 정렬이 맞지 않는 애매한 간격이기 때문이다.

64bit double 타입 기준으로 보면 0.1과 0.2는 살짝 큰 값으로 맵핑되지만, 0.3은 살짝 작은 값으로 맵핑된다. 0.3 근처에 표시할 수 있는 두 값은 0.299999999999999988…(0x3fd333333333333)와 0.300000000000000044…(0x3fd3333333333334)가 있는데 전자가 후자보다 0.3에 가깝기 때문에 전자로 선택되는 것이다. 이 상황을 그림으로 표시하면 아래와 같다.

10진수 표기법과 실제 변환되는 2진수 double 값. 2진수 표기법으로는 0.1, 0.2, 0.3에 대응하는 정확한 값이 없기 때문에 오차가 작은 값으로 변환된다.(rounded value) 0.1, 0.2는 살짝 큰 값으로, 0.3은 살짝 작은 값으로 변환된다.

🤔: 0.1 + 0.2 > 0.3 라고? 🤖: 시킨대로 정확히 했다.

여기에 최초의 질문인 왜 0.1 + 0.2 > 0.3이 성립하는 가에 대한 답이 있다. 왜 0.1 + 0.2를 실행하면 0.30000000000000004이 나오는가? a) 우리가 소스코드 쓴 “10진수 소수 문자열”은 컴퓨터에게 “정확히” 전달되지 않고, b) 컴퓨터는 전달 받은 값을 이용해 “정확한" 연산을 하지만, c) 그 결과는 우리에게 “정확히” 전달되지 않는다.

0.1과 0.2는 실제보다 살짝 큰 값으로 변환되고, 이 둘을 합한 값은 0.3보다 살짝 큰 값으로 계산된다. 많은 경우 이 값은 0.3에 대응하는 rounded value와 동일하기 때문에 문제가 발생하지 않는다. 실제로 32bit float 타입에서는 0.3이 출력된다. 하지만 불행하게도 64bit double 타입의 경우 0.3 근처에는 0.299… 값과 0.300… 값이 있고, rounded value는 전자(0.299…)가 선택되지만, 0.1 + 0.2 계산은 후자(0.300…)로 계산된다. 그렇기 때문에 출력될 때도 0.3이 아닌 0.30000000000000004가 출력되는 것이다. 이를 수직선으로 표현해보면 아래와 같다.

0.1 + 0.2 > 0.3 이 되는 상황. 0.1, 0.2, 0.3을 각각 double 타입으로 변환해 놓고 보면 올바른 계산 결과이다.

0.1 + 0.2가 희기한 케이스로 보이지만, 실제로는 아주 많이 발생한다.

Float형에 대한 흔한 오해들

오차는 큰 숫자를 다룰 때에나 발생할 것이다.

흔히 24934828.297534 x 0.00097870896 같은 숫자를 계산해서 소숫점 아래 자리수가 길어져서 일부를 잘라야 할 때만 오차가 생길 것으로 오해한다. 물론 이 경우에도 오차가 생기지만 Float/Double 타입의 경우 10진수와 2진수 변환과정에서 생기는 근본적인 오차가 문제이기 때문에 대부분의 소수점 계산에서 오차가 발생한다.

소숫점 위치 변환 같은 간단한 계산에는 오차가 발생하지 않을 것이다.

1.005 x 1000는 1005로 문제 없이 계산될 것으로 기대한다. 10진수 기준으로 생각하기 때문에 생기는 오해이다. 2진수로 변환될 때 이미 오차가 발생하고, 10은 2의 배수가 아니기 때문에 유효숫자(significand)를 유지하는데 아무런 도움이 되지 않고 오차가 증폭된다. 그 전에 이미 우리는 0.1 + 0.2 에서도 오차가 발생하는 것을 보았다.

어라라? 그럼 다들 어쩌고 있나?

이 문제를 전혀 인지하지 못하고 있던 사람에게는 지금까지의 내용이 충격일 수도 있겠다. 정확해야 할 컴퓨터의 계산에 이런 오차가 상시로 있을 수 있다니! 그럼 소숫점 계산이 필요한 때는 어떻게 해야할까? 다행히 이 문제는 고적전인 문제로 해결법들이 많이 나와 있다.

Float / Double 형을 쓰지 않는다.

굳이 Float/Double을 쓰지 않더라도 목적이 해결되는 경우가 있다. 계산 과정 중에 1.6 같은 소수를 곱하지만 결과물로 소숫점을 유지하지 않아도 되는 경우이다. 이런 경우는 애초에 16을 곱하고 10을 나누는 식으로 계산을 변형해서 수행하면 된다.

Fixed-point 기법을 사용하여 정수형 값을 유지한다.

그 외에는 kg/g, 달러/센트같은 단위 때문에 소숫점 아래 고정된 자리까지만 필요한 경우가 있다. 이럴 때는 Fixed-point 기법을 사용해서 내부 데이터는 1000을 곱한 정수로 유지하고 표시할 때에만 1000을 나눠서 표시하는 방법을 쓸 수 있다. 도입하기 간편하지만 확장성이 떨어지고, 작업하면서 곳곳에서 신경쓸 부분들이 생기는 단점이 있다.

Float / Double 형을 쓰되 주의하며 쓴다.

위와 같이 소숫점 아래 두세자리만 쓸 경우에는 조금 신경쓰면 대부분의 경우에 문제 없이 동작하게 만들 수 있다. 두 숫자를 비교할 때는 오차 허용 범위를 고려하여 비교하고, 출력해야 할 때는 소숫점 아래 특정 자리수까지 표현되도록 반올림하는 방법이다. 하지만 ‘대부분'의 경우일 뿐 특이한 경우에는 오동작할 가능성이 여전히 남아 있다. 오픈소스 회계 프로그램 GnuCash 의 경우 이런 문제 때문에 내부 자료형을 Double형에서 시작했다가, Fixedpoint 방식으로 전환했다고 한다.

신경쓰지 않고 Float / Double 형을 쓴다.

소숫점을 다루면서 정확한 값을 유지하지 않아도 되는 경우는 의외로 많다. 금융이나 설계 데이터를 다루는 경우는 어쩔 수 없지만, 확률 계산이나 데이터 통계의 평균/표준편차 계산 등 “적당히” 정확한 계산이면 충분한 경우는 아주 많다. 어짜피 소숫점 두자리 까지만 표시하는 경우가 많기도 하고, 목적 자체가 참고 값 계산일 뿐으로 세밀한 오차는 크게 중요하지 않기 때문이다. 반드시 필요한 경우가 아니라면 욕심을 버리고 float/double을 쓰고, 남는 에너지를 더 중요한 본질에 쏟는게 낫다.

자릿수 변경 정도만 필요하다면 과학적 기수법을 활용한다.

10진수 소수 파싱 모듈은 과학적 기수법을 지원하는데, 이를 이용하면 10진수 소숫점 이동을 오차(?)없이 할 수 있다. 예를 들어 위에서 언급한 1.005 x 100를 원하는 대로 계산해보자. 1.005 x 10²는 “1.005e2” 로 표기할 수 있다. 따라서 1.005를 문자열로 변환한 다음 “e2”를 붙여서 다시 파싱하면 100.5에 대응되는 rounded value로 파싱된다!

Number(1.005 + “e2”) == 100.5

이를 활용하여 JavaScript에서 소숫점 아래 특정 자릿수에서 올바르게 반올림하는 방법에 대한 글도 있으니 참고해보자.

이 방법 자체는 유지보수 및 안정성 측면에서 별로 추천하는 방법은 아니지만 후에 소개할 방법에 비해 도입 비용이 아주 적기 때문에 자신의 상황에 맞춰서 활용해봐도 좋을 것 같다.

오차가 없는 자료형을 쓴다.

오차를 야기한 근본적 원인은 소수점 표기법을 사용한 것과 2진수로 변환을 했기 때문이다. 각각의 원인을 제거하는 방법으로 두가지 해결법이 있다.

먼저 소수점 표기법 대신 분수 표기법을 사용하는 것이다. 과학적 계산을 위해 π나 e, 제곱근 같은 무리수를 다루는 경우가 아니라면, 유리수 계산으로 충분하기 때문에 분수 표기법을 사용하여 데이터를 저장할 수 있다. 이 표기법에 기반하면 1/3같은 값도 오차 없이 저장 및 계산을 할 수 있다. Apache common math의 Fraction 같은 자료형이 이를 지원한다. Clojure 언어에서는 Ratio 라는 이름으로 언어의 기본 자료형으로 제공된다.

다른 접근법으로는 IEEE-754의 아이디어를 그대로 활용하되, 2진수 기반이 아닌 10진수 기반으로 데이터를 저장하는 방법이다. 소숫점을 무시한 숫자를 integer로 저장하고, 소숫점 위치를 별도의 integer로 저장하는 것이다. Java의 BigDecimal 같은 Decimal 타입이 이를 지원한다. 구현체와 접근법, 목적이 여럿이고 성능과 특성도 제각각이니 알아보고 골라써야 한다. 사실 IEEE-754에서도 동일한 아이디어에 기반한 decimal32 같은 데이터 타입을 정의하고 있다! 다만 아쉽게도 이를 지원하는 언어나 프로세서는 아직 거의 없다.

Fraction이나 Deciaml 같은 특수한 자료형을 사용하는 접근법은 근본적인 해결책이긴 하나 성능 외에도 고려할 점이 많다. 내가 사용하는 시스템 전반적으로 해당 타입을 지원하는 지 고려해 봐야한다. 클라이언트 측 언어, 서버 측 언어, DB, DB 측 스크립트 언어, ETL 과정에 사용하는 언어, 통신 프로토콜 등등을 살펴봐야 한다. 언어가 달라 여러 구현체를 써야 한다면, 해당 구현체들 사이에 동작의 차이는 없는 지를 살펴봐야 한다. 지금 당장은 한쪽에서만 쓰더라도 요구사항이 바뀌어 다른 쪽에서도 데이터를 다룰 수 있어야 하는 경우가 비일비재하기 때문이다.

통신 과정에 Serialize 레이어로 JSON을 사용한다면 숫자형식이 아닌 문자열 형식으로 주고 받아야 할 수도 있다. JavaScript나 Dart같이 일부 언어에 내장된 JSON 모듈은 숫자를 무조건 Double 형식으로 파싱해버리기 때문이다. 물론 이를 튜닝할 수 있는 JSON 모듈을 도입할 수도 있지만, 외부 라이브러리 추가가 힘든 경우도 있다.

닫으며

Floating-point 데이터 타입은 기본적인 데이터 타입이라 별다른 내용이 없을 것 같았는데 의외로 재밌는 내용들이 많았다. 다음은 공부하면서 참고한 사이트들이다.

까맣게 잊고 살았던 내용의 복습도 되었고, 오해하고 있었던 부분들도 바로 잡을 수 있었다.

마지막으로 BGPworks 는 항상 함께할 열정적인 인재를 모집하고 있습니다.

--

--