은행 현장의 언어 · 금액을 실수로 다루면 생기는 오차 · 이론
합계가 1원씩 어긋나는데 아무도 재현하지 못한다
한 줄 요약
금액은 통화마다 소수 자리가 다르고, 그 자리를 부동소수점에 맡기면 합계가 원장과 조용히 어긋난다. 저장은 정수 최소단위로, 끊는 자리는 정책으로, 나머지는 배분 규칙으로 못박는 것이 이 문제의 전부다.
왜 이게 필요했나
수수료 정산 배치가 어느 날부터 "합계가 3원 안 맞는다" 는 소리를 듣기 시작합니다. 담당자가 표를 열어 한 줄씩 짚어 보면 어느 줄도 틀리지 않았습니다. 그런데 더하면 다릅니다. 재현해 보라는 말에 같은 입력을 다시 넣으면 그때는 맞습니다. 이런 일이 두 달쯤 이어지다가 결국 "배치를 다시 돌리면 맞는다" 는 운영 절차가 생깁니다.
원인은 대개 두 군데입니다. 금액을 double 로 들고 있었거나, 끊는 자리를 코드마다 다르게 정했거나. 둘 다 한 줄에 1 밖에 어긋나지 않아서 단위 시험이 못 잡습니다. 시험 데이터는 12.34 처럼 착한 숫자를 쓰고, 착한 숫자에서는 틀리지 않기 때문입니다.
여기에 통화가 섞이면 문제가 하나 더 붙습니다. "소수 두 자리" 는 달러의 사정이지 세상의 규칙이 아닙니다. 원과 엔은 소수 자리가 0 이고, 쿠웨이트 디나르와 바레인 디나르는 3 입니다. 금(XAU)처럼 소수 자리라는 개념 자체가 없는 코드도 같은 표에 들어 있습니다.
어떻게 동작하나
통화 코드의 유지기관은 [SIX](https://www.six-group.com/en/products-services/financial-information/data-standards.html) 이고, 목록 원본은 [List One XML](https://www.six-group.com/dam/download/financial-information/data-center/iso-currrency/lists/list-one.xml) 입니다. 각 항목의 CcyMnrUnts 가 그 통화의 소수 자리입니다. 발행일 2026-01-01 판에서 직접 읽은 값은 이렇습니다.
KRW 0 JPY 0 CLP 0 (최소단위가 통화 단위 그 자체)USD 2 (센트)KWD 3 BHD 3 TND 3 (필스)XAU N.A. (금. 소수 자리가 숫자가 아니다)그래서 금액을 다루는 코드의 첫 결정은 정수 최소단위로 저장한다 입니다. 1,234.56 달러는 123456 과 USD 두 값으로 저장하고, 화면에 낼 때만 소수점을 끼워 넣습니다. 이렇게 하면 더하기와 빼기에서 오차가 생길 자리가 없어집니다.
오차가 생길 수 있는 곳은 곱하기와 나누기 뿐입니다. 요율을 곱하면 최소단위보다 작은 자리가 나오고, 그 자리를 어떻게 끊을지 정해야 합니다. 파이썬의 [decimal 모듈](https://docs.python.org/3/library/decimal.html)은 quantize() 로 자리를 끊고 ROUND_HALF_UP ROUND_HALF_EVEN 같은 모드를 고르게 합니다. 기본값은 은행가 반올림(ROUND_HALF_EVEN)인데, 이것은 파이썬 내장 round() 도 마찬가지입니다. 그래서 "0.5 는 올림" 이라고 믿고 짠 코드가 정확히 절반의 경우에 다른 답을 냅니다. 둘 중 무엇이 맞는지는 기술이 정하지 않습니다. 어느 쪽인지 문서에 적고, 코드가 그 하나만 쓰게 만드는 것이 답입니다.
저장 쪽에도 같은 함정이 있습니다. [SQLite 의 자료형](https://www.sqlite.org/datatype3.html)은 컬럼이 아니라 값에 붙고, INTEGER 로 선언한 칸에 실수를 넣으면 REAL 로 들어갑니다. [PostgreSQL 의 numeric](https://www.postgresql.org/docs/16/datatype-numeric.html) 문서는 numeric 이 정확한 계산을 하지만 느리고, double precision 은 반대라고 분명히 적어 두고는, 돈을 다루는 값에는 정확한 쪽을 쓰라고 안내합니다.
마지막 조각이 배분입니다. 1,000원을 3명에게 나누면 333, 333, 333 이고 1원이 남습니다. 이 1원을 버리면 합계가 안 맞고, 모두에게 올려 주면 총액을 넘깁니다. 최대잉여법은 몫을 내림으로 준 뒤 남은 만큼을 잉여가 큰 쪽부터 1씩 주는 방식입니다. 이러면 합계가 정확히 맞고, 누가 1원을 더 받았는지도 규칙으로 설명됩니다.
현장에서 만나는 모습
첫째, 환산 순서로 싸웁니다. 줄마다 환산해 원 단위로 끊어 더한 값과, 먼저 다 더한 뒤 한 번 환산한 값은 다릅니다. 둘 다 틀린 계산이 아니라 다른 계약입니다. 청구서에 줄마다 원화가 찍혀야 하면 앞쪽이고, 총액만 찍히면 뒤쪽입니다. 어느 쪽인지 정하지 않고 두 시스템이 각자 짜면 매달 몇 원짜리 대사 항목이 생깁니다.
둘째, 표시 형식을 여기저기서 만듭니다. 화면, 청구서, 상대 기관 파일이 저마다 소수점을 붙이면 엔화에 소수점이 찍히는 사고가 납니다. 파싱과 표시를 한 함수로 모으고 금액이 그 문을 지나게 하면, 통화표를 한 곳만 고쳐도 됩니다.
셋째, 반올림 정책이 코드에만 있습니다. 담당자가 바뀌면 아무도 근거를 모릅니다. 정책 파일에 적어 두고 그 값을 보고서에 함께 실으면, 나중에 왜 이 숫자가 나왔는지 설명할 수 있습니다.
실무에서 진짜 중요한 것
- 금액과 통화를 절대 떼어 놓지 않는다. 통화 없는 금액은 숫자일 뿐입니다.
- 저장은 정수 최소단위. 표시할 때만 소수점을 만든다.
- 끊는 자리와 반올림 모드는 정책이다. 코드가 아니라 문서가 먼저다.
- 배분은 합계부터 맞춘다. 나머지 1을 누가 받는지까지 규칙으로 적는다.
- 환산은 순서까지 계약이다. 줄 단위인지 총액 단위인지 먼저 정한다.
다음 실습에서 할 것
수수료 원장 600행과 통화표를 직접 만들고, 같은 자료를 실수로 계산한 값과 정수로 계산한 값이 몇 줄이나 갈라지는지 셉니다. 그다음 최소단위 정수로 다시 적재해 REAL 사본과 견주고, 파싱과 표시를 한 파일로 모으고, 두 반올림 모드의 차이를 세고, 최대잉여법으로 나머지까지 맞는 배분을 만들고, 환산 순서에 따른 차이를 기록한 뒤 마지막에 보고서 하나로 묶습니다.