한 모델만 믿지 말고 AI 코드 교차 검증하는 법

AI에게 코드를 짜게 하고 같은 세션에서 "검토해 달라"고 부탁해 본 적 있는가. 방금 자기가 심은 버그를 그대로 지나친다. 사람으로 치면 내가 쓴 답을 내가 채점하는 셈인데 AI 코딩에서는 이 구도가 어느새 기본값처럼 굳어졌다. 편하니까 그렇다. 창 하나만 열어 두고 쓰고 검토까지 시키면 손이 덜 간다.
혼자 쓰고 혼자 승인한 결과물은 나를 여러 번 배신했다. 그럴듯한 설명이 붙었지만 실제로는 틀린 출력이 나왔다. 화면은 멀쩡한데 설정이 조용히 어긋나 있기도 했다. 제일 뼈아팠던 건 커밋하지 않은 작업 파일을 지워 버린 파괴적 명령이다. 셋 다 "확인했습니다"라는 말과 함께 통과됐다. 도장만 찍혔지 검토는 없었다.
그 뒤로 규칙을 하나 세웠다. 쓴 쪽은 승인하지 못한다. 검증은 작성자와 다른 벤더에게 맡겼다. 계획과 테스트와 구현을 만들 때마다 각각 다시 확인한다.
AI는 자기 코드의 실수를 못 본다
자기 코드를 자기가 검토하면 왜 약할까. 방금 그 답을 만든 확률 분포가 검토할 때도 그대로 남아서다. 틀린 가정으로 코드를 짰으면 같은 가정으로 검토하니 그 부분이 눈에 안 들어온다. 사람이 자기 글의 오타를 못 잡는 것과 비슷한데 모델은 이 편향이 더 노골적이었다. 자기 출력을 낙관하도록 다듬어져 있으니 "문제없어 보입니다"로 수렴한다.
내가 겪은 실패는 결이 저마다 달랐다. 그럴듯하지만 틀린 출력이 대표적이다. 설명이 매끄럽고 컴파일까지 되니 맞아 보이는데 경계 조건 하나에서 어긋난다. 조용히 잘못된 설정은 더 음흉했다. 파라미터나 환경 변수 하나가 틀렸는데 에러가 안 나서 며칠 뒤에야 드러났다. 그래도 제일 아팠던 건 파괴적 명령이다. 검증한다던 세션이 되돌리지 않은 작업 파일을 git 명령으로 날렸다. "깨끗한 상태에서 확인하겠다"는 선의가 아직 커밋 안 한 코드를 통째로 지웠다.
세 경우 모두 공통점이 있었다. 실수를 만든 주체와 검토하는 주체가 같았다. 그러니 실수가 실수로 안 보인다. 검토라는 이름만 붙었을 뿐, 실제로는 같은 편향을 한 번 더 통과시킨 셈이다. 특히 세 번째 파괴적 명령은 검토를 아무리 정성껏 시켜도 안 걸렸다. 명령을 내린 판단과 그 판단을 검토하는 판단이 한 몸이었으니까. 이걸 인정하고 나서야 검증을 손보기 시작했다.
쓴 쪽은 승인하지 못한다
고친 방법 자체는 단순하다. 코드를 쓴 주체는 그 코드를 승인할 권한이 없다. 승인은 다른 주체가 한다. 이 한 줄을 규칙으로 못 박으니 자기 채점이 구조적으로 불가능해졌다.
말은 쉬운데 지키기는 은근히 까다롭다. 검증을 시키면 결국 자기 결과를 감싸는 쪽으로 기운다. 그래서 작성과 검증을 같은 대화, 같은 세션에 두지 않았다. 검증하는 쪽은 작성 맥락을 물려받지 않고 결과물만 새로 받는다. 무엇을 왜 그렇게 짰는지 모르는 상태에서 산출물만 보면 작성자의 자기 정당화에 휩쓸릴 여지가 줄어든다. 아는 게 적을수록 더 냉정해지는 역설이다.
핵심은 권한 분리다. 작성자는 "이렇게 짰다"까지만 하고 통과냐 불통이냐 판정은 넘긴다. 판정하는 쪽이 반려하면 다시 작성자에게 돌아가 고친다. 이 왕복이 귀찮아 보여도 내 경우에는 자기가 자기를 통과시키는 것보다 덜 틀렸다. 반려가 한 번 붙을 때마다 나중에 터질 사고 하나를 미리 막은 셈이었다.
검증하는 쪽에 넘기는 자료도 매번 새로 챙겼다. 새 세션은 앞의 대화를 하나도 기억하지 못한다. 목표와 완료 기준, 손대면 안 되는 파일, 이미 시도했다가 막힌 길까지 한 문서에 적어 통째로 넘겼다. 맥락을 덜 주면 검증자가 엉뚱한 걸 붙잡고 너무 많이 주면 작성자 논리에 물든다. 그 사이 어디쯤을 매번 손으로 맞췄다.
벤더를 나눠야 맹점이 겹치지 않는다
작성자와 검증자를 나눴는데 둘 다 같은 모델이면 반쪽짜리다. 같은 계열은 맹점도 비슷하다. 내가 겪은 범위에서 한 모델이 놓치는 패턴은 그 모델의 다른 인스턴스도 대체로 놓쳤다. 이름만 다른 검증자를 붙여 놓고 안심하면 안 되는 이유다.
서로 다른 회사의 모델은 학습 데이터도, 튜닝 방향도, 실수 습관도 다르다. 한쪽이 정규식 경계를 잘 흘리면 다른 쪽은 그걸 곧잘 잡아냈다. 반대로 그쪽이 약한 부분은 이쪽이 메웠다. 완벽한 검증자는 어디에도 없지만 서로 다른 실수 습관을 겹쳐 놓으면 빈틈이 체감상 줄었다.
실제로는 Claude 계열, GPT 계열, Gemini 계열처럼 만든 회사가 다른 셋을 섞어 썼다. 한 벤더가 계획을 짜면 나머지 둘이 그 계획을 뜯어본다. 내가 겪은 범위에서 세 곳이 동시에 같은 실수를 하는 경우는 드물었다. 한 곳이라도 "여기 이상하다"고 걸면 그 지점은 반드시 다시 봤다. 만장일치를 기다리지 않고 한 표의 반대에 무게를 실었다. 검증은 다수결이 아니라 반례 찾기에 가깝다.
셋을 다 돌리면 느리지 않냐고 물을 수 있다. 검증끼리는 서로 독립적이라 동시에 돌렸다. 세 검증자가 각자 다른 창에서 같은 산출물을 보고 결과만 한자리에 모았다. 체감 대기시간은 크게 늘지 않았다. 늘어난 건 창 개수지 대기 시간이 아니었다.
계획·테스트·구현을 단계마다 검증한다
검증을 맨 마지막에 한 번만 하면 늦다. 계획이 틀렸으면 그 위에 쌓은 테스트도 구현도 같이 틀어진다. 다 만든 뒤에 되돌리려면 지운 코드가 아까워서 자꾸 봐주게 된다. 그래서 단계를 셋으로 끊고 각 단계가 끝날 때마다 검증을 붙였다.
먼저 계획을 세우고 다른 벤더 둘이 그 계획을 검증한다. 통과하면 그 계획으로 테스트를 짜고 또 다른 벤더 둘이 테스트를 뜯어본다. 테스트가 통과하면 구현을 하고 다시 검증한다. 앞 단계가 초록으로 바뀌기 전에는 다음 단계로 넘어가지 않는다.
이렇게 끊으니 틀린 게 멀리 못 갔다. 계획 단계에서 잡히는 문제는 코드 한 줄 짜기 전에 걸러진다. 테스트 단계에서 걸리면 구현이 오염되기 전에 멈춘다. 손이 더 가지만 마지막에 몰아서 디버깅하는 것보다 총량은 줄었다. 여기에 결정적 검사는 주관 판단과 분리해 자동으로 돌렸다. 테스트가 초록인지, 린트와 타입체크와 빌드가 통과하는지는 답이 정해져 있다. 답이 정해진 것을 굳이 모델에게 의견으로 물어볼 이유가 없다.
근거가 비면 통과가 아니라 실패다
검증자에게 판단할 근거를 안 주면 어떻게 될까. 관대한 기본값이면 "별문제 없어 보임"으로 통과시킨다. 이게 가장 위험했다. 아무 근거 없이 통과된 것과 꼼꼼히 확인해 통과된 것이 겉으로 구분이 안 되니까. 둘 다 초록불인데 속은 완전히 다르다.
그래서 기본값을 반대로 뒀다. 스펙이나 판단 기준이 비어 있으면 통과가 아니라 실패로 처리한다. 판단할 재료가 없다는 건 판단하지 못했다는 뜻이다. 판단하지 못한 것은 통과일 수 없다. 검증자에게는 늘 무엇을 만족해야 하는지, 어떤 산출물을 보는지, 테스트는 어디 있는지 전부 채워서 넘겼다. 하나라도 비면 그 검증은 무효로 봤다.
검증자의 태도도 바꿨다. 잘 됐는지 확인하는 대신 어디가 틀렸는지부터 찾게 했다. 반박을 먼저 시도하고 못 깨야 그제야 통과다. 칭찬하는 검토는 마음은 편한데 쓸모가 없었다. "이 부분이 이 입력에서 깨진다"처럼 구체적으로 물고 늘어지는 검토라야 진짜 버그가 나왔다. 통과의 문턱을 높이는 게 아니라 통과의 방향 자체를 반대로 돌린 셈이다.
검증 밖에서도 신뢰하지 않는 습관. 교차 검증만으로는 다 못 잡는 구멍이 두 개 더 있었다. 하나는 모델이 스스로 내린 판단을 실행 단계에서도 믿어 버리는 문제, 다른 하나는 모델이 자기가 뭘 쓰고 있다고 말하는 것 자체를 믿어 버리는 문제다. 둘 다 "검증했으니 안전하다"는 믿음과는 결이 다르다. 검증을 아무리 촘촘히 해도 실행 순간과 설정 값 자체를 의심하지 않으면 같은 자리에서 다시 뚫린다.
파괴적 명령은 프롬프트가 아니라 훅으로 막는다
"커밋 안 한 파일은 지우지 마"라고 프롬프트에 적어 두면 막힐까. 안 막힌다. 모델은 그 지시를 잊거나 지금은 예외라고 스스로 판단하거나 아예 다르게 해석한다. 앞에서 작업 파일을 날린 사고도 프롬프트엔 조심하라고 분명히 적혀 있었다. 부탁은 지켜질 때만 지켜진다.
진짜로 막으려면 실행 단계에서 막아야 한다. 위험한 명령이 실제로 실행되기 직전에 훅이 가로채 차단하는 방식이다. 되돌릴 수 없는 삭제, 강제 리셋, 히스토리를 덮어쓰는 명령은 프롬프트의 부탁이 아니라 자동 관문에서 걸리게 했다. 모델이 하겠다고 결정해도 관문이 안 된다고 하면 실행이 안 된다. 결정권과 실행권을 떼어 놓았다.
관문을 의사코드로 옮기면 뼈대는 이렇다. 실제 구현이 아니라 설명용이다.
# 실행 직전 훅 (의사코드)
on before_run(command):
if command matches 파괴적_패턴(리셋·강제삭제·히스토리덮어쓰기):
deny("되돌릴 수 없음 — 사람 확인 필요")
log(command, 사유)
차이는 신뢰를 어디에 두느냐다. 프롬프트 가드레일은 모델의 선의를 믿는다. 훅 가드레일은 아무도 안 믿는다. 되돌릴 수 없는 일일수록 후자여야 했다. 부탁은 안 지켜져도 그만이지만 관문은 매번 같은 자리에서 같은 방식으로 막는다. 사람이 실수로 위험한 명령을 흘려도 같은 관문이 잡아 주니 덤으로 안전했다.
관문에 걸린 명령은 조용히 삼키지 않고 왜 막혔는지 흔적을 남겼다. 무엇이 왜 차단됐는지 로그로 남아야 다음에 같은 실수를 반복하지 않는다. 차단이 잦은 명령은 아예 작업 방식을 바꿔 관문에 닿지 않게 다듬었다. 막는 데서 끝내지 않고 막힌 기록을 다시 규칙으로 되먹였다.
설정은 실측하고 비용은 티어로 나눈다
모델 설정은 표시된 이름을 안 믿는 편이 안전했다. 한번은 특정 모델을 쓰고 있다고 믿었는데 로그를 열어 보니 그 이름이 뒤에서 더 가벼운 모델로 조용히 폴백되고 있었다. 화면의 표시명과 실제 호출된 모델이 달랐다. 자기 보고나 라벨을 그대로 받아들였으면 끝내 못 잡았을 문제다. 수치를 지어내지 않아도 실제 로그를 한 번 확인하는 것만으로 드러났다.
그 뒤로는 무엇을 쓴다고 표시되는지가 아니라 실제로 무엇이 호출됐는지를 로그로 확인했다. 표시명과 자기보고와 기본값은 참고만 하고 판정은 실측으로 내린다. 오래된 모델 이름이 문서에만 살아 있고 실제로는 다른 모델이 응답하는 경우가 내가 겪은 범위에서는 생각보다 흔했다.
비용은 작업 난도에 맞춰 나눴다. 포맷 정리나 단순 조회 같은 가벼운 일은 싼 모델에 맡긴다. 다중 파일 변경이나 애매한 로직은 중간 모델로, 아키텍처 설계나 뿌리 깊은 원인 분석처럼 어려운 것만 제일 비싼 모델로 올린다. 처음부터 최상위 모델을 기본값으로 두지 않는다. 낮은 티어에서 시작해 막히거나 미덥지 않을 때만 한 단계 올렸다. 한도를 넘기면 같은 계열의 다른 모델로 폴백했다. 대신 폴백했다는 표시를 결과에 남겨 어느 조건에서 나온 답인지 알 수 있게 했다.
지금까지의 판단을 한 장으로 줄이면 이렇다.
| 점검 항목 | 안전한 기본값 |
|---|---|
| 누가 승인하나 | 작성한 주체가 아닌 다른 주체 |
| 검증 모델 | 작성자와 다른 벤더, 최소 둘 |
| 검증 시점 | 계획·테스트·구현 단계마다 |
| 근거가 없을 때 | 통과가 아니라 실패 |
| 검증 태도 | 확인이 아니라 반박부터 |
| 파괴적 명령 | 프롬프트 부탁이 아니라 실행 훅에서 차단 |
| 모델 설정 | 표시명이 아니라 실제 호출 로그 |
| 비용 | 낮은 티어부터, 어려운 것만 상위 모델 |
이 표를 프로젝트 규칙 파일 맨 앞에 붙여 두면 급할 때도 자기 채점으로 미끄러지지 않는다. 쓴 쪽은 판정하지 않는다. 판정은 다른 벤더가 한다. 근거가 없으면 통과가 아니다. 이 세 줄만 지켜도 앞서 겪은 사고 대부분은 안 났다.
비슷한 실전 기록은 LLM 활용에 모아 둔다. 여기서 한 걸음 더 남은 숙제는 검증자끼리 의견이 갈릴 때의 판정 규칙이다. 교차 검증은 맹점을 줄여 주지만 두 벤더가 서로 다르게 말할 때 누구를 따를지 미리 못 박아 두지 않으면 결국 사람이 매번 끼어들게 된다. 지금은 임시로 이렇게 둔다. 두 검증자가 갈리면 반대표(반박)를 기본으로 채택하고 그 반박이 재현되지 않으면 무효로 본다. 다음 실험은 이 임시 규칙을 코드로 굳히는 일이다.
아직 댓글이 없습니다.