
오전에 출근하자마자 슬랙 알림이 울렸다. 재무 담당자 K씨였다.
“A사 이번 달 정산금액이 세금계산서랑 1원 차이가 나는데, 혹시 뭐 바뀐 거 있어요?”
없다. 아무것도 바꾼 게 없다. 그런데 1원이 다르다.
오후 내내 파고들었다. 원인은 허무했다. 정수 나눗셈에서 소수점이 조용히 잘리고 있었다. 고치는 건 한 줄이었다. 데이터를 좀 더 들여다보니 잘린 소수점보다 0.5를 어디로 보내느냐가 더 큰 차이를 만들고 있었다.
#round(0.5)가 항상 1이 되는 것은 아니다
학교에서 배운 반올림은 5 이상이면 올리고 4 이하면 버리는 사사오입이다. 이 방식에서 0.5는 1이 되어야 한다. Python 2까지는 그랬다.
# Python 2
round(0.5) # → 1.0
round(1.5) # → 2.0
round(2.5) # → 3.0
Python 3에서 실행하면 달라진다.
# Python 3
round(0.5) # → 0
round(1.5) # → 2
round(2.5) # → 2
0.5는 0이 되고 2.5도 2가 된다. Python 3에서 대체 무슨 일이 있었던 걸까.
#은행가 반올림
이 동작에는 이름이 있다. 은행가 반올림(Banker’s Rounding), 다른 이름으로는 짝수 반올림이다. 0.5처럼 정확히 중간값일 때 무조건 올리는 대신 가장 가까운 짝수 쪽으로 반올림한다.
- 0.5 → 0 (가까운 짝수는 0)
- 1.5 → 2 (가까운 짝수는 2)
- 2.5 → 2 (가까운 짝수는 2)
- 3.5 → 4 (가까운 짝수는 4)
사사오입은 건건이 보면 문제가 없다. 0.5를 1로 올릴 뿐이다. 그런데 수백만 건 거래에 반복 적용하면 이야기가 달라진다. 0.5를 항상 1로 바꾸다 보면 올림 편향이 조금씩 쌓인다. 건수와 금액이 커질수록 이 편향은 무시할 수 없는 오차가 된다.
이 문제를 규칙으로 정리한 것이 은행가 반올림이다. 중간값이 고르게 분포한다면 0.5일 때 올리거나 내리는 횟수가 장기적으로 반반이 되어 편향이 상쇄된다. 1985년 IEEE 754 부동소수점 표준이 제정되면서 이 방식이 부동소수점 연산의 기본 반올림 모드로 채택됐고, 이후 대부분의 하드웨어가 이 표준을 따른다. 언어의 round() 함수 기본값은 그와 별개다.
은행가 반올림이라는 이름의 유래는 불분명해서, 은행권의 공식 표준이었다는 증거는 찾지 못했다. 이름만 은행가이지 출처는 금융권이 아닐 수도 있다.
한자로는 오사오입(五捨五入)이라고 부른다. 사사오입이 4는 버리고 5는 올린다는 뜻인 것처럼, 오사오입은 같은 5를 버리기도 하고 올리기도 한다는 뜻으로, 앞자리가 짝수면 버리고 홀수면 올린다.
Python 3가 이 방식으로 바꾼 이유도 같다. Python 2의 반올림은 0에서 멀어지는 방향, 곧 사사오입이었는데, Python 3는 통계적으로 더 공정하고 IEEE 754 표준에도 맞는 가장 가까운 짝수 방식으로 바꿨다. 내 짐작으로는 Python을 과학 계산과 데이터 분석에 많이 쓰는 만큼 대량 연산에서 편향이 쌓이는 걸 줄이는 쪽을 택한 것이다.
#언어마다 기본값이 다르다
Java와 Kotlin의 Math.round()는 양수에서는 사사오입과 같은 결과를 내지만 음수에서는 다른데, 그 이야기는 뒤에서 한다. Kotlin에는 함정이 하나 더 있다. kotlin.math.round()는 중간값을 짝수 쪽으로 보내고, roundToInt()는 양의 무한대 쪽으로 보낸다. 같은 언어 안에서 함수 이름 하나로 정책이 갈린다. 차이가 먼저 드러나는 건 정밀한 소수점 계산에 쓰는 BigDecimal에서다. BigDecimal의 setScale()은 반올림이 필요한 자리에서 방식을 명시하지 않으면 실행을 거부한다. BigDecimal("2.0").setScale(0)은 버릴 것이 없으니 통과하고, 2.5는 예외를 낸다.
BigDecimal("2.5").setScale(0)
// → ArithmeticException: Rounding necessary
// (0.5를 올릴지 버릴지 알 수 없으므로)
이건 버그가 아니라 설계다. setScale()은 값이 깎이는 순간 반올림 방식을 개발자가 직접 명시하도록 강제한다. 그래서 RoundingMode를 함께 써야 한다.
import java.math.BigDecimal
import java.math.RoundingMode
BigDecimal("2.5").setScale(0, RoundingMode.HALF_UP) // 3, 사사오입
BigDecimal("2.5").setScale(0, RoundingMode.HALF_EVEN) // 2, 은행가 반올림
BigDecimal("2.5").setScale(0, RoundingMode.DOWN) // 2, 무조건 버림
같은 2.5인데 어떤 RoundingMode를 고르느냐에 따라 결과가 달라진다. Python이 기본값을 정해 둔 것과 달리 setScale()은 값이 깎이는 자리에서 선택을 강제한다. 기본값의 유무는 언어보다 API에 달려 있어서, 같은 BigDecimal이라도 MathContext는 기본값이 HALF_UP이고 Kotlin의 BigDecimal 나눗셈 연산자 /는 묻지도 않고 HALF_EVEN을 쓴다.
언어마다 결과가 다르고, 그 차이는 각자의 설계에서 온다.
| 언어와 방법 | 기본 동작 |
|---|---|
Python 3 round() | 은행가 반올림, HALF_EVEN |
Java와 Kotlin Math.round() | 가장 가까운 정수, 중간값은 양의 무한대 쪽 |
Java와 Kotlin BigDecimal.setScale() | 반올림이 필요하면 명시 필수, 생략하면 예외 |
JavaScript Math.round() | 가장 가까운 정수, 중간값은 양의 무한대 쪽 |
JavaScript와 Java, Kotlin의 Math.round()는 엄밀히 사사오입이 아니어서, 양수에서는 같지만 음수에서는 다르다. 세 언어 모두 Math.round(-2.5)는 -3이 아니라 -2다. 가장 가까운 정수로 반올림하되, 정확히 중간이면 양의 무한대 쪽을 고르기 때문이다. 같은 값을 BigDecimal("-2.5").setScale(0, RoundingMode.HALF_UP)으로 처리하면 -3이 나온다. 정산 시스템에서 환불이나 조정 같은 음수 금액을 다루면 이 차이가 문제가 된다.
#0.5센트의 행방
소수점이 흔한 달러 기준 시나리오를 하나 보자. N사는 광고 대행사에 월 광고비의 15%를 수수료로 정산한다. 이번 달 광고비는 $18,336.30이 집행됐다.
수수료 = $18,336.30 × 0.15 = $2,750.445
이 값은 금액을 십진수 그대로 계산했을 때 나온다. 같은 계산을 float로 하면 2,750.445가 나오지 않는다. 18,336.30도 0.15도 이진 부동소수점으로는 정확히 표현되지 않아서, 곱한 값이 2,750.4449999…가 된다. 0.5센트 경계에 닿지도 않으니 round(fee, 2)는 2,750.44를 돌려주고, 이 값에는 사사오입을 적용해도 은행가 반올림을 적용해도 2,750.44가 나온다. 앞에서 round(2.5) 예시가 설명대로 동작한 것은 0.5와 2.5가 이진수로 정확히 표현되는 값이어서다.
from decimal import Decimal, ROUND_HALF_UP, ROUND_HALF_EVEN
fee = 18336.30 * 0.15
print(f"{fee:.20f}") # 2750.44499999999970896170
print(round(fee, 2)) # 2750.44
fee = Decimal("18336.30") * Decimal("0.15")
print(fee) # 2750.4450
print(fee.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)) # 2750.45
print(fee.quantize(Decimal("0.01"), rounding=ROUND_HALF_EVEN)) # 2750.44
그래서 금액은 반올림 정책을 고르기 전에 계산 타입부터 정해야 한다. Python은 Decimal, Java와 Kotlin은 BigDecimal처럼 십진수를 정확히 다루는 타입으로 계산하고, 반올림은 그 위에서 정책으로 고른다. 이때 값은 float를 거치지 않고 Decimal("18336.30")처럼 문자열에서 만든다. Decimal(18336.30)으로 만들면 float의 오차가 그대로 따라온다.
정확한 금액 $2,750.445는 소수점 세 자리다. 달러는 소수점 두 자리까지 표현한다. 이 0.5센트를 어디로 보낼지 정해야 한다.
| 정책 | 청구 금액 |
|---|---|
사사오입, HALF_UP | $2,750.45 |
은행가 반올림, HALF_EVEN | $2,750.44 |
절사, DOWN | $2,750.44 |
사사오입은 올린다. 은행가 반올림은 앞자리 4가 짝수이므로 내린다. 절사는 무조건 버린다. 같은 금액인데 정책에 따라 결과가 달라진다.
$0.01. 하나만 보면 크지 않게 느껴진다. 클라이언트가 수십 개고 매달 쌓이면 연간으로는 무시하기 어려운 숫자가 된다. 어느 쪽이 맞는가라는 질문에 코드는 답을 주지 않는다. 로직에는 문제가 없다. 이 차이가 쌓여 정산 시스템과 회계 장부가 맞지 않는 오류로 재무팀에 발견된다.
만약 결제 시스템과 정산 시스템이 각자의 반올림 정책을 가지고 있다면? 프론트와 백엔드가 다른 정책을 써서 사용자 콘솔과 어드민이 보여주는 금액과 API가 계산하는 금액이 달라진다면? 개별 코드와 단위 테스트만 보면 오류는 없다. 보이지 않는 버그는 그런 틈에서 증폭된다.
#정책은 코드 밖에서 정한다
이 글을 쓰는 2026년, 반올림은 코드 밖에서도 뜨거운 이슈가 됐다. 2025년 11월 미국에서 유통용 1센트 동전, 곧 페니의 마지막 주조가 있었다. 페니 공급이 줄면서 현금 거래의 반올림 정책이 비즈니스와 법률 문제로 번지고 있다. Square는 현금 거래를 5센트 단위로 반올림하는 기능을 POS에 베타로 넣었고, 맥도날드는 매장별로 현금 반올림 정책을 도입했다. 어떤 체인은 고객에게 유리하게 내리고 어떤 체인은 올린다.
주마다 대응도 다르다. 인디애나는 반올림 법안을 통과시켰고, 코네티컷은 무조건 내림을 권고하고, 워싱턴은 3월 23일 현금 거래 끝자리를 가장 가까운 5센트로 맞추는 법안에 서명했다. 1센트와 2센트와 6센트와 7센트는 내리고, 3센트와 4센트와 8센트와 9센트는 올린다. 전자결제는 여전히 센트 단위까지 정확하게 처리되지만 현금에 대한 연방 차원의 통일 기준은 아직 없다.
정산 시스템에서 반올림은 언어의 기본 동작이 아니라 논의해야 하는 정책이다. 어떤 방법을 쓸지는 계약서에 명시된 조건, 세금계산서 기준, 업계 관행이 정한다. 이것은 기술 스펙이 아니라 비즈니스 의사결정이고, 그 결정이 정책으로 코드에 반영되어야 한다. 주석도 함께 남긴다.
// 2024-03-15 재무팀 협의. 계약서 4.2조 기준.
val ROUNDING_MODE = RoundingMode.HALF_UP
이를 명시하지 않으면 또다시 슬랙 메시지를 받을 수 있다. 이번엔 1원이 아닐 수 있다.
#참고 자료
- Shopify Engineering, Bound to Round: 8 Tips for Dealing with Hanging Pennies: 반올림 편향, 이해관계자 커뮤니케이션, 코드 일관성까지 다루는 실무 가이드다.
- Founding Minds, Rounding Numbers in the Financial Domain: 빌링 프로젝트에서 반올림 전략을 잡아간 경험을 IFRS와 GAAP 맥락과 함께 적는다.
- Alipay+, Banker’s Rounding: Alipay+가 은행가 반올림을 채택한 이유와 표준 반올림 대비 편차를 수치로 보여준다.
- 지마켓 기술블로그, BigDecimal A to Z: Java와 Kotlin에서
BigDecimal과RoundingMode를 쓸 때 참고할 레퍼런스다.