부동소수점 오차 이해하기: 앱 금액·센서 계산 주의점

금액이 1단위 어긋났을 때 먼저 구분할 것은 이진 소수의 표현 오차와 반올림 규칙의 차이입니다. 두 문제를 섞으면 double을 int로 바꾼 뒤에도 앱과 서버의 합계가 다를 수 있습니다. 센서 값에는 측정 단위에 맞는 허용 오차가 필요하지만, 정확한 최소 단위 금액을 비교할 때 같은 규칙을 쓰면 안 됩니다.

2026년 10월 8일 Windows x64의 Dart 3.13.4 VM에서 소수 합산, 문자열 출력, 최소 단위 변환, 반올림 시점, 근사 비교, 비유한 값의 JSON 변환을 직접 실행했습니다. 분석기는 오류가 없었고 23개 기대 조건이 통과했습니다. 아래 수치는 결제 서비스나 실제 센서에서 가져온 기록이 아니라 격리 코드의 관찰 결과입니다.

0.3처럼 보이는 값부터 실제 출력으로 확인

void main() {
  final sum = 0.1 + 0.2;
  print(sum);                  // 0.30000000000000004
  print(sum == 0.3);           // false
  print(1.005.toStringAsFixed(2)); // 1.00
}

0.1을 10,000번 더한 결과는 1000.0000000001588이었습니다. 1000과의 차이는 약 1.5882e-10입니다. 모든 소수가 항상 틀린다는 뜻은 아닙니다. 특정 계산 경로에서 저장된 근사값이 기대하는 수학적 값과 달라졌다는 관찰입니다.

toStringAsFixed(2)는 저장된 double을 두 자리 문자열로 표현합니다. 입력 1.005를 업무 규칙상 정확한 십진수로 만든 뒤 반올림하는 연산은 아닙니다. 화면에 1.00이 나왔다고 문자열을 다시 double로 파싱해 계산을 반복하면 최초 표현 오차를 해결하지 못합니다.

Dart 부동소수점 23개 검증과 항목별 합계별 반올림 3 대 2 결과 비교
2026년 10월 8일 격리 환경의 실제 실행 결과를 요약한 설명 도식입니다.

소수 문자열을 최소 단위로 바꾸는 경계

실험에서는 “비음수, 소수점 이하 최대 두 자리, 최소 단위는 1/100”이라는 계약을 정했습니다. 사용자 문자열을 double로 먼저 읽지 않고 정수 부분과 소수 부분을 따로 읽어 BigInt로 합쳤습니다.

BigInt parseCents(String input) {
  final m = RegExp(r'^(\d+)(?:\.(\d{1,2}))?$').firstMatch(input);
  if (m == null) throw FormatException('금액 형식 오류');
  return BigInt.parse(m.group(1)!) * BigInt.from(100)
      + BigInt.parse((m.group(2) ?? '').padRight(2, '0'));
}
입력 관찰 결과 판단
0.10 + 0.20 10 + 20 = 30 최소 단위 십진 입력을 정수로 보존
90071992547409.93 9007199254740993 BigInt에서 정확히 보존
1.005 FormatException 세 자리 소수 정책을 별도로 정해야 함
-1.00, 1e2, NaN, 1,000 모두 거부 이 예제의 입력 계약 밖

이 파서는 음수 환불, 지역별 구분자, 통화마다 다른 소수 자릿수를 처리하지 않습니다. 그런 요구가 있다면 입력 계약과 테스트를 먼저 확장합니다. “잘못된 값을 0으로 바꿔 계속 진행”하면 오류와 실제 영액을 구분할 수 없으므로 예제는 명시적으로 거부합니다.

정수 연산에서도 반올림 위치는 합의해야 한다

최소 단위 1인 항목 세 개에 각각 50%를 적용하는 입력을 실행했습니다. 비음수 half-up 규칙으로 항목마다 반올림하면 1+1+1=3이고, 합계 3에 한 번 적용하면 2입니다. 이 차이는 부동소수점 없이도 발생했습니다.

// amount와 numerator는 비음수, denominator는 양수
final product = amount * BigInt.from(numerator);
final d = BigInt.from(denominator);
final rounded = (product * BigInt.two + d) ~/ (d * BigInt.two);

앱과 서버가 공유해야 할 계약은 “정수를 쓴다”보다 구체적입니다. 할인 전후 어느 값에 비율을 적용하는지, 항목별인지 합계별인지, 절반에서 올리는지 짝수로 맞추는지, 음수는 어떻게 처리하는지를 정합니다. 위 식은 비음수 half-up 실험용이며 음수에 그대로 재사용하지 않습니다.

센서 비교에는 절대 오차와 상대 오차를 함께 둔다

유한한 a와 b의 차이가 max(absTol, relTol × max(|a|, |b|)) 이하면 근사 일치로 판단했습니다. 기본값은 절대 1e-6, 상대 1e-9였습니다. 이는 센서 사양에서 가져온 값이 아니라 함수 동작을 확인하기 위한 테스트값입니다.

비교 실행 결과 왜 다른가
0.1+0.2와 0.3 근사 일치 표현 차이가 허용 범위 안
1e9와 1e9+0.1 상대 오차 포함 시 일치 값 규모에 따라 허용 차이가 커짐
같은 입력, relTol=0 불일치 절대 1e-6만으로는 부족
0과 0.001 불일치 실제 차이를 모두 숨기지는 않음
NaN 또는 Infinity 비교 거부 정상 측정값 범위 밖

허용 오차는 단위와 값 범위에 따라 선택합니다. 상대 오차가 큰 수에서 너무 넓어지지 않도록 측정 범위도 제한해야 합니다. 로그인 권한, 잔액 일치, 식별자 중복 같은 정확한 판단을 이 함수로 대체하지 않습니다.

전송 전에 NaN과 Infinity를 드러내기

이번 Dart VM에서 NaN, 양의 Infinity, 음의 Infinity를 jsonEncode에 넣은 세 경우 모두 JsonUnsupportedObjectError가 발생했습니다. isFinite를 확인해 오류로 분리하고, 분모 0·누락 데이터·단위 변환 중 어디서 만들어졌는지 추적합니다. 임의의 0으로 치환하면 장애를 정상 측정으로 기록할 수 있습니다.

재현 명령과 적용 전 확인할 한계

dart analyze floating_point_test.dart
dart run floating_point_test.dart
// checks: 23, passed: true

실행은 Windows Dart VM에서만 했습니다. Flutter Web, 다른 CPU, decimal 라이브러리, 실제 PG 정산은 실행하지 않았습니다. 웹 int의 정밀도 제한까지 피하려고 큰 최소 단위 예제는 BigInt를 사용했고, API 전송 계약은 정확한 숫자 문자열을 허용하는지 별도로 확인해야 합니다.

이 글은 소수 계산과 반올림 정책을 다룹니다. 정수 필드 폭과 직렬화 순서는 16비트 헤더 패킹 검증에서 확인할 수 있습니다.

실행 결과를 대조한 공식 원문은 Dart double, toStringAsFixed, 플랫폼별 숫자 표현입니다. 문서는 2026년 10월 8일 확인했고, 실행 버전 3.13.4와 문서의 최신 패치 버전을 구분했습니다.