에디블로그
Engineer's Field Notes

AI 자동화로 매일
한 편씩 쓰는
엔지니어 운영 노트

Claude Code · 자동화 파이프라인 · 사고 회고까지. 잘 굴러간 기록 + 깨진 흔적도 같이 남깁니다.

사람이 할 수 있는 일은,
AI도 할 수 있어야 합니다.
매일 한 편 쓰면서 검증 중.
— 이번 주 가장 많이 읽힌 글 TOP 3
백엔드/프레임워크 & 언어

[Java] 돈 계산에 double 쓰면 안 되는 이유: BigDecimal 사용법과 함정

반응형
[Java] 돈 계산에 double 쓰면 안 되는 이유: BigDecimal 사용법과 함정

[Java] 돈 계산에 double 쓰면 안 되는 이유: BigDecimal 사용법과 함정

0.1 + 0.2 == 0.3이 false라는 건 다들 한 번쯤 들었는데, 이게 정산 금액 1원 차이로 돌아오면 더 이상 퀴즈가 아니에요. 돈을 다루는 백엔드에서 부동소수점은 "가끔 틀리는" 게 아니라 구조적으로 10진 소수를 표현 못 하는 타입이고, 그래서 BigDecimal이 규율이 돼요. 그런데 BigDecimal에도 함정이 세 개 있어요.

이 글은 double이 왜 돈에 안 되는지(원리), BigDecimal의 3대 규율(생성자·비교·나눗셈), 그리고 "경로 전체"를 지키는 타입 설계(DB·JSON까지) 순서로 짚어볼게요. 함정 시리즈 1편이에요.

01. double은 0.1을 저장하지 못해요

double 부동소수점 오차. 0.1 더하기 0.2는 0.30000000000000004가 되고 비교가 깨진다. double은 2진수라 10진 소수 0.1이 무한소수가 되어 정확히 저장되지 않는 IEEE 754의 본질적 한계다
System.out.println(0.1 + 0.2);            // 0.30000000000000004
System.out.println(1.03 - 0.42);          // 0.6100000000000001
System.out.println(10000.0 * 0.07);       // 700.0000000000001 (수수료 7%)
System.out.println(0.1 + 0.2 == 0.3);     // false

버그가 아니라 IEEE 754 부동소수점의 본질이에요. double은 값을 2진수로 저장하는데, 10진수 0.1은 2진수로는 무한소수예요(1/3이 10진수로 0.333...인 것처럼요). 그래서 저장하는 순간부터 이미 근사값이고, 연산할수록 오차가 누적돼요.

"오차가 0.00000000000000004인데 무슨 문제야"라고 생각하기 쉬운데, 돈에서는 두 경로로 사고가 돼요. 첫째, 반올림 경계 — 699.9999...원을 반올림하면 700원이지만, 700.0000...1원에 버림 정책이 걸리면 옆 시스템과 1원이 어긋나요. 둘째, 대량 집계 — 건당 티끌이 수백만 건 합산에서 원 단위 차이로 자라요. 정산 대사에서 "1원 안 맞음"으로 발견되는 그 종류예요. 돈은 단 1원도 "대충"이 안 되는 도메인이라, 근사 타입 자체가 실격이에요.

02. BigDecimal 규율 ①, 생성은 문자열로

그래서 자바의 답은 BigDecimal(10진수를 정확히 표현)인데, 첫 줄부터 함정이 있어요.

new BigDecimal(0.1);
// → 0.1000000000000000055511151231257827021181583404541015625

double 생성자는 이미 오염된 double 값을 그대로 받아요. 0.1의 근사값이 정밀하게 박제되는 거죠. BigDecimal을 썼는데도 오차가 나는 미스터리가 대개 여기서 시작돼요.

new BigDecimal("0.1");          // ✅ 문자열 생성자 — 정확히 0.1
BigDecimal.valueOf(0.1);        // ✅ 내부적으로 문자열 경유 — 안전
BigDecimal 3대 함정. double 생성자는 오염된 값을 박제하므로 문자열 생성자를 쓰고, equals는 scale까지 비교해 1.0과 1.00이 다르므로 compareTo로 비교하며, 나눗셈은 무한소수에서 예외가 나므로 scale과 RoundingMode를 명시한다

03. 규율 ②, 비교는 compareTo로

new BigDecimal("1.0").equals(new BigDecimal("1.00"));      // false!
new BigDecimal("1.0").compareTo(new BigDecimal("1.00"));   // 0 (같음)

equals는 값뿐 아니라 scale(소수점 자릿수)까지 비교해요. 1.0과 1.00이 다른 객체라는 거죠. "금액이 같은데 if문이 안 탄다"의 단골 원인이고, HashSet·HashMap의 키로 쓸 때도 같은 함정이 있어요. 값 비교는 항상 compareTo() == 0이에요.

