CS for Building Good Services — Relearning Textbook Ideas by Measuring
A Float Is a Promise to Be Close — Sums, Comparisons, IDs, Variance, Percentages
한국어 원문으로 표시합니다.
한 줄 요약
float 는 틀리는 게 아니라 정해진 방식으로 가깝게 적습니다. 문제는 그 '가깝게' 가 서비스의 규칙과 부딪치는 자리입니다. 합계가 순서에 따라 바뀌고, == 가 거짓이 되고, 64비트 ID 가 브라우저에서 옆 ID 로 바뀌고, 분산이 음수가 되고, 백분율의 합이 99 가 됩니다. 이 모듈은 그 자리를 하나씩 숫자로 확인합니다.
왜 이게 필요했나
정산 배치의 합계가 재실행마다 몇 원씩 다르다는 신고가 옵니다. 병렬로 나눠 더하는 순서가 매번 달랐기 때문입니다. 관리자 화면에서 주문을 누르면 엉뚱한 주문이 열립니다. 서버는 ID 를 JSON 숫자로 보냈고 브라우저는 그것을 double 로 읽었습니다. 응답 시간 대시보드의 표준편차가 가끔 NaN 입니다. 에포크 밀리초로 제곱의 평균을 구하다가 음수 분산의 제곱근을 구했습니다. 어느 것도 예외를 내지 않습니다.
어떻게 동작하나
0.1 은 0.1 이 아니다. 파이썬 튜토리얼은 거의 모든 플랫폼에서 float 가 53비트 정밀도의 IEEE 754 binary64 라고 적고, 0.1 로 저장되는 실제 값이 0.1000000000000000055511151231257827021181583404541015625 임을 Decimal.from_float(0.1) 로 보여 줍니다. decimal 문서대로 Decimal(float) 은 이진 값을 손실 없이 십진으로 옮기므로, 이것이 float 의 실제 값을 보는 가장 정직한 창입니다. float.hex() 는 같은 값을 가수와 지수로 보여 줍니다.
합은 순서를 탑니다. 한 번 더할 때마다 결과를 53비트로 반올림하므로, 큰 값 옆에서 작은 값의 아랫자리가 잘립니다. 그래서 같은 수열도 앞에서부터, 뒤에서부터, 정렬해서 더하면 다른 값이 나옵니다. math.fsum 은 여러 부분합을 들고 다니며 잃어버린 자리를 추적합니다. 하나 조심할 것은 내장 sum() 입니다. functions 문서에 따르면 3.12 부터 float 합산이 더 정확하고 교환 법칙에 가까운 알고리즘으로 바뀌었습니다. 그래서 이 실습에서 '앞에서부터 더한 합' 은 for 루프의 += 로 정의합니다. 다른 언어나 3.11 이하에서 돌던 코드가 옮겨 오면 숫자가 달라질 수 있다는 뜻이기도 합니다.
비교는 == 대신 math.isclose 입니다. 기본값은 rel_tol=1e-09, abs_tol=0.0 이고, 문서는 0 과 비교할 때 isclose(x, 0) 이 0 이 아닌 x 에 대해 늘 거짓이므로 알맞은 abs_tol 을 주라고 적습니다. 잔액이 0 이어야 하는 검사에 abs_tol 을 빠뜨리면 1e-16 짜리 잔액 때문에 실패합니다.
2^53 너머의 정수. MDN 의 Number.MAX_SAFE_INTEGER 는 9007199254740991(2^53 − 1)이고, MAX_SAFE_INTEGER + 1 === MAX_SAFE_INTEGER + 2 가 참이 된다고 적습니다. RFC 8259 6절도 binary64 를 쓰는 구현끼리는 [−(2^53)+1, 2^53−1] 범위의 정수에서 값이 정확히 일치한다고 말합니다. 그 범위를 벗어나면 이런 합의가 없습니다. 스노플레이크처럼 2^60 근처에서 커지는 64비트 ID 를 JSON 숫자로 보내면, 받는 쪽이 double 로 읽는 순간 가까운 ID 들이 같은 값이 됩니다. 해법은 ID 를 문자열로 보내는 것입니다. 파이썬 쪽 json.loads 는 정수를 int 로 읽어 멀쩡하므로 서버 시험으로는 드러나지 않습니다.
반올림. round() 는 두 후보가 똑같이 가까우면 짝수 쪽으로 보내고, 문서는 round(2.675, 2) 가 2.68 이 아니라 2.67 인 것이 버그가 아니라 2.675 를 float 로 정확히 적을 수 없기 때문이라고 설명합니다. 반올림 모드를 정책으로 못박으려면 문자열에서 바로 Decimal 을 만들어 quantize(..., rounding=ROUND_HALF_UP) 을 씁니다. 줄마다 반올림해 더한 값과 합계를 한 번 반올림한 값이 다른 것은 오차가 아니라 계약의 차이입니다.
분산. 교과서 식 E[x²] − E[x]² 은 두 큰 수의 차입니다. Algorithms for calculating variance 항목은 표준편차가 평균에 비해 작을 때 이 소거가 특히 나쁘다고 적고, 4·7·13·16 을 10^9 만큼 옮기면 이 식이 −170.67 을 낸다는 예를 듭니다(참값 30). 같은 항목이 소개하는 Welford(1962)의 방법은 평균과 편차 제곱합을 한 번에 갱신해 이 소거를 피합니다.
백분율. 각 몫을 따로 반올림하면 합이 99 나 101 이 됩니다. 최대 나머지 방식(해밀턴 방식)은 몫의 정수 부분을 먼저 나눠 주고, 남는 자리를 소수 부분이 큰 순서로 하나씩 줍니다.
현장에서 만나는 모습
- 서버 로그의 주문 ID 와 화면에 뜬 ID 가 끝 두세 자리만 다르면 먼저 double 을 의심합니다. 2^60 근처에서는 이웃한 double 의 간격이 256 이라, 끝자리가 그 폭 안에서 뭉개집니다.
- 청구서의 줄 합과 합계가 몇 센트 다르다는 문의는 계산 오류보다 '어디서 반올림하나' 를 정하지 않은 계약의 문제인 경우가 많습니다. 금액의 정수 최소단위·반올림 정책·배분은 '은행 현장의 언어' 코스의 fde-bank-money 가, 나머지 1원을 어느 품목에 줄지는 '주문은 100건인데 정산은 98건이다' 코스의 dk-com-allocate 가 깊게 다룹니다.
- 모니터링 화면의 표준편차가 가끔 비는 것은 음수 분산의 제곱근이 NaN 이 된 흔적일 수 있습니다. 값을 기준점만큼 빼 두거나 Welford 로 바꾸면 사라집니다.
다음 실습에서 할 것
몇 가지 소수의 실제 값을 보고, 3,010줄 금액 흐름을 네 가지 순서로 더하고, == 와 isclose 가 다르게 답하는 쌍을 셉니다. 64비트 주문 ID 가 double 에서 몇 쌍 충돌하는지 세고 문자열로 보내는 함수를 만들고, 반올림 정책을 Decimal 로 못박고, Welford 로 분산을 구하고, 최대 나머지로 합이 100 인 백분율을 만든 뒤, 전부를 대시보드 응답 하나로 모읍니다.