프로젝트
약 19분 분량 학습 프로젝트/결제 시스템

AI를 세 자리에 붙여 재고, 둘은 켰고 하나는 규칙에 넘겼다

PaymentAILLM평가운영 자동화결제 시스템
목차

개요

결제 백엔드의 운영 업무 세 자리에 로컬 LLM을 붙여 재고, 두 자리는 켰고 한 자리는 규칙에
넘겼습니다. 처음 켠 자리는 장애 로그를 읽어 원인 유형을 고르는 일입니다. 실제 로그 12건에서 규칙 기반 분류기는
7건을 맞히고 5건을 기권했으며 틀린 건 없었습니다. 같은 12건에서 로컬 모델(qwen3:8b)은 11건을
맞히고 1건을 틀렸습니다. 이 수치는 4회 반복에서 한 번도 바뀌지 않았습니다.

세 자리는 이렇습니다. 대사가 안 맞는 건의 원인을 고르는 일, 그 건에 무슨 일이 있었는지 한
문단으로 요약해 운영자에게 보여주는 일, 그리고 장애 로그를 읽어 원인 유형을 고르는 일입니다.
앞의 둘을 껐습니다. 원인 분류는 모델이 답할 수 있는 유형을 규칙 분류기가 이미 답하고 있어서
개선이 0이었고, 운영자용 요약은 두 방식을 비교할 표본을 사람이 아직 고르지 않았습니다.

켜고 끄는 기준은 절대 정확도가 아니라 기존 방식 대비 개선입니다. 여기서 기존 방식은 규칙이고,
규칙이 이미 답하는 자리라면 모델이 90%를 맞혀도 얻는 게 없습니다.

수치보다 오래 붙들고 있었던 것은 평가 장치 자체가 틀리는 방식입니다.
같은 작업 안에서 세 번 걸렸습니다. 제가 로그에 정답을 흘려 놓고 모델이 잘 맞힌다고 읽을 뻔했고,
가린 채 비교한다고 해놓고 사실 가려지지 않았으며, 결손을 잡으라고 만든 검사기가 결손을
통과시키는 쪽으로 고장 나 있었습니다.

세 자리 모두 확정은 사람이 합니다. 켰다는 것은 후보를 제시한다는 뜻이고, 상태를 바꾸거나
장부에 쓰는 일은 없습니다.


1. 표본이 4건이면 한 건이 결론을 뒤집습니다

장애 로그 원인 분석은 원래 켜지 않기로 한 기능이었습니다. 이유가 “규칙보다 나쁘다”가 아니라
“표본이 얇다”였습니다.

처음 실험에 쓴 로그는 4건이었습니다. 3건에서 4건으로 늘리는 것만으로 규칙과 모델의 우열이
바뀌었습니다. 한 건이 결론을 뒤집는 실험은 아직 판정이 못 됩니다.

그래서 표본을 늘렸습니다. 지어내지 않는다는 조건을 걸었습니다. 대사 원인 분류를 실험할 때
평가용 사례 27건을 손으로 만들었다가, 그 27건의 분포가 실제로 들어오는 건들과 달라서 결론이
뒤집힌 적이 있기 때문입니다. 제가 상상할 수 있는 장애만
실험 대상이 되면 그 실험은 제 상상력을 잴 뿐입니다.

실제로 장애를 일으켜서 받았습니다

IncidentLogCaptureTest라는 테스트가 진짜 장애를 내고 그때 찍힌 로그를 파일로 받아 적습니다.

표본실제로 한 일로그 문장을 낸 주체
RACE_CONDITION-01두 커넥션이 같은 두 행을 반대 순서로 FOR UPDATEMySQL이 낸 SQLState 40001, Deadlock found
RACE_CONDITION-02다른 스레드가 version을 계속 올려 WHERE version=N이 0행을 맞게 함앱: 영향 행 0건, 재시도 소진
RACE_CONDITION-03행을 잡고 놓지 않은 채 innodb_lock_wait_timeout=3MySQL이 낸 Lock wait timeout exceeded
DB_TIMEOUT-02풀 크기를 1로 두고 커넥션을 물고 안 돌려줌HikariCP가 낸 Connection is not available
CERT_EXPIRY-01실제로 만료된 인증서를 쓰는 호스트에 붙음JDK가 낸 CertificateExpiredException: NotAfter ... 2015
PG_UNAVAILABLE-02죽은 포트로 승인 요청 3회JDK가 낸 ClosedChannelException
PG_UNAVAILABLE-03503을 내는 서버를 띄워 3회 호출앱: 응답 503과 본문
REPLICATION_LAG-0130초 지연을 건 진짜 MySQL 복제본에서 조회앱: 커밋했는데 조회 0건, 35초 뒤 1건

로그 문장의 상당수를 MySQL과 JDK와 HikariCP가 직접 냈다는 점이 중요합니다. 제가 쓴 문장으로만
채우면 그 문장을 알아보는 능력을 재게 됩니다.

