개발 slecs

입금 통보 파서에 AI 검증 얹어 정산 수기 보정 60% 줄임

목차

이커머스 결제 플랫폼에서 파트너별 입금 통보 메시지를 자동 매칭하는 파서를 운영하다 보면, 언젠가 반드시 한 번은 이 벽에 부딪힌다. 결제대행사마다 메시지 포맷이 다르고, 같은 은행이라도 채널(가상계좌 vs 실시간이체 vs 무통장입금)마다 필드 순서와 구분자가 달라진다. 처음엔 정규식 몇 개로 감당됐다. 그런데 파트너가 늘어나고 연동 채널이 추가되면서 조건 분기가 기하급수적으로 불어났다.

기존 파서 구조의 진짜 문제는 단순히 코드가 길다는 게 아니었다. 수정 가능성이 없어진다는 거였다. 새 포맷이 추가될 때마다 기존 분기에 영향을 줄지 모르니 겁이 나서 손을 못 대고, 조심스럽게 맨 아래에 분기 하나 더 붙이는 식으로 흘렀다. 테스트도 "내 케이스만" 붙이게 되고, 결국 파서 전체가 블랙박스가 된다. 입금자명 한 글자 누락 같은 사소한 차이가 매칭 실패로 이어지고, 그게 매일 운영팀 수기 보정 리스트에 쌓이는 구조였다. 운영팀 입장에서는 매일 아침 리스트부터 확인하는 게 루틴이 된 상태.

구조 먼저 - 핸들러 레지스트리로 분리

AI를 얹기 전에 기반 구조를 정리했다. 핸들러 레지스트리 패턴, 흔히 전략 패턴(Strategy Pattern)이라고 부르는 것과 비슷한 형태다. 은행/결제대행사 식별자로 적합한 파서를 골라내고, 공통 추상 계층에서 검증·정규화·로깅을 처리하게 분리했다.

레이어 역할
진입 컨트롤러 메시지 수신, 인증
레지스트리 식별자 기반 핸들러 라우팅
추상 핸들러 공통 검증 / 저장 / 멱등성
구체 핸들러 은행별 파싱 로직

이렇게 나눠두면 새 은행 핸들러를 추가할 때 레지스트리에 등록하고 구체 클래스만 만들면 된다. 공통 검증이나 중복 수신 방어(멱등성) 로직은 건드릴 필요가 없다. 기존에 반나절이던 신규 은행 대응이 30분짜리 작업이 됐다. PR도 작아진다. 영향 범위가 구체 핸들러 하나로 격리되니까 리뷰도 훨씬 편해졌다.

구체 핸들러 구조는 대략 이런 식으로 잡았다.

class BaseDepositHandler:
    def parse(self, raw: str) -> DepositCandidate:
        raise NotImplementedError

    def validate(self, candidate: DepositCandidate) -> bool:
        # 공통 검증: 금액 > 0, 시각 파싱 가능, 입금자명 non-empty
        return (
            candidate.amount > 0
            and candidate.depositor.strip() != ""
            and candidate.transferred_at is not None
        )

    def handle(self, raw: str) -> DepositResult:
        candidate = self.parse(raw)
        if not self.validate(candidate):
            return DepositResult.fail("validation_error")
        return self._save(candidate)


class KbBankHandler(BaseDepositHandler):
    def parse(self, raw: str) -> DepositCandidate:
        # KB 특화 파싱 로직만 여기에
        ...

추상 계층에 공통 로직을 몰아두면 단위 테스트 작성이 훨씬 쉬워진다. BaseDepositHandlervalidate 하나 테스트하면 모든 하위 핸들러에 자동 적용된다. 이게 파서 커버리지가 올라간 주된 이유다. 구조 분리가 테스트를 더 쉽게 만든 거지, 테스트를 일부러 더 열심히 짠 게 아니다.

AI 검증을 얹는 방식 - 전부 보내면 망한다

구조를 정리한 뒤에 진짜 하고 싶었던 걸 얹었다. 정규식이 통과해도 의미가 깨지는 케이스들이 있었다. 입금자명 자리에 광고 문구가 섞이거나, 금액 필드에 전화번호 일부가 파싱되거나. 룰 기반으로 이걸 다 잡으려면 정규식이 다시 누더기가 된다. 그냥 "이 조합이 의미상으로 맞는가"를 판단하는 게 훨씬 자연스러운 문제다.

