미국 경비관리 SaaS(ClaimBook) 연재의 한 편이다. 시작 이야기 · 앞 편: 감사 대비 통제를 처음부터 넣은 이야기.
법인카드 경비의 핵심 잡일은 카드 명세서 한 줄 ↔ 실제 영수증을 맞추는 일(reconciliation)이다. 이걸 AI로 자동화했는데, 초기 자동 매칭률이 실사용 데이터에서 20%대밖에 안 나왔다. 나머지는 사람이 수동으로 맞춰야 하니 자동화의 의미가 반쯤 사라진다.
여기서 하마터면 “매칭 알고리즘이 약하다”고 뭉뚱그릴 뻔했다. 대신 매칭 안 된 건을 하나씩 열어봤다. 그랬더니 원인이 하나가 아니라 계층적으로 달랐다. 층을 하나씩 벗기니 매칭률이 단계적으로 올라갔다.

층 1. 후보 범위가 양쪽으로 잘못돼 있었다
자동 매칭이 보는 후보는 너무 좁았고(법인카드로 분류되고 카드번호까지 매핑된 것만), 사람에게 주는 수동 추천은 너무 넓었다(금액 ±5%까지). 그래서 금액이 정확히 같은데도 자동으로 안 붙고, 추천 목록엔 금액 다른 쓰레기가 떴다.
- 자동 후보에서 “법인카드로 이미 분류됨” 제약을 뺐다. AI가 personal로 잘못 분류했어도 금액·날짜만 맞으면 매칭하고, 매칭 시점에 corporate로 자동 재분류(reclass)한다.
- 수동 추천은 반대로 금액 정확 일치로 좁혀 노이즈를 없앴다.
이 하나로 20%대 → 40%대.
층 2. AI가 날짜를 틀리게 읽었다 (2자리 연도)
다음으로 남은 unmatched를 보니 패턴이 있었다. 영수증의 2자리 연도(yy)를 AI가 과거 연도로 해석해서, 실제로는 이번 달인 거래의 expense_date가 700일 넘게 과거로 찍혔다. 같은 실수가 여러 건이었다.
두 겹으로 막았다. 프롬프트에 오늘 날짜와 상식 규칙을 주고,
Today's date is #{Date.current}. Receipts are usually dated within the last 90 days;
if the printed date is older, the year is almost certainly a 2-digit year in the current decade.
그래도 AI가 이상한 날짜를 뱉으면 파싱 후 sanity check로 보정했다.
def sanitize_parsed_date(raw)
parsed = Date.parse(raw.to_s) rescue nil
return nil unless parsed
today = Date.current
return parsed if parsed >= today - 90 && parsed <= today + 30 # 합리적 범위면 그대로
candidate = Date.new(today.year, parsed.month, parsed.day) rescue nil # 같은 월/일, 올해로
candidate = Date.new(today.year - 1, parsed.month, parsed.day) if candidate && candidate > today + 30
candidate || parsed
end
원본 값은 date_corrected_from에 보존해 추적성을 남겼다. 이 층에서 40%대 → 60%대 후반.
층 3. 날짜가 아예 없는 영수증 — 도메인 통찰이 알고리즘을 이겼다
그래도 안 붙는 게 있었다. AI가 일부 영수증의 날짜를 NULL로 뽑은 경우였다. 여기서 매칭 규칙을 더 정교하게 만들려다, 실무 통찰 한마디가 방향을 바꿨다 — “금액이 정확히 같고 카드 뒷 4자리가 맞으면, 날짜가 없어도 그건 같은 거래다.”
맞는 말이었다. 그래서 매칭 후보에서 expense_date가 NULL이어도 허용하도록 열었다(금액 정확 일치 + 같은 조직 조건 하에서). 알고리즘을 더 똑똑하게 만드는 대신 도메인 규칙을 반영한 것이다. 60%대 후반 → 70%대 초반.
층 4. 그래도 영수증이 없는 거래 — 마감할 길을 준다
일부 카드 거래는 애초에 영수증이 없다(분실 등). 이건 매칭의 문제가 아니라 마감의 문제였다. 시스템에 “영수증 없음”을 합법적으로 종결하는 길이 없어서 사이클을 못 닫았다. no_receipt 상태와 사유를 두어, 마감은 매칭 안 된 건만 막고 “영수증 없음”으로 마킹된 건 통과시켰다.
배운 것
- “정확도가 낮다”는 단일 문제가 아니다. unmatched를 하나씩 열면 원인이 계층적으로 갈린다. 뭉뚱그려 알고리즘을 손대는 것보다 층을 벗기는 게 빠르다.
- AI 파싱은 틀린다는 전제로 설계한다. 프롬프트 가이드(입력 측)와 파싱 후 sanity guard(출력 측)를 둘 다 둔다. 그리고 보정 시 원본을 보존해 추적성을 남긴다.
- 도메인 통찰이 알고리즘보다 셀 때가 있다. “금액+카드 4자리면 날짜 없어도 같은 거래”는 규칙 한 줄로 매칭을 늘렸다.
- 100% 자동은 환상이다. 영수증 없는 거래처럼 자동으로 못 푸는 꼬리를 위해 escape hatch(합법적 종결 상태)를 남겨야 사이클이 닫힌다.
한계 · 다음 계획
- 지금 보정은 규칙 기반이다. 반복되는 오파싱 패턴을 피드백 루프로 모아 프롬프트·규칙에 자동 반영하는 쪽을 실험 중이다.
- 매칭률은 데이터 품질에 크게 좌우된다. 원천(카드사 CSV·영수증)의 품질 편차를 어떻게 흡수할지가 다음 과제다.
- 다음 편에서는 영수증을 여러 장 한 번에 올리는 벌크 업로드를 어떻게 결론 냈는지 쓴다.
FAQ
Q. 매칭률을 한 번에 올리는 방법은 없었나요?
없었다. 원인이 후보 범위, 날짜 오파싱, 날짜 결측, 영수증 부재로 계층이 달라서, 하나의 큰 개선이 아니라 층을 하나씩 벗기는 방식으로만 올랐다.
Q. AI 날짜 오파싱을 프롬프트만으로 못 막나요?
많이 줄지만 0은 안 된다. 그래서 출력 측에 sanity guard를 하나 더 뒀다. 입력(프롬프트)과 출력(후처리) 양쪽을 막는 게 안정적이다.
Q. 날짜 없이 금액만으로 매칭하면 오매칭이 늘지 않나요?
그래서 조건을 좁게 걸었다. 금액 정확 일치 + 같은 조직 + (가능하면) 카드 뒷자리. 근접(±범위) 후보에는 이 완화를 적용하지 않는다.
Q. 영수증 없는 거래를 그냥 넘기면 통제가 약해지지 않나요?
넘기는 게 아니라 사유를 남겨 마킹한다. 매칭 안 된 건은 여전히 마감을 막고, “영수증 없음”은 기록과 함께 종결되는 별도 상태다.