개발 slecs

입금 계좌 로테이션과 AI 입금자명 자동 매칭 도입

목차

파트너가 늘어나면서 단일 입금 계좌 구조가 슬슬 깨지기 시작했다. 일 거래량이 일정 수준을 넘어가니 은행 쪽에서 거래 패턴을 의심하기도 하고, 한도에 막혀서 오후 늦게 입금이 튕기는 사고도 종종 났음. 파트너 입장에서는 한 번 튕긴 경험이 재충전 의욕을 꺾어버려서, 단순한 운영 이슈로 끝나지 않고 매출 직격타로 돌아왔음.

문제를 구조적으로 뜯어보면 단일 계좌는 크게 세 가지 리스크를 동시에 안는다. 한도 소진이 오후에 몰리는 거래 패턴과 겹치면 정상 입금이 거절되고, 이 거절 자체가 은행 이상거래 탐지 로직을 건드려서 다음 날 거래까지 영향을 주기도 한다. 계좌 하나에 전체 파트너 자금이 섞이다 보니 정산 단계에서 분리도 어려웠고, 그걸 운영팀이 수기로 보정하고 있었음. 이 정도면 사람 의존도가 너무 높은 구조였다.

그래서 이번 스프린트에 파트너별 복수 계좌 로테이션을 도입했음. 동시에 입금자명 매칭에 AI를 끼워 넣었음. 두 기능이 별개처럼 보이지만 실은 한 묶음이라, 같이 풀어야 깔끔하게 떨어졌음. 계좌가 늘어나면 입금자 추적이 어려워지기 때문에, 매칭 고도화 없이 로테이션만 넣으면 운영 부담이 그냥 다른 곳으로 이동할 뿐이다.

로테이션 전략 정하기

후보 전략을 깔아두고 비교했음.

전략 장점 단점
라운드로빈 구현 단순 계좌별 한도 편차 무시
잔여 한도 가중치 한도 고갈 늦춤 상태 동기화 비용
파트너 고정 매핑 입금자 추적 쉬움 한도 분산 효과 적음

라운드로빈은 처음에 떠올리기 쉬운 구조지만, 계좌마다 한도가 다른 현실에서는 한 계좌가 먼저 소진돼버리는 상황을 막지 못한다. 파트너 고정 매핑은 운영자 입장에서 추적이 편하지만, 특정 파트너에 거래가 몰리면 결국 단일 계좌 문제와 같아진다.

결국 잔여 한도 가중치 + 시간대별 폴백으로 갔음. 같은 계좌가 연속 선택되지 않도록 최근 N분 사용 이력을 빼는 쿨다운도 넣었음. 운영자가 특정 계좌를 임시 비활성화할 수 있는 토글도 같이 만들었음. 은행 점검 시간대가 계좌마다 다를 수 있고, 이상거래 의심이 잡히면 그 계좌만 빼고 나머지로 돌려야 하니까 이 토글이 실제로 꽤 자주 쓰인다.

구현할 때 쿨다운 분기를 단순화하면 이런 형태다.

def select_account(pool: list[Account], cooldown_minutes: int = 5) -> Account:
    recent_used_ids = get_recently_used(minutes=cooldown_minutes)
    candidates = [a for a in pool if a.id not in recent_used_ids and a.active]
    if not candidates:
        # 폴백: 활성 계좌가 없으면 쿨다운 무시
        candidates = [a for a in pool if a.active]
    return max(candidates, key=lambda a: a.remaining_limit)

폴백 분기가 중요한 건, 쿨다운이 너무 엄격하면 계좌가 부족한 상황에서 선택 자체가 실패해버리기 때문이다. 대부분의 상황에서 연속 사용보다 입금 실패가 훨씬 나쁜 결과를 낳는다. 그리고 몇 가지 더 챙겼음.

  • 선택 로직은 한 곳으로 모으고, 컨트롤러는 결과만 받아쓰게 분리했음. 전략 교체가 필요할 때 컨트롤러를 건드리지 않으려면 이 선이 명확해야 함
  • 계좌 풀 변경은 캐시 무효화 이벤트로 즉시 반영되게 했음. 매 요청마다 DB를 찌르면 부하가 문제가 된다
  • 로테이션 결정 근거를 로그로 남겨서, 왜 이 계좌가 뽑혔는지 사후 추적이 가능하게 했음. 장애가 생겼을 때 "이 시점에 왜 이 계좌로 갔는지"를 설명하려면 결정 시점의 컨텍스트가 반드시 필요하다

