Computer Science/Data Structure

[실수(Real Number)] 부동 소수점과 오차

빵빵0 2023. 10. 2. 01:16
실무에서 소수점을 중요하게 다루는 작업이 많이 이뤄진다. 실수 계산의 정확도가 떨어지는 경우를 발견하고, 해결하는 작업을 하면서 다시금 부동 소수점에 대해 공부를 하게 되었고 이를 정리한 글이다.

컴퓨터는 0과 1로 이루어진 기계어를 사용한다. 인간은 수를 표현할 때 기본적으로 10진법을 사용하지만, 컴퓨터는 0과 1을 사용한 2진법을 사용한다. 그러므로 컴퓨터가 수를 표현하는 법에 대해서 얘기하려면 10진수를 2진수로 바꾸는 방법에 대해 알아둘 필요가 있다.

이진 기수법

0, 1, 10, 11, 100, 101, 110, 111, 1000, ... 이런 식으로 10진수 기준으로 2가 나올 차례가 되면 2를 쓰는 대신 자릿수를 늘려주는 것이다.

10진수에서는 10^n에 해당하는 수가 될 때마다 자릿수가 올라갔다면, 2진수에서는 2^n에 해당하는 수가 될 때마다 자릿수가 올라간다.

 

10진수 정수를 2진수로 바꾸을 그림으로 설명하면 아래와 같다.

10진수를 1이 될 때까지 계속 2로 나눠가면서 나머지를 구하고, 밑에서부터 거꾸로 읽으며 왼다.

10진수 35는 2진수 100011 이다.

 

그렇다면, 실수는 어떻게 변환할까?

실수는 보통 정수부와 소수부로 나눈다.

정수부는 일반 정수 변환과 똑같이 2진수로 변환하면 된다.

 

문제는 소수부인데, 얼핏 생각하면 소수점 뒤에 있는 숫자들을 하나씩 2진수로 바꿔버리면 되지 않냐고 생각할 수 있지만, 그렇게하면 아래 예시처럼 서로 다른 10진수 숫자가 2진수로 변환되었을 때 중복이 되는 문제가 있다. (정수부도 그렇게 10진수를 자리별로 쪼개서 2진수로 변환하면 같은 문제가 발생)

1.9 -> 1.1001
1.41 -> 1.100 1

소수부는 정수부 변환의 정 반대로 하면 된다.

즉, 정수부에서는 10진수를 2로 나눠가면서 1, 0을 뽑았다면, 소수부는 10진수에 2를 곱해가면서 1,0을 뽑아낸다.

그리고 정수부를 변환할 때는 1이 나오면 종료했다면, 소수부는 0이 나오면 종료하고, 결과를 위에서부터 읽어준다.

 

0.625를 이진수로 변환하는 예시를 보자.

0.625 * 2 = 1.25 -> 1을 빼내고 나머지 0.25
0.25 * 2 = 0.5 -> 0을 빼내고 나머지 0.5
0.5 * 2 = 1.0 -> 1을 빼내고 나머지 0

빼낸 숫자들을 위에서부터 읽어주면 0.625 -> 0.101이 된다.

 

직관적으로 알 수 있는 사실은 소수부의 숫자가 n/(2의 배수) 꼴의 분수로 표현되는 숫자면(2의 마이너스 거듭제곱) 2진수로 바꿨을 때 자릿수가 적고, 그렇지 않을 수록 자릿수가 늘어날 것이라는 사실이다.


실수의 표현 방식

컴퓨터에서 실수를 표현하는 방법은 정수에 비해 훨씬 복잡하다. 왜냐하면, 컴퓨터에서는 실수를 정수와 마찬가지로 2진수로만 표현해야 하기 때문이다. 따라서 실수를 표현하기 위한 다양한 방법들이 연구되었으며, 현재에는 다음과 같은 방식이 사용되고 있다.

고정 소수점(fixed point) 방식

실수는 보통 정수부, 소수부로 나눌 수 있다

