개발 slecs

입금 판정 배치에 AI 보조 판정과 폴백 큐 도입

목차

입금 완료 판정 배치가 은행별 텍스트 포맷을 if-else로 분기 쳐놓은 구조라는 건 처음 접했을 때부터 알았는데, 참을 수 있는 동안은 건드리지 않았다. 신규 은행 연동할 때마다 케이스 추가하고, 포맷 살짝 바뀌면 패턴 업데이트하고. 문제는 항상 야간 배치 돌고 나서 발견됐다. 아침에 미처리 큐 쌓여있고, 알림 안 나갔고, 그걸 수동으로 털어내는 흐름이 반복됐다. 이번에 큐 배치에 AI 판정 한 단계 끼워넣고, 폴백 경로까지 정리했다.

왜 패턴 매칭이 한계에 부딪히냐면

은행 입금 통보 문자 포맷이 표준화된 게 없다. 같은 은행도 계좌 종류, 시스템 버전, 채널에 따라 텍스트가 미묘하게 다르다. "홍길동 님 100,000원 입금"이 어느 날은 "100000원 입금(홍길동)"으로 온다. 정규식 하나로 잡아주던 케이스를 둘로 쪼개야 하는 순간이 생기고, 그 업데이트를 놓치면 매칭 실패로 미처리 큐에 쌓인다.

더 골치 아픈 건 신규 은행 연동이다. 계약 맺고 나서 실제 통보 텍스트를 보기 전까지는 패턴을 못 짠다. 텍스트 받고, 패턴 짜고, 테스트하고, 배포하는 사이클을 매번 반복했고, 그 사이 실운영 건은 수동 처리였다. if-else 분기가 은행 수만큼 쌓여가는 구조 자체가 문제였다.

이번 개선의 핵심은 이 구조 자체를 바꾸는 거였다. 룰이 못 잡는 케이스를 사람이 바로 처리하는 게 아니라, AI가 한 번 더 시도하고 그 다음에 사람으로 가는 계층을 끼워넣는 것.

처리 흐름 설계

추상 핸들러에서 공통 전처리를 담당하고, 구체 핸들러는 패턴 매칭 책임만 진다. 이 분리가 생각보다 중요했는데, 정규화 로직이 핸들러마다 흩어져 있으면 AI에 넘길 컨텍스트도 은행마다 제각각이 된다. 추상 핸들러에 정규화와 AI 컨텍스트 주입을 묶어두니 프롬프트 구성이 일관성 있게 나왔다.

흐름은 단순하다.

  • 큐에서 대기 건 pop
  • 추상 핸들러가 공통 정규화 수행
  • 구체 핸들러의 룰 매칭 시도
  • 실패하면 AI 판정 호출
  • 신뢰도 낮으면 수동 검수 큐로 이동

판정 분기를 코드로 보면 이렇다:

if (ruleMatched) confirm()
else if (aiScore >= 0.85) confirm(aiResult)
else queueForManualReview()

임계값 0.85는 임시 수치다. 운영 데이터가 충분히 쌓이면 재조정할 예정이고, 너무 높게 잡으면 수동 검수 큐 부담이 커지고 너무 낮게 잡으면 오판 리스크가 올라간다. 초기엔 보수적으로 가는 게 맞다고 봤다.

폴백 매트릭스를 케이스별로 명시해두면 운영 중 판단 기준이 생긴다:

케이스 처리
룰 매칭 성공 즉시 완료
룰 실패 + AI 고신뢰 AI 결과 채택
룰 실패 + AI 저신뢰 수동 검수 큐
AI 호출 실패/타임아웃 재시도 후 수동 검수

AI 호출 실패를 별도 케이스로 분리한 게 포인트다. AI가 낮은 신뢰도를 돌려주는 것과 아예 응답을 못 받는 건 다르게 처리해야 한다. 타임아웃이나 외부 서비스 장애 상황에서 배치가 멈추거나 건을 드랍하는 건 허용 안 된다. 재시도 후 수동 검수로 가는 경로를 명확히 잡아두지 않으면 그쪽에서 구멍이 생긴다.

