부동 소수점과 정밀도
1. 개요(Outline)
컴퓨터는 내부적으로 0과 1, 두 가지만 활용해 모든 것을 표현합니다. 숫자의 표현 또한 마찬가지입니다. 사람은 일반적으로 숫자 표현에 10진법을 사용하지만, 컴퓨터는 0과 1만을 사용해야 한다는 제약 때문에 2진법을 채택해야 합니다. 이때 표현하고자 하는 숫자가 정수인지 아닌지에 따라 처리 방식이 크게 나뉩니다. 이번 포스팅에서는 컴퓨터의 숫자 표현 방법, 그 중에서도 부동 소숫점을 집중적으로 다룹니다.
2. 정수 표현(Integer Rerpesentation)
현대 컴퓨터에서는 대부분 비슷한 숫자 표현법을 채택합니다. 정수는 부호 비트와 절댓값 방식과 보수법이 있습니다. 부호 비트와 절댓값 방식은 간단합니다. 최상위 비트로 부호를 표현하고, 나머지 비트로 숫자의 절댓값을 표현하기만 하면 됩니다. 하지만 부호를 위해 활용하는 저 1개의 비트 때문에 표현 가능한 숫자의 범위가 절반으로 줄고, 숫자 0이 음수와 양수 두 가지로 표현된다는 문제점이 있습니다. 보수법은 일반적인 2진법 표기로 양수를 표현하고, 음수는 절댓값이 같은 양수 표현에서 모든 비트를 반전시키는 방식입니다. 이는 크게 1의 보수법, 2의 보수법으로 나뉘지만 1의 보수법은 부호 비트와 절댓값 방식과 마찬가지로 0의 표현이 두 가지라는 문제 때문에 현대 컴퓨터에서는 일반적으로 2의 보수법을 채택하여 정수를 표현합니다.
3. 부동 소수점(Floating Point)
실수 표현은 정수 표현과는 크게 차이가 있습니다. 정수 표현 마찬가지로 실수 표현도 다양한 방식이 있지만 대표적으로는 고정 소수점 방식과 부동 소수점 방식이 널리 알려져 있습니다. 고정 소수점 방식은 부동 소수점 방식에 비해 간단합니다. 정수 부분의 비트 크기와 소수 부분의 비트 크기를 고정적인 값으로 설정하는 방식이에요. 예를 들어, 32bit로 고정 소수점을 정의한다면 10bit를 정수 표현에, 나머지 22bit를 소수 표현에 사용할 수 있겠죠.
하지만 고정 소수점은 값의 표현 범위가와 정밀도(Precision)가 반비례 관계에 있다는 문제점이 있어요. 만약 소수 표현에 사용하는 bit 크기가 작다면 표현 가능한 소수의 범위가 작아지고 이에 따라 연산 결과도 한정적입니다. 반대로 소수 부분 bit 크기를 키운다면 표현 가능한 정수의 범위가 작아지겠죠. 하지만 예측 가능한 숫자 범위 내의 연산만 필요하다면 고정 소수점 방식도 좋은 선택지입니다. 이번 포스팅의 주제인 부동 소수점보다 연산 속도도 빠르기 때문에 게임이나 DSP 어플리케이션에 많이 채택되어 활용된다고 합니다. GnuCash라는 오픈소스 화폐 관리 어플리케이션은 부동 소수점을 사용하다가 복잡한 반올림 규칙 때문에 고정 소수점 방식으로 전환했다고 해요.
부동 소수점은 같은 bit 크기에서 고정 소수점보다 더 넓은 범위의 숫자를 표현할 수 있는 방식입니다. 다음 그림은 IEEE 754는 현대 컴퓨터에서 가장 널리 쓰이는 부동 소수점 표현의 표준 형태를 나타냅니다.
위 그림에서 알 수 있듯, 부동 소수점은 숫자 표현에 있어 지수(Exponent)와 가수(Mantissa) 개념을 활용합니다. 이때 가수는 지수의 밑보다 작은 한 자리 수 정수, 즉, 정수 부분이 1인 소수로 정규화한 형태의 숫자에서 소수점 이하 부분의 숫자값을 의미합니다. 말로 표현하면 복잡하지만, 1.1875
라는 정규화된 숫자가 있다면 가수는 1875
가 됩니다. 부동 소수점 표현은 다음과 같은 수식으로 표현할 수 있습니다.
$$((1.mantissa) \times 2 ^ {(exponent - 127)})$$
즉, 어떤 소수를 2의 거듭제곱과 정규화된 수의 곱 형태로 나타내는 것이 부동 소수점 방식입니다. 부동 소수점 방식의 부동(浮動)은 ‘떠나니며 움직인다’라는 뜻입니다. 이는 지수 값에 따라 소수점이 이동하는 것처럼 보이는 것에서 비롯되었어요. 표현 가능한 값을 단순 계산해보면, 앞서 살펴보았던 고정 소수점보다 훨씬 많은 값을 표현할 수 있음을 알 수 있습니다. 그 이유는 bit에 저장되는 정보들만으로 값을 표현하는 것이 아닌 지수와 가수 개념을 활용한 추가적인 연산 과정이 있기 때문이에요. 이런 연산은 CPU의 연산 담당 모듈인 ALU의 FPU(Floating Point Unit)에서 처리합니다. 추가적인 연산 과정이 있기 때문에 고정 소수점 방식보다는 느리지만 값의 표현 범위가 훨씬 넓기 때문에 가장 많이 채택된 방식이 되었습니다. C/C++ 포함하여 다양한 언어들에서도 Floating Point 자료형을 지원하고 있어요. 참고로 JavaScript는 다른 강타입 언어들과 달리 숫자 자료형이 세분화되어 있지 않고, number 타입만이 존재하는데 이는 IEEE-754 표준을 따르는 64bit 기반 부동 소수점입니다.
그런데 Floating Point를 지원하는 언어에서 연산을 활용해보신 분들은 이상한 점들을 느꼈을 수도 있어요. 예를 들어 0.1 * 0.1
의 연산 결과를 출력해보면 0.010000000000000002
와 같은 말도 안 되는 값이 나오거나 하는 현상들이 있습니다. 이런 현상은 부동 소수점의 정밀도 문제에 기인합니다.
4. 정밀도(Precision)
앞서 고정 소수점의 문제점에서 정밀도라는 단어를 사용했습니다. 정밀도란, 측정이나 계산의 결과가 서로 얼마나 가까운지 나타내는 값이라고 합니다. 고정 소수점 방식, 부동 소수점 방식에서는 간단하게 소수를 표현하기 위한 비트 수(bit length)라고 생각하면 될 것 같네요.
고정 소수점 방식이든 부동 소수점 방식이든 컴퓨터에서는 어떤 소수들을 정확하게 표현할 수 없습니다. 게다가 소수들의 표현 범위 또한 10진수보다도 한정적이에요(참고: 컴퓨터에서는 0.1 × 0.1 이 0.01 이 아닙니다). 그 이유는 역시 컴퓨터가 2진법을 활용하고, 어떤 값을 표현하기 위해 할당할 수 있는 저장 공간도 한정되어 있기 때문이에요. 그럼에도 불구하고 우리가 사용하는 계산기나 은행 시스템, 혹은 쇼핑몰의 리뷰 점수 등은 소수의 연산까지도 정확하게 표현합니다. 이제 왜 오차가 발생하는지 더 자세하게 알아보고, 이를 해결하기 위해서는 어떻게 해야하는지 알아봅시다.
먼저 우리에게 익숙한 10진법으로 소수가 어떻게 표현되는지 살펴보겠습니다. 아래 수식처럼 모든 10진수 유한 소수는 10의 거듭제곱의 합 형태로 나타낼 수 있습니다.
$$(125.625 = (1 \times 10^2) + (2 \times 10^1) + (5 \times 10^0) + (6 \times 10^{-1}) + (2 \times 10^{-2}) + (5 \times 10^{-3}))$$
무한 소수는 어떨까요? 무한 소수도 유한 소수와 마찬가지로 표현할 수 있지만 큰 차이점은 일반적인 항의 합을 나열하는 형태로는 표현할 수 없다는 것입니다. 소수점 아래의 항이 끝 없이 이어지기 때문이에요. 무한 소수는 10의 거듭 제곱으로 표현할 수 없기 소수입니다. 다시 말해 분수 꼴로 표현 했을 때, 분모를 10의 거듭제곱 꼴로 표현할 수 없다는 의미에요.
2진법으로 표현한 소수도 동일합니다. 유일한 차이점은 10의 거듭제곱의 합 형태가 아니라 2의 거듭제곱 합 형태로 표현하는 점이죠. 무한 소수가 있다는 것도 같습니다. 분수 꼴로 표현했을 때, 분모가 2의 거듭제곱 꼴로 표현할 수 없는 수라면 2진법에서는 무한 소수로 표현될 거에요. 예를 들어, 0.1
이라는 숫자를 기약분수로 표현하면 분모가 10인데, 분모에 어떤 자연수를 곱해도 2의 거듭 제곱 형태로는 표현이 불가능합니다. 즉, 10진법으로 표현한 유한 소수 0.1
은 2진법으로 표현했을 때 무한 소수가 되는 거에요.
$$(0.1_{(10)} = 0.0001100110011…_{(2)})$$
컴퓨터에서는 숫자를 표현할 떄는 한정된 크기(워드 단위)를 갖기 때문에 위와 같은 무한 소수는 완벽하게 표현할 수 없게 됩니다. 어떤 방식이든 표현 가능한 범위를 넘어서게 되면, 넘어선 범위의 정보들은 잘리고 근사치로 표현하는 거죠.
이제 앞서 보았던, 0.1 * 0.1
과 같은 연산에서 왜 오차가 발생하는지 알 수 있습니다. 부동 소수점 방식 0.1
을 표현하기 위해서는 다음과 같이 1.0
과 2의 거듭 제곱의 곱 형태로 표현해야 합니다.
$$(1.0 * 2^{(지수)})$$
하지만 지수에 어떤 수를 넣어도 절대 0.1
이 될 수는 없어요. 10진수 0.1
을 분수로 표현하여 분모를 2의 거듭제곱 꼴로 변형하려 해도 불가능하기 때문이에요. 그래서 0.1
* 0.1
을 하게 되면, 근사치를 곱해버리는 꼴이 되는데 이 떄의 오차가 더욱 커져 예측하지 못한 결과를 얻게 되는 것입니다.
이런 오차를 줄이고, 표현의 범위도 더 넓히기 위해 부동 소수점 방식에는 단정밀도(32bit)와 배정밀도(64bit) 등 데이터 크기에 따라 세분화된 구분을 두고 있습니다. 일반적으로 흔히 알고 있는 C계열 언어의 float
키워드는 단정밀도를, double
키워드를 배정밀도를 나타냅니다.
그럼 정확한 연산을 위해서는 어떻게 해야할까요? 단순하게 정밀도를 위한 bit 공간을 더 할당하는 될까요?
자료형의 크기는 고정되어 있고, 만약 자료형 자체의 크기를 늘리더라도 정확도는 조금 향상되겠지만 오차는 계속 존재하며 메모리를 더 많이 사용한다는 단점이 크게 다가옵니다. 이런 문제를 해결 하기 위해 임의 정밀도(arbitrary-precision)라는 개념을 활용합니다. 간단하게 임의 정밀도는 데이터를 고정된 비트 수로 처리하지 않고, 가변적으로 처리하는 방법입니다. 물론 이를 실제로 구현하는 것은 굉장히 복잡한 일입니다. 일반적으로 소프트웨어적으로 구현하게 되는데 적용하는 알고리즘에 따라 효율이 크게 달라지고, 다양한 상황을 고려해야 하기 때문이에요.
Don’t reinvent the wheel!
바퀴를 재발명하지 마세요!
컴퓨터에서 숫자를 정확히 다루어야 하는 요구는 먼 과거부터 있었기 때문에 정확한 연산을 처리하기 위한 안정적인 라이브러리나 내장 기능들이 풍부하게 존재합니다. 따라서 연구의 목적이 아니라면 필요에 따라 적절한 것을 선택해서 잘 활용할 수 있으면 되겠죠?
Java에는 BigDecimal이라는 자료형이, JavaScript에는 decimal.js 라이브러리가 있습니다. 사용법은 별도로 포스팅하지 않으니, 만약 필요하다면 사용법과 원리를 잘 파악하고 유용하게 활용할 수 있을 거에요.
5. 결론(Conclusion)
- 컴퓨터에는 숫자를 처리하는 다양한 방식이 있다.
- 소수를 표현하는 방법은 고정 소수점, 부동 소수점 방식이 있지만 둘 다 완벽한 연산을 지원하지 못 한다.
- 만약 정확한 연산을 필요로 한다면, 임의 정밀도를 지원하는 자료구조, 라이브러리를 활용할 수 있다.
6. 참고(Reference)
- Computer Organization & Design, David Patterson, John Hennessy
- Medium(Seo Jay), Fixed Point Number
- TCPSchool, C언어 - 실수의 표현
- Stackoverflow, Why are floating point numbers inaccurate?