따라서 실수를 표현하는 가장 간단한 방식은 소수부의 자릿수를 미리 정하여, 고정된 자릿수의 소수를 표현하는 것이다.

 

32bit 실수를 고정 소수점 방식으로 표현하면 다음과 같다.

부호는 0이면 양수, 1이면 음수

예를 들어, 7.625를 2진수로 변환하면 111.101이다. 고정 소수점 방식에서는 아래와 같이 저장한다.

소수부의 경우, 앞에서부터 채우며 남는 뒷자리는 다 0으로 채운다.

 

이 방식은 구현하기 편리하지만, 사용하는 비트 수 대비 표현 가능한 수의 범위 또는 정밀도가 낮다는 단점이 있다.

때문에 범용 시스템에서는 거의 쓰이지 않고, 높은 정밀도가 필요 없는 소규모 시스템에서는 간혹 쓰이기도 한다.

부동 소수점(floating point) 방식

실수는 보통 정수부와 소수부로 나누지만, 가수부, 지수부로 나누어 표현할 수 있다.

부동 소수점 방식은 이렇게 하나의 실수를 가수부와 지수부로 나누어 표현하는 방식이다.

 

이는 2진수 변환을 그래도 쓰지 않고 정규화(Normalization)을 거치기 때문이다.

정규화라는 단어는 수학, 컴퓨터 분야에서 다양한 의미로 쓰이지만 여기서 말하는 정규화는 2진수를

1.xxxxx * 2^n

꼴로 변환하는 것을 말한다.

변환하는 방법은 간단한데, 정수부에 1만 남을 때까지 소수점을 왼쪽(*정수부가 0일 경우에는 오른쪽)으로 이동시키고, 이동한 칸 수를 n으로 한다. 예를 들어, 10진수 7.625, 2진수 111.101을 정규화하면 1.11101 * 2^2가 된다.

여기서 소수점을 '이동' 시킨다는 데서 '부동' 소수점, floating point라는 용어가 나온게 아닐까 싶다.

 

따라서 고정 소수점 방식과 다르게 부동 소수점 방식은 다음 수식을 이용하여 매우 큰 실수까지도 표현할 수 있다.

±(1.가수부)×2^(지수부-127)

어디서 많이 본 표현 방식

현재 대부분의 시스템에서는 부동 소수점 방식으로 실수를 표현하고 있다.

IEEE 부동 소수점 방식

현재 사용되고 있는 부동 소수점 방식은 대부분 IEEE 754 표준을 따르고 있다.

32비트의 float형 실수를 IEEE 부동 소수점 방식으로 표현하면 다음과 같다.

23자리 가수부는 정규화 결과 소수점 오른쪽에 있는 숫자들을 왼쪽부터 그대로 넣으면 된다. 남는 자리는 0으로 채운다.

(참고: 소수점 왼쪽은 정규화를 거치면 무조건 1이기 때문에 신경쓰지 않고, 표현도 하지 않는데 이 1을 hidden bit라고 부르기도 한다)

남은 8자리 지수부는 n에 해당하는 수를 2진수로 바꿔 넣으면 된다. 그러나 IEEE 표준에 따르면 n을 그대로 넣는 것이 아닌, 'bias'라고 하는 지정된 숫자를 더한 다음 넣는다.

Bias에 대해서는 다음 글에서 다룬다.

 

IEEE 표준에서 32비트를 쓰는 경우 bias는 127이라고 규정하고 있다.

위의 10진수 7.625, 2진수 111.101 예제를 다시 보자. 부동소수점 방식으로 표현하면, 1.11101 * 2^2 이고 n = 2이다.

따라서 2+ 127 = 129를 2진수로 바꾼 10000001이 지수부에 들어간다.

결론적으로 7.625는 컴퓨터에서 아래와 같이 저장된다.

 

64비트의 double형 실수를 IEEE 부동 소수점 방식으로 표현하면 다음과 같다.