시행착오와 설계 결정

초기 설계에서 AI를 1차로 두는 안을 검토했다. 룰 기반이 유지보수 부담이니 아예 AI에 맡기자는 논리였는데, 두 가지 이유로 뒤집었다.

첫째, 응답 지연과 호출 비용. 매칭 가능한 건까지 전부 AI에 보내면 불필요한 지연과 비용이 생긴다. 룰 기반이 처리할 수 있는 케이스는 여전히 대다수다. AI를 보강 수단으로 두면 호출 횟수가 전체 건수의 일부로 줄어든다.

둘째, 날짜별 정확도 편차. AI 모델은 동일한 입력에 항상 동일한 신뢰도를 돌려주지 않는다. 운영 초기엔 이 편차 자체를 데이터로 봐야 하는데, AI를 1차로 두면 그 편차가 전체 배치 결과에 바로 영향을 미친다. 룰이 커버하는 범위가 방파제 역할을 해야 편차 측정도 의미가 생긴다.

그래서 룰 기반 1차, AI 2차 보강으로 확정했다. AI는 룰이 못 잡는 엣지케이스 처리 용도로 역할을 제한했다.

신규 은행 추가 코스트 문제는 추상 핸들러 구조 덕에 상당히 줄었다. 이전엔 새 은행 붙일 때 텍스트 보고, 패턴 짜고, 분기 추가하고, 테스트하는 과정을 전부 직접 했다. 지금은 구체 핸들러에 패턴만 추가하면 AI 보강과 폴백은 자동으로 따라온다. 두 곳에 먼저 적용해보고 동일 패턴으로 나머지를 확장할 예정이다.

class AbstractDepositHandler:
    def normalize(self, raw_text: str) -> dict:
        # 공통 정규화 - 금액, 이름, 시각 추출
        ...

    def build_ai_context(self, normalized: dict) -> str:
        # 프롬프트 컨텍스트 구성 - 핸들러별로 달라지지 않도록 여기서 통제
        ...

    def process(self, raw_text: str):
        normalized = self.normalize(raw_text)
        if self.match_rule(normalized):
            return self.confirm()
        ai_result, score = self.call_ai(self.build_ai_context(normalized))
        if score >= 0.85:
            return self.confirm(ai_result)
        return self.queue_for_manual_review()

class BankAHandler(AbstractDepositHandler):
    def match_rule(self, normalized: dict) -> bool:
        # 은행 A 패턴 매칭만 여기서
        ...

한 가지 더 강조하고 싶은 건 로그 보존이다. AI 판정 결과를 채택했을 때, 어떤 입력을 넣어서 어떤 신뢰도가 나왔는지 호출 로그가 남아야 한다. 나중에 오판이 발생했을 때 추적 가능해야 하고, 임계값 튜닝할 때도 과거 데이터가 필요하다. 임계값 + 수동 검수 큐 + 호출 로그 세 가지를 한 세트로 묶어둔 이유가 여기 있다. 셋 중 하나라도 빠지면 운영에서 못 버틴다는 게 설계 당시의 판단이었고, 지금도 바뀌지 않는다.

금융 도메인에서 AI를 판정에 끼워넣을 때 가장 조심해야 할 건 "편리해서 단일 결정자로 올려버리는" 유혹이다. AI 응답을 그대로 신뢰하면 0.x% 오판이 잔액 사고로 직결된다. 폴백 경로가 예외 처리가 아니라 설계의 본체여야 한다는 게 이번 작업에서 가장 확실하게 정리된 부분이다. 룰 기반과 AI는 경쟁 관계가 아니라 계층 관계로 두는 게 안정적이고, 신뢰도 임계값은 운영 데이터 쌓인 뒤에 튜닝하는 게 답이다. 처음부터 적절한 값을 맞추긴 어렵다.

댓글 0

첫 댓글 달아줘.