입금 알림 파싱 오류와 오매칭 정산 버그 수정
목차
은행 알림을 파싱해서 파트너 정산에 반영하는 구조는 생각보다 많은 곳에서 비슷하게 돌아간다. 메신저 앱으로 날아오는 입금 알림을 서버가 받아서 금액과 입금자명을 뽑아내고, 그걸 내부 파트너 DB랑 매칭해 잔액을 올리는 흐름. 단순해 보이지만 의존하는 인터페이스가 은행 앱의 알림 포맷, 즉 외부 시스템의 자유 텍스트라는 게 문제다. 계약 스펙이 아니다. 관습이다. 은행 입장에서 알림 템플릿 한 줄 바꾸는 건 UI 개선이지 브레이킹 체인지가 아니다. 공지할 이유도 없다.
이번에 그게 터졌다. 포맷이 조용히 바뀌었고, 기존 정규식이 전부 미스. 입금은 실제로 들어왔는데 시스템 상태는 아무 변화 없음. 운영팀이 수동으로 메우다 한계에 도달했고, 그 시점에 이슈가 올라왔다. 파싱 로직이 죽은 지 얼마나 됐는지도 처음엔 불분명했다. 이번에 두 가지를 한꺼번에 잡았다.
멀티라인으로 바뀐 포맷과 정규식 수정
기존 알림은 한 줄짜리 문장이었다.
OO은행 입금 100,000원 홍길동
신규 포맷은 줄바꿈이 끼고 금액 앞 공백도 달라져 있었다.
OO은행
입금 100,000원
홍길동
기존 정규식은 \s+로 공백과 탭을 흡수하게 짜놨는데, 사실 \s는 줄바꿈(\n)도 포함한다. 그렇다면 멀티라인도 됐어야 하는 거 아닌가 싶지만, 문제는 파서가 아니라 수신 레이어에 있었다. 메시지를 받아서 파서에 넘기기 전에 개행 기준으로 먼저 split하는 전처리가 있었고, 파서는 이미 잘린 첫 줄("OO은행")만 받고 있었다. 전체 메시지를 유지한 채 넘겨야 한다는 전제가 중간에 깨진 거다.
수정은 두 단계였다. 수신 레이어가 원본 메시지 전체를 파서에 넘기도록 먼저 바꿨고, 파서 정규식은 금액 토큰을 앵커로 삼아 앞뒤를 별도 캡처 그룹으로 분리했다.
# 변경 전 - 단일 라인 문자열 가정
pattern = r"입금\s+(\d[\d,]+원)\s+(\S+)"
# 변경 후 - 금액 앵커 기준으로 이름을 별도 줄에서 픽업
AMOUNT_RE = re.compile(r"([\d,]+원)")
NAME_RE = re.compile(r"^([^\s\d].+)$", re.MULTILINE)
def parse_notification(text: str):
amount_m = AMOUNT_RE.search(text)
if not amount_m:
return None
after_amount = text[amount_m.end():]
name_m = NAME_RE.search(after_amount)
name = name_m.group(1).strip() if name_m else None
return {"amount": amount_m.group(1), "name": name}
[\s\S]+? 비탐욕 매칭을 처음 시도했는데, 여러 메시지가 연달아 들어오는 경우 다음 메시지 경계까지 먹어버릴 수 있어서 결국 파싱을 두 단계로 분리했다. 정규식 하나로 다 처리하려다 복잡도만 올라가는 전형적인 케이스다. 분리하면 각 단계를 독립적으로 테스트할 수 있고, 다음에 포맷이 또 바뀌었을 때 수정 범위도 좁아진다.
양쪽 포맷-기존 단일 라인과 신규 멀티라인-을 픽스처 파일로 박아두고 회귀 케이스로 추가했다. 이게 있으면 다음 포맷 변경 때 테스트가 먼저 깨지고 배포 전에 잡힌다. 없으면 또 운영팀이 먼저 발견한다.
No Name 필터와 미매칭 큐
파싱이 성공해도 문제가 생기는 케이스가 있었다. 입금자명이 없을 때 외부 시스템이 "No Name"이라는 리터럴 문자열을 꽂아 넣는다. 파서 입장에선 완벽하게 파싱된 정상 알림이다. 이름도 있고 금액도 있다.
그런데 매칭 로직에서 이름을 기준으로 파트너를 찾다 보니, 우연히 같은 금액대의 다른 파트너 룰에 걸렸고 엉뚱한 계정에 잔액이 반영됐다. 정산이 끝난 다음에 이게 발견되면 되돌리는 비용이 크다. 파트너 잔액 롤백, 내역 정정, 경우에 따라 파트너 커뮤니케이션까지 가야 할 수 있다. 그냥 미매칭으로 남겨두는 게 모든 면에서 싸다.
| 케이스 | 이전 동작 | 수정 후 |
|---|---|---|
| 정상 이름 | 매칭 OK | 매칭 OK |
| 빈 이름 → "No Name" | 다른 파트너에 오매칭 | 미매칭 큐 보관 |
| 공백 / null | NPE 직전 통과 | 미매칭 큐 보관 |
수정 자체는 단순했다. 이름 추출 이후 정규화 단계에서 No Name, 공백, null 여부를 체크하고, 해당하면 자동 매칭 로직을 타지 않고 미매칭 입금 큐로 흘려보낸다. 운영팀이 큐를 열어서 파트너를 수동으로 지정하면, 이후 정산 처리는 기존 로직을 그대로 탄다. 흐름은 한 단계 늘었지만 오매칭 리스크가 제거된다.
핵심 판단은 하나다. 매칭 실패보다 매칭 오류가 훨씬 위험하다. 매칭 실패는 큐에 남아서 눈에 보인다. 오매칭은 정상 처리된 것처럼 조용히 통과하고, 이상함을 알아챌 때까지 아무도 모른다. 더 무서운 쪽은 후자다.
회고
이번 이슈를 두 개 묶어서 보면 공통 원인이 있다. 제어 밖 시스템의 출력에 구조를 가정했다는 거다. 은행 알림 포맷, 외부 API 응답, 서드파티 웹훅-이것들은 SLA나 스키마 계약이 없는 자유 텍스트거나 그에 준하는 약한 계약이다. 아무 때나 바뀔 수 있다. 그쪽이 나쁜 게 아니라, 그걸 가정하고 짠 우리 로직이 취약했던 거다.
방어하는 방법은 몇 가지로 요약된다.
- 실제 알림 샘플을 픽스처로 박아두고 회귀 테스트를 돌린다. 포맷이 바뀌면 테스트가 먼저 깨지고 배포 전에 잡힌다.
- 파싱 성공/실패율을 별도 메트릭으로 기록해두면, 실패율이 갑자기 올라갈 때 알람을 받을 수 있다. 이번엔 그게 없어서 운영팀이 직접 발견하기 전까지 인지가 늦었다.
- 파싱은 성공했는데 값이 이상한 경우-금액이 0이거나, 이름이 예약어 문자열이거나-를 명시적으로 처리하는 분기를 만든다. 오매칭의 주범은 파싱 실패가 아니라 이 케이스다.
- 포맷이 여러 버전 공존해야 할 수 있으니, 순서 있는 후보 파서 목록으로 구성해두는 방식도 유용하다. 첫 번째 패턴 실패 시 다음 후보 시도, 다 실패하면 미매칭.
잘못된 정산을 되돌리는 비용이 미매칭 큐 손작업보다 훨씬 크다는 건 이번에 다시 체감했다. 파서가 죽는 건 비교적 빠르게 발견되지만, 파서가 틀리게 살아있는 건 한참 뒤에 발견된다. 확신이 없으면 보수적으로 큐로 보내는 쪽이 맞다. 애매한 상황에서 자동 매칭을 강행하는 건 시스템이 아니라 운영팀에 빚을 떠넘기는 거다.
댓글 0
첫 댓글 달아줘.