04. 규율 ③, 나눗셈엔 scale과 반올림을 명시

BigDecimal.ONE.divide(new BigDecimal("3"));
// → ArithmeticException: Non-terminating decimal expansion

BigDecimal.ONE.divide(new BigDecimal("3"), 2, RoundingMode.HALF_UP);
// → 0.33  ✅ 자릿수와 반올림 방식 명시

1/3 같은 무한소수는 "어디서 끊을지"를 모르면 예외예요. 나눗셈(그리고 모든 반올림 지점)에는 scale과 RoundingMode를 명시해요.

여기서 중요한 게 — 반올림 정책은 기술 선택이 아니라 비즈니스 결정이에요. 수수료를 올림할지 내림할지, 분배 후 남는 1원을 누가 갖는지는 코드가 아니라 정책이 정해요. 참고로 HALF_UP(사사오입)이 일상적 반올림이고, HALF_EVEN(은행가 반올림)은 .5를 짝수 쪽으로 보내 대량 집계의 편향을 없애는 방식이에요. 뭐가 됐든 한 곳(공통 유틸·정책 문서)에 명시하고 전사가 통일하는 게 핵심이에요 — 시스템마다 반올림이 다르면 그게 바로 대사 불일치예요.

05. 한 구간만 double이어도 전체가 오염돼요

금액 타입 경로 전체 규율. 중간 계산 한 곳만 double이어도 전체가 오염되므로, 자바는 BigDecimal이나 최소 단위 정수, DB는 DECIMAL 컬럼, JSON은 문자열이나 정수로 경로 전체를 통일한다

API는 BigDecimal인데 중간 계산 한 줄이 doubleValue()를 거치면, 거기서 오염되고 끝까지 전파돼요. 경로 전체의 타입 규율이 필요해요.

  • 자바 — BigDecimal로 통일. 대안으로 최소 단위 정수(원 단위면 long, 소수점 거래면 "전"·"마이크로원" 단위 정수)도 강력해요 — 정수 연산은 오차 자체가 없으니까요. 오차는 없지만 범위는 별개라, 곱셈 중간값이 커지는 계산은 오버플로를 따로 챙겨야 해요. 암호화폐·외환처럼 소수점 깊은 도메인에서 흔한 선택이에요.
  • DBDECIMAL(19, 4) 같은 고정소수점 컬럼. FLOAT·DOUBLE 컬럼은 금액에 금지예요. JPA는 BigDecimal ↔ DECIMAL이 자연 매핑돼요.
  • JSON — 함정이 하나 더 있어요. 자바스크립트의 Number도 double이에요. 금액을 JSON 숫자로 내리면 프론트에서 같은 오차가 나요(큰 ID가 깨지는 것과 같은 원리). 금액은 문자열("12345.67")이나 최소 단위 정수로 내리는 게 안전해요.

06. 자주 만나는 문제

BigDecimal을 쓰는데도 값이 미세하게 어긋나요

어딘가에서 double 생성자나 doubleValue() 경유를 의심해요. 경로 전체를 추적하면 한 줄이 나와요.

금액 비교 if문이 가끔 안 타요

equals의 scale 비교예요. compareTo() == 0으로 바꿔요.

옆 시스템과 정산이 1원 안 맞아요

반올림 정책(방식·적용 시점·자릿수)이 서로 다른 거예요. 어느 단계에서 몇 자리로 어떻게 반올림하는지를 양쪽이 문서로 맞춰요. 건별 반올림 vs 합산 후 반올림의 차이도 단골 원인이에요.

프론트에서 금액이 이상하게 보여요

JSON 숫자로 내린 금액이 JS double에서 깨진 거예요. 문자열로 내려요.

정리

돈 계산은 결국 다섯 가지 습관으로 압축돼요. 한 곳의 구멍이 경로 전체를 오염시키는 종류라, 컨벤션·공통 유틸로 박아두는 게 답이에요.

  • double은 쓰지 않아요 — 2진수는 10진 소수를 못 담아요.
  • BigDecimal 생성은 문자열로 — double 생성자는 오염값을 박제해요.
  • 값 비교는 compareTo — equals는 scale까지 봐요.
  • 나눗셈·반올림엔 scale과 RoundingMode 명시 — 반올림 정책은 비즈니스 결정이에요.
  • DB DECIMAL·JSON 문자열까지 경로 전체 통일 — 한 구간만 double이어도 끝까지 번져요.

이어지는 함정은 자정에 터지는 버그예요. 타임존과 날짜 처리로 넘어가요.

출처: Java — BigDecimal · What Every Computer Scientist Should Know About Floating-Point Arithmetic

반응형

📚 같이 보면 좋은

"이 포스팅은 쿠팡 파트너스 활동의 일환으로, 일정액의 수수료를 제공받습니다."