전체 회차
C-022Day 222026.06.17

부동소수점과 실수 계산 오차의 원인

컴퓨터의 2진법 기반 구조로 인해 10진 소수를 정확히 표현할 수 없어 0.1+0.2≠0.3 같은 오차가 발생하는 원리

#floating-point#binary#ieee754#precision#data-types#numerical-error
01

영상

· video
9:52youtu.be/ZQDsWySjY6g
03

학습 자료

· material

개요

프로그래밍을 하다 보면 예상치 못한 계산 오차를 경험합니다. 예를 들어 JavaScript나 Java에서 0.1 + 0.2를 계산하면 0.3이 아닌 0.30000000000000004가 나옵니다. 이는 버그가 아니라 컴퓨터가 숫자를 표현하는 방식의 근본적인 특성 때문입니다.

이 문서에서는 컴퓨터가 실수(부동소수점)를 어떻게 저장하는지, 그리고 왜 이런 오차가 발생하는지를 이해합니다. 특히 정수와 실수의 저장 방식 차이, 10진법과 2진법 간의 변환 문제, 그리고 IEEE 754 표준이 어떻게 작동하는지를 학습하면, 단순히 오차를 회피하는 것에서 벗어나 프로그램을 더 정확하게 설계할 수 있습니다.

배경 / 사전 지식

진법과 변환

  • 10진법: 우리가 일상적으로 사용하는 수 체계. 숫자 0~9를 사용하고, 각 자리는 10의 거듭제곱으로 표현됨 (예: 123 = 1×10² + 2×10¹ + 3×10⁰)
  • 2진법: 컴퓨터가 내부적으로 사용하는 수 체계. 0과 1만으로 표현하며, 각 자리는 2의 거듭제곱으로 표현됨 (예: 1011₂ = 1×2³ + 0×2² + 1×2¹ + 1×2⁰ = 11₁₀)

소수 표현

  • 10진 소수: 0.5, 0.25, 0.1 등을 소수점 이하 자리로 표현
  • 2진 소수: 2⁻¹(1/2), 2⁻²(1/4), 2⁻³(1/8) 등으로 표현. 예: 0.5₁₀ = 0.1₂, 0.25₁₀ = 0.01₂
  • 무한반복소수: 1/3 = 0.333... (10진), 1/5 = 0.00110011... (2진) 처럼 끝나지 않는 소수

비트와 바이트

  • 비트(bit): 0 또는 1을 저장하는 최소 단위
  • 바이트(byte): 8비트 = 1바이트
  • 32비트: 32개의 비트를 사용하여 정수나 실수를 저장 (4바이트)

핵심 개념

1. 10진 소수의 2진 변환 문제

10진법의 소수는 2진법으로 변환할 때 정확히 표현되지 않는 경우가 많습니다.

예: 0.6을 2진법으로 변환

  • 정수 부분이 없으므로 소수 부분만 표현
  • 0.6 = 0.6 × 2 = 1.2 → 첫 자리에 1, 남은 값 0.2
  • 0.2 × 2 = 0.4 → 두 번째 자리에 0, 남은 값 0.4
  • 0.4 × 2 = 0.8 → 세 번째 자리에 0, 남은 값 0.8
  • 0.8 × 2 = 1.6 → 네 번째 자리에 1, 남은 값 0.6
  • 다시 처음으로... → 결과: 0.1001₂ (무한 반복)

하지만 우리가 메모리에 모든 자리를 저장할 수는 없으므로, 특정 자리에서 자르거나 반올림합니다. 이 순간 오차가 발생합니다.

예: 0.1을 2진법으로 변환

  • 0.1 = 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
  • 0.6 × 2 = 1.2 → 다섯 번째 자리에 1, 남은 값 0.2
  • 다시 반복... → 결과: 0.00011001100110011...₂ (무한 반복, "0011" 패턴 반복)

0.1과 0.2 모두 2진법에서 정확히 표현될 수 없으므로, 이들을 더하면 오차가 누적됩니다.

2. 정수형(Integer) 저장 방식

컴퓨터는 제한된 메모리로 효율성을 높이기 위해 정수와 실수를 다르게 저장합니다.

32비트 정수형(int)

[부호 비트: 1비트][절대값: 31비트]
  • 부호 비트(첫 번째 비트): 0이면 양수, 1이면 음수
  • 절대값 비트(나머지 31비트): 숫자의 크기를 2진법으로 표현
  • 표현 범위:
    • 양수: 0 ~ 2³¹ - 1 (약 21억)
    • 음수: -1 ~ -2³¹ (약 -21억)

예: 정수 29를 32비트 int로 저장

  • 29 = 2⁴ + 2³ + 2² + 2¹ = 16 + 8 + 4 + 1 = 11101₂
  • 저장: 0 + 00000000000000000000000000011101 (0은 양수 표시)

