개요
프로그래밍을 하다 보면 예상치 못한 계산 오차를 경험합니다. 예를 들어 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 오차 발생 과정
-
0.1을 float로 변환
- 10진수: 0.1
- 2진수: 0.00011001100110011...₂ (무한 반복)
- 32비트float에 저장: 어느 시점에서 자르므로 정확한 0.1이 아님 → 약간 크면 0.10000000149... 같은 값
-
0.2를 float로 변환
- 10진수: 0.2
- 2진수: 0.00110011001100110...₂ (무한 반복)
- 32비트float에 저장: 역시 반올림 오차 발생
-
덧셈 수행
- 컴퓨터가 저장한 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)