자바로 계산기 같은 걸 만들어보다가 한 번쯤 겪는 당황스러운 순간이 있습니다. 0.1 + 0.2를 계산했는데 결과가 0.3이 아니라고 나오는 상황입니다. 분명 초등학교 때 배운 계산인데, 컴퓨터가 틀렸나 싶어지죠.
사실 이건 버그가 아니라 부동소수점(floating point)을 다루는 컴퓨터라면 피할 수 없는 특성입니다. 이번 글에서는 왜 이런 일이 생기는지, 그리고 실무에서는 이 문제를 어떻게 우회하는지 정리해보겠습니다.
1. 직접 확인해보기
말보다 코드로 먼저 보는 게 빠릅니다.
public class FloatingPointExample {
public static void main(String[] args) {
double a = 0.1;
double b = 0.2;
double sum = a + b;
System.out.println("0.1 + 0.2 = " + sum);
System.out.println("결과가 0.3과 같은가? " + (sum == 0.3));
}
}
실행하면 다음과 같은 결과가 나옵니다.
0.1 + 0.2 = 0.30000000000000004
결과가 0.3과 같은가? false
0.1과 0.2를 더했는데 눈에 보이는 값은 0.30000000000000004이고, 0.3과 비교하면 false가 나옵니다. 사람 눈에는 거의 같은 값인데 컴퓨터는 다르다고 판단하는 겁니다.
2. 왜 이런 일이 생길까
컴퓨터는 모든 숫자를 2진수로 표현합니다
자바의 double은 내부적으로 숫자를 2진수(0과 1)로 저장합니다. 그런데 문제는, 10진수로는 깔끔하게 끝나는 소수라도 2진수로 바꾸면 무한히 반복되는 경우가 많다는 점입니다.
0.1을 2진수로 바꿔보면 다음과 같습니다.
0.1 (10진수) = 0.0001100110011001100110011... (2진수, 0011이 무한 반복)
익숙한 비유를 들어보면, 10진수에서 1/3을 소수로 쓰면 0.333333...으로 무한히 반복되죠. 3진수 세상에서 살았다면 1/3은 깔끔하게 "0.1"로 끝났을 텐데, 우리가 쓰는 10진수 체계에서는 무한소수가 되는 겁니다. 컴퓨터의 2진수 세계에서는 0.1이 딱 그런 처지에 놓여 있습니다.

