앱 개발에서 실수 표현

부동소수점이 앱에서 문제를 만드는 이유

앱에서 쓰는 float/double(부동소수점)은 “연속적인 실수”를 그대로 저장하는 방식이 아니라, 제한된 비트로 근사값을 표현합니다. 그래서 사람 입장에서는 딱 떨어져야 하는 값(금액, 비율, 진행률, 좌표 변화)이 내부적으로는 아주 미세하게 어긋난 상태로 남을 수 있고, 그 미세한 오차가 비교 연산, 누적 계산, 직렬화(서버 통신), 표시 반올림에서 실제 버그로 터집니다.

아래에서 앱 개발에서 자주 겪는 상황을 “실제 사례” 중심으로 정리해드리고, 바로 적용할 수 있는 코드 예시까지 함께 알려드리겠습니다.

결제 금액이 1원씩 어긋나는 문제

상황

장바구니에서 “정률 할인 → 쿠폰 → 세금/수수료”처럼 단계가 늘어나면, 화면에 표시한 금액과 서버/PG 정산 금액이 1원씩 차이 나는 민원이 생깁니다. 원인은 대부분 double로 금액을 계산하면서 중간 반올림 타이밍이 흔들리기 때문입니다.

잘못된 예시 (Dart/Flutter)

double subtotal = 9900.0;
double discountRate = 0.15; // 15%
double discounted = subtotal * (1.0 - discountRate); // 8415.0 "같아 보이지만"
double fee = discounted * 0.035;                      // 수수료
double total = discounted + fee;

// 화면 표시를 위해 반올림
int displayWon = total.round(); // 특정 케이스에서 1원 차이 발생 가능

실무에서 안전한 방식: “최소 단위 정수”로 계산

/// 원 단위 정수로만 계산한다.
int applyPercentRound(int amountWon, int ratePermyriad) {
  // ratePermyriad: 만분율(0~10000), 예: 15% = 1500
  // 반올림(half-up)하려면 +5000 후 /10000
  return (amountWon * ratePermyriad + 5000) ~/ 10000;
}

void main() {
  final int subtotalWon = 9900;

  // 15% 할인 => 할인금액
  final int discountWon = applyPercentRound(subtotalWon, 1500);
  final int discountedWon = subtotalWon - discountWon;

  // 수수료 3.5% => 만분율 350
  final int feeWon = applyPercentRound(discountedWon, 350);

  final int totalWon = discountedWon + feeWon;
  print(totalWon); // 금액 로직이 흔들리지 않음
}

실무 포인트는 “계산 기준”은 정수로 고정하고, 마지막 출력만 포맷팅하는 것입니다. 이 방식이면 앱/서버/결제 모듈이 언어가 달라도 결과가 재현 가능해집니다.

버튼 활성화나 조건 분기가 깨지는 문제

상황

“총액이 0이면 결제 버튼 비활성화” 같은 로직을 == 비교로 구현하면, 내부 값이 0.0000000001 같은 형태로 남아 간헐 버그가 납니다. 특히 쿠폰/포인트/프로모션이 여러 번 적용되는 화면에서 잘 터집니다.

잘못된 예시

if (total == 0.0) {
  disablePayButton();
}

실무 패턴 1: 금액은 정수로 비교

if (totalWon == 0) {
  disablePayButton();
}

실무 패턴 2: 불가피하게 실수를 쓸 때는 “근사 비교” 사용

bool nearlyEqual(double a, double b, {double absTol = 1e-9, double relTol = 1e-9}) {
  final diff = (a - b).abs();
  if (diff <= absTol) return true;
  return diff <= relTol * (a.abs() + b.abs()).clamp(0.0, double.infinity);
}

if (nearlyEqual(total, 0.0)) {
  disablePayButton();
}

금액처럼 “최소 단위가 명확한 값”은 근사 비교를 쓰는 것 자체가 위험 신호인 경우가 많고, 이때는 설계를 정수 기반으로 되돌리는 편이 더 안전합니다.

애니메이션 진행률 1.0에 정확히 도달하지 않음

상황

진행률(progress)이 0→1로 끝나야 하는데, 프레임 누적과 부동소수점 오차가 겹치면 마지막에 0.999999로 남아서 애니메이션 종료 조건이 깨집니다. 결과적으로 페이드가 끝나지 않거나, 스냅 위치가 살짝 어긋나고, 스크롤/슬라이더가 미세하게 흔들립니다.

실무 코드: clamp + 종료 스냅

double normalizedProgress({
  required Duration elapsed,
  required Duration total,
}) {
  if (total.inMicroseconds <= 0) return 1.0;
  final p = elapsed.inMicroseconds / total.inMicroseconds;
  return p.clamp(0.0, 1.0);
}

