최근 사내에서 CS50 스터디를 시작했습니다. 본 포스팅은 CS50의 두번째 주제인 ‘C언어’ 강의를 듣다가 평소 궁금했던 고정 소수점(fixed point)과 부동 소수점(floating point)에 대해 추가적으로 정리하는 목적입니다. 강의 내용과는 크게 관련은 없습니다!
본 포스팅에서는 다음의 내용을 다룹니다.
- 실수를 2진수로 변환하는 방법
- 고정 소수점(fixed point)
- 부동 소수점(floating point)
- 파이썬에서 부동 소수점
- 마무리
실수를 2진수로 변환하는 방법
지난 글에서 정수 10진수를 2진수로 변환하는 방법을 알아보았습니다. 하지만 컴퓨터가 정수만 다루는 것은 아닙니다. \(123.73\)과 같은 실수도 당연히 다룰 수 있어야겠죠. 하지만 변함 없는 사실은 정수든 실수든 컴퓨터는 0과 1로만 이루어진 2진수만 다룰 수 있습니다. 그렇다면 실수를 2진수로 어떻게 변환해야 할까요?
10진수는 10의 거듭제곱의 합으로 표현할 수 있다는 사실은 쉽게 받아들일 수 있습니다.
같은 원리로 2진수는 2의 거듭제곱의 합으로 표현할 수 있습니다.
\(N\)진법 실수도 정확하게 같은 원리로 \(N\)의 거듭제곱의 합으로 표현할 수 있습니다. 10진수 실수 123.73을 10의 거듭제곱의 합으로 표현하면:
2진수로 표현된 실수 \(1.101_{(2)}\)를 2의 거듭제곱의 합으로 표현하면:
그렇다면 10진수 실수를 2진수 실수로 변환하려면 어떻게 해야할까요? 먼저 정수 10진수 13을 2진수로 변환해보겠습니다. 2진수는 2의 거듭제곱의 합으로 표현할 수 있다는 원칙만 계속 가져가면 됩니다.
이제 10진수 실수 \(0.75\)를 2진수 실수로 표현해보겠습니다. 역시나 2진수는 2의 거듭제곱의 합으로 표현할 수 있다는 사실만 기억하면 됩니다.
자 그렇다면~ 10진수 0.3을 2진수 실수로 변환하는 문제 하나만 더 풀어 보겠습니다!
소수점 자리 밑에서 \(0011\)이 계속 반복됩니다. 정수를 2진수로 변환하는 것은 전혀 어려운 문제가 아니었지만 실수를 2진수로 변환할 때에는 이처럼 무한히 커지는 비트를 처리하는 문제가 대단히 중요할 것 같습니다. 특히 컴퓨터는 하드웨어(특히 메모리)의 저장용량의 한계로 인해 이처럼 무한히 반복되는 수를 저장할 수가 없습니다.
그렇다면 컴퓨터가 실수를 잘 처리할 수 있는 방법이 있어야 할 것 같습니다. 먼저 컴퓨터에서 실수를 표현하기 위해 모든 메모리를 다 사용할 수는 없으므로 실수를 표현할 때 사용할 비트의 수를 정해두어야 하겠습니다. 파이썬에서 넘파이를 활용해 보신 분들은 float16
, float32
, float64
와 같은 수 표현 체계를 보신적이 있을겁니다. 이는 넘파이에서 실수(float)을 표현할 때 몇 비트를 사용할 것인지를 사용자가 임의로 정하게 해둔 것입니다. 예를 들어 float32
는 실수를 32비트를 활용해 표현합니다. 그리고 당연하게도 더 많은 비트를 쓸 수록 더 큰 실수를 표현할 수 있지만 그만큼 메모리를 비효율적으로 사용할 가능성이 커집니다.
앞으로 글의 전개는 실수를 32비트로 표현하는 것을 기준으로 설명드리겠습니다. 컴퓨터가 실수를 다루는 두 가지 방법은 고정 소수점(fixed point)과 부동 소수점(floating point)입니다.
고정 소수점(Fixed Point)
원리
고정 소수점(fixed point)은 정수를 표현하는 비트와 소수를 표현하는 비트수를 미리 정하고(고정) 해당 비트만을 활용하여 실수를 표현합니다.
- 처음 1비트는
sign
(부호)을 나타냅니다. 양수는 0, 음수는 1입니다. - 다음 15비트는
integer part
(정수부)를 나타냅니다. - 다음 16비트는
fractional part
(소수부)를 나타냅니다. - 그리고 정수부와 소수부의 경계를 소수점의 위치라고 생각하고 2진수로 변환된 수를 그대로 넣으면 됩니다.
- 마지막으로 남는 자리는 모두 0으로 채우면 됩니다.
fixed point, 고정 소수점
예를 들어, 10진수 7.625를 32비트 고정 소수점으로 표현해보겠습니다. 먼저 10진수 7.625를 2진수로 표현하면:
이를 32비트 고정 소수점으로 표현 해보면:
고정 소수점의 장단점
고정 소수점 방식은 밑에서 다룰 부동 소수점 방식에 비해서 실수를 표현하는 방법이 단순하고, 속도가 빠르다고 합니다. 그러나 다음과 같은 단점 때문에 부동 소수점에 비해서 잘 쓰이지 않는 방식이라고 합니다.
interger part
와fractional part
에 사용할 비트가 고정되어 있기 때문에 큰 실수를 표현하기 어렵습니다.integer part
는 딱 15개의 비트,fractional part
는 딱 16개의 비트만 사용 가능합니다.
부동 소수점(Floating Point)
원리
부동 소수점 표현 방식은 ‘움직이지 않는다’라는 부동(不動)으로 오해하기 쉽습니다. 그러나 여기서 부동은 움직이지 않는다는 뜻이 아니라 떠다닌다, 부유하다의 의미를 가지는 부동(浮動)입니다. 단어에서 유추를 해보면, 소수점이 옮겨다니는 방식의 실수 표현법이라고 이해하면 쉽습니다.
부동 소수점 표현 방식은 고정 소수점 표현 방식과 비트를 사용하는 체계가 다릅니다. 그리고 부동 소수점은 이를 표현하는 다양한 체계가 있는데, 일반적으로 가장 널리 쓰이는 표준은 IEEE 754라고 합니다. IEEE 754에 따르면 실수를 다음과 같이 부동 소수점 방식으로 표현할 수 있습니다.
- 2진수를 정규화(normalize) 합니다.
- 처음 1비트는
sign
(부호)를 나타냅니다 (0은 양수, 1은 음수). - 다음 8비트는
exponent
(지수부)를 나타냅니다. 정규화 과정에서 얻어낸 지수에 bias를 더한 값으로 채웁니다. - 다음 23비트는
mantissa
(가수부)를 나타냅니다. 소수 부분의 값으로 채웁니다.
floating point, 부동 소수점
정규화
먼저 컴퓨터 공학에서 말하는 정규화란 2진수를 \(1.\)xxx\(\cdots \times 2^n\) 형태로 나타내는 것입니다. 10진수 \(7.625\)을 정규화 해보겠습니다. 먼저 이를 2진수로 변환하면 \(111.101_{(2)}\)입니다. 그리고 나서 정규화 하면:
여기서 exponent
는 \(2\)라는 값을 얻어 냈습니다.
Exponent
그렇다면 이 \(2\)를 바로 exponent
부분에 채우면 될까요? 안타깝게도 그렇지 않습니다. IEEE 754 표준에서는 32비트로 실수를 표현할 때는 \(127_{(10)}\)이라는 bias를 더한 값으로 채우라고 명시하고 있습니다. 즉 \(129(2+127)\)을 2진수로 변환한 값인 \(10000001_{(2)}\)를 채웁니다.
왜 bias라는 값을 따로 두었을까요? 그 이유는 지수가 음수일 경우에 대처하기 위함입니다. 예를 들어 \(0.000101_{(2)}\)라는 2진수를 정규화 하면 \(1.01_{(2)} \times 2^{-4}\)라는 값을 얻게 됩니다. 위의 예시와 다르게 지수가 음수(\(-4\))로 나왔습니다. 그렇다면 지수가 음수인 것을 어떻게 나타내야 할까요?
가장 간단한 아이디어는 전체 수가 양수인지 음수인지를 나타내는 sign 값에 1 비트를 할당하는 것과 같은 원리로 지수가 음수인 것을 나타내기 위해 1 비트를 사용하는 것입니다. 그러나 이경우 표현할 수 있는 수의 범위가 작아진다는 아주 큰 단점이 있습니다.
따라서 IEEE 754 표준에서는 8 비트로 음수와 양수 모두를 표현하기 위한 하나의 장치로 bias 라는 값을 두었습니다. 이를 통해 exponent
부분은 항상 unsigned(0과 양수) 값만 가지게 셋팅을 하는데요. 8bit는 256개의 숫자를 표현 할 수 있으므로 unsinged 값의 범위는 10진수 0~255가 되겠습니다.
127은 0~255 구간의 딱 절반에 해당하는 숫자입니다. 10진수 기준으로
- 0~127 구간은 음수 (실제 exponent는 -127~0)
- 128~255 구간은 양수 (실제 exponent는 1~128)
참고로 \(0(00000000_{(2)})\)과 \(255(11111111_{(2)})\)는 각각 0과 무한대 등을 나타내기 위해 특별히 할당된 숫자이기 때문에 앞서 설명드린 정규화 방법이 적용되지 않는다고 합니다.
Mantissa
mantassia는 고정 소수점에서의 fractional part와 같은 역할입니다. 정규화 결과의 소수 부분을 mantassia 자리에 그대로 넣고, 남는 자리는 0으로 채우면 되겠습니다.
실제 예시
지금까지 정리한 내용을 바탕으로 \(7.625_{(10)}\)를 부동 소수점으로 표현해보겠습니다.
- 2진수 변환: \(111.101_{(2)}\)
- 정규화: \(1.11101_{(2)} \times 2^2\)
- Exponent: \(2_{(10)}+127_{(10)}\)(bias) \(= 129_{(10)} = 10000001_{(2)}\)
- Mantassia: \(11101_{(2)}\)
부동 소수점의 장단점
부동 소수점은 고정 소수점에 비해서 더 큰 실수를 표현할 수 있습니다. 예를 들어 고정 소수점은 정수 부분에 15개의 비트를 사용한다고 말씀드렸는데요. 이는 총 \(2^{15} =\)개의 정수를 표현할 수 있다는 것입니다. 생각 보다 큰 숫자라고 생각이 들 수 있습니다.
그렇다면 부동 소수점은 총 얼마나 큰 정수를 표현할 수 있을까요? 부동 소수점은 exponent
에 8 비트를 사용합니다. 8 비트를 채울 수 있는 가장 큰 숫자는 \(11111111_{(2)}\) 이지만, 부동 소수점 표현에서 이는 무한대의 숫자를 나타내는 특별한 수이므로 실질적으로 가장 큰 숫자는 \(1111110_{(2)}\)입니다. 이를 10진수로 바꾸면 254입니다. 254에는 bias 127이 더해져 있죠. 따라서 정규화 결과로 가질 수 있는 가장 큰 지수는 \(127(254-127)\)입니다. 다시 말해 32비트 부동 소수점에서 가질 수 있는 가장 큰 수의 정규화 결과는 \(1.\)xxx\(\times 2^{127}\)와 같은 형태입니다. 정규화 이전의 값을 떠올려 보면 고정 소수점에 비해서 정수 자리에 훨씬 더 많은 비트를 할당할 수 있음을 알 수 있습니다.
그렇다면 부동 소수점의 단점은 없을까요? 실수 연산이 부정확할 수 있다는 것이 부동 소수점 표현 방식의 가장 큰 단점입니다. 이는 컴퓨터 하드웨어가 가지는 본질적인 한계점 때문에 완벽하게 극복할 수는 없습니다. 예를 들어 십진수 0.3을 2진수로 변환하면 \(0.0100110011\cdots_{(2)}\) 처럼 특성 수가 무한이 반복됩니다. 따라서 컴퓨터가 실수 부분을 표현할 수 있는 비트수를 다 써버리게 되어 근사치로 표현되는 것입니다.
파이썬에서 부동 소수점
실제로 제가 주로 사용하는 언어인 파이썬에서도 부동소수점을 활용해서 실수를 표현하고 있는데요. 정말로 실수값이 근사치로 나타나는지 알아보려고 합니다. 먼저 간단하게 0.1을 출력해볼게요
1
2
>>> print(0.1)
0.1
0.1을 우리가 알던 그 0.1로 잘 표현하고 있는 것처럼 보이는데요…. 과연 그럴까요? 0.1을 소수점 50번째 자리까지 출력해보겠습니다. 예상되는 결과는 \(0.10000\cdots000\)이겠죠?
1
2
>>> print("{:.50f}".format(0.1))
0.10000000000000000555111512312578270211815834045410
그러나 파이썬에서 말해주는 결과는 달랐습니다. 왜 이런 결과가 나타날까요? 0.1은 \((1/2)^n\)의 합으로 딱 맞게 나타낼 수 없기 때문입니다. 우리는 십진수로 숫자를 보고 있지만 컴퓨터는 내부적으로 부동소수점 방식을 이용해 2진수로 실수를 나타내고 있는데, 소수 부분을 \((1/2)^n\)의 합으로 딱 맞게 나타낼 수 없습니다. 파이썬에서는 특정 부분에서 반올림을 하여 실수를 출력합니다. 따라서 실제값과 근사값의 오차가 존재하게 되는데 이를 부동 소수점 반올림 오차(rounding error)라고 합니다.
그렇다면 실수 부분을 \((1/2)^n\)의 합으로 딱 맞게 나타낼 수 있는 수는 우리가 생각하는 값을 도출할까요? 0.625를 소수점 50번째자리까지 출력해겠습니다.
1
2
>>> print("{:.50f}".format(0.625))
0.62500000000000000000000000000000000000000000000000
\(0.625 = 2^{-1} \cdot 1 + 2^{-2} \cdot 0 + 2^{-3} \cdot 1\) 처럼 \((1/2)^{n}\)의 합으로 딱 나누어 떨어집니다. 따라서 어느 순간까지만 실수 부분을 2진수로 변환하면 되기 때문에 쓰이는 비트의 수가 딱 정해져 있습니다.
이처럼 파이썬에서도 부동소수점으로 실수를 표현하기 때문에 실수를 근사치로 표현한다는 문제점이 여전히 있습니다. 따라서 실수에 대한 비교 연산자를 사용할 때 주의를 기울여야 합니다.
1
2
>>> print(0.1 + 0.2 == 0.3)
False
인간은 0.1과 0.2의 합이 0.3과 같다(True)라는 것을 알고 있습니다. 그러나 컴퓨터는 부동 소수점 연산의 근본적인 오차로 False를 출력하고 있습니다. 왜냐하면 컴퓨터는 0.1, 0.2, 0.3을 각각 다음과 같이 근사치로 표현하고 있기 때문입니다.
1
2
3
4
5
6
7
>>> print("{:.50f}".format(0.1))
0.10000000000000000555111512312578270211815834045410
>>> print("{:.50f}".format(0.2))
0.20000000000000001110223024625156540423631668090820
>>> print("{:.50f}".format(0.3))
0.29999999999999998889776975374843459576368331909180
만일 파이썬에서 실수끼리의 비교연산을 정확하게 하고 싶다면 decimal 모듈의 Decimal을 사용하면 됩니다. Decimal은 10진수로 처리하여 정확한 소수점 자리를 나타내도록 합니다.
1
2
3
>>> from decimal import Decimal
>>> print(Decimal('0.1') + Decimal('0.2') == Decimal('0.3'))
True
마무리
지금까지 컴퓨터가 숫자, 특히 실수를 표현하는 방법에 대해 알아보았습니다. 컴퓨터가 실수를 표현하는 가장 대표적인 방법은 고정 소수점(fixed point)과 부동 소수점(floating point)입니다. 고정 소수점은 interger part
와 fractional part
에 정해진 비트수를 할당하여 실수를 표현하는 방식입니다. 반면 부동 소수점 방식은 exponent
의 개념을 도입하여 소수점을 이리저리 옮겨 다닐 수 있습니다. 결과적으로 부동소수점 방식은 고정 소수점 방식에 비해 더 큰 수를 표현할 수 있다는 측면에서 대부분의 컴퓨터 시스템에서 실수를 부동 소수점 방식으로 표현하고 있습니다.
그러나 고정 소수점이든 부동 소수점이든 컴퓨터가 가진 하드웨어의 한계로인해 정확하게 표현할수 있는 실수는 그리 많지 않습니다. 왜냐하면 고정소수점이든 부동 소수점이든 결국 소수를 표현하는 부분에 특정 개수의 비트를 할당하는데, 이 부분을 2진수로 변환하였을 때 \((1/2)^{n}\)의 합으로 정확하게 표현할 수 있는 실수가 그리 많지 않기 때문입니다.
특히나 제가 주로 사용하는 언어인 파이썬에서도 실수를 부동 소수점 방식으로 표현하고 있는데, 필연적으로 실수값을 계산할 때 오차가 발생합니다. 따라서 우리는 각자 사용하는 언어에서 실수에 대한 어떤 연산(비교 연산 등)을 할 때 항상 직관과 다른 결과가 발생할 수 있다는 사실을 인지하고 하여야 하겠습니다.