흉내낸 표본을 하나 지웠습니다

복제 지연은 처음에 커밋하지 않은 트랜잭션으로 만들었습니다. 다른 커넥션에서 조회하면 아직 안
보이니까 “방금 쓴 것을 못 읽는다”는 증상이 똑같이 나옵니다.

증상은 같지만 원인이 다릅니다. 열린 트랜잭션과 복제 지연은 다른 고장이고, 조치도 다릅니다.
정답이 틀린 표본은 코퍼스를 오염시킵니다. 지우고 다시 만들었습니다.

MySQL 컨테이너를 하나 더 띄우고 CHANGE REPLICATION SOURCE TO ... SOURCE_DELAY=30으로
30초 지연 복제를 걸었습니다. 원본에 커밋한 직후 복제본을 조회하면 0건이 나오고, 35초를 기다린
뒤 같은 조회를 하면 1건이 나옵니다. 그걸 확인하고 나서야 표본으로 썼습니다.

이렇게 4건이 12건이 됐습니다. 유형당 1건에서 3건이니 여전히 많지는 않습니다.


2. 정확도는 비슷했고, 갈린 것은 틀리는 방식이었습니다

12건에 규칙과 모델을 나란히 태웠습니다. 4회 반복해서 매번 같은 숫자가 나왔고, 틀리는 건도
매번 같은 건이었습니다.

rule 맞음 7 · 기권 5 · 틀림 0
ollama:qwen3:8b 맞음 11 · 기권 0 · 틀림 1
ollama:qwen3:8b + 근거 대조 맞음 11 · 기권 0 · 틀림 1

숫자만 보면 모델이 4건 앞섭니다. 그런데 두 방식은 성격이 다릅니다. 규칙은 자기가 아는 패턴에만
답하고 모르면 입을 다뭅니다. 12건에서 한 번도 틀리지 않았습니다. 모델은 더 많이 맞히는 대신
모르는 것에도 답을 냅니다. 그리고 틀린 답에도 근거를 그럴듯하게 답니다.

여기서 둘 중 하나를 고르는 문제로 보면 손해가 납니다. 모델로 통째로 바꾸면 규칙이 확실히
답하던 7건까지 모델의 오답 위험에 들어갑니다.

그래서 규칙을 앞에 두고, 규칙이 기권한 자리에만 모델을 부르는 구성을 만들었습니다.

public Optional<IncidentDiagnosis> diagnose(String logText) {
Optional<IncidentDiagnosis> byRule = rule.diagnose(logText);
if (byRule.isPresent() && byRule.get().cause() != IncidentCause.UNKNOWN) {
return byRule;
}
Optional<IncidentDiagnosis> byModel = model.diagnose(logText);
if (byModel.isPresent() && !grounding.grounded(byModel.get(), logText)) {
return Optional.empty(); // 인용이 원문에 없으면 버린다
}
return byModel;
}

같은 12건에서 이 구성은 11건을 맞히고 1건을 틀렸습니다. 점수는 모델 단독과 같은데, 규칙이
답한 7건은 모델을 아예 거치지 않습니다. 기본값을 여기로 옮겼습니다.

화면에 낼 때는 규칙이 낸 답과 모델이 낸 답을 구분합니다. 둘은 신뢰도가 다르기 때문입니다.

public record IncidentDiagnosis(IncidentCause cause, String evidence, Source source) {
public enum Source { RULE, MODEL }
/** 출처를 안 밝히면 모델로 본다. 덜 믿는 쪽이 기본값이어야 합니다. */
public IncidentDiagnosis(IncidentCause cause, String evidence) {
this(cause, evidence, Source.MODEL);
}
}

3. 평가 장치가 세 번 저를 속이려 했습니다

3.1 제가 로그에 정답을 적어 놓고 있었습니다

첫 캡처를 끝내고 결과를 보니 모델이 규칙보다 4건 앞섰습니다. 그 숫자를 그대로 쓸 뻔했습니다.

로그를 다시 읽어 보니 제가 앱 로그에 이렇게 찍고 있었습니다.

재고 차감 낙관적 락 충돌 — 1회차 재시도 (version=0 이 이미 바뀜)
복제 지연 0초 — 조회가 없다고 답한 것은 행이 없어서가 아니다

“낙관적 락 충돌”과 “복제 지연”은 제가 붙인 진단명입니다. 모델은 그 문구를 읽기만 하면 정답을
맞힙니다. 규칙은 제가 지어낸 한국어 문구를 패턴에 갖고 있지 않으니 기권합니다. 그 상태에서
나온 4건 차이는 실력 차가 아니라 제가 흘린 정답입니다.

앱이 찍는 줄을 관측된 사실까지로 낮췄습니다.

재고 차감 UPDATE 영향 행 0건 — 1회차 재시도 (조회 당시 version=0)
조회 노드 상태 Seconds_Behind_Source=0