double은 저장할 수 있는 비트 수가 정해져 있기 때문에(64비트), 이 무한히 반복되는 2진수를 어느 지점에서 잘라낼 수밖에 없습니다. 그리고 이 절단 과정에서 아주 작은 오차가 생깁니다. 이 오차는 눈에 보이지도 않을 만큼 작지만, 0.1과 0.2를 더하는 것처럼 연산을 거치면 오차끼리 합쳐지면서 0.30000000000000004처럼 눈에 보이는 차이로 드러나는 겁니다.
이건 자바만의 문제가 아니라, IEEE 754라는 표준을 따르는 대부분의 언어(C, C++, Python, JavaScript 등)에서 똑같이 발생하는 현상입니다.
3. 그래서 어떻게 비교해야 할까
이 문제 때문에 부동소수점 값은 == 연산자로 직접 비교하면 안 됩니다. 대신 "두 값의 차이가 아주 작은 허용 범위 안에 있으면 같다고 보자"는 방식을 씁니다. 이 허용 범위를 흔히 엡실론(epsilon) 이라고 부릅니다.
핵심은 Math.abs()로 두 값의 차이(절댓값)를 구하고, 이 값이 엡실론보다 작은지 확인하는 것입니다.
public class EpsilonComparison {
static final double EPSILON = 1e-9; // 허용 오차 범위
public static void main(String[] args) {
double sum = 0.1 + 0.2;
double target = 0.3;
// 잘못된 비교
System.out.println("직접 비교(==): " + (sum == target));
// 올바른 비교: 차이의 절댓값이 엡실론보다 작은지 확인
boolean isEqual = Math.abs(sum - target) < EPSILON;
System.out.println("엡실론 비교: " + isEqual);
}
}
직접 비교(==): false
엡실론 비교: true
Math.abs(sum - target)은 두 값의 차이를 항상 양수로 만들어줍니다. 이 차이가 1e-9(0.000000001)보다 작으면, 실질적으로 같은 값이라고 판단하는 겁니다. 0.1 + 0.2와 0.3의 실제 차이는 0.00000000000000004 정도라서, 1e-9보다 훨씬 작으니 "같다"고 결론 내릴 수 있습니다.
재사용 가능한 비교 메서드 만들기
매번 Math.abs(a - b) < EPSILON을 직접 쓰는 대신, 메서드로 뽑아두면 편합니다.
public class EpsilonUtils {
static final double EPSILON = 1e-9;
public static boolean isEqual(double a, double b) {
return Math.abs(a - b) < EPSILON;
}
public static void main(String[] args) {
System.out.println(isEqual(0.1 + 0.2, 0.3)); // true
System.out.println(isEqual(1.0 / 3, 0.333333333)); // false (오차가 엡실론보다 큼)
}
}
여기서 <(작다)를 쓰는 이유는, 엡실론이 "이 정도 차이까지는 봐준다"는 허용 오차의 한계선이기 때문입니다. 두 값의 차이(Math.abs(a - b))가 이 한계선보다 작아야 비로소 "같다고 봐줄 수 있는 범위 안에 들어왔다"는 뜻이 됩니다. 반대로 차이가 엡실론보다 크거나 같다면, 그건 오차라고 봐주기엔 너무 큰 차이라서 실제로 다른 값이라고 판단하는 겁니다.
참고로 <와 <= 중 어느 쪽을 써도 실무에서는 결과가 거의 똑같습니다. 두 실수의 차이가 엡실론과 정확히 같은 값이 나올 확률은 거의 0에 가깝기 때문입니다 (애초에 부동소수점 오차 자체가 예측 불가능한 값이라서요). 그래서 관례상 <를 쓰지만, <=로 바꿔도 실제 동작에는 차이가 거의 없습니다.
4. 엡실론 값은 어떻게 정해야 할까
엡실론 값을 무작정 작게 잡으면 안 됩니다. 상황에 따라 적절한 크기를 골라야 합니다.
- 너무 작은 엡실론 (예: 1e-15): 부동소수점 오차 자체보다 더 작아서, 정작 같아야 할 값도 다르다고 판단할 수 있습니다.
- 너무 큰 엡실론 (예: 0.1): 실제로 다른 값인데도 같다고 잘못 판단할 위험이 있습니다.
일반적인 계산에는 1e-9 ~ 1e-6 정도가 널리 쓰이고, 다루는 숫자의 크기가 아주 크거나 작다면 절대적인 차이(절대 오차) 대신 상대적인 비율로 비교하는 방식도 고려해야 합니다.
public static boolean isEqualRelative(double a, double b, double epsilon) {
double diff = Math.abs(a - b);
double largest = Math.max(Math.abs(a), Math.abs(b));
return diff <= largest * epsilon;
}
값이 100000.1과 100000.2처럼 큰 수라면, 절대 오차 1e-9는 너무 엄격한 기준이 됩니다. 값 자체가 큰 만큼 부동소수점 계산에서 생기는 오차도 자연히 커지기 때문입니다. 반면 상대 오차 방식은 값의 크기에 비례해서 허용 범위를 같이 늘려주기 때문에, 숫자가 커지든 작아지든 일관된 기준으로 비교할 수 있습니다.
돈 계산처럼 오차가 절대 허용되지 않는다면
엡실론 비교로도 부족한 경우가 있습니다. 예를 들어 결제 금액, 정산 로직처럼 오차가 조금이라도 있으면 안 되는 곳에는 double 자체를 쓰지 않고 BigDecimal을 사용하는 게 정석입니다.
import java.math.BigDecimal;
BigDecimal a = new BigDecimal("0.1");
BigDecimal b = new BigDecimal("0.2");
BigDecimal sum = a.add(b);
System.out.println(sum); // 0.3
System.out.println(sum.equals(new BigDecimal("0.3"))); // true
BigDecimal은 숫자를 2진수가 아니라 10진수 그대로 다루기 때문에 이런 오차가 생기지 않습니다. 다만 연산 속도가 double보다 느리기 때문에, 정확성이 중요한 곳(금융, 결제)에만 선택적으로 쓰는 게 일반적입니다.
마무리
정리하면 이렇습니다.
- 컴퓨터는 숫자를 2진수로 저장하는데, 0.1처럼 10진수로는 깔끔한 소수도 2진수로는 무한히 반복되는 경우가 많습니다.
- 정해진 비트 수 안에 담기 위해 이 무한소수를 어딘가에서 잘라내야 하고, 그 과정에서 아주 작은 오차가 생깁니다.
- 그래서 부동소수점 값은 ==로 직접 비교하면 안 되고, Math.abs(a - b) < EPSILON 형태로 오차 허용 범위를 두고 비교해야 합니다.
- 오차가 절대 허용되지 않는 상황(금액 계산 등)에서는 double 대신 BigDecimal을 쓰는 게 안전합니다.
부동소수점 오차는 없앨 수 있는 게 아니라 "그런 게 있다는 걸 알고 대응하는" 문제입니다. 앞으로 if (a == b)로 실수 비교하는 코드를 보면, 한 번쯤 엡실론 비교로 바꿔야 하는 건 아닌지 점검해보면 좋을 것 같습니다.
