double 반올림에서 발생하는 0.4999…의 저주와 깔끔한 해결법

1. 문제의 시작

C/C++에서 double 값을 반올림할 때, 아래와 같은 코드는 너무 흔하다.

(int)(value + 0.5)

대부분의 경우는 잘 동작한다. 하지만 특정 값, 장시간 실행, 환경 차이가 겹치면 예상과 다른 결과가 로그에 남기 시작한다.

1.5  → 1
2.5  → 2
10.05 → 10.0

이 현상은 흔히 이렇게 불린다.

“0.4999…의 저주”

2. 원인: CPU 버그가 아니라 IEEE 754

이 문제는 Intel CPU 버그가 아니다. AMD, ARM 환경에서도 동일하게 발생할 수 있다.

핵심 원인은 단순하다.

double은 10진 소수를 정확히 표현하지 못한다

예를 들어 다음과 같다.

1.5  → 1.499999999999…
2.5  → 2.499999999999…

그래서 다음 연산이 문제가 된다.

(double)1.5 + 0.5 = 1.999999999999…
(int)(1.999999999999…) = 1

수학적으로는 맞아 보이지만, 컴퓨터 내부 표현에서는 틀린 결과가 된다.


3. 기존 방식들의 한계

❌ (value + 0.5) 캐스팅

  • 부동소수점 오차에 취약
  • 음수까지 고려하면 코드가 복잡해짐

❌ round / lround 계열

  • 플랫폼 및 구현 차이
  • FPU 반올림 모드 영향 가능

❌ 문자열 기반 반올림

  • 성능 저하
  • locale / 포맷 영향
  • 숫자 연산으로 보기 어려움

4. 핵심 아이디어

문제는 항상 이 지점에서 발생한다.

… 0.499999999999

그래서 해결 전략은 의외로 단순하다.

0.5보다 아주 조금 더 큰 값을 더해준다

이것은 꼼수가 아니라, IEEE 754 환경에서 의도를 정확히 전달하기 위한 보정이다.


5. 깔끔한 해결 코드

#include <cmath>

double MJRound(double value, int pos = 0)
{
    // 1. 소수점 이동
    double scale = std::pow(10.0, pos);
    double shifted = value * scale;

    // 2. 0.4999… 문제 방지를 위한 epsilon 보정
    double margin = (shifted >= 0.0)
        ? 0.50000000001
        : -0.50000000001;

    // 3. 소수점 제거 후 원복
    return std::trunc(shifted + margin) / scale;
}

6. 이 방식의 장점

  • 양수 / 음수 처리 로직이 단순함
  • round() 계열 함수 의존 없음
  • FPU 반올림 모드 영향 최소화
  • 장시간 실행에서도 결과 안정적
  • 코드만 봐도 의도가 명확함

7. 요약

  • 이 문제는 특정 산업이나 CPU의 문제가 아니다
  • 부동소수점 수학의 구조적 한계
  • 반올림에는 epsilon 보정이 필요하다
  • 반올림은 생각보다 시스템 레벨 문제에 가깝다

8. 한 줄 결론

double 반올림에서 가장 위험한 건 “이 정도면 되겠지”라는 확신이다.

+ Recent posts