진단명은 MySQL과 JDK가 스스로 낸 문장에만 남겼습니다. 그러고 다시 쟀더니 결과가 같았습니다.
누출이 원인이 아니었던 겁니다. 다만 이 확인을 안 했으면 결론이 맞아도 근거가 없었습니다.

3.2 가린다고 해놓고 가려지지 않았습니다

두 번째 자리는 대사 화면에 띄우는 운영자용 요약입니다. 같은 사실로 두 문단을 만들어 하나는
템플릿이 쓰고 하나는 모델이 쓰게 한 뒤, 어느 쪽이 나은지 사람이 고르게 하는 장치를 만들어
두었습니다. 표본은 계속 0건이었습니다.

먼저 심판 모델을 세워 1차 신호를 만들어 봤습니다. 자기선호를 피하려고 생성은 qwen3:8b,
심판은 계열이 다른 llama3.1:8b로 갈랐습니다. 위치 편향이 알려져 있어서 A와 B를 바꿔 두 번
묻고, 답이 뒤집히면 동점으로 셌습니다.

템플릿 0 · 모델 0 · 동점 8 (그중 순서 뒤집힘 8)

8건 전부 뒤집혔습니다. 심판이 고른 것은 요약의 질이 아니라 앞에 놓인 쪽이었습니다.

나중에 문헌을 찾아보니 위치 편향은 널리 보고돼 있습니다. 순서만 바꿔도 최대 30%가 반전되고
평균 order-flip이 0.236입니다. 표준 완화책이 정확히 제가 한 것이었습니다. 양쪽 순서로 돌려
순서에 따라 답이 달라지면 동점으로 처리하면 위치 편향 신호가 거의 0이 됩니다. 그러니 이
결론은 “뒤집혀서 못 쓴다”보다 “표준 완화책을 적용했더니 8건 전부 동점이라 심판이 신호를 0으로
준다”가 정확합니다.

여기까지의 근거는 llama3.1:8b 하나였습니다. 훨씬 강한 심판을 세우면 달라지는지 보려고,
케이스 모양을 넷으로 갈라 30건을 새로 뽑고 이번에는 Claude에게 물었습니다. 이 코드를 같이
작성한 모델이므로 중립적인 심판은 아닙니다. 그 점은 뒤에서 다시 다룹니다.
케이스 모양은 이렇게 갈랐습니다. 금액 불일치, 외부에만 있음, 부분취소 미반영,
이의제기까지 붙은 긴 사건입니다. 앞선 실험은 금액만 다른 같은 모양을 반복하고 있었습니다.

출처 선호 템플릿 4 · 모델 26 (26/30, 단측 p = 0.00003)
위치 선호 A 18 · B 12 (18/30, 단측 p = 0.18)
모델이 A 자리일 때 모델을 고른 비율 15/16
모델이 B 자리일 때 모델을 고른 비율 11/14

강한 심판은 순서에 흔들리지 않았습니다. 자리를 바꿔도 같은 쪽을 골랐으니 내용으로 고른
것입니다. 그러니 “모델 심판으로는 이 차이를 못 가린다”는 결론은 모델 크기 문제였습니다.

그런데 이 26대 4를 승격 근거로 쓸 수 없습니다. 두 가지가 겹칩니다. 심판이 이 코드를 같이
작성한 모델이라 자기가 만든 쪽을 고를 이유가 있고, 무엇보다 가려지지 않았습니다. 템플릿 쪽
출력은 이렇게 생겼습니다.

주문 ORD-B000 — 2026-06-01 · ORDER · 주문 생성 10,000원 그다음 2026-06-01 · PAYMENT ·
결제 READY → IN_PROGRESS (USER, TOSS_PAYMENTS) 그다음 ...

“그다음”으로 이어 붙인 사슬은 한눈에 알아볼 수 있습니다. 심판은 어느 쪽이 템플릿인지 알고
읽은 셈입니다. 그 상태에서 나온 선호는 자기선호 편향과 구별되지 않습니다. 이 실험이 말해 주는
것은 강한 심판이 순서에 안 흔들린다는 것까지이고, 어느 요약이 나은지는 여기서 못 정합니다.

3.3 결손을 잡으라고 만든 검사기가 결손을 통과시켰습니다

30건을 읽다가 편향과 무관하게 확인되는 차이를 하나 봤습니다. 모델이 금액을 빠뜨린 건이
있었습니다.

2026-06-14에 대사 상태가 AMOUNT_MISMATCH로 변경되어 내부 기록과 외부 기록의 금액이 다름을
나타냈습니다.

내부가 얼마고 외부가 얼마인지가 없습니다. 대사 불일치를 조사하는 사람에게 금액 없는 요약은
그 자리에서 쓸모가 없습니다. 취향의 문제가 아니고 빠진 것이므로, 세면 됩니다.

