🎯 글을 쓰게 된 이유
코딩을 하다가 문득 이상한 현상을 발견했다.
double a = 0.1;
double b = 0.2;
System.out.println(a + b == 0.3); // false???
분명히 초등학교 수학으로는 0.1 + 0.2 = 0.3인데, Java에서는 false가 나온다.
계산기로 해봐도 0.3이 나오는데, 컴퓨터는 왜 이 간단한 계산을 틀릴까?
혹시 Java만의 문제인가 싶어서 다른 언어들도 테스트해봤다.
- JavaScript: 0.1 + 0.2 === 0.3 → false
- Python: 0.1 + 0.2 == 0.3 → False
- C++: 동일한 현상 발생
도대체 왜 모든 언어에서 이런 일이 일어나는 걸까?
그래서 오늘은 부동소수점 연산의 함정과 Java에서의 해결책에 대해 파헤쳐보기로 했다.
🔍 실제로 어떤 값이 나오는지 확인해보자
public class FloatingPointTest {
public static void main(String[] args) {
double a = 0.1;
double b = 0.2;
double result = a + b;
System.out.println("0.1 + 0.2 = " + result);
System.out.println("0.1 + 0.2 == 0.3: " + (result == 0.3));
System.out.println("실제 저장된 값들:");
System.out.printf("0.1 = %.17f%n", a);
System.out.printf("0.2 = %.17f%n", b);
System.out.printf("0.3 = %.17f%n", 0.3);
System.out.printf("0.1 + 0.2 = %.17f%n", result);
}
}
출력 결과:
0.1 + 0.2 = 0.30000000000000004
0.1 + 0.2 == 0.3: false
실제 저장된 값들:
0.1 = 0.10000000000000001
0.2 = 0.20000000000000001
0.3 = 0.29999999999999999
0.1 + 0.2 = 0.30000000000000004
뭔가 이상하다. 0.1이 정확히 0.1이 아니라 0.10000000000000001로 저장되어 있다!
💾 컴퓨터가 소수를 저장하는 방식: IEEE 754
컴퓨터는 모든 것을 이진법(0과 1)으로 처리한다. 그런데 십진법의 소수를 이진법으로 변환할 때 문제가 생긴다.
십진법 0.1을 이진법으로 변환해보자
십진법 0.1을 이진법으로 변환하면
0.1 (십진법) = 0.000110011001100110011... (이진법, 무한반복)
마치 십진법에서 1/3 = 0.333333...처럼 무한반복되는 것과 같다.
IEEE 754 표준의 구조
IEEE 754 는 컴퓨터에서 부동소수점을 표현하는 데 가장 널리 쓰이는 표준이다.
IEEE 754의 부동 소수점 표현은 크게 세 부분으로 구성되는데,
최상위 비트는 부호를 표시하는 데 사용되며, 지수 부분(exponent)과 가수 부분(fraction/mantissa)이 있다.
부호 비트는 1비트이며 실수를 32비트(float)로 표현할 때는 exp가 8비트, frac이 23비트이며
64비트(double)로 표현할 때는 exp가 11비트, frac이 52비트가 된다.
- 부호 비트 (sign bit): 0이면 양수, 이면 음수
- 지수부 (exponent): 2의 e승일 때 e+bias. bias는 거듭제곱의 범위가 음수와 양수에 걸쳐 고르게 나타날 수 있도록 정해놓은 오프셋으로, 32비트 자료형에서는 (2^8-1)-1 = 127의 값을 가진다.
- 가수부 (fraction): 의 형태로 표기한 가수. 왼쪽부터 차례대로 채운다.
Java의 double 타입은 64비트로 구성된다

