부동소수점 수(Floating Point Numbers)

부동소수점 수는 컴퓨터에서 실수를 표현하는 핵심적인 방식으로, 프로그래밍에서 매우 중요한 개념이다.

부동소수점은 컴퓨터에서 실수를 표현하는 효율적인 방법이지만, 그 특성과 한계를 이해하는 것이 중요하다.
정밀도 문제, 반올림 오차, 비교 문제 등을 인식하고 적절히 대응하는 것이 안정적인 소프트웨어 개발의 핵심이다.

실용적인 측면에서는:

  1. 정확한 산술이 필요한 영역에서는 Decimal 같은 정밀 타입 사용
  2. 부동소수점 비교 시 epsilon 값을 활용한 근사 비교 적용
  3. 부동소수점의 특수한 값들(NaN, Infinity 등)을 적절히 처리
  4. 필요에 따라 반올림 정책을 명확히 설정하고 일관되게 적용

이러한 원칙을 따르면 부동소수점 관련 문제를 최소화하고 안정적인 시스템을 구축할 수 있다.

부동소수점의 기본 개념

부동소수점(Floating Point)은 실수를 컴퓨터에서 표현하기 위한 방식으로, 이름에서 알 수 있듯이 소수점의 위치가 ‘부동(floating)‘하다. 이는 과학적 표기법과 유사한 방식으로, 숫자를 표현한다.

과학적 표기법과의 유사성

일반적인 과학적 표기법에서 실수는 다음과 같이 표현된다:

1
2
1.23456 × 10^5 = 123456
5.67890 × 10^-2 = 0.0567890

부동소수점 수는 이와 유사하게 다음 형태로 표현된다:

1
significand × base^exponent

여기서:

컴퓨터에서는 이진수 체계를 사용하므로 기수는 2가 된다.

IEEE 754 표준

현대 컴퓨터에서 사용하는 부동소수점 표현 방식은 대부분 IEEE 754 표준을 따른다.
이 표준은 1985년에 처음 도입되어 2008년과 2019년에 개정되었다.

IEEE 754의 주요 형식

  1. 단정밀도(Single Precision, 32비트, float)
    • 1비트: 부호(sign)
    • 8비트: 지수(exponent)
    • 23비트: 가수(fraction/mantissa)
  2. 배정밀도(Double Precision, 64비트, double)
    • 1비트: 부호(sign)
    • 11비트: 지수(exponent)
    • 52비트: 가수(fraction/mantissa)
  3. 확장정밀도(Extended Precision, 80비트, long double)
    • 일부 프로세서에서 지원 (x86 계열)
  4. 4배정밀도(Quadruple Precision, 128비트)
    • 소프트웨어적으로 구현됨

부동소수점의 내부 구조

단정밀도(32비트) 구조

단정밀도 부동 소수점은 다음과 같이 32비트로 구성된다:

1
| 부호(1비트) | 지수부(8비트) | 가수부(23비트) |

배정밀도(64비트) 구조

배정밀도 부동 소수점은 다음과 같이 64비트로 구성된다:

1
| 부호(1비트) | 지수부(11비트) | 가수부(52비트) |

부동 소수점 값 계산 방식 (예시로 이해하기)

부동 소수점의 실제 값은 다음 공식으로 계산된다:

1
실제 값 = (-1)^부호 × (1.가수부) × 2^(지수부-편향값)

여기서 편향값(bias)은 단정밀도에서 127, 배정밀도에서 1023이다.

단정밀도 예시: 값 10.5 표현하기

10.5를 이진수로 변환하면 1010.1(2)이다.

  1. 이를 정규화하면 1.0101 × 2^3이 된다.
  2. 부호는 양수이므로 0이다.
  3. 지수는 3이므로, 편향값 127을 더해 130(10)이 된다. 이진수로는 10000010(2)이다.
  4. 가수부는 소수점 이하인 0101이 된다. 23비트를 채우기 위해 나머지는 0으로 채운다:
    01010000000000000000000

따라서 10.5의 단정밀도 표현은:

1
0 10000010 01010000000000000000000

이제 이 비트들을 해석해 보면:

배정밀도 예시: 값 0.1 표현하기

0.1은 이진수로 정확히 표현할 수 없고 무한히 반복되는 형태를 갖는다. 이진수 근사값은 다음과 같다: 0.1(10) ≈ 0.00011001100110011…(2)

  1. 정규화하면 1.1001100110011… × 2^-4가 된다.
  2. 부호는 양수이므로 0이다다.
  3. 지수는 -4이므로, 편향값 1023을 더해 1019(10)가 된다. 이진수로는 01111111011(2)이다다.
  4. 가수부는 소수점 이하인 1001100110011…이 되지만, 52비트만 저장할 수 있으므로 일부만 저장된다.