그래서 사실 묶음의 금액이 요약에 남아 있는지 기계로 세는 검사기를 만들었습니다. 처음 구현이
이랬습니다.

private boolean mentions(String text, long amount) {
return text.contains(NumberFormat.getNumberInstance(Locale.KOREA).format(amount))
|| text.contains(Long.toString(amount));
}

contains("17300")은 본문에 173000이 있어도 참입니다. "17,300"117,300 안에서도
잡힙니다. 엉뚱한 금액이 결손을 덮어 주는 구조입니다. 검사기가 고장 나는 방향이 문제였습니다.
막는 쪽으로 틀리면 멀쩡한 요약이 반려될 뿐인데, 이건 통과시키는 쪽으로 틀립니다.

숫자 경계를 보게 고쳤습니다. 앞은 숫자나 쉼표가 아니어야 하고, 뒤는 숫자거나 쉼표에 숫자가
이어지는 형태가 아니어야 합니다.

private boolean standalone(String text, String needle) {
return Pattern.compile("(?<![\\d,])" + Pattern.quote(needle) + "(?!\\d|,\\d)")
.matcher(text).find();
}

경계를 좁히기만 하면 반대 실수가 납니다. 템플릿 출력에는 "외부 17,300, 내부 기록 없음"처럼
자릿수 구분이 아닌 쉼표가 금액 뒤에 옵니다. 그 자리는 통과시키면서 17,300,000은 막아야
합니다. 고친 뒤에도 템플릿이 30건 모두 결손 0인 것으로 확인했습니다.


4. 간헐적인 결함은 오프라인 평가로 못 막습니다

고친 검사기로 여섯 번 쟀습니다.

template 0/30 (여섯 번 모두)
ollama:qwen3:8b 4 · 0 · 1 · 0 · 1 · 2 / 30

빠뜨린 자리에 규칙성이 있었습니다. 관측된 결손 다섯 건 중 넷이 대사 결과가 “외부에만 있음”인
건이었습니다. 사실이 네 개뿐이라 사건이 짧은데, 짧을수록 요약하려다 숫자를 버립니다. 그
유형은 앞서 대사 원인 분류에서 가장 위험한 유형으로 짚은 것과 같습니다. 결제사는 처리했다는데
우리 장부에는 없는 건이고, 운영자가 제일 먼저 봐야 하는 것이 그 금액입니다.

같은 코드로 0건이 나오는 회차와 4건이 나오는 회차가 섞입니다. 오프라인에서 몇 번을 돌려 0이
나와도 다음 호출에서 빠뜨립니다. “이 모델은 금액을 안 빠뜨린다”는 평가로 증명할 수 없습니다.
런타임에서 매번 보는 수밖에 없습니다.

AmountCoverageGuard를 붙였습니다. 이미 있던 출처 검증기와 방향이 반대입니다. 출처 검증은
없는 숫자를 지어냈는지 보고, 이 가드는 있는 숫자를 버렸는지 봅니다. 둘 다 통과해야 요약이
나가고, 버린 건은 dropped_amounts로 지표와 감사 기록에 남습니다.

실험 코드가 운영에서 도는 그 가드를 그대로 씁니다. 실험용 판정기를 따로 만들면 둘이 갈리고,
그때부터 실험 결과는 운영에 대해 아무 말도 못 합니다.

어느 요약이 나은지는 아직 안 재였습니다. 인간 선호 평가 문헌은 50건이면 충분하고 차이가
뚜렷하면 25건에서 50건 사이에 승자가 드러난다고 봅니다. 그래서 한동안 “사람이 고른 25건이
모이면 그때 판정한다”로 두었는데, 그 기준은 제가 만든 것이고 만들어 놓고 보니 영영 안
닫히는 상태가 됐습니다. 표본을 모으는 일이 뒤로 밀리는 동안 문서에는 미정만 남습니다.

바뀐 것은 하나입니다. 사람이 고를 때 금액이 빠진 요약은 후보에 올라오지 않습니다. 그 결손을
“모델이 못 쓴다”의 근거로 잘못 세지 않게 됐습니다.


5. 두 번째 자리는 근거가 달라서 켰습니다

운영자용 요약은 어느 쪽 문장이 더 읽히는지를 끝내 못 쟀습니다. 사람이 고른 표본이 0건이고,
앞 절의 26대 4는 블라인드가 깨진 값입니다.

그런데 재기 전에 봐야 할 것이 있었습니다. 금액 결손 가드를 붙일 때 버린 요약을 어떻게 하는지는
안 봤습니다.
코드를 보니 이랬습니다.

List<Long> dropped = coverageGuard.missing(raw.get(), facts);
if (!dropped.isEmpty()) {
record(orderNo, "dropped_amounts", null, facts);
return Optional.empty(); // 운영자 화면이 통째로 빈다
}

