djm03178's profile image

djm03178

May 15, 2020 15:20

부동소수점 자료형에 대한 이해 1

floating-point , float , double

서론

많은 프로그래밍 언어들에서 우리는 부동소수점 자료형을 사용하게 됩니다. 대표적으로 C, C++의 floatdouble, 그리고 long double이 있습니다. 이러한 자료형들을 사용할 때에는 항상 듣는 주의사항이 있습니다. “오차가 발생할 수 있으니까 주의해야 해!”

이러한 경고는 듣는 것만으로는 잘 와닿지 않지만, 부동소수점 자료형들을 실제로 사용해보면 상식적으로는 이해되지 않는 어처구니 없는 상황을 목격하게 됩니다. 대표적으로 0.1 + 0.2 == 0.3이 거짓이라거나, 1.0 / 7.0을 7번 더했는데 1.0과 다르다고 나오는 것들을 보면서 비로소 이 자료형은 이런 간단한 연산 하나 정확하게 수행할 줄 모르는 몹쓸 놈이구나 하고 생각하게 됩니다.

하지만 그 오차가 왜, 어떻게, 얼마나 발생하게 되는지를 아는 것은 간단하지 않고, 왜 이런 자료형들의 이름을 부동소수점 (floating-point) 자료형이라고 부르는지도 선뜻 이해되지는 않습니다. 이 글에서는 부동소수점 자료형이 고정소수점 자료형과 가지는 차이, 장단점을 알아보고 현대 컴퓨터에서 가장 많이 쓰이는 부동소수점 표준인 IEEE 754를 통해 구성 요소를 파헤쳐 보도록 하겠습니다.

고정소수점과 부동소수점

보통 학교에서 소수에 대해 가장 먼저 배우고 또 가장 많이 사용하는 일반적인 소수 표기는 고정소수점 (fixed-point)입니다. 점 (.)을 기준으로 왼쪽에 정수부를 적고, 오른쪽에 소수부를 적습니다. 이를 한정된 공간에 나타내려면, 아래 그림과 같이 특정 위치를 기준점으로 잡은 뒤 양쪽에 각각 정수부와 소수부를 적으면 됩니다.

고정소수점 자료형의 구조

이 방법은 정수가 표현하는 범위를 소수 자릿수만큼 밑으로 내린 것과 같기 때문에 범위 내의 모든 수를 오차 없이 정확하게 표현할 수 있고 정수형과 똑같은 방법으로 계산할 수 있다는 장점이 있습니다. 하지만 이 방법에는 매우 큰 단점이 있습니다. 바로 표현 범위가 매우 작다는 것입니다. 본래 10자리를 표현할 수 있던 자료형의 5자리를 떼어 소수부에 나눠준다면, 그 자료형이 표현할 수 있는 수의 범위는 정수부 기준으로 다섯 자리밖에 되지 않는다는 뜻이 됩니다.

이러한 단점을 보완하기 위해 널리 쓰이는 자료형이 바로 부동소수점 자료형입니다. 이는 학교에서 배운 개념들 중 유효숫자 와 연관이 깊습니다. 또한 과학적 표기 에 해당합니다. 이에 대해 간단히 설명하자면, $0.0035$라는 값을 $3.5 \times 10^{-3}$ 과 같은 방법으로 표기해서, 그 값을 표현하는 데에 실제로 의미가 있는 숫자들을 모으고 거기에 다른 수를 곱하여 그 값의 실제 크기를 표현하는 방식입니다. C 계열 언어들에서도 10진수로 이러한 표기를 지원하는데, 예를 들어 1e9 (= $10^9$) 등이 있습니다.

이러한 표기를 사용하기 위해서는 가수부지수부 가 필요합니다. 가수부는 앞서 언급한 유효숫자들이 들어가는 부분이고, 지수부는 $10^n$의 $n$이 들어가는 부분입니다. 물론, 일반적으로 컴퓨터는 2진법을 사용하므로 컴퓨터에서 사용하는 부동소수점 자료형은 $2^n$이 됩니다.

부동소수점 자료형의 구조

