AI를 잘 맞히게 만드는 게 목표가 아니었다
Article
2편 끝에서 룰이 무너졌다. 구 시스템 컬럼 CUST_CD · CUST_NM · COL1 · FLAG1 · TEMP_YN · REG_DT 여섯 개가 전부 신뢰도 0.000이었다. 동의어 사전에 없는 이름이니 당연하다.
이제 AI를 켤 차례다. 모델은 Claude Opus 4.8을 썼다.
그런데 이 단계를 시작하면서 세운 목표가 좀 이상했다. 잘 맞히게 만드는 게 아니었다.
먼저 결과부터
구 시스템 품목 테이블을 그대로 물렸다. 룰과 AI를 같이 돌려서 나란히 찍었다.

| 컬럼 | 이름 매칭(룰) | AI (Opus 4.8) | |
|---|---|---|---|
ITEM_CD | 모름 0% | 품목코드 95% | 룰이 못 맞힌 걸 읽어냈다 |
ITEM_NM | 모름 0% | 품목명 96% | |
COL7 | 모름 0% | 규격 88% | 헤더가 아무것도 안 알려주는 컬럼 |
UNIT_CD | 단위 90% | 단위 93% | 둘 다 맞혔다 |
FLAG1 | 모름 0% | 모르겠음 5% | 사람에게 넘어간다 |
REG_DT | 모름 0% | 모르겠음 5% | 사람에게 넘어간다 |
COL7이 흥미롭다. 컬럼명이 COL7이다. 이름은 아무것도 알려주지 않는다. AI가 붙인 근거는 이랬다.
"
48mm x 100m,1100 x 1100처럼 크기나 치수를 나타내는 값이라 규격으로 보입니다. 다만 컬럼 이름이 뜻을 알 수 없어 확인이 필요합니다."
헤더가 아니라 샘플 값을 근거로 들었다. 룰이 절대 할 수 없는 종류의 판단이다. 동의어 사전은 이름만 본다. 그리고 맞혔으면서도 **"컬럼 이름이 뜻을 알 수 없어 확인이 필요하다"**고 덧붙였다. 확신도를 95·96이 아니라 88로 낮춘 이유가 그 문장에 적혀 있다.
그런데 여기서 중요한 건 못 맞힌 두 개다
FLAG1과 REG_DT가 5%다. 임계값 85% 미만이라 미리 채워지지 않고 사람에게 넘어간다. 화면 오른쪽 끝의 두 칸만 "선택하세요"로 비어 있고, 아래에는 *"아직 고르지 않은 컬럼이 2개 있습니다"*가 떠 있다.
이게 왜 중요한지는 반대 상황을 상상하면 바로 나온다.
그래서 프롬프트에 제일 공들인 문장이 이거였다.
"확신이 없으면 confidence를 낮게 매겨라. 억지로 필드를 고르지 마라."
그리고 화면의 채점판에는 이렇게 적어 뒀다. "AI도 모른 것이 0이면 오히려 의심하세요."
승인 파이프라인은 한 줄도 안 바뀌었다
2편에서 룰에 신뢰도를 매기면서 이렇게 적어 뒀었다.
"AI가 들어와도 승인 게이트는 한 줄도 안 바뀐다. 제안자만 룰에서 AI로 갈린다."
지켜졌다. 실제로 바뀐 건 데이터 한 글자다. suggestion_source 컬럼 값이 'RULE'에서 'AI'로 바뀌었다. 승인 로직도, 적재도, 롤백도, 화면 구조도 안 건드렸다.
적재를 담당하는 ImportWriter는 지금도 제안자가 누구인지 모른다. 확정된 제안을 저장할 뿐이다.
- 제안룰 또는 AI. 여기만 갈린다
- 저장
suggested_field·confidence·reason— 같은 컬럼 - 승인사람이
human_decision을 채운다 — 같은 화면 - 적재
ImportWriter는 제안자를 모른다 — 같은 코드
tool use가 아니라 Structured Outputs
AI에게 JSON을 받는 방법이 두 가지다. tool use로 함수 호출 파라미터를 받거나, 응답 자체를 스키마로 강제하거나.
tool use는 도구 호출의 파라미터를 검증하는 기능이다. 우리가 원하는 건 도구 호출이 아니라 응답 자체가 정해진 모양이어야 한다는 것이다. 그래서 Structured Outputs를 골랐다. Java SDK에 결과 클래스를 넘기면 스키마를 자동으로 도출한다.
얻은 게 하나 있다. 자유 텍스트를 파싱하는 코드가 한 줄도 없다.
"AI 응답에서 JSON 부분만 잘라내기" 같은 코드는 처음엔 잘 돌다가 어느 날 모델이 앞에 설명 한 줄을 붙이면서 조용히 깨진다. 그 코드가 아예 없으면 그렇게 깨질 일이 없다.
| 결정 | 근거 |
|---|---|
| 파일당 1회 호출 (컬럼당 아님) | 싸고 빠른 것도 있지만 진짜 이유는 AI 가 컬럼 간 관계를 본다는 것. ITEM_CD 옆에 ITEM_NM 이 있으면 앞이 코드, 뒤가 이름이라는 걸 알아낸다. 컬럼을 따로 물으면 그 맥락이 사라진다 |
| AI 호출은 트랜잭션 밖 | 외부 HTTP 를 트랜잭션 안에서 하면 그게 커넥션 풀이 마르는 경로다. 1편에서 파일 IO 를 두고 똑같은 결정을 했다 |
confidence 는 받는 즉시 BigDecimal | SDK 는 double 로 준다. 0.85 경계 비교를 double 로 하면 흔들린다. 범위 밖 값(모델이 1.5 를 뱉는 일이 있다)은 clamp 하고 DB CHECK 가 한 번 더 막는다 |
| 없는 필드를 제안하면 버린다 | AI 가 필드명을 지어내는 일이 실제로 있다. 그대로 저장하면 승인 화면 드롭다운에 없는 값이 뜨고, 사람은 그게 왜 거기 있는지 영영 모른다 |
| 재시도 0회 | 실패하면 기다리지 말고 룰로 간다 |
마지막 항목이 다음 이야기로 이어진다.
AI가 죽어도 이관은 굴러간다
통합 테스트에 이런 게 있다.
- com.anthropic.errors.UnauthorizedException: 401 invalid x-api-key
- 그런데 제안이 나왔다. 룰로 폴백했다.
- 이관 진행 · 승인 화면 정상 · 적재 정상
키가 없거나, 호출이 실패하거나, 타임아웃이면 자동으로 룰로 폴백한다. 재시도는 0회다.
그리고 통합 테스트는 AI를 끈 채로 돈다. 테스트 설정에서 키를 비워 두기 때문이다. 그래서 "AI 없이도 이관이 굴러간다"는 성질이 매 테스트 실행마다 검증된다. 문서에 적힌 약속이 아니라 실행되는 검사다.
AI는 있으면 좋은 것이지 없으면 안 되는 것이 아니다. 이관 도구가 외부 API 하나에 인질로 잡히면 그건 도구가 아니다.
원본 데이터가 외부로 나간다
AI가 COL7을 맞히려면 샘플 값을 봐야 한다. 컬럼명만으로는 절대 못 맞힌다. 즉 실제 데이터 일부가 외부 서버로 전송된다.
실무에 이런 기능을 넣을 때 가장 먼저 막히는 지점이 여기다. 회사 데이터가 외부로 나가는 걸 누가 승인해 주느냐의 문제라서, 도구를 아무리 잘 만들어도 이 답이 없으면 못 쓴다. 그래서 이 결정만은 화면에 그대로 노출하기로 했다.
보내는 것
컬럼당 3행
- 컬럼명
- 컬럼당 샘플 3행
- 3,000 행 중 3행이다
안 보내는 것
나머지 전부
- 나머지 2,997 행
- 금액 · 거래처 전체 목록
- 파일 자체
그리고 승인 화면에 *"AI에게 보낸 것: 컬럼명 + 컬럼당 샘플 3개"*를 표시하고, 그 3개 값을 열에 그대로 찍었다. 무엇이 나갔는지 사람이 보고 판단할 수 있어야 한다.
이 결정은 혼자 내리지 않았다. 선택지를 정리해서 합의하고 정했다. 데이터 경계는 개발자가 조용히 정할 문제가 아니다.
AI를 채점할 정답지를 미리 만들어 뒀다
컬럼 두 개를 추가했다. rule_suggested_field와 rule_confidence다. AI가 제안할 때 룰도 같이 돌려서 결과를 나란히 저장한다.
그래서 화면 한 줄에 셋이 같이 뜬다.
이름 매칭: 모름 0% | AI: 규격 88% | 내 선택: 규격
위 화면의 COL7 줄이 정확히 그 모양이다. 그리고 화면 아래 요약이 **"이름만으론 몰랐던 컬럼 5 · 그중 AI가 알아낸 것 3 · AI도 모르는 것 2"**를 센다. 이 숫자가 곧 채점표다.
파이프라인에는 아무 쓸모가 없는 컬럼이다. 채점용이다. "AI가 룰보다 나은가"를 말이 아니라 숫자로 답하기 위한 것이고, 이게 가능한 건 2편에서 룰을 먼저 만들어 뒀기 때문이다. AI부터 만들었으면 비교할 대상이 없어서 "AI가 잘한다"는 주장이 근거 없는 말로 남았을 것이다.
다음 기능에서, 코드를 쓰기 전에 멈췄다
그다음은 마스터데이터 병합이었다. (주)한빛 · 한빛상사 · 한빛 상사 · 한빛(주) 를 한 거래처로 합치는 기능이다. AI가 제안하고 사람이 승인하는 두 번째 사례로 만들 생각이었다.
그런데 데이터를 보다가 멈췄다. 넷 다 사업자번호가 같았다.
사업자번호가 같으면 규칙이 100% 잡는다. AI를 붙일 이유가 없다. 이대로 만들면 "AI가 병합을 제안한다"고 말은 하는데 실제로는 규칙이 다 하고 AI는 옆에서 구경하는 물건이 된다.
문제가 없는데 해결책을 만들고 있었다.
실무에서 마스터 병합이 어려운 진짜 이유는 "이름이 조금 다르다"가 아니다. 이런 거다.
엑셀에는
대성유통 / 234-56-78901, 구 시스템에는대성 / (사업자번호 없음). 같은 회사인데 코드도 이름도 다르고, 사업자번호는 한쪽에만 있다.
그래서 구 시스템 시드에 세 건을 심었다. 2편에서 엑셀을 일부러 더럽힌 것과 같은 이유다.
| 구 시스템 | 사업자번호 | 무엇을 시험하는가 |
|---|---|---|
대성 | 없음 | 규칙이 손을 든다. AI 가 이름으로 잡아야 한다 |
(주)성원 | 300-10-1000 | 엑셀은 300-10-10000 — 자릿수 하나가 빠졌다 |
대성물류 | 777-11-22222 | 함정. 이름은 비슷한데 번호가 명백히 다르다. 묶으면 사고다 |
결과 — AI가 함정을 밟지 않았다
- [규칙] 100% 한빛 4건 "사업자번호가 모두 같습니다"
- [AI] 88% 성원물산 ↔ (주)성원
- "사업자번호가 마지막 한 자리만 차이 나고 이름도 성원으로 같습니다"
- [AI] 35% 대성유통 / 대성 / 대성물류
- "대성은 두 곳 모두와 이름이 겹쳐 어느 쪽과 같은 회사인지 확실하지 않습니다.
- 담당자 확인이 필요합니다."
규칙이 잡을 수 있는 건 규칙이 잡고(100%), 규칙이 손드는 자리를 AI가 받았다(88%). 그리고 함정 앞에서는 35%로 애매하다고 말했다.
앞에서 FLAG1을 5%로 넘긴 것과 똑같은 장면이다. 두 번이면 패턴이다.
그런데 처음엔 AI가 아무 말도 안 했다
첫 스캔에서 AI는 대성 무리를 아예 제안하지 않았다. 확신이 없으니 묶지 않은 것이다.
규칙대로 행동한 건데 결과가 나빴다. 화면에 아무것도 안 뜨니, 사람은 그런 거래처가 있다는 것조차 모른다. 조용한 누락이다. 2편에서 INNER JOIN이 고아 전표를 증발시킨 것과 같은 모양이다.
프롬프트를 이렇게 고쳤다.
"애매하면 낮은 confidence로 올려라. 침묵하지 마라. 어차피 사람이 승인하기 전에는 아무것도 병합되지 않는다. 낮은 확신으로 올리는 것은 '합치자'가 아니라 '이거 좀 봐 주세요'다."
그러자 35%로 올라왔다.
병합은 삭제가 아니다
병합 자체의 설계는 되돌릴 수 있느냐가 전부였다.
진 쪽을 지우지 않는다. merged_into_id로 대표를 가리키게 하고 비활성화한다. 원본을 파괴하지 않는다는 원칙이기도 하지만 실질적인 이유가 있다. 이관 기록(import_row.entity_id)이 그 거래처를 가리키고 있어서, 지우면 원본 드릴다운과 이관 롤백이 같이 깨진다.
그리고 옮긴 참조를 한 줄씩 기록한다. 병합하면 전표 수백 건의 거래처 참조가 바뀌는데, 어느 전표가 원래 누구를 가리켰는지 안 남기면 되돌릴 수 없다.
- 병합 전: #1=121 #4=109 #5=135 #6=122
- 병합 후: #1=487 #4=0 #5=0 #6=0 전표 366건이 대표로 이동
- 되돌린 후: #1=121 #4=109 #5=135 #6=122 정확히 원상 복구
"전부 대표에서 진 쪽으로 되돌린다"가 아니다. 어느 전표가 원래 누구 것이었는지까지 복구된다.
되돌릴 수 있게 만드는 방법이 기능마다 다르다는 게 재미있다. 이관은 import_row로, 재고는 append-only 원장으로, 병합은 참조 이동 기록으로 되돌린다. 셋 다 "무엇을 했는지 남긴다"는 같은 원칙의 다른 구현이다.
트랜잭션 경계가 정반대인 두 기능
이 프로젝트에는 전표를 여러 건 한꺼번에 확정하는 기능도 있다. 병합과 나란히 놓으면 트랜잭션 경계가 정반대다.
전표 일괄 확정
전표 1건 = 트랜잭션 1개
- 300건 중 3건이 재고 부족으로 실패
- 297건은 확정된다
- 실패 3건은 리포트에 남는다
- 부분 성공이 의미 있다
마스터 병합
전체가 한 트랜잭션
- 전표 300건은 새 거래처를,
- 187건은 옛 거래처를 가리키는 상태
- 이건 부분 성공이 아니라 손상이다
- 그 상태로 매출을 집계하면 조용히 틀린다
여기까지의 AI
두 기능을 붙이고 나서 AI가 하는 일이 뭔지 정리하면 이렇다. 컬럼 이름을 읽고, 후보를 묶어 올린다. 그게 전부다. 어느 쪽도 데이터를 직접 바꾸지 않는다. AI 출력은 제안 테이블에만 저장되고, 업무 테이블에 쓸 권한이 없다.
여기까지는 사실 안전한 편이다. AI가 틀려도 사람이 안 누르면 아무 일도 안 일어난다.
다음 편은 그 성질이 깨지는 자리다. AI에게 SQL을 쓰게 하고, 영수증 이미지를 눈으로 읽게 한다. 자연어로 "지난달 서울창고 생수 재고 알려줘"라고 물으면 AI가 조회문을 만들고, 그게 진짜 DB에서 실행된다. 제안이 아니라 실행이다.
그래서 방어를 네 겹으로 세웠고, "모든 거래처를 삭제해줘"라고 시켜 보는 것부터 시작했다.