금액 결손은 30건 중 0~4건으로 간헐적입니다. 모델을 켜면 그 확률로 요약이 아예 없는 화면
보게 됩니다. 템플릿은 그 자리에서도 늘 뭔가를 냅니다. 어느 쪽이 잘 읽히는지를 재기 전에 이미
켜면 안 되는 상태였습니다.

버렸을 때 템플릿으로 떨어뜨렸습니다. 폴백도 같은 가드를 통과해야 나갑니다. 검사를 건너뛰는
경로를 만들지 않는 것이 중요합니다.

기권한 자리에서도 떨어뜨립니다. 여기서 전에 내린 판단을 하나 바꿨습니다. 원래는 모델이 기권하면
그대로 비웠고 근거가 “억지로 만든 문장은 일을 늘린다”였습니다. 그 근거는 모델에게 억지로 쓰게
하는 것
에 대한 것이지, 사실을 그대로 옮기는 템플릿에는 해당하지 않습니다. 빈 화면과 사실
목록 중에서는 사실 목록이 낫습니다.

그래서 켰습니다. 근거가 “더 낫다”가 아닙니다. 모델이 잘 쓰면 그 문장이 나가고, 못 쓰면
템플릿이 나갑니다. 모델을 켠 최악이 템플릿을 켠 것과 같아졌으므로, 못 잰 것이 위험으로
남지 않습니다.

품질 판정은 열어 두지 않고 닫았습니다

있는 증거로 닫고, 그 증거가 무엇인지 정확히 적는 편이 낫습니다.

심판 llama3.1:8b 8건 전부 순서에 뒤집힘 → 신호 0
심판(강한 모델) 모델 26 · 템플릿 4 (30건, 단측 p = 0.00003), 위치 편향 없음
기계 검사 금액 결손: 템플릿 0/30, 모델 0~4/30 (간헐적, 가드로 막음)
사람 0건

이 증거가 지지하는 것. 강한 심판은 순서에 안 흔들리고 일관되게 모델 쪽을 골랐습니다.
템플릿이 이긴 4건은 전부 모델이 금액을 빠뜨린 건이고, 그 실패는 이제 가드가 막습니다.

이 증거가 지지하지 않는 것. 26대 4를 “모델 요약이 더 낫다”의 근거로 쓸 수 없습니다.
블라인드가 성립하지 않았고, 심판이 이 코드를 같이 작성한 모델이라 자기선호와 갈라낼 수 없습니다.

그래서 결론은 모른다입니다. 켠 근거는 여전히 폴백이지 품질이 아닙니다. 사람이 고른 표본이
생기면 그때 이 심판과 일치도를 재서 심판의 쓸모를 판정하고, 그때 처음으로 품질을 말할 수
있습니다. 그 표본이 안 생겨도 이 기능의 상태는 지금 그대로입니다.


6. 못 켠 자리를 넓히려다 규칙의 구멍을 찾았습니다

껐던 첫 자리(대사 원인 분류)를 다시 봤습니다. 켤 유형을 넓히면 되지 않나 싶었는데, 넓히려면
규칙이 못 내는 원인이어야 합니다. 규칙 분류기가 내는 값을 세어 보니 아홉 종 중 일곱이었습니다.

규칙이 내는 것 수수료 차이 · 부분취소 미반영 · 시간대 경계 · PG 파일 지연
망취소 타이밍 · 내부 기록 유실 · 위변조 의심
규칙이 못 내는 것 중복 기록 · 그 밖

“그 밖”은 모델 금지 목록이므로 남는 것은 중복 기록 하나였습니다.

그 자리도 모델의 자리가 아니었습니다

대사 엔진이 내는 결과 유형은 넷뿐입니다. 일치, 내부에만 있음, 외부에만 있음, 금액 불일치입니다.
중복 기록은 별도 유형으로 오지 않고 외부가 내부의 정수배인 금액 불일치로 나타납니다.
같은 줄이 두 번 실리면 외부 합계가 두 배가 되기 때문입니다.

배수인지는 나눗셈입니다. 산수로 결정되는 자리에 모델을 부르면, 이 프로젝트가 처음부터 반대해 온
그것이 됩니다.

넣기 전에 그 건들이 어디로 가고 있었는지 봤습니다

금액 불일치 규칙은 수수료도 취소도 아니면 마지막 갈래에서 위변조 의심을 붙입니다.

PG 파일에 같은 줄이 두 번 실린 평범한 운영 사고에 위변조 딱지가 붙고 있었습니다. 위변조
의심은 이 시스템에서 모델 금지 목록에 넣어 둘 만큼 무거운 값입니다. 그게 파일 중복에 붙으면,
확인하는 사람은 매번 그 의심부터 배제해야 합니다.

if (internal > 0 && diff < 0 && (-diff) % internal == 0) {
long times = (-diff) / internal + 1;
out.add(CauseSuggestion.likely(ResolveCause.DUPLICATE_RECORD, ...));
}