이 방식은 고정소수점(fixed-point) 표현으로, 소수점 위치가 정해진 형태입니다. 하지만 정수만 저장 가능합니다.

3. 부동소수점(Floating Point) 저장 방식

실수를 저장하기 위해 부동소수점 방식을 사용합니다. "부동"은 소수점이 고정되지 않고 "떠다닌다"는 의미입니다.

32비트 부동소수점(float)

[부호: 1비트][지수: 8비트][가수(mantissa): 23비트]
  • 부호(1비트): 0이면 양수, 1이면 음수
  • 지수(8비트): 소수점이 몇 칸 이동하는지 표현 (바이어스 127을 빼서 실제 지수값 계산)
  • 가수(23비트): 소수점이 이동한 후의 소수점 뒤 부분

예: 9.6 (10진)을 2진 부동소수점으로 변환

1단계: 10진수를 2진수로 변환

  • 정수 부분: 9₁₀ = 1001₂
  • 소수 부분: 0.6₁₀ = 0.1001₁₀... (위에서 구한 반복 패턴)
  • 합: 1001.1001...₂

2단계: 정규화 (1.xxx × 2ⁿ 형태로 변환)

  • 1001.1001₂를 왼쪽으로 3칸 이동 → 1.0011001₂ × 2³
  • 지수 n = 3

3단계: 비트로 저장

  • 부호: 0 (양수)
  • 지수: 3 + 127 = 130 = 10000010₂ (바이어스 127 더함)
  • 가수: 00110010000000000000000 (1.0011001에서 1 생략, 뒤의 0011001만 23비트에 저장)

이렇게 하면 매우 큰 수(10⁶⁰)부터 매우 작은 수(10⁻⁶⁰)까지 표현할 수 있습니다.

4. IEEE 754 표준

대부분의 프로그래밍 언어와 하드웨어는 IEEE 754 표준을 따릅니다.

  • 단정밀도(Single Precision): 32비트 (대부분의 float 타입)
  • 배정밀도(Double Precision): 64비트 (대부분의 double 타입)
    • 부호: 1비트
    • 지수: 11비트
    • 가수: 52비트

배정밀도가 더 많은 비트를 사용하므로 더 높은 정밀도와 더 큰 범위를 제공합니다.

작동 원리

0.1 + 0.2 오차 발생 과정

  1. 0.1을 float로 변환

    • 10진수: 0.1
    • 2진수: 0.00011001100110011...₂ (무한 반복)
    • 32비트float에 저장: 어느 시점에서 자르므로 정확한 0.1이 아님 → 약간 크면 0.10000000149... 같은 값
  2. 0.2를 float로 변환

    • 10진수: 0.2
    • 2진수: 0.00110011001100110...₂ (무한 반복)
    • 32비트float에 저장: 역시 반올림 오차 발생
  3. 덧셈 수행

    • 컴퓨터가 저장한 0.1 + 저장한 0.2 계산
    • 각각의 오차가 누적되어 0.30000000149... + 0.20000000298... = 0.30000000447...
    • 또는 다른 반올림으로 인해 0.30000000004... 등의 값 출력

단계별 계산 플로우

입력 (10진)         →  2진 변환           →  저장 (유한 비트)    →  역변환          →  출력 (10진)
0.1 + 0.2          0.0001100... (무한)   0.10000001...       0.1000000149...   0.30000000004...
                                         0.20000001...
                                         (합계)

코드 예시

JavaScript에서의 부동소수점 오차

// 예상: 0.3, 실제: 다름
console.log(0.1 + 0.2);  // 0.30000000000000004

console.log(0.1 + 0.2 === 0.3);  // false (같지 않음!)

// 오차 확인
console.log(0.1 + 0.2 - 0.3);  // 5.551115123125783e-17 (매우 작은 오차)

위 코드에서:

  • 0.1 + 0.2는 기대값 0.3이 아닌 0.30000000000000004를 반환합니다.
  • 이는 0.1, 0.2 각각이 2진법에서 무한 반복분수이기 때문입니다.
  • 오차는 매우 작지만(5.55e-17), 정확한 비교(===)를 할 때는 문제가 됩니다.

Java에서의 정수형 범위

// int는 32비트, 최대값: 2^31 - 1 = 2,147,483,647
int maxInt = 2147483647;
System.out.println(maxInt);       // 2147483647
System.out.println(maxInt + 1);   // -2147483648 (오버플로우!)

// long은 64비트, 훨씬 큰 범위
long maxLong = 9223372036854775807L;
System.out.println(maxLong);      // 9223372036854775807

int 범위를 초과하면 오버플로우가 발생하여 음수로 변합니다.

정확한 계산: BigDecimal 사용 (Java)

import java.math.BigDecimal;

BigDecimal a = new BigDecimal("0.1");
BigDecimal b = new BigDecimal("0.2");
BigDecimal result = a.add(b);