double snapToEndIfClose(double p, {double eps = 1e-6}) {
  if (1.0 - p <= eps) return 1.0;
  if (p <= eps) return 0.0;
  return p;
}

실무에서는 “수학적으로는 도달해야 한다”가 아니라 “종료 조건을 코드로 보장한다”가 정답입니다.

지도/운동 기록에서 거리·속도가 누적되며 흔들리는 문제

상황

GPS 좌표를 초 단위로 받아 거리·속도를 계속 누적하면, 작은 오차가 축적되어 “오늘 달린 거리”가 일정 구간에서 과하게 튀거나, 반대로 덜 잡히는 현상이 생깁니다.

실무 대응

  • 좌표/거리 계산은 double로 하되, 저장/전송/표시 단에서 자릿수를 강제합니다.

  • 누적값은 일정 간격으로 기준을 재정렬(리셋/재계산)하거나, 서버 기준 점검과 동기화합니다.

예시: 표시/저장 전에 자릿수 고정

double roundTo(double value, int decimals) {
  final factor = Math.pow(10, decimals).toDouble();
  return (value * factor).round() / factor;
}

// 예: 위경도는 소수 6자리, km는 3자리 등 도메인 규칙 고정
final latStored = roundTo(lat, 6);
final lonStored = roundTo(lon, 6);
final distanceKmStored = roundTo(distanceKm, 3);

핵심은 “계산은 근사”라는 현실을 인정하고, 제품 요구사항에 맞는 의미 단위로 값을 고정하는 것입니다.

서버 통신(JSON)에서 값이 달라지는 문제

상황

앱과 서버가 서로 다른 언어/런타임이면 실수 파싱·포맷팅 과정에서 미세한 차이가 생깁니다. 이 차이는 정렬, 랭킹, 중복 제거, 캐시 키, 서명 검증 같은 곳에서 장애로 이어집니다.

실무 권장: 의미 단위로 보내기

  • 돈: 최소 단위 정수로 보내기(원/센트)

  • 비율: 만분율/퍼밀 같은 정수 스케일로 보내기

  • 좌표: 자릿수 고정 문자열로 보내기

예시: 금액(원)과 할인율(만분율)을 정수로 전송

final payload = {
  "subtotalWon": subtotalWon,     // int
  "discountRate": 1500,           // 만분율 int (15% = 1500)
  "feeRate": 350,                 // 만분율 int (3.5% = 350)
};

예시: 소수 자릿수 고정 문자열로 전송(좌표 등)

String fixed(double v, int decimals) => v.toStringAsFixed(decimals);

final payload = {
  "lat": fixed(lat, 6),
  "lon": fixed(lon, 6),
};

NaN/Infinity가 전파되며 화면이 깨지는 문제

상황

분모가 0이 되는 순간, 혹은 데이터 누락/비정상 입력으로 NaN/Infinity가 생기면, 그 값이 차트 축 계산·레이아웃·애니메이션에 들어가 “갑자기 화면 전체가 깨지는” 형태로 확산됩니다.

실무 방어: UI/저장/전송 전에 isFinite 체크

double safeNumber(double v, {double fallback = 0.0}) {
  if (v.isNaN || v.isInfinite) return fallback;
  return v;
}

// 사용 예
final safeAvg = safeNumber(sum / count, fallback: 0.0);

이 방어는 “문제가 생겼을 때만”이 아니라, 데이터가 외부 입력(API, 센서, 사용자 입력)인 모든 경계에서 적용하는 편이 안전합니다.

결론

앱 개발에서 실수는 단순한 숫자 타입이 아니라 비교, 누적, 표시, 직렬화, 예외값 전파까지 연결되는 품질 이슈의 출발점이 됩니다. 특히 금액처럼 최소 단위가 명확한 도메인은 부동소수점을 계산 기준으로 두는 순간 1원 단위 불일치, 검증 실패, 정산 차이 같은 문제가 반복될 가능성이 커지므로, 원·센트 등 최소 단위 정수로 계산 기준을 고정하는 설계가 가장 안전합니다. 반대로 좌표, 센서, 애니메이션처럼 실수를 쓸 수밖에 없는 영역은 계산과 표시·저장·전송을 분리하고 자릿수 고정, 근사 비교, 종료 스냅, 유한값 검사 같은 방어 규칙을 운영 표준으로 삼아야 재현성과 안정성을 확보할 수 있습니다. 결국 핵심은 “실수를 쓰지 말자”가 아니라, 값의 의미가 바뀌는 경계를 명확히 두고 그 경계에서 오차와 예외값이 확산되지 않도록 통제하는 것입니다.

FAQ

금액 계산에서 double을 쓰면 왜 1원 차이가 나나요?