이 자료형의 단점은 명확합니다. 전체 공간의 상당한 부분을 지수부에 떼어주었기 때문에 그만큼 표현할 수 있는 유효숫자의 개수가 줄어듭니다. 반면에 장점 역시 명확합니다. 고정소수점과는 비교가 되지 않을 정도로 큰 범위의 수를 표현할 수 있다는 점입니다. 또한 매우 작은 수들 역시 비교적 정확하게 표현하는 것이 가능합니다.1 이에 대한 고찰은 잠시 뒤에 하도록 하겠습니다. 이러한 표기법을 부동소수점이라고 부르는 이유는 바로 이 지수부의 크기에 따라 소수점의 위치가 바뀌기 때문입니다.

IEEE 754

위 문단에서는 부동소수점의 대략적인 개념에 대해 설명했지만, 컴퓨터에서 구체적으로 바이트, 비트 단위로 어떻게 적용시키는지는 아직 감이 잡히지 않습니다. 어느 지점에서 지수부와 가수부를 나눌 것인지, 양수나 음수, 0, NaN 등은 어떻게 표현할 것인지 등에 대해 알아본 것이 없습니다. 이 기준을 정하는 방법은 여러 가지가 있지만, 이 글에서는 컴퓨터에서 가장 널리 사용되는 표준인 IEEE 754를 기준으로 설명하겠습니다.

IEEE 754에서는 다음과 같은 것들을 정의합니다.

  • 산술 형식
  • 형식 변환
  • 반올림 규칙
  • 연산
  • 예외 처리

IEEE 754는 2진법뿐 아니라 10진법에 대해서도 형식을 정의하지만, 이 글에서는 2진법에 대해서만 다루겠습니다. 이 표준에서 다루는 이진법 표기에는 크게 다섯 가지 (16비트, 32비트, 64비트, 128비트, 256비트)가 있는데, 요즘 컴퓨터에서 C, C++의 float로 가장 자주 쓰이는 32비트를 기준으로 설명하도록 하겠습니다.

비트 구조

IEEE 754의 2진법 32비트 실수형은 다음과 같은 구조를 사용합니다.

32비트 부동소수점 자료형의 구조

이 그림은 왼쪽이 가장 높은 비트, 오른쪽이 가장 낮은 비트를 나타냅니다. 크게 세 개의 부분으로 나누어지는데, 각 부분은 다음과 같은 의미를 가집니다.

  • 부호 비트 (sign bit): 정수형과 같이 $0$이면 양수, $1$이면 음수를 나타냅니다.
  • 지수부 (exponent): $2^{k-bias}$에서의 $k$값을 나타냅니다. $bias$는 거듭제곱의 범위가 음수와 양수에 걸쳐 고르게 나타날 수 있도록 정해놓은 오프셋으로, 32비트 자료형에서는 $127$의 값을 가집니다.
  • 가수부 (fraction): $1.xyz… _ {(2)}$의 형태로 표기한 가수입니다. 왼쪽부터 차례대로 채웁니다.

분석 프로그램

실수 값이 이 구조에 어떻게 저장되는지를 보다 쉽게 알아보기 위한 분석 프로그램을 하나 작성하고, 이 코드를 바탕으로 구조를 이해하고 float형의 비트 값을 보고 10진수로 해석하는 방법에 대해 설명하도록 하겠습니다. 이 프로그램은 float가 32비트인 IEEE 754 표준을 따르면서 대다수 개인용 컴퓨터의 아키텍처가 사용하는 little endian 방식을 사용하는 환경에서 동작합니다.

#include <iostream>
#include <string>
using namespace std;

int binary_to_int(string s)
{
	int result = 0;
	for (int i = 0; i < (int)s.size(); i++)
		result += ((s[i] - '0') * (1 << ((int)s.size() - i - 1)));
	return result;
}

void print_bits(float f)
{
	const int bias = 127;
	uint32_t val = *reinterpret_cast<uint32_t*>(&f);
	string s;
	for (int i = 31; i >= 0; i--)
		s += (val & (1u << i)) ? '1' : '0';
	int sign = s[0] - '0';
	int exponent = binary_to_int(s.substr(1, 8));
	double fraction = 1 + binary_to_int(s.substr(9, 23)) / double(1 << 23);

	cout << fixed;
	cout.precision(9);
	cout << f << ": " << s << '\n';
	cout << "sign: " << sign << " (" << (sign ? "negative" : "positive") << ")\n";
	cout << "exponent: " << s.substr(1, 8) << " (" << exponent << ", -bias = " << exponent - bias << ")\n";
	cout << "fraction: " << s.substr(9, 23) << " (" << fraction << ")\n";
	cout << endl;
}