System.out.println(result);  // 0.3 (정확!)
System.out.println(result.equals(new BigDecimal("0.3")));  // true

BigDecimal을 사용하면:

  • 문자열로 숫자를 입력하므로 초기 변환 오차 방지
  • 10진 기반 연산을 수행하여 정확한 결과 획득

정확한 계산: decimal.js 사용 (JavaScript)

// decimal.js 라이브러리 사용
Decimal.set({ precision: 50 });

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

console.log(result.toString());  // '0.3' (정확!)

함정·실수

함정 1: 부동소수점끼리 직접 비교

// 위험한 코드
if (0.1 + 0.2 === 0.3) {
  console.log('같습니다');  // 실행 안 됨
}

// 올바른 코드: 오차 범위 내에서 비교
const epsilon = 0.0001;
if (Math.abs((0.1 + 0.2) - 0.3) < epsilon) {
  console.log('거의 같습니다');  // 실행됨
}

회피법: 두 부동소수점 수를 비교할 때는 epsilon(작은 오차값)을 설정하여 범위 내 비교를 수행합니다.

함정 2: 오차가 누적되는 반복 계산

let sum = 0;
for (let i = 0; i < 10; i++) {
  sum += 0.1;
}
console.log(sum);  // 0.9999999999999999 (1이 아님!)

회피법: 반복되는 금액 계산(예: 금융)에서는 처음부터 정수로 저장하고 나중에 나누거나, 라이브러리를 사용합니다.

함정 3: 지수 오버플로우·언더플로우

// 32비트 float 최대값: 약 3.4 × 10^38
// 범위를 초과하면 Infinity
const huge = 1e40;
console.log(huge);  // Infinity

// 32비트 float 최소양수: 약 1.2 × 10^-38
// 범위 미만이면 0으로 간주
const tiny = 1e-50;
console.log(tiny);  // 0

회피법: 매우 큰 수나 작은 수를 다룰 때는 64비트 double을 사용하거나, 단위를 조정하여 범위 내에 유지합니다.

베스트 프랙티스

1. 언어별 정확한 계산 라이브러리

| 언어 | 라이브러리 | 사용 예 | |------|----------|--------| | Java | java.math.BigDecimal | new BigDecimal("0.1").add(new BigDecimal("0.2")) | | JavaScript | decimal.js, big.js | new Decimal('0.1').plus('0.2') | | Python | decimal.Decimal | Decimal('0.1') + Decimal('0.2') | | C# | decimal 타입 | 0.1m + 0.2m |

금융, 회계, 과학 계산 등 정확성이 중요한 영역에서는 항상 이들 라이브러리를 우선합니다.

2. 금액 계산 패턴

// 나쁜 예: 실수로 직접 계산
let price = 19.99;
let tax = price * 0.1;  // 오차 가능
let total = price + tax;

// 좋은 예: 센트 단위 정수로 변환
let priceInCents = 1999;  // 1999센트 = $19.99
let taxInCents = Math.round(priceInCents * 0.1);  // 정수 연산
let totalInCents = priceInCents + taxInCents;
let displayTotal = (totalInCents / 100).toFixed(2);  // 표시할 때만 소수로

3. 부동소수점이 적절한 경우

// 과학 계산: 상대 오차가 무시할 수 있는 수준
const G = 6.67430e-11;  // 만유인력 상수
const mass1 = 1.989e30;  // 태양의 질량
const mass2 = 5.972e24;  // 지구의 질량
const distance = 1.496e11;  // AU (천문단위)

// 오차가 무시할 수 있는 수준이므로 float 사용 가능
const force = (G * mass1 * mass2) / (distance * distance);

4. 디버깅 팁

// 부동소수점 오차를 시각화
console.log((0.1 + 0.2).toFixed(20));  // '0.30000000000000004441'
console.log((0.1).toFixed(20));        // '0.10000000000000000555'
console.log((0.2).toFixed(20));        // '0.20000000000000001110'

// 2진 표현을 확인
console.log((0.1).toString(2));  // 이진법으로 표현 (단순화됨)

참고

영상에서 언급된 자료:

  • 얄팍한 코딩사전 YouTube 채널: https://www.yalco.kr
  • 책: "혼자 공부하는 얄팍한 코딩지식" (https://www.yalco.kr/book/)

IEEE 754 표준:

  • 공식 표준: IEEE Standard for Floating-Point Arithmetic (IEEE 754-2019)
  • 온라인 자료: https://en.wikipedia.org/wiki/IEEE_754

라이브러리:

  • JavaScript decimal.js: https://mikemcl.github.io/decimal.js/
  • Java BigDecimal: 표준 라이브러리 (java.math.BigDecimal)
  • Python decimal: 표준 라이브러리 (decimal.Decimal)