이 작업은 거창한 AI 프로젝트가 아니었습니다. 매일 오전마다 재무팀 한 사람과 운영팀 한 사람이 같은 엑셀을 열고, 같은 공식이 맞는지 서로 불러 주며 확인하는 병목이 있었을 뿐입니다. 두 사람의 오전 90분이 거기에 들어갔습니다.
저는 그 90분을 없애려고 스크립트를 썼고, 한 번 크게 실패한 뒤에 다시 썼습니다. 아래는 그 여섯 주의 기록입니다. 코드보다 신뢰에 관한 이야기에 가깝습니다.
이 글은 여러 현장에서 반복적으로 마주친 정산 자동화 상황을 하나의 사례로 재구성한 것입니다. 특정 회사나 실제 사건의 기록이 아니며, 숫자는 이야기를 설명하기 위한 예시 값입니다.
1주차 — “엑셀 복사 금지”라는 한 줄에서 시작했습니다
업무를 전부 자동화하겠다고 선언하는 대신, 첫 주의 목표를 하나로 줄였습니다. 사람이 셀을 직접 복사하지 않게 만드는 것입니다. 복사가 사라지면 어디서 숫자가 바뀌는지 추적할 수 있고, 추적이 되면 그때부터 규칙을 코드로 옮길 수 있다고 봤습니다.
스크립트는 40줄이었습니다. 결제 대행사에서 내려받은 CSV와 사내 주문 시스템에서 뽑은 CSV를 읽어 주문번호로 맞춰 보고, 차이가 나는 행만 출력하는 일이 전부였습니다. 그런데 이 40줄을 쓰는 과정에서 엑셀 안에 숨어 있던 팀 규칙들이 하나씩 드러났습니다. 파일 이름의 날짜가 정산일인지 거래일인지, 수수료를 원 단위에서 버리는지 반올림하는지, 취소 건의 부호를 어디서 뒤집는지 같은 것들입니다. 아무도 문서로 적어 두지 않았고, 두 사람의 머릿속에서만 일치하던 규칙이었습니다.
2주차 — 눈에 보인 증상: 스물세 줄의 빨간 행
스크립트를 처음 전체 데이터에 돌린 날, 하루치 약 1만 2천 건 중 스물세 건에서 합계가 맞지 않았습니다. 금액 차이는 대부분 1원에서 3원 사이였고, 가장 큰 것도 12원이었습니다. 재무팀이 그때까지 손으로 맞추던 시트에서는 이런 행이 보이지 않았습니다. 사람이 눈으로 볼 때는 “대충 맞으니 넘어가는” 범위였기 때문입니다.
대사 스크립트 첫 출력 (발췌)
$ python reconcile.py --date 2xxx-xx-14
읽음: orders.csv 12,043행 / settlement.csv 12,043행
매칭 실패: 0건
금액 불일치: 23건
order_id 내부합계 정산합계 차이
ORD-0004182 38,500 38,499 +1
ORD-0004907 17,820 17,819 +1
ORD-0011355 9,240 9,238 +2
...
차이 합계: +37 (평균 +1.6)
차이가 전부 양수라는 점이 눈에 걸렸습니다. 사람이 실수해서 생긴 오차라면 부호가 양쪽으로 흩어져야 하는데, 우리 쪽 합계가 항상 조금씩 컸습니다. 저는 이 관찰을 메모해 두고도 한 주 동안 무시했습니다.
3주차 — 틀린 가설: “사람이 실수하는 것이다”
제가 처음 세운 가설은 단순했습니다. 두 사람이 수작업으로 시트를 만지는 동안 어딘가에서 값을 잘못 옮기고 있고, 그래서 숫자가 어긋난다는 것입니다. 이 가설이 맞다면 해법도 명확합니다. 사람의 손을 완전히 빼고 스크립트가 전량을 처리하면 됩니다.
그래서 3주차에 수작업 단계를 전부 걷어내고 스크립트 출력만으로 정산표를 만들었습니다. 결과는 실패였습니다. 사람이 한 번도 만지지 않은 데이터에서도 스물세 건 근처의 불일치가 그대로 나왔습니다. 오히려 개수가 늘어난 날도 있었습니다.
가설을 죽인 실험은 이랬습니다. 재무팀이 지난달에 손으로 맞춘 대사표를 원본 그대로 받아 와, 같은 입력에 대해 스크립트가 계산한 값과 행 단위로 비교했습니다. 사람이 만든 시트와 스크립트가 서로 다른 행에서 틀리는 것이 아니라, 같은 행에서 같은 방향으로 틀렸습니다. 사람도 스크립트도 동일한 규칙을 따랐고, 그 규칙 자체가 어긋나 있었다는 뜻입니다.
범인은 반올림이었습니다. 수수료율을 곱한 뒤 부동소수점으로 누적하고 마지막에 한 번 반올림하는 우리 계산과, 건별로 원 단위에서 절사한 뒤 더하는 정산 파일의 계산이 달랐습니다. 파이썬 인터프리터에 한 줄을 쳐 보고 나서야 납득했습니다.
문제를 재현한 최소 예시
>>> 38500 * 0.033
1270.4999999999998 # 기대한 값은 1270.5
>>> round(38500 * 0.033)
1270 # 은행가 반올림 + 부동소수점 오차
>>> from decimal import Decimal, ROUND_HALF_UP
>>> (Decimal("38500") * Decimal("0.033")).quantize(
... Decimal("1"), rounding=ROUND_HALF_UP)
Decimal('1271')
여기에 시간대 문제가 한 겹 더 있었습니다. 정산 파일의 거래 시각은 UTC였고 우리 주문 시스템은 KST를 그대로 저장하고 있었습니다. 자정 전후 9시간 구간의 주문은 어느 날짜에 속하는지가 두 시스템에서 달랐습니다. 일별 합계를 맞출 때는 이 차이가 “차이 합계 +37” 같은 잔여로 남습니다.
4주차 — 실제로 바꾼 것
고친 것은 두 가지였습니다. 금액 계산에서 float를 완전히 몰아내고, 날짜 경계를 한 곳에서만 결정하도록 만들었습니다. 특히 후자는 함수 시그니처에 강제로 드러나게 했습니다. 나이브한 datetime이 들어오면 아예 예외를 던지도록 한 것입니다.
reconcile/money.py — 수정 후
from datetime import datetime, timezone, timedelta
from decimal import Decimal, ROUND_HALF_UP
from zoneinfo import ZoneInfo
KST = ZoneInfo("Asia/Seoul")
WON = Decimal("1")
def fee(amount: Decimal, rate: Decimal) -> Decimal:
"""건별로 원 단위 반올림합니다. 누적 후 반올림하지 않습니다."""
return (amount * rate).quantize(WON, rounding=ROUND_HALF_UP)
def settlement_date(ts: datetime) -> str:
if ts.tzinfo is None:
raise ValueError(f"tz 정보가 없는 시각입니다: {ts!r}")
return ts.astimezone(KST).strftime("%Y-%m-%d")
def total(rows) -> Decimal:
return sum((fee(r.amount, r.rate) for r in rows), Decimal("0"))
ValueError를 던지는 줄은 일부러 남겼습니다. 조용히 UTC로 가정하고 넘어가면 같은 사고가 6개월 뒤에 다시 납니다. 이런 식으로 “틀린 입력은 계산되지 않게” 만드는 방법은 Python 오류 처리 가이드에 정리해 둔 패턴과 같은 계열입니다.
그리고 이 두 함수에만 테스트를 붙였습니다. 자정 직전 주문, 취소 후 재결제, 수수료율이 바뀐 날 같은 경계 사례를 골라 열두 개를 만들었습니다. 스크립트 전체가 아니라 규칙이 사는 곳만 테스트한 것인데, 이후 1년 동안 회귀를 잡아 준 것은 전부 이 열두 개였습니다.
5주차 — 신뢰를 되돌리는 절차가 더 오래 걸렸습니다
기술적으로는 4주차에 끝났습니다. 그런데 재무팀은 그다음 3주 동안 스크립트 결과를 그대로 쓰지 않았습니다. 3주차에 한 번 틀린 숫자를 내놓은 도구였기 때문입니다. 저는 이 부분을 크게 과소평가했습니다.
그래서 결과를 “정답”이 아니라 “제안”으로 내놓는 구조를 만들었습니다. 스크립트는 매일 아침 8시에 돌면서 대사표를 만들고, 불일치가 있으면 슬랙 채널에 요약을 올린 뒤 사람의 승인 버튼을 기다립니다. 승인 전까지는 아무것도 확정되지 않습니다.
- 입력 파일이 없거나 행 수가 전일 대비 20% 이상 변하면 계산하지 않고 멈춥니다.
- 실패 알림은 예외 문자열이 아니라 “정산 파일이 아직 도착하지 않았습니다(마지막 확인 08:12)”처럼 운영팀이 읽는 문장으로 보냅니다.
- 같은 날짜를 두 번 돌려도 결과가 같도록, 실행 단위를 날짜 키로 묶어 멱등하게 만듭니다.
- 사람이 승인한 시점과 승인자를 로그에 남겨 두어 나중에 되짚을 수 있게 합니다.
세 번째 항목은 3주차 사고 때 재실행으로 중복 정산이 날 뻔한 경험에서 나왔습니다. 실패한 배치를 다시 돌리는 일은 반드시 생기므로, 재실행이 안전한지를 먼저 확인하지 않으면 자동화는 사고를 두 배로 키우는 장치가 됩니다.
“우리는 자동화보다 검수 시간을 먼저 줄였고, 그다음에 사람을 루프 밖으로 천천히 빼냈습니다.”
6주차 — 스크립트가 팀 루틴이 된 방식
여섯 주가 지난 시점에 오전 90분은 대략 10분으로 줄었습니다. 다만 줄어든 것은 계산 시간이 아니라 확인 시간이었습니다. 두 사람이 서로 숫자를 불러 주던 절차가, 불일치 스물세 건 목록을 같이 보고 승인하는 절차로 바뀌었을 뿐입니다.
저는 이 프로젝트가 Python이어서 가능했던 부분과 그렇지 않은 부분을 구분하려고 합니다. 언어가 기여한 것은 40줄짜리 시작을 허용했다는 점, 그리고 재무팀 담당자가 reconcile.py를 열어 수수료율 상수를 직접 읽고 “이 값이 맞다”고 확인할 수 있었다는 점입니다. 스크립트가 팀의 공식 절차로 굳어지는 과정에서 이 가독성은 실제로 힘을 발휘했습니다. 다른 예제들은 심층 가이드에 언어별로 정리해 두었습니다.
반대로 언어와 무관했던 것은 반올림 규칙, 시간대 경계, 그리고 신뢰 회복 절차입니다. 이 셋은 어떤 스택으로 옮겨도 똑같이 남습니다.
다음에 같은 일을 한다면
가장 크게 바꿀 것은 순서입니다. 저는 “사람의 실수를 없앤다”는 목표로 시작했지만, 실제로 필요한 것은 “사람과 시스템이 어디서 다르게 계산하는지 드러낸다”는 목표였습니다. 자동화를 정답 생성기로 설계하면 한 번의 오차가 도구 전체의 신뢰를 무너뜨립니다. 반면 차이 검출기로 설계하면, 틀린 날조차 유용한 정보를 남깁니다.
- 반복 업무는 기능보다 실패 보고 형식부터 자동화하는 편이 낫습니다.
- 금액과 시각은 계산이 아니라 타입으로 다뤄야 합니다.
Decimal과 타임존 인식datetime을 경계에서 강제합니다. - 테스트는 스크립트 전체가 아니라 규칙이 사는 함수에 붙입니다. 경계 사례 열 개면 대개 충분합니다.
- 재실행이 안전한지 먼저 확인합니다. 멱등하지 않은 배치는 사고를 복제합니다.
- 사람을 루프에서 빼는 것은 마지막 단계입니다. 승인 단계를 남겨 두면 도입 속도가 오히려 빨라집니다.
남길 만한 원칙 하나를 고르라면 이것입니다. 자동화가 대체하는 것은 사람의 계산이 아니라 사람의 확신입니다. 그래서 도구가 스스로 얼마나 확신하지 못하는지를 먼저 말하게 만들어야, 사람이 그 도구를 쓰기 시작합니다. 경계 사례를 어떻게 테스트로 남기는지는 Python 테스트 가이드에 예제와 함께 정리해 두었습니다.