이것이 배정밀도로 0.1을 표현할 때의 비트 패턴이다. 그러나 0.1을 정확히 표현할 수 없기 때문에, 실제 저장되는 값은 0.1에 매우 가깝지만 정확히 0.1은 아니다.

이로 인해 다음과 같은 상황이 발생한다:

1
2
3
# 이 코드를 실행하면 예상과 다른 결과가 나옵니다
print(0.1 + 0.2 == 0.3)  # False가 출력됨
print(0.1 + 0.2)         # 0.30000000000000004가 출력됨

특수한 값들

IEEE 754는 몇 가지 특수한 값을 정의한다:

특수값 표현

  1. ±0
    • 모든 비트가 0이고 부호 비트만 다름
    • 표현: s 00000000 00000000000000000000000
  2. ±무한대(Infinity)
    • 지수 비트가 모두 1이고 가수 비트가 모두 0
    • 표현: s 11111111 00000000000000000000000
  3. NaN(Not a Number)
    • 지수 비트가 모두 1이고 가수 비트가 0이 아님
    • 표현: s 11111111 (가수 ≠ 0)
  4. 서브노멀 수(Subnormal/Denormalized Numbers)
    • 지수 비트가 모두 0이고 가수가 0이 아님
    • 표현: s 00000000 (가수 ≠ 0)
    • 암시적 선행 1이 없는 특수한 경우로, 0에 가까운 매우 작은 수를 표현할 때 사용됨

부동소수점의 정밀도와 한계

부동소수점은 실수를 완벽히 표현할 수 없다는 근본적인 한계가 있다.

정밀도 문제

부동 소수점의 정밀도 한계는 가수부의 비트 수에 의해 결정된다:
제한된 정밀도
- 단정밀도: 23비트 가수부 → 약 7자리 십진 정밀도
- 배정밀도: 52비트 가수부 → 약 15-17자리 십진 정밀도
소수점 이하 자릿수가 일정 수 이상이 되면 정확한 표현이 불가능해진다는 것을 의미한다.

한계가 발생하는 이유

부동 소수점의 한계가 발생하는 주요 이유는 다음과 같다:

  1. 이진 표현의 한계: 우리가 일상적으로 사용하는 십진수 체계에서 정확히 표현할 수 있는 수(예: 0.1, 0.2)가 이진수 체계에서는 무한히 반복되는 수가 될 수 있다.
    예를 들어, 1/10 = 0.1(10)은 이진수로 0.0001100110011…(2)로 무한히 반복된다.
    컴퓨터는 이를 제한된 비트 수로 반올림하여 저장하기 때문에 정확한 값을 저장할 수 없다.

    1
    
    0.1(10) = 0.00011001100110011…(2) (무한 반복)
    
  2. 제한된 비트 수: 컴퓨터는 유한한 비트 수(32비트 또는 64비트)를 사용하여 수를 저장한다. 무한한 정밀도를 가진 실수를 유한한 비트로 정확히 표현하는 것은 원천적으로 불가능하다.

  3. 균등하지 않은 분포: 부동 소수점 숫자들은 수직선 상에 균등하게 분포하지 않는다. 0에 가까울수록 더 조밀하게 분포하고, 숫자가 커질수록 표현 가능한 수 사이의 간격이 커진다.

    예를 들어, 단정밀도에서:

    • 1.0과 그 다음으로 표현 가능한 수 사이의 간격: 약 1.2 × 10^-7
    • 10^7과 그 다음 표현 가능한 수 사이의 간격: 약 1.2
  4. 반올림 오차: 연산 결과가 정확히 표현할 수 없는 값이면 가장 가까운 표현 가능한 값으로 반올림된다. 많은 연산을 수행할수록 이 오차가 누적될 수 있습니다.

실제 예시 코드 (Python)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 부동소수점 정밀도 문제 예시
x = 0.1 + 0.2
y = 0.3
print(f"x = {x}")  # 출력: 0.30000000000000004
print(f"y = {y}")  # 출력: 0.3
print(f"x == y: {x == y}")  # 출력: False

# 매우 큰 수와 작은 수의 덧셈
big = 1e16
small = 1.0
result = big + small
print(f"big = {big}")
print(f"small = {small}")
print(f"big + small = {result}")  # small이 흡수됨
print(f"big + small - big = {result - big}")  # 0.0 출력

# 누적 오차 예시
sum_val = 0.0
for i in range(1000):
    sum_val += 0.001
print(f"0.001을 1000번 더한 결과: {sum_val}")  # 1.0이 아닌 값 출력
print(f"예상값과의 차이: {sum_val - 1.0}")

