반올림한 정산 금액이 엑셀과 1원씩 어긋나는 건이 딱 절반만 나온 이유
문제 발생
쿠폰 할인액을 플랫폼과 판매자가 반씩 부담하는 정산 코드였습니다. 할인액이 홀수면 반이 .5로 끝나니 원 단위로 반올림하기로 했고, 계약서에도 "소수점 첫째 자리에서 반올림"이라고 적혀 있었습니다.
platform_share = round(discount / 2)
seller_share = round(discount / 2)월말에 회계팀이 같은 데이터를 엑셀 ROUND(할인액/2, 0)으로 다시 계산해 맞춰 봤는데 합계가 250원 모자랐습니다. 건별로 비교하니 할인액이 홀수인 500건 중 정확히 250건에서 제 숫자가 1원씩 작았고, 나머지 250건은 맞았습니다. 틀린 건만 늘어놓고 보니 1,205원, 1,209원, 1,213원처럼 반으로 나눈 몫이 짝수인 것들뿐이었습니다. 1,207원의 반인 603.5는 604로 맞게 나오는데, 1,205원의 반인 602.5는 603이 아니라 602였습니다.
원인 분석
Python의 round()는 .5를 짝수 쪽으로 보냅니다. 내장 함수 문서는 두 배수와의 거리가 같으면 짝수 쪽으로 반올림한다고 적고, 그래서 round(0.5)와 round(-0.5)는 둘 다 0이고 round(1.5)는 2라고 예를 듭니다. 602.5는 602와 603의 한가운데이니 짝수인 602로, 603.5는 짝수인 604로 갑니다. 몫이 짝수인 건만 틀린 이유가 이겁니다. 학교에서 배운 "5면 올린다"가 아니라, 은행가 반올림(banker's rounding)이라고도 부르는 round half to even 방식입니다.
Python 2에서 넘어온 코드가 특히 위험합니다. What's New in Python 3.0 문서에 변경 사항으로 올라 있습니다. round()의 반올림 전략이 바뀌어서 정확히 중간인 값은 0에서 먼 쪽 대신 가장 가까운 짝수로 가고, 예를 들어 round(2.5)가 이제 3이 아니라 2를 돌려준다고요. 옛날 코드를 그대로 옮기면 결과가 조용히 달라집니다.
이 규칙이 기본값인 데는 이유가 있습니다. Java RoundingMode.HALF_EVEN 문서의 설명이 간결합니다. 반올림을 여러 번 반복할 때 누적 오차를 통계적으로 가장 작게 만드는 방식이고, IEEE 754의 roundTiesToEven에 해당한다고요. .5를 늘 올리면 합계가 조금씩 커지는데, 짝수 쪽으로 번갈아 보내면 올림과 내림이 상쇄됩니다. 통계에는 좋은 성질이지만 "건별로 5는 올린다"고 약속한 정산에는 맞지 않는 규칙입니다.
도구마다 "반올림"이 다릅니다. 같은 2.5를 넣어도 돌아오는 값이 갈립니다. 직접 돌려 본 결과와 문서를 같이 적어 둡니다.
| 어디서 | 2.5 | -2.5 | 규칙 |
|---|---|---|---|
Python round(), NumPy np.round() | 2 | -2 | 짝수로 |
JavaScript Math.round() | 3 | -2 | +∞ 방향으로 |
Java Math.round() | 3 | -2 | +∞ 방향으로 |
Java BigDecimal HALF_UP | 3 | -3 | 0에서 먼 쪽으로 |
MySQL ROUND(2.5) 정확값 | 3 | -3 | 0에서 먼 쪽으로 |
MySQL ROUND(25E-1) 근사값 | 2 | -2 | C 라이브러리에 따라, 대개 짝수로 |
MDN은 Math.round가 소수부가 정확히 0.5면 +∞ 방향으로 간다고 적고, Java Math.round 문서도 중간값은 양의 무한대 방향이라고 적습니다. 그래서 둘 다 2.5는 3인데 -2.5는 -2입니다. MySQL 문서는 정확값은 0에서 먼 쪽으로, 근사값은 C 라이브러리에 따라 대개 짝수로 간다고 하면서 ROUND(2.5)는 3, ROUND(25E-1)은 2인 예를 보여 줍니다. 엑셀 문서는 규칙을 문장으로 적지 않지만 예시가 ROUND(-1.475, 2) → -1.48이라 0에서 먼 쪽으로 가는 걸로 보입니다. "반올림"이라는 한 단어 안에 적어도 세 가지 규칙이 있는 셈입니다.
정확히 .5일 때만 이 규칙 차이가 드러납니다. round(2.675, 2)가 2.68이 아니라 2.67인 건 다른 문제입니다. 문서의 Note대로 2.675는 이진 부동소수점으로 정확히 표현되지 않아 실제로는 2.67499999…로 저장돼 있어서입니다. 이번 사고는 2로 나눈 값이라 .5가 정확했고, 그래서 규칙 차이가 그대로 드러났습니다. 반대로 1500 * 0.035처럼 비율을 곱하면 52.5가 아니라 52.50000000000001이 나와서, 어느 쪽으로 갈지는 그날의 오차가 정합니다.
해결 방안
- 돈은
Decimal로 계산하고 반올림 규칙을 이름으로 적습니다.decimal모듈의ROUND_HALF_UP은 문서대로 중간값을 0에서 먼 쪽으로 보냅니다. 학교식 반올림이고, 엑셀이나 MySQL 정확값과 같은 결과가 나옵니다. 단,Decimal의 기본 컨텍스트도ROUND_HALF_EVEN이라서quantize()에 모드를 안 주면round()와 똑같이 602가 나옵니다.
from decimal import Decimal, ROUND_HALF_UP
def half_up(value: Decimal) -> int:
return int(value.quantize(Decimal("1"), rounding=ROUND_HALF_UP))
platform_share = half_up(Decimal(discount) / 2) # 1205 -> 603Decimal은 정수나 문자열로 만듭니다. 문서에 따르면 float을 넣으면 그 이진값이 그대로 변환돼서 Decimal(2.675)는 2.67499999…가 되고, 반올림하면 다시 2.67이 나옵니다. Decimal("2.675")라야 2.68입니다.
- 나눠 갖는 금액은 한쪽만 반올림하고 다른 쪽은 뺍니다. 양쪽을 따로 반올림하면 1,205원이 602 + 602 = 1,204원이 돼서 1원이 증발합니다. 플랫폼 부담분을 먼저 정하고 판매자 부담분은 할인액에서 빼면 합이 항상 할인액과 같습니다. 어느 쪽이 1원을 더 내는지는 규칙으로 정해 문서에 적어 둡니다.
platform_share = half_up(Decimal(discount) / 2)
seller_share = discount - platform_share # 1205 -> 603 + 602-
정수로만 계산할 수 있으면 그렇게 합니다. 원 단위 금액을 반으로 나눠 반올림하는 건
(discount + 1) // 2로 끝납니다. 소수가 아예 안 생기니 반올림 규칙을 고를 일도 없습니다. 비율 계산도amount * 35 // 1000처럼 정수 산술로 바꾸고, 반올림이 필요하면(amount * 35 + 500) // 1000으로 합니다. 환불처럼 음수 금액이 섞이면//가 음의 무한대 방향으로 내리니 그 경우만 따로 확인합니다. -
계약서와 코드에 규칙 이름을 적습니다. "소수점 첫째 자리에서 반올림"만으로는 부족했습니다. 0에서 먼 쪽(half up)인지 짝수(half even)인지 적고, 코드에도
ROUND_HALF_UP처럼 이름이 보이게 둡니다. 다른 팀이 엑셀이나 SQL로 검산할 때 어느 규칙으로 맞춰야 하는지 바로 알 수 있습니다. -
테스트에 .5로 끝나는 값을 짝수, 홀수 한 쌍씩 넣습니다. 602.5와 603.5, 음수가 있다면 -602.5까지. 이번처럼 절반만 틀리는 버그는 값 하나로는 못 잡습니다. 포맷 문자열도 같은 규칙이라
f"{2.5:.0f}"도'2'를 돌려줍니다. 화면에 보여 주는 자리에서 포맷으로 반올림하고 있었다면 거기도 확인합니다.
공식 문서
- Python: Built-in Functions — round()
- Python: What's New In Python 3.0 — Builtins
- Python: decimal — Rounding modes, quantize()
- NumPy: numpy.round — Notes
- MDN: Math.round()
- Java: RoundingMode — HALF_UP, HALF_EVEN
- Java: Math.round(double)
- MySQL: Mathematical Functions — ROUND()
- Microsoft: ROUND function
댓글0
댓글을 남기려면 로그인이 필요해요. 로그인
아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.