결정적이 아니라 유력으로 냈습니다. 같은 주문에 결제가 실제로 두 번 일어났는데 우리 쪽에
한 건만 남은 경우도 숫자가 똑같습니다. 그 둘은 산수로 안 갈리므로 사람이 봐야 합니다. 수수료와
부분취소를 결정적으로 낸 것과 다른 점입니다.

중복으로 설명되면 위변조 의심을 함께 내지 않습니다. 설명이 있는데 의심을 붙이면 배제 확인이
늘어납니다.

그래서 이 자리는 여전히 꺼져 있습니다

넓힐 후보가 하나 있었고 그것도 규칙의 몫이었습니다. 이 자리에서 모델이 규칙보다 나은 구간을
아직 못 찾았습니다.
찾으면 그때 켭니다.


7. 정답을 정한 사람이 저라는 것

장애 로그 코퍼스의 정답은 무엇을 주입했는지 제가 알고 붙인 라벨입니다. 그 라벨을 이번 작업에서
세 번 틀렸습니다.

첫째, 복제 지연을 커밋하지 않은 트랜잭션으로 만들었다가 지웠습니다.

둘째, Lock wait timeout exceededDB_TIMEOUT으로 붙였다가 RACE_CONDITION으로
고쳤습니다. SQLState가 데드락과 같은 40001이고, 같은 자원을 동시에 고쳐서 생기는 고장입니다.

셋째는 아직 고치지 않았습니다. 모델이 4회 내내 틀렸다고 채점된 그 한 건입니다.

2026-09-05T08:29:44 INFO WebhookService : 웹훅이 결제보다 먼저 도착 — 보류 retry=6
2026-09-05T08:29:49 INFO WebhookService : 웹훅이 결제보다 먼저 도착 — 보류 retry=7
...
2026-09-05T08:30:19 WARN WebhookPendingScheduler : 웹훅 보류 재시도 소진 webhookEventId=3

제 라벨은 QUEUE_BACKLOG입니다. 재시도가 쌓이다 소진되는 모습을 보고 붙였습니다. 모델은
매번 RACE_CONDITION이라고 답했습니다. 로그 첫 줄이 “웹훅이 결제보다 먼저 도착”이고, 이건
분류 정의상 “순서가 꼬였다”에 더 가깝습니다.

모델이 맞다면 실제 성적은 12건 중 12건이고 유일한 오답은 제 라벨입니다. 판정하지 않고
남겨 뒀습니다. 지금 고치면 모델에 유리하게 채점 기준을 고치는 것이 됩니다. 앞서 대사 원인
분류에서 기준을 표본을 보고 고쳤다가 91%에서 100%라는 숫자가 허수가 된 적이 있습니다.


8. 조건은 적어 뒀는데 그걸 잴 기구가 없었습니다

업계에서 자동 조치는 알림 → 승인 후 실행 → 자동 실행 세 칸으로 올라갑니다. 신뢰가
쌓이는 만큼 권한을 올립니다.

이 프로젝트는 첫 칸에 있으면서 세 번째 칸의 조건을 적어 뒀습니다. 증거가 결정적일 것,
금액이 임계 미만일 것, 그 유형의 실측 오류율이 선언한 한도 안일 것.

그런데 마지막 조건을 계산하는 코드가 없었습니다.

승인 여부를 재는 장치는 있었습니다. 확정 이벤트를 받아 제안과 대조해 맞음·틀림·기권을 셉니다.
그게 프로메테우스 카운터였습니다. 재시작하면 사라지고, 태그가 결과와 블라인드 여부뿐이라
원인 유형별로 갈리지 않았습니다. 조건이 “그 유형의”인데 유형이 없었습니다.

두 번째 칸이 세 번째 칸의 데이터를 만듭니다

사람이 승인한 기록이 유형별로 쌓여야 오류율이 생깁니다. 안 쌓으면 세 번째 칸은 영영 못 켭니다.
사다리를 건너뛸 수 없는 이유가 신중함이 아니라 구조였습니다.

확정마다 한 행을 남기고, 유형별 오류율을 내는 판정기를 만들었습니다.

표본 = 맞음 + 틀림 (기권은 맞고 틀림이 아니라 분모에서 뺀다)
오류율 = 틀림 / 표본 (최소 30건 · 허용 5%, 설정으로 열어 둠)

가린 표본만 셉니다. 제안을 보여준 뒤 고르게 하면 앵커링이 생겨 일치율이 올라갑니다.
섞어서 세면 모델이 사람을 설득한 결과를 모델의 정확도로 읽게 됩니다.

판정만 하고 켜지 않습니다. 실제로 올리는 것은 코드 변경이어야 합니다. 지표가 좋아졌다고
권한이 자동으로 올라가면 되돌릴 자리가 없습니다.


9. 안 붙인 것도 판단이라 적어 뒀습니다