개발 시 고려사항

정밀도 문제 해결 방법

  1. 적절한 타입 사용

    • 정확한 계산이 필요한 경우(금융 등) 부동소수점 대신 정수나 고정소수점, 혹은 Decimal 타입 사용
  2. 비교 방법

    • 절대 오차(absolute error)나 상대 오차(relative error)를 사용하여 비교
    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    # 절대 오차를 이용한 비교
    def is_equal(a, b, epsilon=1e-9):
        return abs(a - b) < epsilon
    
    # 상대 오차를 이용한 비교 (더 안전한 방법)
    def is_equal_relative(a, b, epsilon=1e-9):
        if a == 0 or b == 0:
            return abs(a - b) < epsilon
        return abs((a - b) / max(abs(a), abs(b))) < epsilon
    
  3. 반올림 제어

    • 필요에 따라 명시적으로 반올림 처리
    1
    2
    3
    4
    5
    6
    
    # 반올림 제어
    from decimal import Decimal, ROUND_HALF_UP
    
    value = Decimal('1.345')
    rounded = value.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
    print(rounded)  # 1.35 출력
    

언어별 고정밀도 구현

Python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
from decimal import Decimal, getcontext

# 정밀도 설정
getcontext().prec = 28

# Decimal 타입 사용
a = Decimal('0.1')
b = Decimal('0.2')
c = a + b
print(c)  # 정확히 0.3 출력
print(c == Decimal('0.3'))  # True 출력
Java
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import java.math.BigDecimal;
import java.math.RoundingMode;

public class BigDecimalExample {
    public static void main(String[] args) {
        // BigDecimal 사용
        BigDecimal a = new BigDecimal("0.1");
        BigDecimal b = new BigDecimal("0.2");
        BigDecimal c = a.add(b);
        
        System.out.println(c);  // 0.3 출력
        System.out.println(c.equals(new BigDecimal("0.3")));  // true 출력
        
        // 나눗셈 시 반올림 모드 지정
        BigDecimal x = new BigDecimal("1");
        BigDecimal y = new BigDecimal("3");
        BigDecimal result = x.divide(y, 10, RoundingMode.HALF_UP);
        System.out.println(result);  // 0.3333333333 출력
    }
}
JavaScript
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// JavaScript에서는 Decimal.js 라이브러리 사용 가능
// npm install decimal.js

const Decimal = require('decimal.js');

const a = new Decimal(0.1);
const b = new Decimal(0.2);
const c = a.plus(b);

console.log(c.toString());  // "0.3" 출력
console.log(c.equals(0.3));  // true 출력

7. 부동소수점 연산과 성능

하드웨어 지원

  1. FPU(Floating-Point Unit)
    • 현대 프로세서에는 부동소수점 연산을 위한 전용 하드웨어 내장
    • 과거에는 별도의 코프로세서(예: x87)였지만 현재는 CPU에 통합됨
  2. SIMD 명령어
    • SSE, AVX 등의 SIMD 명령어 세트로 병렬 부동소수점 연산 지원
    • 과학 계산, 그래픽스, 머신러닝 등에서 성능 향상에 중요

컴파일러 최적화

컴파일러는 부동소수점 연산을 최적화할 수 있지만, 때로는 이로 인해 예상치 못한 결과 발생 가능:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 컴파일러 최적화로 인한 예상치 못한 결과
float a = 0.1f;
float b = 0.2f;
float c = a + b;

// 이 조건은 항상 참이 아닐 수 있음
if (c == 0.3f) {
    printf("Equal\n");  // 실행되지 않을 가능성 높음
} else {
    printf("Not equal\n");  // 대부분 이 부분이 실행됨
}

주요 함정과 최적 사례

일반적인 함정

  1. 정확한 동등 비교

    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    // 잘못된 방법
    if (0.1 + 0.2 === 0.3) {
        console.log("Equal");  // 실행되지 않음
    }
    
    // 올바른 방법
    if (Math.abs((0.1 + 0.2) - 0.3) < 1e-10) {
        console.log("Equal");  // 실행됨
    }
    
  2. 누적 오차

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    
    # 문제의 코드
    total = 0.0
    for i in range(1000):
        total += 0.001  # 누적 오차 발생
    
    # 개선된 코드
    total = 0
    for i in range(1000):
        total += 1
    total = total / 1000.0  # 정수로 계산 후 변환
    
  3. 숫자 형식 변환 문제

    1
    2
    
    double d = 0.1;
    float f = (float) d;  // 정밀도 손실 발생
    

