Floating Point: IEEE 754와 유한 비트로 근사하는 실수 표현
부동소수점은 실수를 부호, 지수, 가수의 조합으로 근사해 유한한 비트에 담는 표현 방식이다. IEEE 754 표준은 이 근사 규칙을 정의해 언어와 하드웨어 사이의 계산 결과를 일치시킨다. 0.1 + 0.2가 0.3이 되지 않는 현상은 버그가 아니라 이 표현 방식의 구조적 한계에서 비롯된다.
1. 개념
부동소수점(floating point)은 실수를 다음 세 요소의 조합으로 표현하는 방식이다.
- 부호(sign): 양수/음수
- 지수(exponent): 소수점 위치를 이동시키는 값
- 가수(mantissa, fraction): 유효 숫자
값은 대략 (-1)^sign × 1.mantissa × 2^(exponent - bias) 형태로 계산된다. "부동(浮動)"이라는 이름은 소수점 위치가 지수에 따라 움직인다는 뜻이며, 정수부와 소수부 자릿수가 고정된 고정소수점(fixed point)과 대비된다.
IEEE 754는 이 표현의 비트 배치, 반올림 규칙, 특수값(0, 무한대, NaN)을 정의한 국제 표준이며, 오늘날 거의 모든 언어의 float/double이 이 표준을 따른다.
2. 왜 사용하는가
정수 하나로는 소수를 표현할 수 없고, 고정소수점은 표현 범위와 정밀도를 자릿수 배분으로 미리 고정해야 해서 아주 큰 수와 아주 작은 수를 동시에 다루기 어렵다.
부동소수점은 지수부를 통해 소수점 위치를 유동적으로 옮길 수 있어, 같은 비트 수로 매우 넓은 범위(예: double은 약 10^-308 ~ 10^308)를 다룰 수 있다. 대신 정밀도가 절대 오차가 아니라 상대 오차 형태로 유지된다는 특징을 가진다.
3. 동작 원리
3.1 비트 레이아웃
| 형식 | 전체 비트 | 부호 | 지수 | 가수 | bias |
|---|---|---|---|---|---|
| single (float32) | 32 | 1 | 8 | 23 | 127 |
| double (float64) | 64 | 1 | 11 | 52 | 1023 |
정규화된(normalized) 수는 가수의 최상위 비트가 항상 1이라고 가정하고 이 1을 생략해서 저장한다(암묵적 선행 비트, implicit leading bit). 그래서 52비트로 53비트 정밀도를 표현한다.
3.2 특수값
- 지수 비트가 전부 0, 가수가 0: ±0
- 지수 비트가 전부 0, 가수가 0이 아님: 비정규화 수(subnormal), 0 근처를 더 촘촘히 표현
- 지수 비트가 전부 1, 가수가 0: ±무한대
- 지수 비트가 전부 1, 가수가 0이 아님: NaN(Not a Number)
3.3 오차의 근본 원인
0.1은 이진수로 0.0001100110011...처럼 무한 반복 소수가 된다. 유한한 가수 비트에 저장하려면 어딘가에서 잘라야 하고, 이 절단이 반올림 오차의 근원이다. 십진수로 딱 떨어지는 값도 이진수로는 딱 떨어지지 않는 경우가 많다.
4. 코드 예제
4.1 비트 구조 직접 확인하기 (Python)
import struct
def float_to_bits(f: float) -> str:
# 64비트 double을 빅엔디안 바이트로 변환 후 비트 문자열로 표시
packed = struct.pack('>d', f)
bits = ''.join(f'{byte:08b}' for byte in packed)
sign = bits[0]
exponent = bits[1:12]
mantissa = bits[12:]
return f'sign={sign} exponent={exponent} mantissa={mantissa}'
print(float_to_bits(0.1))
print(float_to_bits(-1.5))
print(float_to_bits(0.0))4.2 오차가 실제로 발생하는 지점 (Python)
from decimal import Decimal
a = 0.1 + 0.2
print(a) # 0.30000000000000004
print(a == 0.3) # False
# Decimal은 십진 기반이라 십진수 계산에서는 오차가 없다
b = Decimal('0.1') + Decimal('0.2')
print(b) # 0.3
print(b == Decimal('0.3')) # True4.3 안전한 비교 방법 (TypeScript)
console.log(0.1 + 0.2); // 0.30000000000000004
console.log(Number.EPSILON); // 2.220446049250313e-16
function nearlyEqual(a: number, b: number, epsilon = Number.EPSILON): boolean {
const diff = Math.abs(a - b);
const scale = Math.max(Math.abs(a), Math.abs(b), 1);
return diff < epsilon * scale;
}
console.log(nearlyEqual(0.1 + 0.2, 0.3)); // true
console.log(0.1 + 0.2 === 0.3); // false5. 시간 복잡도 또는 성능 특성
부동소수점 사칙연산은 하드웨어 FPU(부동소수점 연산 장치)가 처리하며, 정규화된 수에 대한 덧셈/곱셈은 O(1)로 취급한다. 다만 다음 상황에서는 성능 저하가 발생할 수 있다.
- 비정규화 수(subnormal) 연산은 CPU가 특수 경로(마이크로코드 등)로 처리하는 경우가 있어 정규화 연산보다 느려질 수 있다.
- 정수 연산과 부동소수점 연산을 자주 오가는 형변환(type conversion)은 별도의 명령을 소모한다.
- SIMD(예: SSE, AVX)를 활용하면 여러 부동소수점 값을 한 번에 연산해 처리량을 높일 수 있다.
정확한 사이클 수치는 CPU 마이크로아키텍처마다 다르므로 특정 숫자로 단정하지 않는다.
6. 실무 사용 사례
- 그래픽스, 물리 시뮬레이션, 머신러닝 연산: float32/float64로 넓은 동적 범위와 속도가 필요한 영역
- 과학 계산, 신호 처리: double 정밀도가 기본
- 금융, 회계 시스템: 부동소수점 오차가 금액 불일치로 이어지므로 사용하지 않는다. 대신
Decimal,NUMERIC, 또는 최소 단위(원, cent)를 정수로 저장하는 방식을 쓴다.
-- PostgreSQL: 금액은 FLOAT가 아니라 NUMERIC으로 저장한다
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
amount NUMERIC(12, 2) NOT NULL -- 정확한 십진 연산 보장
);7. 주의할 점
==비교 금지: 부동소수점 값은 오차 누적 때문에 직접 동등 비교하면 예상과 다른 결과가 나온다. 허용 오차(epsilon)를 두고 비교한다.- 누적 오차: 반복문에서 부동소수점을 계속 더하면 오차가 누적된다. 루프 카운터로 float를 쓰지 않는다.
- NaN의 특성:
NaN !== NaN이 참이다. 정렬이나 비교 로직에서 NaN을 별도로 처리해야 한다. - 큰 수와 작은 수를 더할 때: 상대적으로 매우 작은 값은 반올림 과정에서 사라질 수 있다(정밀도 손실, catastrophic cancellation은 뺄셈에서 특히 두드러진다).
- 금액 계산에는 float/double을 쓰지 않는다.
8. 핵심 정리
- 부동소수점은 부호·지수·가수 조합으로 실수를 근사하며, IEEE 754가 그 규칙을 표준화한다.
- 넓은 표현 범위를 얻는 대신 상대 오차라는 근본적 한계를 가진다.
- 0.1 + 0.2 ≠ 0.3은 표준 동작이며, 비교 시 epsilon 기반 근사 비교를 써야 한다.
- 금액처럼 정확성이 중요한 도메인은 float 대신 Decimal/NUMERIC이나 정수 기반 최소 단위를 사용한다.