업계에서 AI 가 가장 활발한 축 하나가 AML·제재 스크리닝입니다. 오탐이 그 워크플로에서 가장
많이 나오는 단계라 이름 매칭과 케이스 요약에 모델을 붙입니다.

이 시스템에는 없습니다. 그런데 없는 것이 빠뜨린 것과 구별되지 않았습니다.

붙일 수 없는 이유가 “필요 없어서”가 아닙니다. 스크리닝은 이름으로 대조하는데 대조할
신원이 없습니다. 판매자 엔티티가 없고, 회원은 이메일과 비밀번호 해시와 역할뿐입니다.

그리고 그건 우연이 아닙니다. 카드번호를 서버가 만지지 않아 PCI 범위 밖에 둔 것과 같은
판단의 결과
입니다. 그때 “통제를 안 만든 게 아니라 통제해야 할 데이터를 애초에 안 받는
쪽을 골랐다”고 적었는데, 신원 정보도 같습니다. 스크리닝을 붙이려면 먼저 신원을 받아야 하고,
카드에서 줄인 범위를 다른 쪽으로 늘리는 셈이 됩니다.

시연용 판매자 엔티티를 만들 수도 있었습니다. 안 했습니다. 정산 집계 키는 이미 한 번 조용히
틀려서 가맹점 지급이 통째로 빠졌던 자리
고, 쓰이지 않는 엔티티는 실제로 도는 기능인 척
보입니다.

“역량이 없어서 안 했다”와 “일관성을 지키려고 안 했다”는 다른 말인데, 적어 두지 않으면
구별되지 않습니다.


10. 안 한다고 적어 놓고 그 근거가 틀린 것을 찾았습니다

9절에 “AML 을 안 붙인다, 대조할 신원이 없어서”라고 적었습니다. 그 근거의 절반이
틀렸습니다.
구매자와 판매자를 뭉쳤습니다.

구매자 신원은 안 받는 것이 범위 축소가 맞습니다. 국내 카드결제에 구매자 신원이 필요 없습니다.
판매자는 다릅니다. 돈을 보내려면 누구에게 보내는지 필연적으로 압니다. 사업자등록번호와
계좌 없이 정산할 방법이 없습니다. 신원을 갖는 것이 확장이 아니라 정산의 전제입니다.

판매자 엔티티가 없던 진짜 이유는 개인정보 최소화가 아니라 이 프로젝트가 단일 판매자
구조
여서였습니다. 그건 원칙이 아니라 제품 사실이고, 정산이 판매자 없이 집계만 하는 것
자체가 현실적이지 않았습니다.

ADR 을 개정하고 만들었습니다.

표본이 너무 쉬우면 측정이 아무 말도 안 합니다

임계별 오탐과 미탐을 세려고 표본을 만들었습니다. 모든 임계에서 0/0 이 나왔습니다.

잘 나온 것이 아니라 실제 오탐이 나오는 자리를 안 넣은 것이었습니다. 같은 사람의 다른
로마자 표기와 흔한 이름의 이웃을 넣었습니다.

같음 67 Kim Cheol Su ↔ Kim Chul Soo (같은 사람, 다른 표기)
다름 67 김철수 ↔ 김철순 (다른 사람)

동점입니다. 임계를 어디에 둬도 안 갈립니다. 임계를 잘못 고른 것이 아니라 편집 거리로는
못 하는 일
입니다. 로마자 표기는 음성학적 매칭이, 동명이인은 생년월일과 국적 같은 추가
식별자가 필요합니다.

그래서 이 점수를 사람에게 보낼지 정하는 값으로만 씁니다. 한계를 모르고 임계만 조정하면
오탐과 미탐을 맞바꿀 뿐입니다.

덧붙임(2026-09-07). 이 결론도 틀렸습니다. 실제 제재 스크리닝은 이름과 생년월일과
국적을 함께 맞대고, 경보의 90% 이상이 오탐이라 그 2차 식별자로 후보를 좁히는 단계가 실무의
핵심입니다. 제가 만든 것은 이름까지였습니다. 생년월일과 국적을 넣고 다시 재니 위의 두 사례가
82점과 12점으로 갈렸습니다. 그리고 한글 이름이 안 걸리던 진짜 이유는
UN 명단에 한글이 한 건도 없다는 쪽이었습니다. 다음 편에 적었습니다.

차단할 지급이 있어야 해서 정산을 다시 짰습니다

스크리닝만 만들고 정산이 집계인 채로 두면 막을 대상이 없습니다. 이 키는 이미 한 번 조용히
틀려서 지급이 통째로 빠진 적이 있는 자리
라 조심해서 갔습니다.

기존 주문에 가짜 판매자를 채우지 않았습니다. seller_id 를 비워 두고 그것이 곧
플랫폼 직판을 뜻하게 했습니다. 판매자 행을 만들어 채우면 없던 판매자를 지어내는 것입니다.