부동소수점은 모든 소수값을 정확히 저장하지 못하고 근사값으로 표현하기 때문에, 할인·수수료·세금처럼 단계가 늘어날수록 반올림이 누적되며 화면 표시와 내부 계산 결과가 미세하게 어긋날 수 있습니다. 이 미세한 차이가 최종 반올림 시점에서 1원 단위 차이로 드러나거나 서버 정산 금액과 불일치로 이어집니다.

결제/정산 도메인은 어떤 방식으로 설계하는 게 가장 안전한가요?

원화는 원 단위, 달러는 센트 단위처럼 최소 통화 단위 정수로 계산 기준을 고정하는 방식이 가장 안전합니다. 비율 할인도 만분율 같은 정수 스케일로 처리하고, 반올림 규칙을 한곳에 고정한 뒤 표시 단계에서만 포맷팅하면 앱·서버·PG 환경 차이에도 결과가 흔들리지 않습니다.

실수 비교에서 == 를 쓰면 안 되는 이유가 뭔가요?

같아 보이는 값도 내부적으로는 아주 작은 오차가 남아 있을 수 있어, 동등 비교가 간헐적으로 실패합니다. 그 결과 버튼 활성화 조건, 무료 여부 판단, 임계치 분기 같은 로직이 특정 입력이나 기기에서만 틀어지는 문제가 발생할 수 있습니다.

근사 비교를 쓰면 모든 문제가 해결되나요?

근사 비교는 실수를 써야 하는 영역에서 유용하지만, 금액처럼 최소 단위가 명확한 도메인에 적용하면 오히려 규칙이 흐려져 검증과 정산이 더 어려워질 수 있습니다. 금액은 정수 기준으로 되돌리고, 좌표·애니메이션·센서처럼 실수가 필수인 영역에서만 도메인에 맞는 허용 오차와 비교 규칙을 명확히 두는 것이 현실적인 운영 방식입니다.

애니메이션 진행률이 1.0에 딱 도달하지 않는 문제는 어떻게 막나요?

프레임 누적과 부동소수점 근사가 결합되면 마지막 값이 0.999999처럼 남을 수 있습니다. 진행률을 0~1 범위로 강제(clamp)하고, 종료 조건 근처에서는 목표값으로 스냅시키는 규칙을 두면 종료 미스매치, 떨림, 스냅 오차 같은 UX 문제를 크게 줄일 수 있습니다.

지도/운동 기록에서 거리나 속도가 흔들리는 이유는 뭔가요?

좌표·거리·시간을 장기간 누적하면 작은 오차가 쌓여 드리프트처럼 보일 수 있습니다. 실무에서는 계산은 배정도 중심으로 하되 저장·전송·표시 단계에서 필요한 자릿수로 값을 고정하고, 일정 주기마다 기준점을 재정렬하거나 재계산하는 전략을 함께 사용합니다.

서버 통신에서 실수를 그대로 보내면 어떤 문제가 생기나요?

언어와 런타임이 다르면 파싱·포맷팅·반올림의 미세한 차이로 내부 값이 달라질 수 있고, 이 차이가 정렬, 랭킹, 중복 제거, 캐시 키, 검증 로직에서 불일치로 이어질 수 있습니다. 돈은 최소 단위 정수로, 비율은 정수 스케일로, 좌표는 자릿수 고정 문자열로 보내는 방식이 재현성에 유리합니다.

NaN이나 Infinity는 앱에서 어떤 형태로 터지나요?

분모가 0이 되는 순간이나 비정상 데이터가 유입되면 NaN/Infinity가 생성될 수 있고, 이 값이 차트·레이아웃·애니메이션에 전파되면 화면 전체가 깨지거나 렌더링이 실패하는 형태로 확산됩니다. UI 표시나 저장·전송 전에 유한값 여부를 검사하고 비정상 값은 기본값이나 에러 흐름으로 차단하는 방어가 필요합니다.

float와 double은 어떻게 선택해야 하나요?

성능만으로 결정하기보다 오차가 UX나 비즈니스 규칙을 깨뜨리는지로 판단하는 편이 안전합니다. 좌표·누적 시간·통계처럼 누적 오차가 품질로 이어지는 값은 배정도가 유리하고, 그래픽 파이프라인처럼 대량 처리에서 메모리·대역폭이 병목인 구간은 단정도가 실용적일 수 있습니다. 다만 정산·과금은 타입 선택보다 “정수 기준 설계”가 우선입니다.

팀에서 실수 관련 버그를 줄이려면 어떤 운영 규칙이 효과적일까요?

금액은 최소 단위 정수로만 계산하고, 실수 영역은 표시·저장·전송 경계에서 자릿수 고정 규칙을 강제하며, 실수 비교는 근사 비교 또는 정수 변환 비교로 통일하는 운영 규칙이 효과적입니다. 또한 NaN/Infinity 같은 예외값은 경계에서 차단하고 테스트 케이스에 포함해 재발 가능성을 낮추는 것이 중요합니다.

댓글 남기기