Decimal – 보다 정확한 소수점 산술

들어가기 전에 다음과 같은 코드를 보자. 흔히 하는 실수 중에 하나 인데 float 타입 값으로 반복횟수가 제한된 루프를 만드려고 시도하는 것이다.

a, b = 1, 0.1
while a != 0:
  print(a)
  a -= b

하지만 안타깝게도 이 코드는 의도대로 실행되지 않을 것이다. 왜냐하면 1.0 에서 0.1을 열 번 뺀다하더라도 0이 되지 않기 때문이다. 어째서? (실제로 파이썬 쉘에서 1.0 / 10 == 0.1 이고, 0.1 * 10 == 1 이다)

이것은 파이썬의 문제가 아니다. 바로 컴퓨터는 우리가 학교에서 산수를 배웠던 것이랑은 달리, 이진법에 기반하여 모든 데이터를 표시하기 때문이다. 물론 우리도 학교에서 2진법이라든지 여러 진법에 대해서 배우기는 한다. 예를 들어 1/3을 생각해보자. 이걸 분수가 아닌 소수점을 써서 표기한다면 어떻게 되나? 0.3333333…. 이렇게 끝나지 않는 무한 소수가 된다. 하지만 이것은 1이 3으로 나눠지지 않는다는 문제와는 다른 것이다. 실제로 1은 3으로 나눠지며, 분수의 꼴로 1/3이라고 쓰는 것은 문제가 되지 않는다. 바꿔서 생각해보면 3진법이라면 이 값은 십진법과는 달리 0.1(3)로 딱 떨어져 표시되는 값이 된다.

1/10 은 십진수 표기로는 0.1 이 된다. 이를 이진수로 표시하려면 2의 거듭제곱이 분모인 수들의 합으로 1/10을 표시하고, 그 각각의 분수의 분자들을 순서대로 쓰면 된다. 1/3을 십진법에서 유한한 자리수의 소수로 표시할 수 없는 것처럼, 1/10도 이진수에서는 유한한 자리수의 소수로 표시할 수 없다. 따라서 파이썬에서 float 타입 0.1은 실제로 1/10의 값이 아니라, 약간의 오차를 가진 그에 근사하는 값일 수 밖에 없으며, 이는 단순히 파이썬의 문제가 아닌 컴퓨터 자체의 문제이다.

실제로 정확한 값을 기록하는데 무한개의 자리수가 필요한 값을 사용하기 위해 무한한 양의 메모리를 할 당할 수 없기 때문에 필연적으로 float 타입은 근사값이 된다. C에서는 float 역시 같은 처지이며, 좀 더 정밀한 값을 다루기 위해서 double 타입이 추가로 사용된다. double 타입은 float보다 두 배의 정밀도를 제공해준다는 의미에서 이름이 그렇게 지어졌다.

컴퓨터가 부동소수점 연산에 필연적으로 오차를 동반할 수밖에 없는 운명이지만, 그런 점을 감안하더라도 0.1이 열 개인데 1.0이 되지 않는다는 오차는 의외로 임팩트가 클 수 있다. 1/10 을 보자. 이 값은 파이썬에서 출력했을 때, 대게 0.1로 표시된다. 실제 오차값은 0.000000000000000005551 정도로 매우 작긴하다. 하지만 연산이 거듭될 수록 근사값의 오차는 점점 커지게 된다. 이러한 오차의 증가는 금융이나 회계관련 계산에서는 언젠가 문제가 될 것이다. 앞의 예에서도 이 작은 오차가 쌓이면서 코드가 예상과 다르게 동작하는 것을 볼 수 있지 않았나.

파이썬은 보다 정밀한 부동소수점 계산을 위한 Decimal 타입을 제공한다. 이 모듈은 실제로 우리가 종이와 연필을 써서 계산하는 것과 일치하는 고정 소수점 및 부동 소수점 표기와 계산을 수행하게 한다. 기본적으로 28자리의 유효숫자를 제공하며, 필요한 경우 프로그래머가 요구하는 정밀도까지 확대가 가능하다.

Decimal은 타입은 기본적이 산술 연산 및 float 타입과 같이 사용되는 파이썬 내장함수와 함께 작동할 수 있다. 정수나 실수, 숫자 리터럴 문자열을 넘겨서 Decimal 타입을 만들 수 있다. 이 때 중요한 것은 실수 값을 Decimal()에 넘겨준다고해서 없던 정확도가 생기는 것은 아니라는 점이다.

>>> from decimal import Decimal
>>> Decimal('0.1')
Decimal('0.1')
>>> Decimal(0.1)
Decimal('0.1000000000000000055511151231257827021181583404541015625')

어쨌든 Decimal('0.1')은 실제로 1/10과 동일하게 계산되는 결과를 항상 보여줄 것이다. 다만 유념해야 할 것은 Decimal이라는 이름이 암시하듯, 10진 표기법으로 사용하는 소수점 계산을 정확하게 한다는 것이다. 10진 표기법으로 나타낼 수 없는 1/3과 같은 값은 Decimal에서도 여전히 근사값일 수 밖에 없다.

>>> a = Decimal(1) / Decimal(3)
>>> a * 3
Decimal('0.9999999999999999999999999999')

여담으로 글 서두에 나온 코드는 그럼 어떻게 바꿔야 할까? 1에서 0.1을 열 번뺀 것이 0이 정확하게 되지 않는 다는 사실만 염두에 두면, while a > -0.1 로 쓰면 된다. 하지만 본질적으로 저 코드는 루프를 시작하기 전에 최대 반복횟수가 이미 결정되는 루프이기 때문에 for 문으로 바꿔서 쓰는 것이 더 좋은 습관이다.

a, b = 1, 0.1
for _ in range(10):
  pass
  # 뭔가 반복할 코드

Decimal 타입은 기본적으로 28자리의 정밀도를 지원하는데, 필요에 따라서 더 높은 정밀도를 취하도록 변경할 수 있다. 또한 파이썬의 산술 관련 내장 함수들은 Decimal 타입에 대해서도 잘 작동하도록 만들어져 있다. 다만 주의할 점은 일반적인 float 타입 값과 Decimal 타입 값을 하나의 표현식에서 혼합하여 연산할 수 없다. 이 때에는 float 타입 값을 명시적으로 Decimal 타입 값으로 변환하여 사용해야 한다.