int main()
{
	float f = 123.45f;
	print_bits(f);
}

binary_to_int

binary_to_int 함수는 문자열로 표현된 이진수를 그에 해당하는 정수값으로 바꾸어주는 역할을 합니다. 가장 오른쪽의 자리는 $2^0$, 그 왼쪽은 $2^1$, …, 가장 왼쪽은 $2^{s.size() - 1}$임을 이용하여 값을 구해줍니다.

print_bits 함수를 통해 float 값을 분석한 결과를 출력할 수 있습니다.

먼저 주어진 값을 비트 단위로 확인하기 위해 f 전체를 하나의 부호 없는 정수값으로 강제 변환시켰습니다. float에는 비트 연산을 사용할 수 없기 때문입니다.

uint32_t val = *reinterpret_cast<uint32_t*>(&f);

그 다음은 비트 단위로 확인하여 1인 비트는 ‘1’, 0인 비트는 ‘0’을 문자열로 이어붙여줍니다. 최상위 비트가 가장 왼쪽에 올 수 있도록 높은 비트부터 낮은 비트 순으로 봅니다.

string s;
for (int i = 31; i >= 0; i--)
  s += (val & (1u << i)) ? '1' : '0';

이제 세 부분을 각각 구해 보겠습니다. 부호 비트는 가장 왼쪽의 비트가 0이면 양수, 1이면 음수이므로 s[0] - '0'으로 간단히 구할 수 있습니다. 지수부는 1번 비트부터 8번 비트까지를 정수형으로 표현하면 되므로 s[1,8]을 정수형으로 변환하면 됩니다. 가수부는 s[9,31]의 값을 구하면 되는데, 추가로 해야 할 사항이 있습니다. 이 부분의 값은 위에서 살펴본 것과 같이 1. 뒤에 왼쪽부터 순서대로 이어붙인 수이므로 실제로 나타내는 값은 이를 $2^{23}$으로 나누어준 값에 1을 더한 값이 됩니다.

int sign = s[0] - '0';
int exponent = binary_to_int(s.substr(1, 8));
double fraction = 1 + binary_to_int(s.substr(9, 23)) / double(1 << 23);

실행 결과 및 분석

이제 이 프로그램을 실행시켜보면 다음과 같은 결과를 얻습니다.

123.449996948: 01000010111101101110011001100110
sign: 0 (positive)
exponent: 10000101 (133, -bias = 6)
fraction: 11101101110011001100110 (1.928906202)

우선 눈에 띄는 현상은, 분명히 값을 명시적으로 123.45f로 대입했는데도 불구하고 자릿수를 늘리니 123.449996948과 같이 부정확한 값을 출력했다는 것입니다. 이는 123.45f라는 값을 float의 표현법으로 정확하게 나타낼 수 없음을 보여줍니다.

가장 왼쪽의 비트가 $0$이므로 이 값이 양수임을 볼 수 있습니다. 123.45f 대신 -123.45f를 넣고 실행하면 오직 해당 부분만이 $1$로 변하게 됩니다.

지수부는 $133$이라는 값이 나왔는데, 이는 bias가 적용된 값이므로 실제로 나타내는 값은 $127$을 뺀 $6$입니다. 즉, 가수부가 나타내는 값에 $2^6$을 곱한 것이 실제로 나타내고자 하는 값이 됩니다.

가수부는 매우 복잡해 보이지만 규칙성이 있는데, 이는 십진수의 유리수의 나눗셈에서 순환소수가 만들어지는 원리와 정확히 같은 이유로 만들어집니다. 십진수로 표현했을 때의 값이 $1.928906202$가 나왔는데, 여기에 위에서 구한 지수부의 값 $2^6=64$를 곱하면 $123.449996928$로 $123.45$에 매우 근접한, 그러나 오차가 있는 값이 만들어지는 것을 볼 수 있습니다.

10진수를 float로 변환하기

이제 위와는 반대로, $123.45$라는 10진법의 소수가 float의 형태로 변환되어 저장되는 과정을 파헤쳐 보겠습니다.

가장 먼저 할 일은 부호를 판별하는 일입니다. $123.45$는 양수이므로, 부호 비트는 0으로 바로 결정됩니다.