이와 같은 부동소수점 표현 방식은 고정 소수점 표현 방식에 비해 비트 수 대비 표현 가능한 수의 범위와 정밀도 측면에서 보다 우위에 있다. 정규화, bias 과정이 들어감에도 현재 대부분의 컴퓨터 시스템에서 부동소수점을 이용해 실수를 표현하고 있다.

부동 소수점 방식의 오차

부동 소수점 방식을 사용하면 고정 소수점 방식보다 훨씬 더 많은 범위까지 표현할 수 있다.

하지만 이 방식에 의한 실수 표현은 항상 오차가 존재한다는 단점을 가지고 있다.

이것은 모든 프로그래밍 언어에서 발생하는 기본적인 문제다.

 

부동 소수점 방식에서의 오차는 앞서 살펴본 공식에 의해 발생한다.

이 공식을 사용하면 표현할 수 있는 범위는 늘어나지만, 10진수를 정확하게 표현할 수는 없다.

가수부는 항상 1/2 + 1/8 + 1/16 ... 으로 표현이 된다.

다시 말해, 분모가 2의 거듭제곱 꼴이 아니라면, 부동 소수점으로 표현하면 오차가 생긴다.

가수부(fraction) = t/r 로 표현이 되고, t,r은 서로소라고 하자.
그러면 t/r = Z/2^L 꼴로 표현이 되어야 한다.(단 Z는 0보다 크거나 같은 정수, L은 double의 경우 52 정도(가수부 필드의 길이 정도))
Z로 표현하면, Z = (t*(2^L)) / r 이된다.
Z는 정수가 나와야 하는데 t, r은 서로소다. t의 소인수를 t(1), ... , t(k), r의 소인수를 r(1), ..., r(u) 라고 하자. 1<=x<=u에 대해서 r(x)는 t의 소인수가 아니다. Z가 정수가 되려면 2를 가지고 있을 수 밖에 없으므로, 결론적으로 r의 소인수 집합이 {} 이거나 {2} 이어야 한다.(소인수가 아예 없거나, 2만 있거나)
즉, 가수부는 t/2^u꼴로 표현이 되거나 0이어야 한다. 그렇지 않다면 2진수로 표현했을 때 무한소수로 표현이 될 수 밖에 없다.

따라서 컴퓨터에서 실수를 표현하는 방법은 정확한 표현이 아닌 언제나 근사치를 표현할 뿐임을 항상 명심해야 한다. (2진수로 표현할 방법이 없음)

 

다음 예제는 부동 소수점 방식으로 실수를 표현할 때 발생할 수 있는 오차를 보여주는 예제다.

double num = 0.1;

for(int i = 0;  < 1000; i++) {
    num += 0.1;
}

System.out.print(num);
실행결과: 100.09999999999859

위의 예제에서 0.1을 1000번 더한 합계는 100이 되어야 하지만, 실제로는 100.09999999999859가 출력된다.

 

다음 예제는 자바의 실수형 타입인 double형과 float형이 표현할 수 있는 정밀도를 보여주는 예제다.

float num3 = 1.23456789f;
double num4 = 1.23456789;

System.out.println("float형  변수 num3 : " + num3);
System.out.println("double형 변수 num4 : " + num4);
실행결과:
float형 변수 num3 : 1.2345679
double형 변수 num4 : 1.23456789

float형 타입은 가장 높은 자릿수부터 6자리까지는 정확하게 표현할 수 있으나, 그 이상은 정확하게 표현하지 못한다.

자바의 double형 타입은 가장 높은 자릿수부터 15자리까지 오차없이 표현할 수 있다.

하지만 그 이상의 소수 부분을 표현할 때는 언제나 오차가 발생하게 된다.

 

오차 해결 방법

BigDecimal 자료형 사용; 물론 리소스를 많이 사용하게 될 수 있다.

Python의 경우, deciaml 모듈 or Fraction 모듈

Golang의 경우, Big 패키지 or Deicmal 패키지 사용

 

이 사이트에서 각종 언어들에 대한 부동 소수점 처리 방식 및 그에 대한 결과를 볼 수 있다.

 

참고 사이트