문제 상황: 반올림 오차
// 평균을 소수점 둘째 자리에서 반올림
double sum = 85 + 90 + 78;
double average = sum / 3; // 84.333...
System.out.println(Math.round(average * 10) / 10.0);
// 예상: 84.3
// 실제: 84.30000000000001 또는 84.29999999999999
// 더 심각한 경우
double price = 0.1 * 3; // 0.30000000000000004
System.out.println(Math.round(price * 100));
// 예상: 30
// 실제: 30 (운이 좋으면) 또는 31 (틀림!)
분명히 수학적으로는 정확한 계산인데, 왜 컴퓨터는 이상한 값을 내놓는 걸까? 이 문제를 제대로 이해하려면 두 가지 오차의 근원을 알아야 한다.
첫 번째 오차: 부동소수점 표현 오차
컴퓨터는 2진수를 사용한다
우리가 당연하게 쓰는 0.1이라는 숫자, 컴퓨터에게는 표현할 수 없는 숫자다.
10진수 → 2진수 변환: 0.1 = 1/10 = 1/(2×5)
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.2가 다시 나타남! (무한 반복)
결과: 0.0001100110011001100110011...
마치 10진수에서 1/3 = 0.333...이 무한소수인 것처럼, 2진수에서 0.1은 무한소수다.
유한한 메모리에 무한을 담으려면
IEEE 754 double (64비트):
부호(1비트) / 지수부(11비트) / 가수부(52비트)
0.1의 실제 저장값:
0.00011001100110011001100110011001100110011001100110011001
실제 메모리 값:
0.1000000000000000055511151231257827021181583404541015625
System.out.println(new BigDecimal(0.1));
// 0.1000000000000000055511151231257827021181583404541015625
System.out.println(new BigDecimal(0.2));
// 0.200000000000000011102230246251565404236316680908203125
System.out.println(new BigDecimal(0.1 + 0.2));
// 0.3000000000000000444089209850062616169452667236328125
이게 첫 번째 오차다. 소수를 저장하는 순간부터 이미 정확하지 않다.
두 번째 오차: 반올림 연산 오차
반올림 전에 이미 오차가 있다
double value = 2.5;
System.out.println(new BigDecimal(value));
// 2.5 (정확함! 2.5 = 5/2 = 5/2¹ → 2진수에서 유한소수)
double value2 = 2.15;
System.out.println(new BigDecimal(value2));
// 2.149999999999999911182158029987476766109466552734375
// (오차 있음! 2.15 = 215/100 = 215/(2²×5²) → 2진수에서 무한소수)
반올림 시점의 오차 증폭
// 케이스 1: 운이 좋은 경우
double price = 29.95;
double vat = price * 0.1; // 2.9949999999999997 (실제)
System.out.println(Math.round(vat * 100) / 100.0); // 2.99
// 케이스 2: 경계값에서 틀리는 경우
double price2 = 24.95;
double vat2 = price2 * 0.1; // 2.4950000000000001 (실제)
System.out.println(Math.round(vat2 * 100) / 100.0); // 2.5 (예상: 2.49)
핵심: 연산 결과가 이미 오차를 포함하고, 반올림 경계(0.5)에 걸릴 때 오차 방향에 따라 결과가 달라진다.
왜 특정 숫자만 문제가 될까?
2진수에서 정확한 숫자 vs 부정확한 숫자
유한소수 (정확):
0.5 = 1/2 = 1/2¹ → 0.1
0.25 = 1/4 = 1/2² → 0.01
0.125 = 1/8 = 1/2³ → 0.001
0.75 = 3/4 = 3/2² → 0.11
무한소수 (오차):
0.1 = 1/10 = 1/(2×5) → 0.0001100110011...
0.2 = 1/5 = 1/5 → 0.001100110011...
0.3 = 3/10 = 3/(2×5) → 0.0100110011...
규칙: 분모가 2의 거듭제곱이면 정확, 5가 포함되면 무한소수
// 정확한 계산
double a = 0.5 + 0.25; // 0.75 (정확!)
System.out.println(a == 0.75); // true
// 오차 발생
double b = 0.1 + 0.2; // 0.30000000000000004
System.out.println(b == 0.3); // false
어떻게 해결할까?
1. 정수로 변환
핵심 아이디어: 소수점이 문제라면 소수점을 없애버리자. 정수는 2진수에서 항상 정확하게 표현된다.
// double로 계산
double price = 19.95;
double tax = price * 0.1; // 1.9949999999999997
double rounded = Math.round(tax * 100) / 100.0; // 불안정
// 정수로 변환
long priceInCents = 1995; // 19.95 → 1995센트
long taxInCents = (priceInCents * 10 + 50) / 100; // 200센트
double result = taxInCents / 100.0; // 2.00 (정확!)
공식: 소수점 n자리 반올림 = (값 × 10^n + 5) / 10^n
장점: 빠르고 정확, 코드 간단
단점: 범위 제한, 스케일 미리 정해야 함
적합: 알고리즘 문제, 금액 계산
2. BigDecimal 사용
핵심 아이디어: 10진수를 10진수 그대로 저장하는 자료형을 사용하자.
// double 생성자 금지 (이미 오차 포함)
BigDecimal wrong = new BigDecimal(0.1);
// 문자열 생성자 사용
BigDecimal price = new BigDecimal("19.95");
BigDecimal tax = price.multiply(new BigDecimal("0.1"));
BigDecimal rounded = tax.setScale(2, RoundingMode.HALF_UP);
System.out.println(rounded); // 2.00 (정확!)
장점: 완벽한 정확성, 임의 정밀도
단점: 느림, 코드 장황함
적합: 금융 시스템, 법적 정확성 필요한 경우
3. 앱실론 비교
핵심 아이디어: 완벽한 일치 대신 허용 오차 범위 내에서 같다고 판단하자.
final double EPSILON = 1e-9;
double result = 0.1 + 0.2;
// 절대 금지
if (result == 0.3) { ... }
// 오차 범위 내 비교
if (Math.abs(result - 0.3) < EPSILON) {
// 0.3으로 간주
}
장점: 빠름, 근사값 허용
단점: 엡실론 값 선택 어려움
적합: 과학 계산, 근사 허용되는 경우
정리
오차가 생기는 이유
- 표현 오차: 2진수에서 분모에 5가 있으면 무한소수
- 연산 오차: 오차값들의 계산 → 오차 증폭 → 반올림 경계에서 틀림
황금 규칙
- double == double은 절대 금지
- 정수로 바꿀 수 있으면 바꿔라
- BigDecimal은 문자열 생성자로
- 반올림은 최종 출력 직전에만
🔗 GitHub 저장소
- 전체 프로젝트: blog-code-examples
- 이 글의 예제: floatingpoint 패키지
'CS(Computer Science)' 카테고리의 다른 글
| 응답 지연 시간: 실제로 측정해보기 (0) | 2026.01.01 |
|---|