다음은 이 수의 절댓값이 1.xyz...의 형태가 되도록 정규화시켜야 합니다. 이를 수행하기 전에, 먼저 이 수를 10진수에서 2진수로 변환할 필요가 있습니다. 그러면 $123.45$는 다음과 같이 변하게 됩니다.

$1111011.01110011001100110…$

아쉽게도 이 수는 무한소수이기 때문에 부득이하게 ...으로 뒤쪽을 잘라낼 수밖에 없습니다.2 바로 이 과정에서 오차가 발생하게 됩니다. 가장 앞 자리는 항상 1이고, 이 부분은 비트에 따로 표기하지 않아도 정의상 이미 들어가 있기 때문에 제거해 줍니다.

$111011.01110011001100110…$

이제 정규화를 수행하기 위해 소수점을 가장 앞쪽까지 당깁니다. 이 수에서는 6칸을 앞당겨야 합니다. 그 후 소수점 아래 부분만을 23자리까지 표기하면 다음과 같고, 이 부분이 가수부의 비트 형태가 됩니다.

$11101101110011001100110$

이제 마지막으로 지수부를 결정해야 하는데, 앞서 소수점의 위치를 6칸을 앞당겼고, 이는 가수부가 나타내는 값에 실제로는 $2^6$을 곱해야 한다는 의미입니다. 즉, 지수부의 값도 6이 되어야 합니다. 여기에 bias 값인 127을 더하여, 133에 해당하는 값을 지수부에 표기합니다.

$10000101$

이제 이 세 부분을 부호 비트 - 지수부 - 가수부 순으로 이어붙이면 최종 결과가 만들어집니다.

$01000010111101101110011001100110$

표현 가능한 한계

C의 float.h에는 실수형들이 표현할 수 있는 최소3, 최댓값 등과 같은 여러 속성들이 지정되어 있습니다. float의 경우 이 값들은 Visual Studio 2017 기준으로 다음과 같이 정의되어 있습니다.

#define FLT_DECIMAL_DIG  9                       // # of decimal digits of rounding precision
#define FLT_DIG          6                       // # of decimal digits of precision
#define FLT_EPSILON      1.192092896e-07F        // smallest such that 1.0+FLT_EPSILON != 1.0
#define FLT_HAS_SUBNORM  1                       // type does support subnormal numbers
#define FLT_GUARD        0
#define FLT_MANT_DIG     24                      // # of bits in mantissa
#define FLT_MAX          3.402823466e+38F        // max value
#define FLT_MAX_10_EXP   38                      // max decimal exponent
#define FLT_MAX_EXP      128                     // max binary exponent
#define FLT_MIN          1.175494351e-38F        // min normalized positive value
#define FLT_MIN_10_EXP   (-37)                   // min decimal exponent
#define FLT_MIN_EXP      (-125)                  // min binary exponent
#define FLT_NORMALIZE    0
#define FLT_RADIX        2                       // exponent radix
#define FLT_TRUE_MIN     1.401298464e-45F        // min positive value

이 중 핵심적인 상수 몇 가지에 대해서 살펴보겠습니다.

  • FLT_DIG: 10진수로 표현했을 때 정확성이 보장되는 자릿수입니다. 정확히는 10진수로 표기된 문자열을 float로 변환 후 다시 10진수의 문자열로 재변환했을 때 변하지 않음이 보장되는 자릿수입니다. 흔히 “float는 6자리까지이다”라고 하는 말이 이것을 뜻합니다. 또한 ‘몇 자리가 정확하다’는 것은 수의 절대적인 범위가 아닌 유효숫자의 개수로 정확성이 결정된다는 부동소수점의 특징을 보여주기도 합니다.
  • FLT_MAX: float가 표현할 수 있는 최댓값입니다. 지수부가 11111110이고, 가수부가 11111111111111111111111입니다.
  • FLT_MAX_10_EXP: float가 표현할 수 있는 가장 큰 10진수의 자릿수입니다.
  • FLT_MIN: float가 표현할 수 있는 정규화된 가장 작은 양수입니다. 지수부가 00000001이고, 가수부가 00000000000000000000000입니다.
  • FLT_MIN_10_EXP: float가 표현할 수 있는 가장 작은 10진수의 자릿수입니다.
  • FLT_TRUE_MIN: float가 표현할 수 있는 실제로 가장 작은 양수입니다. 지수부가 00000000이고, 가수부가 00000000000000000000001입니다. 지수부가 00000000인 수는 비정규화된 수라고 부르며, FLT_MIN_EXP보다 더 작은 수들에 대한 표현을 지원하기 위해 그 아랫자리들만을 표기하는 특수한 방법을 사용합니다.