멱등 검사를 판매자별로 옮겼습니다. 전에는 날짜만 보고 건너뛰었는데, 그러면 나중에
등록된 판매자의 정산이 영영 안 나갑니다.

그리고 여기서 기존 테스트가 버그를 잡았습니다. Collectors.groupingBynull 키를
거부한다는 것을 모르고 썼다가 정산 테스트가 전부 깨졌습니다. 돈 경로라 조용히 지나가면
안 되는 자리였는데, 오래전에 써 둔 테스트가 막았습니다.


11. 켤 때 잰 수치가 나중에도 사실인가

업계에서 LLM 을 운영에 올린 뒤 가장 어려운 부분으로 꼽는 것은 좋은 데모를 만드는 것이
아니라 실제 데이터가 들어온 뒤에도 품질을 유지하는 것입니다. 원인으로 지목되는 것이
“대부분의 평가가 감으로 이뤄진다”는 것이고요. 출력 몇 개 읽어 보고 배포한다는 뜻입니다.

그 구멍이 이 프로젝트에 그대로 있었습니다.

평가가 시점 측정이었습니다. 켤 때 재고 문서에 적고 끝입니다. 모델이 바뀌거나 프롬프트가
바뀌면 그 수치는 더 이상 사실이 아닌데, 나빠져도 아무도 모릅니다.

가드와 알림은 계속 도는데 평가만 시간축이 없었습니다. 그런데 문서에 수치가 적혀 있으니
측정된 것처럼 보입니다. 그 수치를 언제 잰 것인지는 아무도 안 물었습니다.

절대 수치는 여전히 안 겁니다

앞에서 “수치를 통과 조건으로 걸지 않는다, 재는 테스트다”라고 적었습니다. 그 판단은 지금도
맞습니다. 모델 정확도에 임계를 걸면 표본이 바뀔 때마다 테스트가 깨집니다.

대신 회귀를 봅니다. 지난번보다 나빠졌으면 표본 탓이 아니라 무언가 바뀐 것입니다.
5% 안쪽으로 떨어지는 것은 모델의 흔들림으로 넘깁니다.

틀린 수는 적을수록 좋으므로 부호를 뒤집어 저장합니다. “클수록 좋다” 하나로 비교하려고요.

두 가지를 의도적으로 정했습니다

기준선이 없으면 만들고 통과시킵니다. 첫 실행에서 깨뜨리면 아무도 안 돌립니다.

갱신은 사람이 합니다. 자동으로 갱신하면 매번 오늘 값이 기준이 되어 천천히 나빠지는
것을 영영 못 잡습니다.

수치만 남기면 “나빠졌다”까지만 압니다

처음에 수치만 저장했습니다. 그러면 회귀가 났을 때 무엇이 바뀌었는지는 매번 처음부터
조사해야 합니다.

"context": { "model": "ollama:qwen3:8b", "samples": "12", "corpusFingerprint": "20460f8c" }

지문이 필요한 이유는 파일 이름만 봐서는 안에 든 내용이 바뀐 것을 모르기 때문입니다.
표본을 고쳐 놓고 모델이 나빠졌다고 읽으면 엉뚱한 데를 팝니다.

기준선을 “14b 로 12건 맞혔다”로 바꿔 두고 8b 로 다시 쟀습니다.

ruleFirst.correct: 12.000 → 11.000
(바뀜) model: ollama:qwen3:14b → ollama:qwen3:8b

“나빠졌다”가 아니라 “모델이 바뀌어서 나빠졌다”가 나옵니다.


남는 것

  • 12건도 유형당 1건에서 3건입니다. CONFIG_DRIFT는 아직 표본이 0건입니다.
  • 모델 하나(qwen3:8b)로만 쟀습니다. 모델을 바꿔도 성립하는지는 모릅니다.
  • 근거 대조 가드가 그 오답을 막지 못했습니다. 가드를 켜든 끄든 점수가 같습니다. 인용은 진짜인데
    결론만 틀린 실패는 여전히 안 걸러집니다. 같은 벽을 세 번째 만났습니다.
  • 운영자용 요약이 템플릿보다 나은지는 모르는 것으로 뒀습니다. 사람이 고른 표본이 생기면
    심판과의 일치도부터 재고, 그때 처음으로 품질을 말합니다.

재는 장치를 만들어 놓고 그 장치가 한 번도 “켜라”고 하지 않으면, 장치가 작동하는지 확인할 길이
없습니다. 항상 “아니오”만 내는 판정기는 판정기가 없는 것과 구별되지 않습니다. 세 자리에 같은
잣대를 댔더니 둘은 켜졌고 하나는 규칙이 가져갔다는 것이, 이 잣대가 실제로 가른다는 증거입니다.


참고

프로필 사진
작성자 @범수

오늘의 노력이 내일의 전문성을 만든다고 믿습니다.

댓글

댓글 수정/삭제는 GitHub Discussions에서 가능합니다.