부동 소수점
부동 소수점은 C혹은 C++을 배우면서 지속적으로 괴롭히던 개념이다.
배울땐 이해가 되어도 머리에 남지 않는 지식 중에 하나로 여기 저기 언어를 배울 때 마다 이 개념에 대해 새로 배우는데 시간을 낭비하는게 아까워 머리에 넣어두기 위해서 정리해두기로 한다.
부동소수점이란?
부동소수점(Floating Point)은 소수점이 움직이는(Floating) 방식으로 실수를 표현하는 방법이다.
컴퓨터는 기본적으로 정수(Integer) 연산을 수행하지만, 실수(소수점이 있는 숫자)를 저장하고 계산할 필요가 있다.
부동소수점 표준(IEEE 754)은 실수를 컴퓨터가 효율적으로 저장하고 연산할 수 있도록 만든 규칙이다.
부동소수점이 필요한 이유
1. 정수형(int)만으로 실수를 표현할 수 없음
정수형(int)은 소수점이 없는 정수만 저장할 수 있는데 실제 계산에서는 소수점이 포함된 숫자(실수, floating point)가 필요하다.
#include <iostream>
int main() {
int a = 3.14;
int b = -2.718;
std::cout << "a = " << a << ", b = " << b << std::endl;
return 0;
}
위 코드에서 저장한 값들은
a = 3, b = -2
이런 결과를 출력한다.
정수형 타입에 실수를 넣으면 소수점의 정보를 버리게 되기 때문에 데이터 손실이 발생한다.
또한 정수형만 사용하더라도 연산에서 실수가 필요한 경우가 발생하게 된다.
#include <iostream>
int main() {
int a = 1;
int b = 2;
int c = a / b; // 1 / 2를 계산
std::cout << "c = " << c << std::endl;
return 0;
}
이렇게 정수형끼리 연산을 수행할때도 나눗셈을 하는 경우는 결과 값에서 실수가 발생하게 되는데 이 경우 그 결과는
c = 0
소수점을 지운 정수값만 출력하게 된다
이 또한 위 처럼 데이터의 손실이 발생하게 된다.
2. 고정소수점(Fixed Point) 방식의 비효율성
초기 컴퓨터에서는 실수의 표현 방식으로 해당 방법을 사용했었다.
고정소수점(Fixed Point)은 소수점의 위치를 고정하여 저장하는 방식으로 실수를 정수처럼 표현하면서도, 소수점을 미리 지정한 위치에서 해석하는 방법으로 아래와 같이
//10 진수 기준 - 소수점자릿수를 2자릿수로 고정
12.34
56.78
90.12
↓
//2 진수 기준 - 소수점 자릿수를 2자릿수로 고정
00001100.0100 (12.34)
00111000.0110 (56.78)
소수점의 위치를 고정시키고 그 아래의 값은 실수값 위의 값은 정수값으로 해서 결과를 만들어내는 방식이였다.
이 고정 소수점 방식은 너무 크거나 너무 작은 숫자를 표현할 수 없었다.
예를 들어, 8비트 환경에서 정수 부분을 6비트, 소수부분을 2 비트로 사용한다고 했을때
2진수 | 10진수
000000.00 → 0.00 (최소값)
000000.01 → 0.25
.
.
.
111111.10 → 63.50
111111.11 → 63.75 (최대값)
이렇게 최소값이 0.00 부터 63.75까지의 값만 표현이 가능했고 소수점 1자리가 올라갈때마다 두가지 비트로 표현할 수 있는 가짓수가 4개 이기 때문에 0.25씩 증가하게 된다.
그러면 0.11, 0.000234와 같은 중간 값들은 표현할 수 없게 된다.
또한 63.75 이상의 값 또한 표현할 수 없다는 문제가 있다.
또한 소수점 이하의 자릿수를 제한해야하기 때문에 정확한 값을 표현하지 못하고 오차가 발생할 가능성이 있었고 정밀도를 올리기 위해서 소수점 자릿수를 늘리면 표현할 수 있는 정수의 범위가 줄어든다는 단점이 있었다.
큰 값을 표현하기 위해서 정수자리를 6자리 소수자리를 2자리로 할 경우 위 경우와 동일하게 소수 자릿수가 변할때 0.25만큼의 변화폭을 보이기 때문에 0.25, 0.50과 같은 값은 표현할 수 있더라도 0.00234와 같은 값은 표현할 수 없게 된다.
반대로 좀더 정말하고 작은 소수점 자리를 조작하기 위해서 정수 부분을 2자릿수로 줄이고 소수점 자리를 6자리로 늘렸을때 00.000001의 값은 0.015625와 같이 작은 값을 통해서 조작이 가능하다.
그런데 정수 자릿수가 2자리로 제한되기 때문에 표현할 수 있는 값은 5를 넘길 수가 없게 된다.
또한 동일하게 숫자의 범위가 제한적이기에 메모리를 비효율적으로 사용하게 된다.
작은 값과 큰 값을 동시에 표현하려면 많은 비트를 사용해야 한다.
결과적으로 고정 소수점 방식에선 소수점이 고정되어 있다는게 가장 단점이며 문제였다.
이를 해결하기 위해서 소수점이 떠나닌다(Floating)라는 개념을 도입해서 작은 값과 큰 값을 모두 표현할 수 있도록 하는 부동 소수점 방식을 사용하게 되었다
3. 부동 소수점 방식
부동 소수점 방식에서는 실수를 \( M \times 10^{E}\)의 형태(과학적 표기법)에 착안해서 실수를 표현 방식을 적용했다
일반 숫자 (10진수) | 과학적 표기법 |
\(4560000000.0\) | \(4.56 \times 10^{9}\) |
\(0.0000456\) | \(4.56 \times 10^{-5}\) |
\(123456789.0\) | \(1.23456789 \times 10^{8}\) |
위 방식이 \( M \times 10^{E}\)의 형태를 사용한 것으로 컴퓨터는 10진수가 아닌 2진수를 사용하기에 \( M \times 2^{E}\) 의 형태를 사용해서 실수를 표현한다
여기서 \( M \)은 가수 부분으로 유효 숫자 부분을 의미하고 \( E\)는 지수로 2의 거듭제곱 값을 의미하는데 이건 아래에서 확인하겠지만 Stored Exponent - Bias값의 결과가 E가 되어야 한다.
추가적으로 부호를 표현하는 부분 S와 Bias라는 부분이 추가된다
$$ (-1)^S \times 1.M \times 2^{(Stored Exponent - Bias)} $$
*Bias란
Bias 값은 "지수(Exponent)를 저장할 때 음수 값을 표현할 수 있도록" 하는 값으로
$$ Bias = 2^{(n-1)} - 1 $$
으로 n은 지수를 저장하는 비트 수이다.
부동 소수점의 경우는 3개의 부분으로 나뉘는데
구성 요소 | float(32비트) | double(64비트) | 설명 |
부호(Sign) | 1비트 | 1비트 | 양수/음수를 구분 |
지수(Exponent) | 8비트 | 11비트 | 수의 크기를 결정 (Bias 적용) |
가수(Mantissa, Fraction) | 23비트 | 52비트 | 유효숫자 저장 |
으로 타입마다 고정된 값이 다르게 되어 있다.
여기서 지수 부분을 가지는 비트 수에 따라서 Bias의 값이 변경된다.
float를 예시로 들어보자.
float의 경우는 8비트를 지수의 값을 표현하기 위해서 사용한다.
그렇기에 Bias의 값은 \(2^{(8-1)} - 1\)을 계산 한 결과인 \(2^7 - 1 = 128 - 1 = 127 \)이 된다.
* 왜 \(2^{(8-1)} - 1\)를 Bias값으로 지정할까?
float를 예를 들자면 float의 지수부분이 가질 수 있는 값은 0 ~ 255까지의 숫자이다.
이 지수에 저장되는 것은 E+Bias의 값이 될 텐데 0이 최소값이 되고 255가 최대 값이 되며 중앙에 E+Bias가 0이 되는 값이 존재해야 음의 숫자와 양의 숫자 대칭적으로 표현할 수 있게 될것이다.
또한 부동 소수점 연산을 할 때 지수끼리의 덧셈/ 뻴셈이 편해지게 된다.
이 값을 왜 필요하냐면 위에서 말한대로 지수를 저장할때 음수 값을 표현하기 위해서로 만약 변환해야하는 값이
\(1.xxxx \times 2^{-5}\) 와 같은 값을 갖고 있다고 한다면 이 지수 부분도 결국 컴퓨터가 이해하기 위해서는 2진수로 표현을 해줘야만 하는데 이 음의 지수를 2진법으로 표현하려면 너무 복잡해지게 되기에 하드웨어 또한 구현이 복잡해지게 된다.
정리를 나중에 다시하겠지만 그냥 정리하자면 음의 지수를 표현해주기 위해서라고 알고 있으면 된다.
이 공식을 이해하기 위해서 10진수를 부동 소수점을 적용한 실수로 변경하는 예시를 보여주겠다.
13.75라는 10진수가 있고 이를 부동 소수점으로 변경하는 과정을 그려보자면
먼저 13.75를 2진수로 변경한다
아래는 10진수를 2진수로 변경하는 과정을 정리한 내용이다.
13.75 -> 정수 자리 : 13/ 실수 자리 0.75로 나누어 2진수로 만든다
//정수자리 13을 2진수로 변경
13 / 2
↓ → 나머지 1 (1)
몫 6 / 2
↓ → 나머지 0 (2)
몫 3 / 2
↓ → 나머지 1 (3)
(4) 몫 1 -> 몫 1 만 남으니 종료
아래부터 읽어서
(4)몫 1 + (3)나머지 1 + (2)나머지 0 + (1)나머지 1 => 1101
//실수자리 0.75를 2진수로 변경
0.75 * 2
↓
1.5 → 정수자리 1 (1)
↓ 실수 내리기
0.5 * 2
1 -> 정수밖에 없으니 종료 (2)
아래부터 읽어서 .11
최종 1101과 .11을 붙여서 1101.11
13.75 ==> 1101.11
이렇게 나온 1101.11이란 이진수에서 소수점을 두번째 숫자 앞까지 옮겨주면서 지수를 추가해주자.
1101.11
↓
1.10111 * 2^3(소수점을 앞으로 3개 옮겼으니 2의 3승을 곱해줘야 함)
여기에 13.75란 값은 기존에 양수이기에 S값은 0으로 하고
(-1)^0 * 1.10111*2^3
여기서 실제 3은 {Stored Exponent - Bias} 의 값이 될텐데 Bias가 float형 데이터의 경우는 127이 되고
3 = Stored Exponent - 127 이
Stored Exponent = 3 + 127
이 되어 실제 저장되는 지수의 값은 130이 된다.
130을 2진법으로 표현하면
1000 0010
이 된다.
이제 가수 부분의 1.10111에서 앞의 1은 생략하고 소수점부분만 가부분에 넣어주고 23비트의 나머지 부분을 0으로 채워준다.
10111000000000000000000 (23비트)
거의 끝났다 여기서 부호 비트를 가장 앞에 붙여주고
0
지수 비트 8 비트를 붙여주고
0 10000010
마지막 가수 비트 23비트를 붙여주면
0 10000010 10111000000000000000000
으로 float형 타입에 담은 13.75의 비트형태가 나온다.
여기서 계속 했갈렸던 부분은
$$ (-1)^S \times 1.M \times 2^{(Stored Exponent - Bias)} $$
이건 2진수로 표현한 부동 소수점을 기반으로
0 10000010 10111000000000000000000
과학적 표현 방법이였던
$$ 1.10111 \times 2^3 $$
를 도출해내기 위한 수식이라고 봐야한다.
0 10000010 10111000000000000000000 이 값의 가장 앞의 비트인 부호 비트가 0이기에
$$ (-1)^S \times 1.M \times 2^{(Stored Exponent - Bias)} $$
에서 S가 0이 되며 양수를 나타내게 되고
$$ (-1)^0 \times 1.M \times 2^{(Stored Exponent - Bias)} $$
0 10000010 10111000000000000000000의 지수 부분인 10000010을 10진수로 변환한 130을 Stored Exponent에 넣어주고
$$ (-1)^0 \times 1.M \times 2^{(130 - Bias)} $$
Bias에는 float형이니까 127을 넣어주고
$$ (-1)^0 \times 1.M \times 2^{(130 - 127)} $$
0 10000010 10111000000000000000000 의 가수 부분인 10111이 M으로 들어가게 되고
$$ (-1)^0 \times 1.10111 \times 2^{(130 - 127)} $$
이 연산을 해보면 결국
$$ 1.10111 \times 2^3 $$
이 되면서 위에서 만들었던 과학적 표현방법이 도출되게 된다.
실제로 부동 소수점 방식으로 실수를 표현하는 방법은
1. 10진법으로 만들어진 실수를 2진법으로 전환하고
2. 과학적 표현방법으로 변경한 후에
3. 부호가 양수일 경우 0을, 음수일 경우 1을 부호비트에 넣어주고 (가장 앞 1번 비트값 지정)
4. 과학적 표현방법으로 나온 지수 부분에 Bias 값을 더해주고 그 값을 2진수로 전환한 값을 지수 비트에 넣어주고(지수 비트 n자리(타입에 따라 다름) 비트값 지정)
5. 과학적 표현방법에 가수 부분에서 소수점 아래 값을 가수 비트에 넣되, 앞부터 채워 넣고 남는 부분은 모두 0으로 비트를 채워준다(가수 비트 n자리(타입에 따라 다름) 비트값 지정)
의 과정을 통해 나온 2진수가 실제 실수의 값이 된다.
주의할 점
부동소수점에선 정확하게 표현할 수 없는 수들이 있다.
그중 0.1과 0.02의 값을 한번 확인해보자.
보다 싶이 0.1은 정확하게 0.1이 아니라 그것보다 약간 큰 숫자이고 0.02는 정확하게 0.02가 아니라 그것보다 살짝 작은 숫자이다.
왜 이런 현상이 발생하는 것일까?
0.1을 한번 2진수로 변경해보자.
// 0.1을 2진수로 변경
0.1 * 2
↓ 0.2 → 정수 부분 0
0.2 * 2
↓ 0.4 → 정수 부분 0
0.4 * 2
↓ 0.8 → 정수 부분 0
0.8 * 2
↓ 1.6 → 정수 부분 1
0.6 * 2
↓ 1.2 → 정수 부분 1
0.2 * 2
↓ 0.4 → 정수 부분 0
0.4 * 2
.
.
.
무한 반복
이렇게 0.1은 에초에 2진수로도 딱 떨어지는 값이 나오지 않고 0.000110011001100110011... (무한 반복) 형태가 된다.
이렇게 무한히 반복되기 때문에 유한한 비트만 사용하는 부동소수점에서는 정확하게 저장할 수 가 없다.
0.02는 어떨까?
//0.02를 2진수로 변환
0.02 * 2
↓ 0.04 → 정수 부분 0
0.04 * 2
↓ 0.08 → 정수 부분 0
0.08 * 2
↓ 0.16 → 정수 부분 0
0.16 * 2
↓ 0.32 → 정수 부분 0
0.32 * 2
↓ 0.64 → 정수 부분 0
0.64 * 2
.
.
.
특정 패턴 없이 무한정 진행
이렇게 무한 반복이 된다.
이 두 수에는 공통점이 있는데 먼저 10진법에서 무한 소수인 값은 2진법으로 정확하게 표현이 불가능하다.
여기서 두 수의 공통 점은 10진법에선 유한소수로 우리가 데이터로 담기 위한 값으로 작성할 수 있으나 2진법으로 전환시에 무한소수가 되는 숫자들이라는 점이다.
10진법에서는 정확히 표현되지만, 2진법에서는 무한소수가 되는 수들의 예로는
- 0.1 = 1/10
- 0.02 = 1/50
- 0.3 = 3/10
로 이를 2진법으로 전환하면
0.1 ≈ 0.00011001100110011... (무한 반복)
0.02 ≈ 0.000101000111101011... (고정 패턴 없이 무한 진행)
0.3 ≈ 0.01001100110011... (무한 반복)
이런식으로 무한대로 반복된다.
컴퓨터는 이 값을 부동소수점 연산을 통해서 근사값을 사용하기 때문에 완전 정확한 값이 아니라 어느정도 데이터의 차이가 존재한다는 점을 알아야한다.
또한 이는 타입에 따라서 값이 다르기도 하다.
이렇게 타입을 달리하여 값을 출력해보면
이렇게 각각 다른점을 볼 수 있다.
여기서 double과 long double의 값이 같은 이유는 Windows에서는 둘다 8바이트(64비트)로 동일한 크기를 갖기 때문이다.
여기서 부동소수점 연산의 누적 오차(Floating Point Accumulation Error) 문제가 발생한다.
0.1을 정확한 0.1로 표현하지 못하기에 연산의 결과가 예상과는 다르며 어느 시점에 이 미세한 연산의 오차가 누적되어 우리가 기대하는 값과 다른 결과가 도출될 수 도 있다.
이 부동 소수점의 오차를 줄이기 위해선 여러 방식을 사용해서 이 오차값을 보정하는 노력이 필요하다.
(반올림 혹은 추가적인 모듈을 이용해서 보정작업이 필요할 수 도 있다.)
부동소수점의 비교
부동 소수점을 사용하면 타입에 따라서 혹은 연산을 진행하면 오차가발생할 수 있기에 직접 비교하는 방식을 사용하지 않고 FLT_EPSILON이란 것을 사용해서 비교하게 된다.
FLT_EPSILON은 float 타입에서 1.0과 구분할 수 있는 가장 작은 값을 의미한다
이 의미는 1.0 + FLT_EPSILON이 1.0과 다른 값으로 인식되는 최소 차이를 의미한다는 것이다.
이게 뭔 의미냐면 먼저 1.0의 값을 먼저 보면
// 1.0 을 2진수로 변환
1
그리고 이를 과학적 표현법으로 전환해보면
$$ (-1)^{0} \times 1.0 \times 2^{0} $$
가 되고 지수 부분은
$$ 0 + 127 = 127 = 0111111_{(2)} $$
이 되고 가수 부분은 1.0에서 소수점 밑의 값이 0이니 최종적으로 1.0의 부동 소수점은
$$ 0 01111111 00000000000000000000000 _{(2)} $$
가 된다.
이걸 C++ 에서 확인해보자면 먼저 \( 0 01111111 00000000000000000000000_{(2)} \) 을 unsigned int 에 넣어주는데 2진법의 숫자이기에 맨 앞에 0b를 붙여주면서 넣어주자.
추가로 float 변수를 하나 생성해주고
이걸 memcpy를 사용해서 num을 x에 넣어주자.
**memcpy는 C 언어에서 제공하는 메모리 복사 함수로, 특정 메모리 영역에서 다른 메모리 영역으로 데이터를 복사할 때 사용된다.
이는 메모리 내용을 그대로 복사하기에 원본과 독립적인 복사본을 만들어준다.