특수한 수들의 표현

부동소수점 자료형은 그 특수한 구조 때문에 오차를 피할 수 없습니다. 그 오차율을 최대한 커버하는 대신, IEEE 754에서는 그를 조금 더 희생하여 특수한 수들을 표현할 수 있는 방법을 정의해두었습니다. 바로 양의 무한, 음의 무한, 그리고 NaN입니다. 이들은 32비트 2진법 부동소수점을 기준으로 다음과 같이 구성됩니다.

  • inf, -inf: 양의 무한과 음의 무한을 표현할 수 있습니다. 모두 지수부를 11111111로 만들고, 가수부는 전체를 0으로 두며, 여기에 부호 비트를 이용하여 양 또는 음을 표시합니다.
  • NaN: 연산 과정에 오류가 있는 경우 만들어집니다. NaN이 만들어지는 방법으로는 여러 가지가 있는데, 대표적인 것이 0을 0으로 나누거나 무한을 무한으로 나누는 것 등이 있습니다. NaN 역시 지수부를 11111111로 만들며, 가수부에 따라 예외 발생 없이 연산을 진행하는 Quiet NaN (모든 비트가 1) 또는 예외를 발생시키며 그 메시지를 담는 Signaling NaN (하나 이상의 비트가 1)으로 나뉩니다.

또한 IEEE 754에서는 양의 0과 음의 0도 구분할 수 있는데, 지수부와 가수부가 모두 0으로 채워지며 부호 비트에 따라 양 또는 음이 결정됩니다. 단, +0 == -0은 규칙으로 정해져 있습니다.

반올림

IEEE 754에서는 반올림 규칙으로 다섯 가지를 지정하고 있습니다.

  • Round to nearest, ties to even: 표현 가능한 가장 가까운 수로 만들되, 정확히 중간점에 있는 경우 가수부의 최하위 자리가 짝수가 되는 방향으로 반올림합니다.
  • Round to nearest, ties away from zero: 표현 가능한 가장 가까운 수로 만들되, 정확히 중간점에 있는 경우 0에서 멀어지는 방향으로 반올림합니다. 즉, 중간점에 있는 수가 양수인 경우 수가 더 커지고, 음수인 경우 수가 더 작아집니다.
  • Round toward 0: 0에 가까워지는 방향으로 버립니다.
  • Round toward +∞: 올림(ceil)합니다.
  • Round toward −∞: 버림(floor)합니다.

특별히 명시되지 않았다면 이러한 반올림은 수학적으로 무한한 정밀도로 계산했을 때를 기준으로 이루어져야 하며, 이를 correct rounding이라고 합니다.

마치며

이번 글에서는 부동소수점 자료형이 고정소수점 자료형에 비해 가지는 차이점과 그 장단점을 알아보았고, 부동소수점에 대한 가장 널리 쓰이는 표준인 IEEE 754를 통해 어떻게 그 수들을 구성하는지를 보았습니다. 또한 대부분의 PC에서 32비트 float에 대해 시각적으로 관찰할 수 있는 프로그램을 하나 만들었고, 표준에서 정하는 다양한 구체 사항에 대해서도 간략하게 알아보았습니다.

다음 글에서는 이 자료형들을 사용하여 실제로 연산을 수행하는 과정에 대해 살펴보고, 발생할 수 있는 다양한 예외 상황에 대한 처리가 어떻게 이루어지는지도 알아보도록 하겠습니다. 또한 하드웨어 단에서 직접적으로 제공하지 않는 복잡한 연산들을 구현한 라이브러리들도 분석해 보도록 하겠습니다.

참고 자료

  1. 유효숫자가 중요하기 때문에, 큰 수에 대해서는 작은 변화를 표현하기 어렵지만 작은 수들끼리의 변화는 표현이 가능합니다. 즉, 절대적인 변화량이 아니라 그 비율에 영향을 받습니다. 

  2. 정확히는, 반올림이 된 것입니다. 

  3. 0보다 큰 수 중 가장 작은 것입니다.