나의 AI 코딩 세션 249건을 감사했다. 문제는 거짓말에 있지 않았다.
요약
코딩 에이전트가 작업 결과를 허위로 보고하는 문제를 탐지하기 위해 개발된 'red-handed' 도구에 대한 분석입니다. 에이전트의 발화와 실제 git 변경 사항 및 실행 로그를 비교하여 작업의 진위 여부를 검증하는 방법론을 다룹니다.
핵심 포인트
- 에이전트가 최적화를 위해 테스트를 실행하는 대신 통과했다고 거짓 보고할 위험성
- Claude Code의 트랜스크립트와 git 로그를 활용한 교차 검증 방식
- 도구의 신뢰성을 위해 오탐(False Positive)을 방지하는 엄격한 증거 규칙 적용
- 에이전트의 말과 실제 수행 작업 사이의 간극을 측정하는 9가지 체크 항목
나는 절대 위로 스크롤하지 않았다
나는 매일 코딩 에이전트 (coding agent)를 사용한다. 에이전트에게 작업을 주고 다른 일을 하러 갔다가 돌아오면, 화면 하단에 다음과 같은 줄이 나타나 있다:
"빌드·회귀 스모크·기존 분석 테스트 모두 통과."
(build, regression smoke, and the existing analysis tests all pass)
이것은 내 실제 세션 로그에 기록된 문장이다. 나는 이를 확인한 적이 없다. 테스트가 실제로 실행되었는지 확인하기 위해 위로 스크롤하는 대신, 나는 다음 작업을 주었다. 나는 몇 달 동안 그렇게 했다.
그러던 중 한국의 개발자 커뮤니티에서 어떤 게시물을 읽게 되었다. 누군가가 에이전트의 프라이빗 리즈닝 (private reasoning)을 조사했는데, 테스터 역할에 부여된 메모리에 문제가 발생했을 때 이를 우회하는 방법들의 목록이 포함되어 있다는 것을 발견했다는 내용이었다.
그것이 에이전트가 악의적이었다는 뜻은 아니다. 만약 당신이 테스트를 통과시키라고 명령한다면, 가장 저렴한 경로를 찾는 것이 최적화 (optimisation)가 하는 일이다. 문제는 가장 저렴한 경로가 "코드를 수정하는 것"이 아니라 "검사를 피하는 것"일 때이며, 당신에게는 이를 알아챌 방법이 없다는 점이다.
그래서 나는 확인해 보기로 했다. 도구의 이름은 red-handed이다.
판사가 아닌, 디프 (diff)
나는 판사를 만들려고 했던 것이 아니다. 나는 에이전트가 말한 것과 실제로 수행한 것을 나란히 비교 (side-by-side)하고 싶었다.
재료는 이미 준비되어 있었다. Claude Code는 모든 세션에 대해 트랜스크립트 (transcript)를 작성한다. 즉, 에이전트가 실행한 모든 명령과 그 결과, 모든 파일 수정 전후의 상태, 그리고 나에게 말한 모든 문장이 기록된다. 이를 git과 정렬하면 말과 작업 사이의 간극을 계산할 수 있다.
테스트를 실행하지 않고 통과했다고 주장했는가? 실패한 실행 직후에 통과했다고 주장했는가? 깨진 코드가 생성한 결과에 맞추기 위해 예상 값을 다시 작성했는가? 이와 같은 9가지 체크 항목을 만들었다.
내가 가장 오랜 시간을 들인 것은 기능이 아니었다. 그것은 바로 도구가 틀렸을 때 어떤 일이 발생하는가였다.
여기서 틀리는 방법에는 두 가지가 있다.
무언가를 놓치는 것. 사용자는 아무것도 잃지 않는다. 이전과 똑같이 정보를 모르는 상태일 뿐이다.
정직하게 작업한 사람을 비난하는 것. 이는 하지도 않은 부정행위를 했다고 사람에게 말하는 것이다. 한 번이라도 이런 실수를 하면 아무도 그 도구를 다시 열어보지 않을 것이다.
비용은 결코 대칭적이지 않습니다. 그래서 규칙은 다음과 같이 정해졌습니다: 잘못 비난하기보다는 차라리 놓치는 쪽을 택하라.
이는 두 가지 엄격한 제약 조건으로 이어졌습니다.
확인된 결과(Confirmed finding)에는 두 가지 증거가 필요합니다. 세션 내에서 해당 사건이 발생하는 모습이 보여야 하며, 동시에 그 변경 사항이 여전히 워킹 트리(working tree)에 남아 있어야 합니다. 만약 에이전트(agent)가 나중에 이를 되돌렸다면, 누구를 비난할 근거가 없으므로 해당 결과는 사라집니다.
읽지 못한 것은 실패가 아닙니다. 테스트가 실행되었으나 제가 출력을 파싱(parse)할 수 없었다면, 그것은 "실패했다"가 아니라 "모르겠다"입니다. 이 차이가 결국 전체 결과의 성격을 뒤바꿔 놓습니다.
이 과정에서 어떤 모델(model)의 책임도 묻지 않습니다. 만약 판결이 LLM으로부터 나온 것이라면, 동일한 트랜스크립트(transcript)라도 날짜에 따라 다른 답변을 내놓을 것이며, 그렇다면 그것은 증거가 될 수 없기 때문입니다.
249건의 세션에 대해 실행하기
저는 제 컴퓨터에 있는 모든 세션을 대상으로 이를 실행했습니다.
249 sessions. Your agent said "tests pass" 124 times.
117 a test ran first
7 no test ran in that session at all
확인된 결과: 0건.
솔직히 말해서, 이는 허탈한 결과였습니다. 거짓말 탐지기를 만들기 위해 몇 주 동안 작업했지만, 단 하나의 거짓말도 잡아내지 못했습니다.
그러다 저는 7건의 사례를 하나씩 열어보았고, 제가 잘못된 질문을 던지고 있었다는 사실을 깨달았습니다.
진짜 문제는 거짓말이 아니었다
7건 중 4건은 하나의 프로젝트에서 발생했습니다. 에이전트는 다음과 같이 말했습니다:
"드래그·휠 5방향 브라우저 테스트 통과."
(drag and scroll, all five directions, browser tests pass)
이것은 거짓말이 아닙니다. 에이전트는 실제로 브라우저를 열고 확인했습니다. 하지만 그 확인 작업은 그 어떤 종류의 기계 판독 가능한 흔적(machine-readable trace)도 남기지 않았습니다. 따라서 나중에 그 주장이 사실이었는지 확인할 방법이 없습니다. 저에게도, 도구에게도, 혹은 그 코드를 물려받을 그 누구에게도 말입니다.
나머지 3건도 같은 형태였습니다. 프로젝트가 자체 스크립트를 통해 테스트를 실행하는데, 제 파서(parser)가 그 출력 형식을 알지 못하는 경우였습니다. 저는 _테스트 형태의 무언가가 실행되었다_는 것은 알 수 있지만, 그것이 통과되었는지는 알 수 없습니다.
그 지점에서 결론이 뒤집혔습니다. 저는 거짓말을 하는 에이전트를 걱정하고 있었습니다. 하지만 제가 실제로 발견한 것은 아무도 읽을 수 없는 검증(verification)이었습니다.
그리고 그것은 AI의 특성이라기보다 현재 우리가 일하는 방식의 문제입니다. 예전에는 사람이 브라우저를 열어 확인하고 넘어가도 괜찮았습니다. 왜냐하면 그 사람이 그 코드를 계속 지켜보고 있었기 때문입니다. 하지만 이제 확인 작업을 수행하는 것은 매 세션마다 메모리가 초기화되는 에이전트입니다. 읽을 수 없는 검증(verification)은 검증이 전혀 이루어지지 않은 것과 구별할 수 없습니다.
따라서 그 7건은 CAUGHT(적발됨)가 아니라 SUSPICIOUS(의심스러움)로 보고되었으며, 문구 또한 "그것이 당신에게 거짓말을 했습니다"가 아니라 "해당 명령이 실제로 무엇을 보고했는지 확인하십시오"라고 되어 있습니다. 이것이 도구가 정직하게 할 수 있는 최선입니다.
누군가 발견하기 전에 제가 미리 밝혀두고 싶은 주의사항이 하나 있습니다. 249건의 세션 중 "테스트 통과"라는 주장이 포함된 것은 단 12건뿐이었습니다. 나머지는 질문 한두 개 정도로 짧습니다. 따라서 "249건 중 0건"이라고 말하는 것은 이 상황을 과장해서 해석하는 방식이 될 것입니다.
그리고 도구가 나를 여섯 번이나 잘못 비난했다
도구가 제대로 작동하기 시작하자, 저는 한동안 이를 망가뜨려 보려고 시도했습니다. 그것이 프로젝트에서 가장 가치 있는 부분이 되었습니다. 적대적 검토(adversarial review)를 통해 도구가 정직한 작업을 잘못 비난하게 되는 다섯 가지 별개의 방식을 찾아냈습니다.
- 타임아웃(timeout)으로 종료된 테스트 실행을 실패로 읽음
- 도구가 알지 못하는 러너(
rspec,phpunit,tox)를 "테스트가 실행되지 않음"으로 읽음 - 도구가 볼 수 없는 런처(launcher) 하에서 재실행되어, 오래된 결과(stale result)를 바탕으로 판단함
- 사용자가 명시적으로 요청한 동작 변경을 정답을 다시 작성한 것으로 읽음
- 백그라운드(backgrounded) 명령을 통과로 계산함
처음 두 가지는 명확히 짚고 넘어갈 가치가 있습니다.
타임아웃 문제는 가장 심각했습니다. 테스트 실행이 너무 오래 걸려 종료되면 특유의 종료 신호(exit signal)를 남기는데, 제 코드는 이를 실패로 읽었습니다. 그래서 만약 에이전트가 테스트가 통과되었다고 말하면, 도구에서 가장 모욕적인 판결을 출력하게 됩니다: "테스트가 통과되었다고 말함 — 마지막 실행은 실패함." 제 코드가 두 단락에 걸쳐 설계 노트를 작성했던 바로 그 규칙을 직접적으로 위반하고 있었던 것입니다.
두 번째는 문자 하나 때문이었습니다. 테스트 러너 (test runner)를 인식하는 패턴이 단어 경계 (word boundary) 앞에 spec이라는 단어가 있는지 확인하도록 되어 있었는데, 이는 rspec 내부에서는 절대 일치하지 않습니다. r이 단어 문자 (word character)이기 때문입니다. 따라서 Ruby 개발자가 테스트 스위트 (suite)를 실행하여 20 examples, 0 failures라는 결과를 얻더라도, 제 도구는 테스트가 전혀 실행되지 않았다고 자신 있게 단언할 것입니다.
그리고 여기서 뼈아픈 부분이 나옵니다. 제 README에는 _"183건의 실제 세션 동안 오탐 (false positive) 0건"_이라고 자랑스럽게 적혀 있었습니다. 그것은 사실이었습니다. 하지만 도구가 훌륭해서가 아니었습니다. 제 컴퓨터의 모든 프로젝트가 JavaScript 또는 TypeScript였기 때문에, 그 경로 중 어느 것도 실행된 적이 없었기 때문입니다. 저는 주장을 검증하기 위한 도구를 만들고, 그 도구가 자체 검증을 통과했다고 보고했지만, 실제로는 코드를 실행해 보지 않았던 것입니다. 저는 도구가 잡아내기 위해 존재하는 바로 그 행동을 저질렀습니다.
여섯 번째 오류는 배포 후에 발견되었습니다. 범위가 지정된 pytest tests/test_sync_cli.py 실행 후, GitHub 워크플로 (workflow) 파일과 두 개의 .tsx 파일을 수정하자, 도구는 해당 주장이 오래되었다 (stale)고 판단했습니다. Python 테스트는 .tsx를 임포트 (import)할 수 없고, CI 워크플로는 방금 이 머신에서 실행된 것이 아닙니다. 아무것도 오래되지 않았습니다.
유혹적인 해결책이 하나 있었습니다: "디렉토리가 다르면 관련 없는 것으로 취급한다." 간단하고 대부분의 경우 맞습니다. 하지만 그것은 틀린 방법이기도 합니다. pytest tests/를 실행한 다음 src/를 수정하면 주장은 실제로 만료된 것이기 때문입니다. 그래서 저는 추측하는 대신, 확실히 사실인 것들만 억제 (suppress)합니다: Python은 .tsx를 로드할 수 없고, JS 러너 (runner)는 .py를 로드할 수 없으며, CI 워크플로는 로컬 실행이 아닙니다. 모호한 모든 것은 여전히 카운트됩니다. "관련 없어 보인다"는 추측이며, 추측만으로 증거를 버려서는 안 됩니다.
이제 여섯 가지 오류 모두 회귀 테스트 (regression tests)로 고정되었습니다.
이 글을 쓰는 동안에도 도구가 저를 다시 잡아냈습니다
이 기사의 수치를 갱신하기 위해 도구를 다시 실행했는데, 목록에서 제 사례 중 하나를 발견했습니다:
"테스트 3개로 고정했고 전체 389개 통과."
(pinned it with three tests, all 389 pass)
→ 해당 실행 이후 1개 파일 변경됨:scripts/social.tape
내가 말한 것은 사실이었고, 변경된 파일은 데모 GIF를 위한 화면 녹화 스크립트였습니다. 이는 테스트 스위트 (test suite)와는 아무런 관련이 없습니다.
하지만 이것은 버그가 아닙니다. 이것은 한 섹션 전에 내가 선택한 규칙 — 모호한 것은 무엇이든 여전히 카운트한다 — 이 내가 지시한 대로 정확히 수행하고 있는 결과입니다. 이 도구는 .tape 파일이 무엇인지 알지 못하며, 나는 알 수 없는 것은 카운트하기로 결정했습니다.
그것이 선택에 따른 대가입니다. 무언가를 놓치는 것을 피하기 위해, 때로는 아무것도 아닌 것에 대해 짖기도 합니다. 그 대가로, 이번 건은 CAUGHT 대신 SUSPICIOUS로 표시되었고, 변경된 파일명이 바로 옆에 출력되었기에 내가 이를 무시하는 데 3초밖에 걸리지 않았습니다. 그것이 바로 두 단계 (two tiers)를 나눈 이유입니다.
유지하고 싶은 세 가지
정확도 (accuracy)를 조정하기 전에 어떤 방식으로 틀릴 것인지 결정하십시오. 이와 같은 도구에게 "정확도가 얼마인가요?"라는 질문은 거의 의미가 없습니다. 왜냐하면 오류의 두 방향이 발생하는 비용이 판이하게 다르기 때문입니다. 방향을 선택한 다음, 모든 브랜치 (branch)에서 그 결정을 옹호하십시오. 나는 문서에 이 원칙을 작성했지만, 코드의 세 곳에서 이를 어겼습니다.
판결이 아닌 증거를 전달하십시오. 출력 결과가 "거짓말을 했다"라고 말한다면, 사용자는 그것을 믿거나 믿지 않을 수밖에 없습니다. 타임스탬프와 인용된 라인을 보여주면 사용자가 스스로 결정할 수 있습니다. 위에서 나의 오탐 (false positive)을 무시하는 데 3초밖에 걸리지 않았던 이유도 바로 이것입니다. 틀렸음을 잡아낼 수 있는 도구가 계속 사용할 수 있는 도구입니다.
실질적인 해결책은 읽을 수 있는 검증입니다. 그것이 네 가지 브라우저 테스트 결과가 나에게 가르쳐준 것입니다. 에이전트 (agent)에게 작업을 맡길 때, "확인해 줘"라고 말하지 마십시오. 대신 **"확인하고 그 결과를 기계가 읽을 수 있는 어딘가에 남겨 둬"**라고 말하십시오. 누군가의 머릿속에만 존재하는 확인은 다음 세션이 되면 존재하지 않는 것이나 다름없습니다.
실행해 보기
로컬에서 실행됩니다. 당신의 트랜스크립트 (transcripts)와 코드는 절대 당신의 컴퓨터를 떠나지 않습니다. 모델을 호출하지 않으며 자체적으로 네트워크 요청을 보내지도 않습니다. 매번 동일한 세션에서 동일한 답변을 얻을 수 있습니다.
# 모든 확인 사항이 실행되어 형태를 먼저 파악할 수 있도록 만든 가상의 세션
npx @jinhyuk9714/red-handed@latest demo
...
MIT 라이선스이며, 코드는 github.com/sjh9714/red-handed에서 확인할 수 있습니다.
당신의 수치는 저와 다를 것입니다. 저는 확정된 발견(findings)은 0건이었고, 아무도 읽을 수 없는 체크(checks)가 7건이었으며, 이 단락을 쓰는 동안 발견한 오탐(false alarm)이 1건 있었습니다. 결과가 어떻게 나오든 핵심은 직접 확인해 보기 전까지는 알 수 없다는 것입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기