입금자명 매칭, 사람 손에서 떼어내기

기존에는 입금자명이 주문자명과 다를 때 운영자가 눈으로 보고 매칭했음. 가족 명의, 회사 명의, 별명, 오타. 하루 수천 건이 쌓이면 누가 봐도 지속 가능한 구조가 아닌데, 그냥 사람이 버텨왔던 거다.

입력: 김민수    / 입금자: 김민쑤
입력: ㈜더블유에이  / 입금자: 더블유에이
입력: 이정훈(부)  / 입금자: 이정훈

규칙 기반으로는 이런 변형을 다 잡지 못한다. 오타 거리 기반 퍼지 매칭을 먼저 검토했는데, 한국어 자모 분리 레벨로 내려가면 복잡도가 급격히 올라가고 한자 법인명 케이스는 패턴이 너무 달라서 규칙이 금방 한계를 맞는다. 그래서 LLM에게 후보 주문 N개와 입금 메시지 1건을 던져서 가장 그럴듯한 매칭과 확신도를 받아오게 했음. 확신도 임계치 위는 자동 매칭, 아래는 운영자 큐로 보냈음.

프롬프트 구조를 대략 쓰면 이런 형태다.

[입금 정보]
입금자명: 김민쑤
금액: 50,000원
입금시각: 14:23

[후보 주문 목록]
1. 주문자: 김민수 / 금액: 50,000원 / 주문시각: 14:20
2. 주문자: 김민혜 / 금액: 50,000원 / 주문시각: 13:55

위 입금이 어느 주문에 대응되는지 판단하고,
후보 번호와 확신도(0.0~1.0)를 JSON으로 반환하라.
확신도 0.85 미만이면 matched: null로 반환.

설계하면서 중요하게 챙긴 것들이 있다.

  • 결정론적 규칙을 먼저 통과시키고, 남은 모호한 건만 LLM으로 보냄. 정확히 일치하거나 띄어쓰기 차이 정도인 케이스를 LLM에 보내는 건 비용 낭비고 응답 지연도 생긴다. 1단계 규칙 필터가 많은 양을 걸러주면 LLM 비용 곡선이 눈에 띄게 완만해진다
  • 프롬프트에 입금 시각과 금액 일치 여부를 같이 넣어줘야 환각이 줄어든다. 이름 정보만 주면 LLM이 억지로 매칭을 맞추려는 경향이 있음. "같은 금액, 10분 이내 입금"이라는 컨텍스트를 함께 주면 확신도 분포가 훨씬 명확해진다
  • 매칭 결과는 항상 사람이 되돌릴 수 있게 보존했음. AI가 틀렸을 때 원복 비용이 곧 신뢰도임. 운영자가 수정하면 그 내용을 로그로 쌓아두는 것까지 세트다. 이게 나중에 피드백 데이터가 된다

확신도 임계치는 처음부터 낮게 잡으면 안 된다. 초기에는 보수적으로 잡고 운영자 피드백을 보면서 조금씩 내렸음. 한꺼번에 낮추면 오매칭이 배치로 들어오고, 그걸 되돌리는 게 더 고통스럽다.

배포하고 나서

자동 매칭률이 운영 큐 도착 기준으로 70%대 후반까지 올라왔음. 처음 며칠은 임계치를 보수적으로 잡고 운영자 피드백으로 천천히 올렸음. 로테이션 쪽은 한도 막힘이 거의 사라졌고, 이상거래 의심이 잡혔을 때 그 계좌만 잠시 빼고 돌릴 수 있어서 운영팀 만족도가 올라갔음.

두 기능을 한 스프린트에 묶은 게 결과적으로 맞는 판단이었다. 계좌가 늘어나는 시점에 매칭 정확도가 뒤처지면 운영 부담이 오히려 커지는데, 이 둘을 따로 가져가면 개선 효과가 반감된다. 묶어서 배포하면 QA가 복잡해지는 단점이 있지만, 이번 경우는 분리했을 때 생기는 문제가 더 컸음.

남은 숙제는 LLM 비용 곡선을 어디서 꺾을지, 그리고 매칭 실패 케이스를 학습 데이터로 다시 환류시키는 파이프라인이다. 지금은 수정 로그가 쌓이고 있고, 이걸 파인튜닝에 쓸지 프롬프트 예시로 넣을지는 아직 결정 전. 볼륨이 어느 정도 차야 판단할 수 있을 것 같음.

댓글 0

첫 댓글 달아줘.