두 번 겪고 나서야 같은 사고인 걸 알았습니다
finance-tracker 작업에서 로컬 모델에 테스트 코드를 맡기고 있습니다. 모델은 Ollama로 띄운 qwen3-coder:30b이고, 편집은 Aider가 합니다.
전에 코드를 맡겼을 때 이런 일이 있었습니다. 명세에 적은 지시문이 산출물의 주석 자리에 그대로 남았습니다. "이 함수를 재사용하라"라고 적으면 코드 주석에 // 이 함수를 재사용하라가 붙어 나오는 식입니다. 그때는 "지시문과 예시를 코드블록으로 시각적으로 분리한다"를 규칙으로 적어 두고 넘어갔습니다.
이번에는 문서였습니다. 그리고 같은 사고라는 걸 알아보는 데 시간이 걸렸습니다.
맡긴 일은 프로세스 문서 한 장이었습니다
테스트가 통과하는데도 프로덕션이 깨지는 패턴 세 가지를 정리한 문서를 로컬 모델에 맡겼습니다. 제 세션에서 실제로 겪은 것들입니다.
| 패턴 | 내용 |
|---|---|
| 목 이름 불일치 | 목이 실제 모듈에 없는 메서드를 갖고 있어 코드가 그걸 불러도 통과 |
| 빈 픽스처 | 목록이 비면 순회 코드가 한 줄도 안 돌아 통과 |
| 로딩 중 단언 | 화면이 로딩에서 일찍 반환해 본문이 안 돌아 통과 |
1회전에서 세 군데가 틀렸습니다
첫 초안은 327초 만에 나왔습니다. 구조는 맞았는데 사실이 셋 틀렸습니다.
패턴 3의 서술이 주객전도였습니다.
실제 사용자는 로딩 화면이 표시되어 본문 콘텐츠가 보이지 않습니다사용자가 로딩에 갇히는 문제가 아닙니다. 테스트가 로딩 껍데기만 보고 통과한다는 게 문제입니다. 대응 처방도 틀렸습니다.
로딩 지연을 시뮬레이션해 코드 경로를 신뢰할 수 있도록 검증합니다지연을 흉내 내는 게 아니라 로딩이 걷힐 때까지 기다린 뒤 단언해야 합니다. 마지막으로 공통 원칙의 뮤테이션 방향이 반대였습니다.
테스트 코드에 의도적인 버그를 도입해 검사가 실패하는지 검증함으로써망가뜨리는 대상은 프로덕션 코드입니다. 테스트를 망가뜨리면 아무것도 알 수 없습니다.
반려는 재작성을 부르지 않았습니다
승인 체인에 반려 사유를 실어 재개했습니다.
poetry run python3 confirm_chain.py --thread test-reliability-patterns \
--resume "reject:세 곳이 사실과 다르다 ..."1초 만에 끝났습니다. 모델 호출이 없었습니다.
"approved": false,
"rejected_reason": "...",
"log": ["dispatch -> vance", "vance 완료", "사람 검수: 반려 (...)", "완료"]체인의 반려 경로는 결정만 기록하고 종료하는 구조였습니다. 문서는 초안 그대로였습니다. 재초안을 받으려면 새로 호출해야 했습니다.
2회전에서 지적은 반영됐고 새 결함이 생겼습니다
지적 세 건을 명세에 못박아 다시 돌렸습니다. 420초. 지적한 것은 전부 고쳐졌습니다. 그리고 공통 원칙 마지막에 이 문장이 붙어 있었습니다.
'테스트 코드에 버그를 도입한다'고 쓰는 것은 방향이 잘못된 것으로,
프로덕션 코드의 오류를 직접 유도해 검증해야 한다.제가 명세에 쓴 문장은 이것이었습니다.
'테스트 코드에 버그를 도입한다' 고 쓰지 마라. 방향이 반대다.금지 지시가 본문 서술로 옷을 갈아입고 들어왔습니다. 내용이 틀린 것도 아닙니다. 다만 그 문서를 읽는 사람에게는 맥락 없는 문장입니다. 아무도 "테스트 코드에 버그를 도입한다"고 쓰자고 하지 않았는데 그걸 반박하고 있습니다.
부정형 지시는 금지 대상을 명세에 실어 나릅니다
"X라고 쓰지 마라"라고 적으려면 명세 안에 X를 적어야 합니다. 모델에게는 그 X도 맥락입니다. 무엇을 쓰지 말라고 적을수록 그 표현이 산출물 근처에 남습니다.
코드에서 주석으로 샜던 것과 같은 메커니즘입니다. 매체만 바뀌었습니다.
| 매체 | 새어 나온 자리 |
|---|---|
| 코드 | 주석 |
| 문서 | 본문 문장 |
세 번째 반려 대신 스레드를 버렸습니다
여기서 판단이 갈렸습니다. 한 번 더 반려하고 고쳐 달라고 할 수도 있었습니다. 그러지 않은 이유는 이렇습니다.
두 번 반려된 스레드는 반려 사유와 이전 초안을 맥락으로 들고 있습니다. 세 번째 시도도 그 위에서 나옵니다. 그 맥락에는 제가 지운 표현들이 전부 들어 있습니다. 지우라고 말할수록 그 표현이 맥락에 쌓입니다.
그래서 지적사항을 압축해 명세를 새로 쓰고 새 스레드로 시작했습니다.
poetry run python3 confirm_chain.py vance "..." --thread test-patterns-fresh명세에서 부정문을 전부 걷어냈습니다
새 명세의 규칙은 하나였습니다. 무엇을 쓸지만 적고 무엇을 쓰지 말지는 적지 않는다.
고치고 싶은 표현이 있으면 올바른 표현을 그 자리에 놓았습니다. 이전 명세와 새 명세의 같은 자리를 비교하면 이렇습니다.
# 이전
프로덕션에서 반드시 오류가 난다고 쓰지 마라. 오류가 안 날 수도 있다.
# 새로
프로덕션에서 오류가 나지 않을 수도 있다. 문제는 검증이 일어나지 않았다는 것이다.결과는 누출 0건이었습니다
204초 만에 나왔습니다. 검사부터 걸었습니다.
for p in "쓰지 마라" "쓰는 것은 방향이" "주의:" "금지한다"; do
printf " '%-14s' %s건\n" "$p" "$(grep -c -- "$p" "$F")"
done'쓰지 마라 ' 0건
'쓰는 것은 방향이' 0건
'주의: ' 0건
'금지한다 ' 0건앞선 두 초안의 사실 오류도 전부 사라졌습니다.
다만 한 문장이 되살아났습니다
같은 검사에서 이게 걸렸습니다.
'사용자 경험' 1건 ← 0이어야 함30행이었습니다.
실제로 데이터가 렌더되지 않아 사용자 경험 상 문제가 발생할 수 있다1회전에서 반려한 주객전도가 부분적으로 돌아왔습니다. 리셋이 만능은 아니었습니다. 지시문 누출이라는 주된 목적은 달성했고, 사실 오류 하나는 남았습니다.
검사가 거짓으로 통과한 순간이 있었습니다
이 글에서 가장 부끄러운 대목입니다.
새 호출 전에 "백지에서 쓰게 한다"며 대상 파일을 지웠습니다. 그런데 모델은 파일을 만들지 않고 본문을 체인 페이로드로만 돌려줬습니다. 앞선 두 번은 기존 파일이 있어서 덮어썼던 것이었습니다.
그 상태에서 제가 돌린 검사가 이렇게 나왔습니다.
'쓰지 마라': 0건
'쓰는 것은 방향이': 0건
'고 쓰지': 0건깨끗해 보입니다. 파일이 없었습니다.
grep -c "$w" "$F" 2>/dev/null || echo 0grep이 파일을 못 찾아 실패하면 || echo 0이 0을 찍습니다. 그 0은 문제가 없다는 뜻이 아니라 검사가 돌지 않았다는 뜻입니다. 이후 검사는 존재 확인을 앞에 붙였습니다.
test -f "$F" || { echo "파일 없음 - 검사 중단"; exit 1; }Vitest 목 API를 검사하던 것과 같은 실수를 셸에서 반복한 셈입니다. 검사 장치 자체가 돌았는지 확인하지 않은 통과는 정보가 아닙니다.
승인이 파일에 닿지 않았습니다
사고 기록 문서를 따로 만들어 승인 처리를 했습니다. 체인은 승인을 기록했습니다.
"approved": true,
"log": ["dispatch -> vance", "vance 완료", "사람 검수: 승인", "완료"]종료코드도 0이었습니다. 그런데 파일은 이랬습니다.
---
title: 지시문이 산출물로 새는 패턴
status: draft
---도구 코드를 봤습니다.
if text == "approve":
output = state.get("output", "")
# 검수를 통과한 초안만 reviewed 로 전환된다.
output = output.replace("status: draft", "status: reviewed")치환 대상이 state["output"]입니다. 체인이 들고 있는 요약 문자열입니다. 모델이 별도로 쓴 파일에는 닿지 않습니다. 산출물이 페이로드로만 오던 시절에는 맞는 코드였고, 파일을 쓰기 시작하면서 어긋났습니다.
성공 출력을 그대로 믿지 않고 다시 조회한 덕에 걸렸습니다.
실측
| 회차 | 소요 | 결과 |
|---|---|---|
| 1회전 | 327초 | 사실 오류 3건 |
| 반려 | 1초 | 기록만, 재작성 없음 |
| 2회전 | 420초 | 지적 3건 반영, 지시문 누출 1건 |
| 새 스레드 | 204초 | 누출 0건, 사실 오류 1건 |
| 사고 기록 문서 | 175초 | 결함 0건 |
같은 날 코드 위임 쪽 수치도 남겨 둡니다. 카드 관리 화면의 컴포넌트 테스트를 Aider에 맡긴 건입니다. 결과물은 PR로 올라갔습니다.
| 항목 | 값 |
|---|---|
| 1회전 | 237초, 22개 중 21개 무수정 통과 |
| 2회전 | 135초 |
| 결함 | 목 설정 과잉, 호출 순서에 의존하는 단언 |
이 글의 규칙을 이 글에 적용해 봤습니다
사고 기록 문서를 만들 때 새 규칙을 그대로 썼습니다. 명세에 부정문을 한 줄도 넣지 않고 무엇을 쓸지만 적었습니다.
대응 1. 명세를 긍정 서술로만 쓴다
무엇을 쓸지만 적고 무엇을 쓰지 말지는 적지 않는다.
잘못된 표현을 고치고 싶으면 올바른 표현을 그 자리에 놓는다.
175초 만에 나왔고 검수에서 결함이 없었습니다. 표본 하나로 규칙이 증명되지는 않지만, 적어도 같은 조건에서 누출이 재발하지는 않았습니다.남는 규칙 셋
명세는 긍정 서술로만 씁니다. 고치고 싶은 표현이 있으면 올바른 표현을 그 자리에 놓습니다. 지우라고 말하는 순간 그 표현이 맥락에 들어갑니다.
두 번 반려된 스레드는 이어가지 않습니다. 지적사항을 압축해 새 명세를 쓰고 새 스레드로 시작합니다. 반려를 반복하는 것보다 리셋이 쌉니다. 이번 경우 세 번째 반려에 들었을 시간과 새 호출 204초를 비교하면 리셋이 더 빨랐습니다.
산출물 검사는 존재 확인부터 합니다. 검사가 돌았는지 확인하지 않은 0건은 통과가 아닙니다.
세 번째가 제일 오래 남을 것 같습니다. 테스트에서 그렇게 여러 번 데고도 셸 스크립트에서 똑같이 당했습니다. 도구가 바뀌면 같은 실수를 새로 배우는 모양입니다.