그래서 LLM을 검증자로 끼웠다. 흐름은 세 단계로 정리됐다.

  • 1차: 룰 기반 추출로 후보 필드 도출 (기존 정규식 + 핸들러)
  • 2차: AI가 원문과 후보 필드를 함께 받아 "이 필드가 실제 입금자/금액/시각이 맞는가" 판정
  • 3차: 신뢰도 임계값 미달이면 보류 큐로 분류, 운영팀 확인 후 재처리

초안에서는 모든 메시지를 AI로 보내는 것도 고려했다. 버리고 나서 잘한 결정이었다고 확신한다. 결제 알림 메시지는 트래픽이 몰리는 구간이 있고, 거기서 LLM 레이턴시가 끼면 전체 매칭 파이프라인이 느려진다. 비용도 문제지만 지연이 더 큰 리스크였다. 1차 룰이 통과한 케이스 - 즉 파싱은 됐는데 필드 신뢰도가 모호한 케이스만 AI로 보내는 게 맞았다. 룰이 못 잡은 건 어차피 재처리 큐 행이라 AI를 써봤자 의미도 없다.

응답 스키마는 처음부터 강제했다. 자유 텍스트로 받으면 그걸 다시 파싱해야 하고, 그게 또 다른 정규식 지옥이다. function calling 또는 JSON Schema로 구조를 고정하고, confidence 필드는 숫자로 받도록 했다.

{
  "depositor": "홍길동",
  "amount": 150000,
  "transferred_at": "2026-03-14T10:23:00+09:00",
  "confidence": 0.91,
  "flags": ["depositor_partial_match"]
}

flags에는 모호하다고 판정한 이유를 담게 했다. 운영팀이 보류 큐를 수기 확인할 때 플래그가 있으면 어디를 봐야 하는지 바로 파악할 수 있다. 보정 건수 자체가 줄어든 것도 있지만, 남은 보정 건들을 처리하는 속도도 빨라진 효과가 있었다.

임계값 튜닝은 실운영 데이터로 몇 차례 조정이 필요했다. 너무 높게 잡으면 보류 큐가 넘치고, 너무 낮게 잡으면 검증 의미가 없어진다. 처음에 잡은 값과 안정화 이후 값이 꽤 달랐다. 이건 배포 전에 예상하기 어렵고, 실트래픽으로 돌려봐야 나오는 수치다.

결과와 회고

운영팀 수기 보정 건수가 직전 주 대비 60%대로 줄었다. 신규 포맷 대응 PR 크기는 평균 3배 작아졌고, 파서 단위 테스트 커버리지도 올라갔다. 공통 로직이 추상 계층으로 올라가면서 테스트가 자연스럽게 붙는 구조가 된 덕분이다.

핸들러 분리는 진작 했어야 했다. 정규식이 완전히 터진 시점 말고, 두 번째 은행이 붙었을 때 이미 패턴이 보였을 텐데. 그 시점에 잠깐 멈추고 구조를 잡았더라면 나중에 훨씬 덜 고생했을 거다. 지나고 보면 항상 명확하지만.

AI 검증을 룰 정리 전에 얹었다면 누더기 위에 또 누더기였을 거라는 확신은 지금도 변함없다. 기반이 정리되지 않은 상태에서 AI를 올리면 AI가 약점을 가리는 게 아니라 그 위에서 같이 흔들린다. 순서가 맞아야 효과가 난다.

LLM을 최후의 검증자로 쓰는 패턴은, 비용 통제만 제대로 하면 꽤 강력하다. 핵심은 결국 두 가지였다. 응답 포맷을 처음부터 스키마로 못 박을 것, 그리고 모든 케이스를 AI로 흘리고 싶은 욕망을 초반에 차단할 것. 룰이 잡을 수 있는 건 룰이 잡게 두고 AI는 룰의 사각지대에만 투입하는 게 운영비와 레이턴시 양쪽에서 정답이었다.

댓글 0

첫 댓글 달아줘.