최적 사례

  1. 올바른 부동소수점 비교

    • 절대적 동등 비교 대신 epsilon 값을 사용한 근사 비교 사용
  2. 정수 연산 활용

    • 가능한 경우 정수 연산으로 전환 (예: 돈 계산 시 센트 단위로 정수 사용)
  3. 누적 오차 최소화

    • Kahan 합산 알고리즘 등 오차 보정 알고리즘 활용
    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    def kahan_sum(values):
        sum_val = 0.0
        compensation = 0.0
        for val in values:
            y = val - compensation
            temp = sum_val + y
            compensation = (temp - sum_val) - y
            sum_val = temp
        return sum_val
    
  4. 연산 순서 고려

    • 부동소수점 연산은 결합법칙이 성립하지 않을 수 있음
    • 큰 수와 작은 수를 더할 때는 작은 수끼리 먼저 더하는 것이 정확도 향상에 도움

부동소수점과 비즈니스 로직

금융, 회계, 과금 시스템 등에서는 부동소수점 사용에 특히 주의해야 한다.

금융 계산에서의 접근법

  1. 고정소수점 사용
    • 달러/센트, 원/전 등으로 분리하여 정수로 계산
  2. Decimal 타입 사용
    • 대부분의 언어에서 제공하는 Decimal 타입 활용
  3. 반올림 정책 명확화
    • 반올림 방식(올림, 내림, 반올림 등)을 명확히 정의하고 일관되게 적용
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// Java에서 금융 계산 예시
import java.math.BigDecimal;
import java.math.RoundingMode;

public class FinancialCalculation {
    public static void main(String[] args) {
        // 이자 계산 예시
        BigDecimal principal = new BigDecimal("1000.00");
        BigDecimal rate = new BigDecimal("0.05");  // 5% 이자율
        BigDecimal years = new BigDecimal("2");
        
        // 단리 계산
        BigDecimal interest = principal.multiply(rate).multiply(years);
        BigDecimal simpleTotal = principal.add(interest);
        
        // 복리 계산 (연복리)
        BigDecimal compoundTotal = principal;
        for (int i = 0; i < years.intValue(); i++) {
            compoundTotal = compoundTotal.multiply(
                BigDecimal.ONE.add(rate)
            );
        }
        
        // 반올림 처리 (소수점 2자리)
        simpleTotal = simpleTotal.setScale(2, RoundingMode.HALF_UP);
        compoundTotal = compoundTotal.setScale(2, RoundingMode.HALF_UP);
        
        System.out.println("원금: " + principal);
        System.out.println("단리 총액: " + simpleTotal);
        System.out.println("복리 총액: " + compoundTotal);
    }
}

부동소수점 디버깅 팁

  1. 값 확인 방법

    • 단순 출력 대신 16진수 형태로 검사하여 내부 표현 확인
    1
    2
    3
    
    float f = 0.1f;
    printf("Value: %f\n", f);
    printf("Hex: 0x%X\n", *(unsigned int*)&f);
    
  2. NaN 및 무한대 검사

    1
    2
    3
    4
    5
    6
    
    // JavaScript에서 NaN 및 무한대 검사
    let value = 0 / 0;  // NaN
    console.log(Number.isNaN(value));  // true
    
    value = 1 / 0;  // Infinity
    console.log(Number.isFinite(value));  // false
    
  3. IEEE 754 비트 패턴 분석

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    
    import struct
    
    def analyze_float(value):
        # float를 32비트 정수로 변환
        bits = struct.unpack('!I', struct.pack('!f', value))[0]
    
        # 비트 추출
        sign = (bits >> 31) & 0x1
        exponent = (bits >> 23) & 0xFF
        fraction = bits & 0x7FFFFF
    
        # 분석 결과 출력
        print(f"값: {value}")
        print(f"이진 표현: {bits:032b}")
        print(f"부호 비트: {sign} ({'음수' if sign else '양수'})")
        print(f"지수 필드: {exponent:08b} (편향값: {exponent - 127})")
        print(f"가수 필드: {fraction:023b}")
    
        # 특수 케이스 확인
        if exponent == 0 and fraction == 0:
            print("이 값은 ±0 입니다.")
        elif exponent == 0xff and fraction == 0:
            print("이 값은 ±무한대 입니다.")
        elif exponent == 0xff and fraction != 0:
            print("이 값은 NaN(Not a Number) 입니다.")
        elif exponent == 0:
            print("이 값은 서브노멀 수입니다.")
    
    # 테스트
    analyze_float(0.0)
    analyze_float(1.0)
    analyze_float(float('inf'))
    analyze_float(float('nan'))
    analyze_float(1.175494e-38)  # 서브노멀 수에 가까운 값
    

용어 정리

용어설명

참고 및 출처