결제 파싱 오류를 LLM 교차검증으로 조기 포착한 정산 파이프라인 개선
목차
알림 페이로드에서 결제 정보를 뽑아 쓰는 파이프라인을 운영하다 보면, 포맷이 생각보다 자주 바뀐다는 걸 뒤늦게 깨닫는다. PG사 업데이트, 서드파티 알림 구조 변경, 담당자 퇴사로 인한 스펙 공백... 정규식으로 떠받치는 파서는 이런 변화에 취약하다. 패턴 하나가 살짝 틀어지면 금액이 0원으로 들어가는 사고가 조용히 발생하고, 정산 타임에 가서야 발견된다.
실제로 그런 케이스가 났다. 사후에 보면 "사람이 봤으면 다 보였을 텐데" 싶은 경우가 대부분이었다. 원문 문자열에 금액이 버젓이 적혀 있는데 정규식이 공백 하나 차이로 매칭을 못 하면 0원이 나온다. 정규식 파서를 버린 건 아니고, 그 위에 검증 레이어를 얹은 거다. 두 개를 병렬로 돌려서 결과가 다를 때만 사람이 개입하는 구조로.
왜 서버 프록시인가
클라이언트나 파이프라인 수집기에서 직접 모델을 호출하는 방식은 두 가지 문제가 동시에 터진다. API 키 노출과 요금 폭주. 알림 재처리가 몰리는 구간이 생기면 같은 원문이 수십 번 모델에 들어갈 수 있고, 요금 청구서를 보고 나서야 뭔가 잘못됐다는 걸 알게 된다.
그래서 백엔드에 검증 전용 게이트웨이를 분리했다.
- 수집기는 원문(raw)과 1차 파싱 결과(JSON) 둘 다 게이트웨이로 넘김
- 게이트웨이는 모델 키를 들고 있고, 캐시·레이트리밋·프롬프트 템플릿 관리를 일괄 처리
- 검증기는 모델 응답을 정규화해서 1차 결과와 필드 단위로 비교
키 보안만의 이점이 아니다. 프롬프트 템플릿과 모델 버전을 수집기 배포 없이 게이트웨이에서 핫스왑할 수 있다는 게 운영 면에서 훨씬 컸다. 파이프라인이 여러 개라면 검증 로직을 한 곳에서 관리할 수 있다는 것도.
캐시 키를 원문 해시로 잡은 게 가장 효과적이었다. 같은 알림이 재처리되거나 중복 수신될 때 모델을 아예 안 부른다. 비용이 거의 1/4 수준으로 떨어졌음. 정산 파이프라인 특성상 재처리 빈도가 높기 때문에 이 최적화 하나가 전체 운영 비용에 생각보다 큰 영향을 줬다.
교차검증 규칙과 구현 패턴
검증 결과는 세 가지 상태로 분류한다.
| 케이스 | 처리 |
|---|---|
| 핵심 필드 모두 일치 | confirmed, 그대로 적재 |
| 금액·일시 불일치 | flagged, 큐에 격리 후 알림 |
| LLM 응답 빔 / 형식 파괴 | unknown, 정규식 결과 신뢰 + 카운터+1 |
핵심은 "LLM을 정답으로 두지 않는 것"이다. 1차 파서와 LLM 둘 다 의심하고, 차이가 날 때만 사람이 보게 한다. 모델이 환각을 일으켜서 정산 데이터가 오염되면 복구 비용이 훨씬 크다. 금액 필드는 특히 조심해야 한다. 모델이 문맥 추론으로 그럴듯한 숫자를 만들어낼 수 있어서 맹신하면 안 됨.
전체 흐름을 도식화하면 이렇다.
[수집] -> [1차 파서] -> [게이트웨이] -> [LLM]
\ /
[교차검증] -> 결과
코드 레벨에서 검증 함수는 단순하게 유지하는 게 낫다. 필드별로 타입 캐스팅 후 비교하고, 불일치 시 diff만 반환하는 식.
def cross_validate(parsed: dict, llm_result: dict, critical_fields: list[str]) -> dict:
mismatches = {}
for field in critical_fields:
v1 = normalize(parsed.get(field))
v2 = normalize(llm_result.get(field))
if v1 != v2:
mismatches[field] = {"parsed": v1, "llm": v2}
if not mismatches:
return {"status": "confirmed", "data": parsed}
if any(f in mismatches for f in ["amount", "timestamp"]):
return {"status": "flagged", "mismatches": mismatches}
return {"status": "confirmed", "data": parsed, "warnings": mismatches}
normalize 안에서 금액은 쉼표·통화 기호 제거 후 정수 변환, 일시는 ISO 8601로 통일한다. LLM이 "₩12,000"과 "12000"을 다르게 돌려줄 수 있어서 문자열 단순 비교하면 false positive가 많이 뜬다. 타입 정규화는 빼먹기 쉬운데 이게 없으면 검증기가 쓸모가 없어짐.
프롬프트는 짧게. 원문 + 필드 스키마 + 예시 한 개면 충분했다. 길게 쓰니 오히려 환각이 늘어났음. 토큰이 길어질수록 모델이 과도하게 추론하거나 예시를 패턴화해서 엉뚱한 값을 생성하는 것 같다. "이 원문에서 다음 필드를 JSON으로 추출하라"는 형태로 단순하게 두는 게 재현성이 훨씬 좋다.
timeout은 2~3초로 짧게 잡았다. 검증이 어차피 비동기라 응답이 늦으면 1차 결과를 그대로 통과시키는 편이 더 안전하다. 검증 지연 때문에 정산 처리 전체가 블로킹되면 안 되니까.
운영하면서 생긴 부산물
"검증 결과가 의심스러움" 자체를 메트릭으로 뽑기 시작하면서 예상 못 한 이점이 생겼다. flagged 비율이 특정 알림 유형에서 급격히 올라가면, 그게 1차 파서가 깨지는 지점이라는 신호다. 별도로 파서 로그를 뒤지지 않아도 교차검증 메트릭만 봐도 어떤 포맷 변화가 들어왔는지 파악할 수 있음.
unknown 카운터도 쓸모가 있다. LLM 응답 형식 파괴가 특정 원문 패턴에 집중되면 프롬프트를 보강해야 한다는 신호다. flagged 카운터와 unknown 카운터를 함께 보면 파서 쪽 문제인지 프롬프트 쪽 문제인지 구분이 된다. 이 두 숫자만 대시보드에 올려놔도 파이프라인 건강 상태를 꽤 정확하게 읽을 수 있다.
운영 초기에 실수했던 건 LLM 응답 실패를 그냥 에러 처리해서 조용히 넘긴 것이었다. 그렇게 하면 unknown 케이스가 쌓이는지 알 방법이 없다. 실패를 silently 넘기지 말고 카운터를 찍어두는 게 나중에 트러블슈팅할 때 훨씬 낫다는 걸 한참 지나서 고쳤음.
회고
"파싱 정확도 100% 만들겠다"는 방향이 왜 위험한지는 운영해보면 금방 안다. 외부 알림 포맷은 내 통제권 밖이다. 포맷이 바뀔 때마다 파서를 고치는 루프에서 빠져나오려면 "틀렸을 때 빨리 잡아낸다"로 방향을 틀어야 한다.
1차 파서는 더 건드리지 않는다. 검증 레이어만 진화시키면 되니까 유지보수 부담도 줄었다. LLM을 단일 의사결정자가 아니라 두 번째 의견으로 끼워 넣는 패턴은 다른 도메인에도 그대로 옮겨 쓸 만하다고 느꼈음. OCR 결과 검증이나 외부 API 응답 정합성 체크 같은 곳에 구조 자체를 그대로 붙일 수 있다. LLM이 잘못될 수 있다는 전제 위에 설계하는 게 핵심이고, 그 전제가 있어야 unknown 케이스 처리가 자연스럽게 따라오고 파이프라인 전체가 모델 품질에 종속되지 않는다.
댓글 0
첫 댓글 달아줘.