배경
무역 ERP 앱에서 화물 중량 데이터를 톤 단위로 저장하고 있었습니다. 그런데 실제 화물은 톤 단위로 딱 떨어지지 않고, 8500kg처럼 kg 단위로도 거래됩니다. 소수점 아래 값이 필요했기 때문에 중량 컬럼에 부동소수점 타입을 사용해 8500kg을 8.5톤으로 저장하고 있었습니다.
문제는 화물 중량에 계산식을 적용해 단가를 보여 주는 화면에서 발생했습니다. 8.5로 보여야 하는 값이 8.49999999... 처럼 길게 표시된 것입니다.
원인
부동소수점 타입은 특성상 0.1이나 8.47 같은 소수를 정확히 표현하지 못하고, 가장 가까운 근사값으로 저장합니다. 예를 들어 8.47을 저장하면 실제로 저장되는 값은 8.47000000000000063948… 입니다.
그런데도 값을 조회하면 8.47로 보입니다. 대부분의 언어는 부동소수점 수를 문자열로 출력할 때 저장 된 값을 그대로 출력하지 않고, 그 값을 다른 부동소수점 수와 구별할 수 있는 가장 짧은 10진수로 출력하기 때문입니다. 그래서 저장하고 조회하기만 할 때는 문제가 드러나지 않습니다.
문제는 계산할 때 드러납니다. 부동소수점 수 끼리의 연산은 원래의 수가 아니라 저장된 근삿값끼리 이루어지고, 연산 결과도 다시 근삿값으로 저장됩니다. 이과정에서 오차가 발생하고, 이 오차가 쌓이면 결과가 정확한 답과 다른 수가 될 수 있습니다. 예를 들어 9.35 / 1.1 의 정확한 답은 8.5 이지만, 계산 결과는 8.4999999999999982236431605997495353221893310546875 로 저장됩니다. 이 값은 8.5로 줄여 출력할 수 없고, 구별할 수 있는 가장 짧은 표현인 8.49999999999998 로 출력됩니다.
해결
화면에 값이 잘못 표시되는 문제만 해결하려면, 출력하기 전에 반올림하기만 하면 됩니다.
그러나 부동소수점 수로 계속 연산하면 연산 과정과 결과를 저장하는 과정에서 오차가 생깁니다. 이 오차가 사용자에게 손해를 줄 만큼 커질 수 있다면 부동소수점 수를 쓰는 지금의 방식 자체를 바꿔야 하므로, 먼저 오차의 크기를 따져봤습니다.
부동소수점 타입 오차 평가
배정밀도(64비트) 부동소수점 타입은 유효 숫자를 15자리까지 정확하게 표현할 수 있습니다. 1000톤짜리 화물을 최소단위인 kg 단위까지 표현해도 7자리 (1,000.000)면 충분하므로, 오차는 kg 단위보다 한참 아래 자리에서 생깁니다.
화물 중량을 톤 단위의 부동소수점 수로 저장할 때, 중량의 크기에 따라 생길 수 있는 최대 오차는 다음과 같습니다.
| 중량 | 생길 수 있는 최대 오차 |
| 8.5톤 | 10억분의 1그램 |
| 100톤 | 10억분의 7그램 |
| 1,000톤 | 1억분의 6그램 |
연산을 거듭하면 위 오차가 누적되어 더욱 커집니다. 그러나 1000톤짜리 값에 연산을 1만 번 거듭하는 극단적인 경우를 가정해도 누적되는 오차는 최대 0.001g 정도입니다.
따라서 최악의 경우에도 오차가 거래의 최소 단위인 kg에 영향을 주지 못하므로, 사용자에게 손해를 줄 일은 없다고 판단했습니다.
선택: 부동소수점 유지
사용자에게 손해를 줄 수준의 오차는 생기지 않으므로, 부동소수점 타입을 유지하고 화면에 표시하기 전에 반올림 하는 방식을 선택했습니다. 프론트엔드에서 값을 표시할 때 최소 단위까지 반올림합니다. 화물 중량의 최소단위는 kg, 즉 0.001톤이므로 소수점 셋째 자리까지 반올림합니다.
// 톤 단위 중량을 최소 단위(kg = 소수점 셋째 자리)까지 반올림
const roundTon = (ton) => Number(ton.toFixed(3));
roundTon(8.499999999999998); // 8.5
주의사항
위 결론은 아래 두 조건이 맞을 때만 성립합니다. 조건이 다르다면 부동소수점 대신 다른 타입을 쓰는 것을 고려해야합니다.
- 배정밀도(64비트)여야 합니다: 단정밀도(32비트)는 숫자 6자리 까지만 정확하게 표현할 수 있어, 1000톤에서는 표현 가능한 값의 간격이 61g까지 벌어집니다. 이런 값을 수백 번 더하고 빼면 kg 단위의 오차가 생길 수 있습니다.
- 값의 최소 단위가 정해져 있어야 합니다: 화물 중량의 최소 단위가 kg이라는 것을 알기 때문에, 그보다 아래 자리는 오차로 보고 반올림해 원래 값을 되찾을 수 있었습니다. 최소 단위가 없으면 어디까지가 실제 값이고 어디부터가 오차인지 구분할 수 없습니다.
또한 부동소수점 타입을 유지하는 한, 계산한 값을 ===로 바로 비교하면 안 됩니다. 9.35 / 1.1 === 8.5 는 false 입니다. 비교할 때도 먼저 최소 단위로 반올림한 뒤 비교해야 올바른 결과가 나옵니다.
마치며
부동소수점 타입을 사용하는 한 오차는 피할 수 없습니다. 그래서 부동소수점 타입을 쓸 때는 그 오차가 해당 도메인에서 실제로 어떤 영향을 주는지 평가해야 합니다.
화물 중량은 최소 단위가 1kg이어서, 1000톤 규모의 값에서도 오차가 최소 단위의 100억분의 1 미만이었습니다. 최소 단위로 반올림하면 사라지는 크기이므로 부동소수점 타입을 그대로 써도 문제가 없었습니다.
반면 금액은 사정이 다릅니다. 세금이나 수수료 계산처럼 결과가 반올림의 경계(예: 0.5원)에 걸리는 경우가 흔하고, 이때는 아주 작은 오차만으로도 반올림 결과가 최소 통화 단위만큼 달라질 수 있습니다. 이런 값에는 부동소수점 대신 정수나 고정소수점 타입을 쓰는 편이 안전합니다.