예시 사진을 보고 이해해보자
-118.625를 float형 부동소수점으로 표현해보자.
- 음수이므로, 부호비트는 1 이다.
- 절대값을 이진법으로 나타내면 1110110.101 이다.
- 정규화 과정 (2진수를 1.xxx * 2^n 꼴로 변환한다) : 소수점을 왼쪽으로 이동시켜, 왼쪽에는 1 만 남겨둔다. 1.110110101×2⁶
- 앞의 정수부분 1은 정규화과정을 거치면 모두 1 이므로 무시한다.
- 가수부는 소수점의 오른쪽 부분을 그대로 쓰고 부족한 뒤의 비트는 0으로 채운다. frac : 11011010100000000000000
- 지수는 6 이므로 32비트 형식의 Bias인 127을 더해 6+127 = 133이된다. 이진법으로 변환한 exp : 10000101
- 결과 : 1 1000 0101 1101 1010 1000 0000 0000 000
문제는 여기서 발생한다:
- 52비트로는 무한반복하는 이진 소수를 정확히 표현할 수 없다
- 어딘가에서 잘라내야 하므로 반올림 오차가 발생한다
🌍 다른 언어들은??
IEEE 754 표준을 따르는 모든 언어에서 동일한 현상이 발생한다:
// JavaScript
console.log(0.1 + 0.2 === 0.3); // false
console.log(0.1 + 0.2); // 0.30000000000000004
# Python
print(0.1 + 0.2 == 0.3) # False
print(0.1 + 0.2) # 0.30000000000000004
결론: Java의 문제가 아니라 IEEE 754 표준의 특성이다.
⚠️ 실무에서 이런 문제가 언제 발생할까?
1. 금융 계산에서의 치명적 오류
public class MoneyCalculation {
public static void main(String[] args) {
double price = 0.10; // 10센트 상품
double tax = 0.05; // 5센트 세금
double total = price + tax; // 15센트여야 함
if (total == 0.15) {
System.out.println("정확한 계산입니다.");
} else {
System.out.println("계산 오류 발생!"); // 이게 실행됨
System.out.printf("예상: 0.15, 실제: %.17f%n", total);
}
}
}
2. 반복 계산에서의 오차 누적
public class AccumulationError {
public static void main(String[] args) {
double sum = 0.0;
for (int i = 0; i < 10; i++) {
sum += 0.1; // 0.1을 10번 더함
}
System.out.println("sum = " + sum); // 0.9999999999999999
System.out.println("sum == 1.0: " + (sum == 1.0)); // false!
// 오차가 계속 누적됨
System.out.printf("정확한 값: %.17f%n", sum);
}
}
3. 게임에서의 좌표 계산 오류
public class GameCoordinate {
public static void main(String[] args) {
double playerX = 0.0;
// 플레이어가 0.1씩 10번 이동
for (int i = 0; i < 10; i++) {
playerX += 0.1;
}
// 목표 지점 도달 확인
if (playerX == 1.0) {
System.out.println("목표 지점 도달!");
} else {
System.out.println("목표 지점 못 도달..."); // 이게 실행됨
System.out.printf("현재 위치: %.17f%n", playerX);
}
}
}
💡 Java에서의 해결책들
1. BigDecimal 사용
import java.math.BigDecimal;
public class BigDecimalSolution {
public static void main(String[] args) {
// 문자열로 생성해야 정확함
BigDecimal a = new BigDecimal("0.1");
BigDecimal b = new BigDecimal("0.2");
BigDecimal result = a.add(b);
BigDecimal expected = new BigDecimal("0.3");
System.out.println("BigDecimal 결과: " + result);
System.out.println("0.3과 같은가? " + result.equals(expected)); // true
// 금융 계산 예시
BigDecimal price = new BigDecimal("19.99");
BigDecimal tax = new BigDecimal("1.60");
BigDecimal total = price.add(tax);
System.out.println("총액: $" + total); // 정확히 21.59
}
}
⚠️ BigDecimal 주의사항
// 잘못된 방법 - 이미 부정확한 double을 사용
BigDecimal wrong = new BigDecimal(0.1);
System.out.println(wrong); // 0.1000000000000000055511151231257827021181583404541015625
// 올바른 방법 - 문자열로 생성
BigDecimal correct = new BigDecimal("0.1");
System.out.println(correct); // 0.1
2. 허용 오차(Epsilon) 비교
public class EpsilonComparison {
private static final double EPSILON = 1e-9; // 0.000000001
public static boolean equals(double a, double b) {
return Math.abs(a - b) < EPSILON;
}
public static void main(String[] args) {
double result = 0.1 + 0.2;
// 직접 비교는 실패
System.out.println("직접 비교: " + (result == 0.3)); // false
// 오차 허용 비교는 성공
System.out.println("오차 허용 비교: " + equals(result, 0.3)); // true
// 게임 좌표 예시
double playerX = 1.0000000000000002;
double targetX = 1.0;
if (equals(playerX, targetX)) {
System.out.println("목표 지점 도달!"); // 이제 성공
}
}
}
3. 정수 연산으로 변환
public class IntegerArithmetic {
public static void main(String[] args) {
// 센트 단위로 계산 (소수점 2자리)
int cents1 = 10; // 0.1달러 = 10센트
int cents2 = 20; // 0.2달러 = 20센트
int totalCents = cents1 + cents2;
double totalDollars = totalCents / 100.0;
System.out.println("총합: $" + totalDollars); // 정확히 0.3
// 게임 점수 계산 (소수점 1자리)
int score1 = 15; // 1.5점 * 10
int score2 = 23; // 2.3점 * 10
int totalScore = score1 + score2;
double finalScore = totalScore / 10.0;
System.out.println("최종 점수: " + finalScore); // 정확히 3.8
}
}
4. Math.round() 활용
public class RoundingSolution {
public static void main(String[] args) {
double result = 0.1 + 0.2;
// 소수점 둘째 자리에서 반올림
double rounded = Math.round(result * 100.0) / 100.0;
System.out.println("반올림 결과: " + rounded); // 0.3
// 더 정교한 반올림 함수
System.out.println("정교한 반올림: " + round(result, 1)); // 0.3
}
public static double round(double value, int places) {
if (places < 0) throw new IllegalArgumentException();
long factor = (long) Math.pow(10, places);
value = value * factor;
long tmp = Math.round(value);
return (double) tmp / factor;
}
}
🚫 절대 하지 말아야 할 것들
// 1. 부동소수점 직접 비교 금지
if (result == 0.3) { }
// 2. BigDecimal을 double로 생성 금지
BigDecimal wrong = new BigDecimal(0.1);
// 3. 금융 계산에 double 사용 금지
double money = 19.99 + 1.01;
// 4. 반복문에서 부동소수점 증가 금지
for (double i = 0; i != 1.0; i += 0.1) { } // 무한루프
🔍 결론
부동소수점 연산의 부정확성은 컴퓨터가 소수를 저장하는 방식인 IEEE 754 방식의 문제였다.
즉, Java의 버그가 아니라, 이진법 체계에서 십진법 소수를 표현할 때 발생하는 수학적 한계다.
핵심 포인트들:
- 문제 인식: 0.1 + 0.2 ≠ 0.3임을 항상 기억
- 상황별 대응: 정확성이 중요하면 BigDecimal, 성능이 중요하면 epsilon 비교
- 직접 비교 금지: == 대신 적절한 비교 방법 사용
- 도메인 고려: 금융은 BigDecimal, 게임은 epsilon, 과학은 상황에 따라
이제 더 이상 0.1 + 0.2가 0.3이 아닌 것에 당황하지 말고, 적절한 해결책을 선택해보자.
'백엔드 멘토링' 카테고리의 다른 글
웹사이트에서 볼 수 있는 유해 광고들, 왜 못막을까? (0) | 2025.08.29 |
---|---|
불법 사이트들은 왜 안잡는걸까? (0) | 2025.08.28 |
게임은 서버와 어떤 방식으로 통신할까? (4) | 2025.08.26 |
번역기가 우리들의 말을 번역해주는 방법 (5) | 2025.08.25 |
구글은 어떻게 그렇게 빠른 검색을 지원할까? (0) | 2025.08.23 |