함수의 매개변수는 순서대로 아래와 같다.
- 1번 매개변수 - dest : 복사한 데이터를 저장할 대상 메모리 (목적지)
- 2번 매개변수 - src : 복사할 원본 데이터 (출처)
- 3번 매개벼눗 - n : 복사할 바이트(byte) 수
- 반환값 : dest 포인터 반환
그래서 보통 세번째 매개변수에는 두번째로 전달준 복사할 원본데이터의 크기를 확인하기 위해서 sizeof(src)를 전달해준다.
그리고 이걸 정확하게 출력하기위해서 precision(64)를 넣어주고
** std::cout.precision(n);
출력할 숫자의 유효 숫자(precision)를 설정하는 함수로 cout에 해당 함수가 걸리면 지금 시점 이후로 std::cout으로 출력되는 모든 숫자는 n개의 크기로 출력하겠다는 의미가 된다.
#include <iostream>
#include <iomanip>
int main() {
double pi = 3.14159265358979323846;
std::cout << "기본 출력: " << pi << std::endl;
std::cout.precision(5);
std::cout << "Precision 5: " << pi << std::endl;
std::cout.precision(10);
std::cout << "Precision 10: " << pi << std::endl;
return 0;
}
기본 출력: 3.14159
Precision 5: 3.1416
Precision 10: 3.141592654
출력해보면
딱 1이 출력된다.
그럼 위에서 말했던 FLT_EPSILON, 1.0과 다른 값으로 인식되는 최소 차이란 무엇일까
1.0보다 큰 값중에 가장 작은 값은
$$ 0 01111111 00000000000000000000000 _{(2)} $$
여기에서 가수 부분이 1 높은
$$ 0 01111111 00000000000000000000001 _{(2)} $$
이 되는데 이게 바로 1.0과 다른 값으로 인식되는 값이 되고
\( 0 01111111 00000000000000000000001 _{(2)} \) 와 \( 0 01111111 00000000000000000000000 _{(2)} \)의 차이를 구하면 이게 1과 다른 값으로 인식되는 최소 차이가 될것이다.
이렇게 추가로 \( 0 01111111 00000000000000000000001 _{(2)} \) 를 변수에 넣어주고 동일하게 float 변수를 하나 추가해서
값을 넣어주고
그 값을 뽑는 동시에 \( 0 01111111 00000000000000000000001 _{(2)} \) 와 \( 0 01111111 00000000000000000000000 _{(2)} \)의 차이를 구해보면
아래와 같이 값이 나오는데
이떄 FLT_EPSILON을 뽑아보면
값이 동일한 것을 볼 수 있다.
이게 바로 앱실론인데 이 앱실론을 기준으로 값을 비교한다
비교의 예시를 한번 보자면
이렇게 만든 변수에서 b값의 값이 증가하다가
a 와 b의 값이 동일해지면 for문을 나가는 로직을 만든다면
이렇게 a와 b의 차이의 절댓값이(fabsf()는 float 값의 절댓값을 구하는 함수) FLT_EPSILON 보다 작거나 같으면 같거나 아주 근사치의 값이라고 확인하고 for loof를 나가도록 만들었다.
동시에 몇번째 루프에서 출력되고 그렇게 만든 값이 무엇인지 출력한다고
로직을 추가하고 실행시켜보면
아무런 값도 도출되지 않는다.
그 이유는 값이 n번 실행될때 부동 소수점의 미세한 차이들이 쌓여서 a = b혹은 a - b 의 절댓값의 크기가 FLT_EPSILON보다 커지기 때문이다.
만약 더하는 값의 크기를 키워서 확인해보면
이렇게 9번째에 해당 값이 도출되었음을 알 수 있다.
비교에서 사용되나 이 또한 결국 미세한 차이들 때문에 값의 비교가 정확하지 않을 수 있다는 점을 알아야한다.
그래서 사실 정밀도를 요한다면 고정소수점, 혹은 정밀도를 제공하는 라이브러리 혹은 라이브러리를 사용해야만 한다.