0. 들어가며
지난 글에서 opencode와 Aider를 맞대결시키고 Aider를 쓰기로 정했습니다. 이번 글은 그 결정을 실제 작업에 적용한 기록입니다.
finance-tracker 가계부 앱의 UI/UX 개편 백로그 15건(#188~202)을 처리했습니다. 디자인 토큰 도입, 정보구조 재편, 다크모드, 마이크로카피 가이드, 인증 설계 ADR 같은 것들입니다. 여기에 작업 중 발견한 파생 이슈 3건이 붙어 총 18건이 됐습니다.
작업 방식은 이랬습니다. git worktree로 레인을 나누고, 낮은 추론이 필요한 작업(기계적 치환, 반복 패턴 적용, 스펙이 명확한 것)을 로컬 Aider에 넘기고, 설계 판단은 제가 합니다. 그리고 품질이 부족해도 제가 직접 코드를 고치지 않습니다. 프롬프트를 보강해서 재위임합니다. 몇 번을 시도해도 안 되면 그때 개입하되, 왜 반복 위임이 실패했는지 기록합니다.
이 제약이 이 글의 전부입니다. 직접 고치는 걸 막아놓으니 실패의 원인을 끝까지 추적할 수밖에 없었고, 그러다 보니 원인이 어디서 오는지가 시계열로 드러났습니다.
측정한 것부터 적습니다.
| 항목 | 값 |
|---|---|
| Aider 실행 횟수 | 143회 |
| 누적 가동 시간 | 3시간 29분 49초 (평균 88초) |
| 최장 단일 실행 | 26분 46초 (폭주, 강제 종료) |
| 오케스트레이터 직접 코드 개입 | 1회 (2줄) |
| 첫 이슈 1차 통과율 | 1/8 |
| 후반 이슈 1차 통과율 | 4/4, 5/5 |
모델은 ollama/qwen3-coder:30b, Aider 0.86.2입니다. 처음부터 끝까지 같은 모델입니다.
1. 첫 이슈에서 1/8을 맞다
디자인 토큰 도입(#190)이 첫 작업이었습니다. Tailwind CSS v4의 @theme 블록에 의미론적 토큰을 정의하고, 컴포넌트의 팔레트 클래스(bg-indigo-700 같은 것)를 토큰 참조로 바꾸는 작업입니다.
첫 실행은 3개 파일을 한 번에 넘기고 --edit-format whole을 썼습니다. 476초가 걸렸고, 결과를 열어보니 이랬습니다.
- JSX 중괄호가 사라짐 —
{r.merchant}가r.merchant로 border,overflow-hidden클래스가 삭제됨rounded-md를 전부rounded-card로 과잉 적용- 회색 계열 등급 오매핑 (
gray-400을ink-muted가 아닌ink-subtle로)
전량 폐기했습니다. 그런데 여기서 중요한 건 실패 자체가 아니라 왜 이런 실패가 났는지입니다.
1.1 프롬프트의 예시가 대상 파일을 오염시켰다
두 번째 시도에서 더 이상한 일이 생겼습니다. 프롬프트에 든 예시를 전부 TransactionList.jsx 에서 뽑아 썼는데, 정작 편집 대상이 Dashboard.jsx 일 때 모델이 TransactionList.jsx 에 대한 no-op 블록을 뱉고 실제 대상은 건드리지 않았습니다.
예시 파일명이 프롬프트 안에 반복해서 등장하니 그게 대상으로 읽힌 겁니다. 고친 방법은 셋입니다.
- 파일별 하드 헤더를 맨 위에 박는다 (
# 편집 대상 파일: X) - 프롬프트 본문에서 다른 파일명을 기계적으로 제거한다
- 치환 개수를 명시한다 ("정확히 24곳")
--edit-format diff 로 바꾸고 파일을 하나씩 넘기니 476초짜리 작업이 91초에 바이트 단위로 정확하게 끝났습니다.
이 패턴을 처음부터 적용한 다음 이슈(#191)는 6회 실행에 470초를 썼고 그중 5회가 1차 통과했습니다. #190이 11회에 2,068초를 쓴 것과 대비됩니다. 같은 모델, 같은 난이도인데 프롬프트 규격만으로 4.4배 차이가 났습니다.
다만 여기서 중요한 건 속도가 아닙니다. 판정 가능한 산출물이 나오기 시작했다는 것입니다. 476초짜리 결과물은 빠르게 만들었어도 쓸 수 없었습니다.
1.2 프롬프트로 우회 못 하는 게 하나 있었다
같은 이슈에서 docs/STYLE_GUIDE.md 를 만들어야 했는데, 문서 안에 코드펜스가 들어 있었습니다. Aider의 diff 파서가 중첩된 코드펜스를 만나 깨졌고, 파일명이 문자 그대로 </div> 인 파일이 생성됐습니다.
프롬프트를 세 번 고쳐봤지만 같은 지점에서 같은 결과가 나왔습니다. 이건 도구 구조 제약입니다. 결국 제가 코드펜스 2줄을 직접 삽입했습니다.
이 사이클의 유일한 직접 개입입니다. 그리고 이걸 "위임 실패"로 집계하지 않기로 했습니다. 모델은 요구한 내용을 정확히 생성했고(규칙 4개·표·예시 전부), 실패 지점은 그 텍스트를 파일에 앉히는 도구 계층이었기 때문입니다. 재위임해도 같은 자리에서 같은 결과가 납니다.
2. 26분간 폭주한 실행을 지표로 잡다
토큰 마이그레이션 후반, 남은 7개 파일 501곳을 처리하는 단계에서 이상한 일이 있었습니다.
Settings.jsx 의 잔여 팔레트 클래스가 248개 → 98개 → 6개로 줄어들고 있었습니다. 순조로웠습니다. 그런데 3차 패스가 끝나지 않았습니다.
체감으로 "오래 걸리네" 하고 넘어갈 뻔했는데, 지표로 확인했습니다.
ps -o etime=,stat=,%cpu= -p <pid>
wc -l raw/190b-Settings-p3.out- 경과 시간 26분 (앞선 패스들은 90~200초)
- 출력 7,768줄 (앞선 패스들은 약 1,400줄)
- CPU 상태는 계속 R (실행 중, 멈춘 게 아님)
같은 성격의 작업이 같은 파일에서 5배 넘는 출력을 내고 있으면 그건 수렴이 아니라 발산입니다. 죽였습니다.
원인은 루프-언틸-드라이 방식 자체였습니다. "남은 것을 계속 바꿔라"라는 지시는 남은 게 6개일 때 오히려 위험합니다. 모델이 바꿀 게 없으니 주변을 건드리기 시작합니다.
해결은 make_tail_prompt.py 였습니다. 남은 클래스를 스크립트로 뽑아 리터럴 before/after 핀포인트 프롬프트를 생성합니다. "남은 걸 알아서 처리해라"가 아니라 "이 6줄을 이렇게 바꿔라"가 됩니다.
2.1 그런데 제 루프 스크립트가 조용히 파일을 건너뛰고 있었다
같은 단계에서 더 나쁜 게 있었습니다. 제가 쓴 루프 스크립트가 이런 형태였습니다.
for spec in "$SPECS"; do
set -- $spec
./run-aider.sh "$1" "$2"
donezsh는 기본 설정에서 파라미터 확장 시 단어분할을 하지 않습니다. set -- $spec 이 분할에 실패했고, $1 에 "라벨 파일경로" 전체가 들어갔습니다. 결과적으로 Settings.jsx(248곳)와 Debts.jsx(99곳)가 아예 처리되지 않았는데 저는 "최종 잔여 0"이라고 보고했습니다.
잔여 0이 나온 이유는 처리되지 않은 파일이 집계에서도 빠졌기 때문입니다. 파일을 직접 세어보고서야 알았습니다.
이 버그를 이 사이클 안에서 두 번 반복했습니다. 마지막 에러처리 작업에서 똑같이 13개 위임이 실행조차 안 된 채 종료코드만 남았습니다. 두 번째엔 이렇게 고쳤습니다.
for pair in "${PAIRS[@]}"; do
slug="${pair%%:*}"; file="${pair#*:}"
before=$(shasum -a 256 "$WT/$file" | cut -d' ' -f1)
./run-aider.sh "$slug" "$file" >/dev/null 2>&1
after=$(shasum -a 256 "$WT/$file" | cut -d' ' -f1)
[ "$before" = "$after" ] && echo "변경없음 ← 확인 필요"
done실행됐다는 걸 종료코드로 믿지 말고 산출물 변화로 확인한다. 이 교훈은 사이클 내내 반복해서 나왔습니다.
3. 잔여 0, 빌드 통과, 테스트 통과인데 40곳이 틀려 있었다
토큰 마이그레이션이 끝났다고 판단한 시점의 상태는 이랬습니다.
- 잔여 팔레트 클래스 0개
- vite 빌드 통과
- 전체 테스트 스위트 통과
그런데 바이트 비교를 돌려보니 40곳이 잘못된 등급으로 치환돼 있었습니다.
| 있어야 할 것 | 실제로 들어간 것 |
|---|---|
border-line-soft |
border-line |
text-ink-subtle |
text-ink-faint |
bg-accent-soft |
bg-accent-bar |
거기에 어디선가 font-medium 이 하나 추가돼 있었습니다.
전부 유효한 토큰이라 빌드가 통과합니다. 렌더도 됩니다. 그냥 색이 조금씩 다릅니다. 테스트가 색을 검사하지 않으니 통과합니다.
원인은 프롬프트와 하네스가 따로 관리된 것이었습니다. 프롬프트에 적힌 매핑표와 하네스의 sed 참조가 서로 다른 곳에서 유지되다 어긋났습니다.
이후로 mapping.py 를 단일 출처로 두고 프롬프트 표와 하네스 참조를 거기서 함께 생성했습니다. 44개 규칙을 순서대로 두되 -500 을 -50 보다 앞에 놓아 접두어 충돌을 피했습니다.
마지막 에러처리 작업에서도 같은 패턴을 썼습니다. error_messages.py 에 (파일, 옛 문구, 새 문구) 34규칙을 두고 프롬프트 13개와 검증 기준을 함께 뽑았습니다. 그리고 생성 직후 매핑이 실제 코드와 맞는지부터 대조했습니다. 34/34 일치를 확인하고 나서 위임을 시작했습니다.
4. 하네스가 15번 틀렸고 그중 11번이 거짓양성이었다
여기부터가 이 글의 본론입니다.
산출물을 판정하는 검증 스크립트를 이슈마다 하나씩 썼습니다(h190.sh ~ h201.sh). 이 스크립트들이 15번 잘못된 판정을 냈습니다. 분류하면 이렇습니다.
| 분류 | 건수 |
|---|---|
| 거짓양성 (멀쩡한 산출물을 실패로) | 11 |
| 거짓음성 (틀린 산출물을 통과로) | 1 |
| 전면 오판 (판정 자체가 무의미) | 1 |
| 강제된 오가정 (제 잘못된 전제를 게이트가 강제) | 1 |
| 잘못된 파일 목록 | 1 |
가장 위험했던 건 거짓양성이 아니라 나머지 넷입니다.
4.1 거짓음성: 6/7만 맞아도 통과
접근성 개선(#192)에서 role 속성 7개를 검사해야 하는데, 하네스가 6개만 확인하고 통과시켰습니다. 빠진 하나가 정확히 문제가 있던 것이었습니다.
"검사했다"와 "다 검사했다"는 다릅니다. 이후 하네스에는 검사 대상 개수 자체를 단언하는 줄을 넣었습니다.
4.2 전면 오판: 리포터 형식을 못 읽어 5개 뮤테이션이 전부 죽은 것처럼 보였다
#193에서 뮤테이션 테스트를 돌렸는데 5개 뮤테이션이 전부 "검출 안 됨"으로 나왔습니다. 테스트에 이빨이 하나도 없다는 뜻입니다.
실제로는 하네스가 node --test 의 출력 형식을 잘못 파싱하고 있었습니다. 테스트는 정상적으로 실패하고 있었는데 하네스가 그걸 못 읽었습니다.
판정 결과가 극단적으로 나오면(전부 실패 / 전부 통과) 대상보다 판정기를 먼저 의심해야 합니다.
4.3 강제된 오가정: 제 잘못된 전제를 게이트가 밀어붙였다
#194에서 topCategories 라는 변수가 안 쓰인다고 판단하고 삭제를 지시했습니다. grep 해보지 않고 단언했습니다.
실제로는 "이번달 Top 5 카테고리" 섹션이 쓰고 있었습니다. 화면이 ReferenceError 로 죽었습니다.
문제는 그다음입니다. 제가 쓴 하네스에 "이 변수가 남아 있으면 실패"라는 게이트가 들어 있었습니다. 모델이 올바르게 변수를 남겨뒀어도 하네스가 실패를 뱉었을 겁니다. 잘못된 전제를 검증 장치가 강제하고 있었습니다.
고칠 때는 게이트 자체를 바꿨습니다. "이 변수 금지"가 아니라 "참조와 선언이 일치하는가" 를 봅니다.
4.4 형태를 추측한 앵커
다크모드(#201)에서 빌드된 CSS에 다크 블록이 들어갔는지 확인하는 게이트를 이렇게 썼습니다.
grep -q "data-theme=\"dark\"" "$BUILT"실패했습니다. 다크 블록은 멀쩡히 방출되고 있었는데, 미니파이어가 따옴표를 떼서 [data-theme=dark] 로 나가고 있었습니다.
소스의 형태를 보고 빌드 산출물의 형태를 추측한 겁니다. 고친 뒤에는 따옴표 유무를 모두 허용하고, 셀렉터 존재만이 아니라 그 블록 안의 토큰 개수(44개)까지 셉니다. 존재 확인보다 개수 대조가 형태 변화에 강합니다.
4.5 체크리스트를 적어놓고도 어겼다
이런 사례들을 모아 하네스 머리에 체크리스트를 주석으로 적어뒀습니다. 그러고도 어겼습니다. 4.4번 사례는 "앵커를 추측하지 말 것"이라고 적어놓은 파일에서 났습니다.
체크리스트를 문서로 두는 것만으로는 지켜지지 않습니다. 게이트를 한 줄 쓸 때마다 "이 앵커가 실제 파일에 지금 있는지 확인했나"를 묻는 편이 낫습니다.
5. 정적 검증이 못 잡는 결함이 여섯 유형 나왔다
빌드 통과, 테스트 통과, 문법 정상인데 실제로는 깨져 있는 경우들입니다. 사이클 내내 하나씩 새로 나왔습니다.
| # | 유형 | 사례 |
|---|---|---|
| 1 | 레이아웃 정렬 | 요소가 겹치거나 어긋남 |
| 2 | 런타임 크래시 | 삭제된 변수 참조로 ReferenceError |
| 3 | 자연어 정확성 | 한국어 조사 오류 |
| 4 | 중복 렌더 | 모달 전환 후 제목이 두 번, 카드가 이중으로 |
| 5 | 헤더 스택 차이 | 개발 서버에선 되는데 운영에선 안 됨 |
| 6 | 조용히 성격이 바뀌는 분기 | 판정 함수가 항상 거짓이 되어 400이 500으로 |
3번은 재미있는 사례라 적어둡니다. 빈 상태 문구에 아직 {label}을 불러오지 못했어요 라고 썼더니 "전체 데이터을"이 됐습니다. 받침 유무에 따라 을/를이 갈리는데 템플릿이 고정돼 있었습니다.
withObjectParticle() 을 만들어 한글 음절 코드로 받침을 판정하게 했습니다. 그런데 이 함수의 주석에 제가 '를'로 둔다 라고 쓴 걸 Aider가 "개선"해서 '를'으로 둔다 로 바꿔놨습니다. ㄹ 받침은 '로'를 받는데 '으로'로 고친 겁니다. 한글 조사 처리 함수의 주석이 한글 조사 오류로 깨졌습니다. 바이트 비교로만 잡혔습니다.
5번은 가장 비싼 누락이었습니다. 다크모드를 넣으면서 <head> 에 인라인 스크립트로 data-theme 을 먼저 찍게 했습니다. 개발 환경에서 전부 검증하고 머지했습니다.
그런데 운영 빌드에서는 CSP가 인라인 스크립트를 차단합니다.
Content-Security-Policy: default-src 'self'; script-src 'self'; ...개발 서버는 Vite가 HTML을 직접 서빙하므로 Express의 보안 헤더 미들웨어를 타지 않습니다. 개발 응답에는 CSP 헤더 자체가 없습니다. 저는 런타임 검증을 전부 개발 경로에서만 돌렸습니다.
증상도 나빴습니다. 깜빡임이 아니라 다크모드가 아예 안 켜집니다. 저장값은 dark인데 화면은 라이트고, 설정 버튼만 "라이트 모드로"(=지금 다크라는 뜻)라고 표시됩니다. 콘솔 에러도 안 납니다.
window.__probe = 'not-run';
const s = document.createElement('script');
s.textContent = "window.__probe = 'ran';";
document.head.appendChild(s);
// __probe === 'not-run'
// securitypolicyviolation: { violatedDirective: 'script-src-elem', blockedURI: 'inline' }고친 방법은 CSP를 완화하는 게 아니라 부트스트랩을 동일 오리진 외부 클래식 스크립트로 빼는 것이었습니다. script-src 'self' 를 그대로 만족하면서, 속성 없는 클래식 스크립트라 파서를 막고 즉시 실행돼 깜빡임 방지도 유지됩니다.
'unsafe-inline' 추가는 앱 전체의 XSS 방어를 무너뜨려서, CSP 해시는 스크립트가 한 글자만 바뀌어도 조용히 깨져서 쓰지 않았습니다.
그리고 하네스 체크리스트에 7번을 추가했습니다. 개발 경로에서만 검증하면 운영 헤더 스택을 통째로 건너뛴다.
6. 여기서 이 글의 제목이 나옵니다
사이클 후반으로 갈수록 이상한 일이 벌어졌습니다.
1차 통과율이 올라갔습니다. #190에서 1/8이었던 것이 #200에서 4/4, #201에서 5/5가 됐습니다. 같은 모델, 같은 도구입니다. 달라진 건 프롬프트 작성 방식뿐입니다.
그런데 통과율이 올라가는 만큼, 남은 실패의 원인이 저에게로 옮겨왔습니다.
전체 결함을 원인별로 다시 세어보면 이렇습니다.
| 원인 | 건수 |
|---|---|
| 하네스 결함 (제가 쓴 검증 스크립트가 틀림) | 15 |
| 오케스트레이터 스펙 결함 (제가 쓴 지시가 틀림) | 4 |
| 도구 구조 제약 | 2 |
| 러너 호출 실수 (제가 쓴 셸 스크립트가 틀림) | 2 |
| 모델 산출물 결함 | 초기 한 이슈에 집중 |
6.1 스펙이 틀려서 산출물이 틀린 네 번
첫 번째. 회귀 테스트 스펙에 이렇게 썼습니다. "import 가 나오면 실패시켜라."
그대로 구현됐습니다. 그런데 검사 대상 파일의 주석에 이런 문장이 있었습니다.
// import 를 쓸 수 없어 저장소 키가 client/src/lib/theme.js 와 중복된다.검사가 주석에 걸려 오탐을 냈습니다. 제 하네스 체크리스트 6번이 "언급과 사용을 구분해라" 인데, 그걸 하네스가 아니라 스펙 단계에서 어겼습니다.
교정 프롬프트에는 주석 제거 후 \bimport\b 로 검사하게 하면서 이 문장을 넣었습니다. "주석을 지워서 통과시키지 마라 — 주석이 옳고 검사가 틀렸다." 이 문장이 없으면 모델이 더 쉬운 쪽(주석 삭제)으로 갈 여지가 있습니다.
두 번째. 회귀 테스트 스펙에 "수집된 메시지가 30개 이상인지 단언해라"라고 썼습니다. 모델이 그걸 위반 배열에 걸었습니다.
assert.ok(errors.length > 0, 'Should have found some 4xx error messages');errors 는 위반 목록입니다. 즉 "영문 메시지가 1개 이상 있어야 통과" 라는 정반대 테스트가 됐습니다. 배열을 둘로 나누라고 명시하지 않은 게 원인입니다.
세 번째. 판정 함수를 타입 기반으로 바꾸면서 호출 지점 하나를 스펙에서 빠뜨렸습니다.
if (isUserInputError(message)) return res.status(400).json({ error: message });isUserInputError 는 이제 instanceof 로 판정합니다. 문자열을 넘기면 언제나 거짓입니다. 사용자 입력 오류가 400 대신 500으로 떨어집니다. 빌드도 테스트도 통과합니다. 뮤테이션으로만 잡혔습니다.
네 번째. 두 파일 네 편집을 한 프롬프트에 넣었더니 한 편집만 적용하고 멈췄습니다. #190에서 이미 배운 "파일당 하나씩"을 제가 어겼습니다.
6.2 그래서 실무적으로 뒤집힌 것
이 사이클에서 굳어진 습관이 하나 있습니다.
게이트가 실패를 뱉으면 대상 코드보다 게이트를 먼저 의심한다.
15건 중 11건이 거짓양성이었으니 확률적으로 그쪽이 맞습니다. 다만 이건 파이프라인이 성숙한 뒤의 이야기입니다. 초기에는 반대였습니다. 첫 이슈에서 게이트를 의심했다면 진짜 결함 7종을 통과시켰을 겁니다.
이 전환점이 언제 오는지가 실무적으로 중요한데, 제 경우엔 "판단을 프롬프트 단계에서 끝내기 시작한 시점" 이었습니다.
가장 위험했던 작업이 다크모드였습니다. 재시도가 0회였는데, 이유는 위임 전에 44개 토큰의 다크 값을 전부 대비비 계산으로 확정해 프롬프트에 리터럴로 박아뒀기 때문입니다. "다크에 어울리는 색을 골라라"라고 시켰다면 판정 자체가 불가능한 산출물이 나왔을 겁니다.
판단이 남아 있는지 알아보는 방법이 있습니다. 산출물을 기계적으로 판정할 수 있는가. 판정 기준을 쓸 수 없다면 판단이 아직 안 끝난 겁니다.
7. 측정하다 알게 된 것: 다크모드는 원래 불가능했다
부수적으로 나온 발견이라 따로 적습니다.
다크모드를 토큰 재정의만으로 구현하려 했는데, 실측해보니 indigo-700 이 배경으로 19곳, 글자로 22곳에 쓰이고 있었습니다.
다크 모드에서 이 둘을 동시에 만족하는 단일 색은 존재하지 않습니다.
- 흰 글자를 얹어 4.5:1을 넘기려면 → 충분히 어두워야 합니다
- 어두운 배경 위에서 글자로 읽히려면 → 충분히 밝아야 합니다
- 두 휘도 구간이 겹치지 않습니다
그래서 토큰을 역할별로 쪼갰습니다. 배경용(accent-hover·danger)과 글자용(accent-strong·expense)입니다. 취향이 아니라 계산에서 나온 결론입니다.
대비비는 oklch → oklab → 선형 sRGB → 상대휘도 순으로 직접 계산했습니다. 그 과정에서 라이트 모드의 기존 값이 기준 미달이라는 것도 발견했습니다(income 3.67:1, ink-faint 2.63:1). 범위 밖이라 별도 이슈로만 기록했습니다.
페이지 배경은 순검정 대신 #121212 로 잡았습니다. 순검정은 대비가 과해 스크롤 시 잔상이 남습니다.
8. 금지 목록은 뚫린다
마지막 작업은 에러 메시지 점검이었습니다. 서버가 내려준 { error: '...' } 가 그대로 alert() 에 실려서 이런 게 사용자 화면에 떴습니다.
purchase_date, merchant, total_amount, months, monthly_amount, start_billing_month required44곳을 한글 사용자 문구로 바꾸고, 회귀 방지 테스트를 넣었습니다. 이 회귀는 조용히 일어나기 때문입니다. 문법 오류가 아니고, 테스트도 통과하고, 화면도 정상으로 보입니다. 사용자만 영문 메시지를 봅니다.
여기서 §6.1 세 번째 사례(문자열을 넘기면 판정이 항상 거짓)를 막는 검사를 짰습니다. 처음엔 이렇게 썼습니다.
인자 이름이
message면 실패시킨다.
뮤테이션으로 isUserInputError(errMsg(e)) 를 심어봤습니다. 그대로 통과했습니다. errMsg(e) 도 문자열을 돌려주니 똑같이 깨지는데, 이름이 message 가 아니라서 검사를 빠져나갔습니다.
금지 목록은 새 표현이 생길 때마다 뚫립니다. 뒤집었습니다.
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
// 인자는 단순 식별자여야 한다. 호출식도 문자열 리터럴도 전부 위반.
if (!IDENTIFIER.test(arg)) errors.push({ file, arg });금지 목록이 아니라 허용 형태로 검사한다. 두 뮤테이션이 모두 잡혔습니다.
8.1 그리고 그 검사에도 구멍이 있었다
작업을 마쳤다고 판단한 뒤에 남은 축을 마저 훑었습니다. 그러다 발견했습니다.
제 회귀 테스트의 정규식이 이랬습니다.
/status\((4\d\d)\)\.json\(\{\s*error:\s*(['"])(.*?)\2/g두 군데가 좁습니다.
4\d\d라서 5xx를 아예 안 봅니다. 실제로 500 응답이Internal server error로 남아 있었는데 통과했습니다. 500은 사용자가 가장 자주 마주치는 실패입니다..이 줄바꿈에 매치되지 않아 여러 줄로 쓰인 응답을 놓칩니다. 503 응답 하나가 그 형태라 수집 대상에 들지도 못했습니다.
넓히면서 하나를 더 넣었습니다.
assert.ok(found.some((f) => f.status.startsWith('5')),
'5xx 응답 메시지가 하나도 수집되지 않았다.');수집 개수 하한만 단언하면 4xx만으로 채워져서 같은 회귀를 다시 놓칩니다.
검사 범위를 좁게 잡으면, 그 밖은 검사한 적이 없는데도 통과로 보입니다. "4xx는 전부 한글"이라는 참인 명제가 "응답 메시지는 전부 한글"로 읽혔습니다. 체크리스트 10번으로 추가했습니다.
9. 하네스 작성 체크리스트 최종본
사이클이 끝난 시점의 10개입니다. 전부 실제로 오판을 낸 사례에서 나왔습니다.
- 앵커는 실제 파일에서 지금 확인한 것만 쓴다. 추측 금지
- "존재 확인"보다 "개수 대조" 가 형태 변화에 강하다
- 소스 형태로 빌드 산출물을 추측하지 않는다
- 정규식 단어 경계(
\b)는 따옴표·괄호 뒤에서 걸리지 않는다 - 문자열 슬라이스 길이는 세어보고 쓴다
- "언급"과 "사용"을 구분한다. 주석 안의 단어가 매치되면 오탐이다
- 개발 경로에서만 검증하면 운영 헤더 스택을 통째로 건너뛴다
- 문서 하네스는 형식이 아니라 주장이 코드와 맞는지를 본다
- 금지 목록이 아니라 허용 형태로 검사한다
- 검사 범위를 좁게 잡으면 그 밖은 검사한 적이 없는데도 통과로 보인다
4번과 5번은 제가 임시로 짠 검증 스크립트에서 하루에 두 번 연달아 낸 실수입니다. <script>(8자)를 9자로 잘라 비교해서 "동기 실행 아님"으로 오판했고, \btype="module"\b 의 뒤쪽 \b 가 " 다음에서 경계를 못 잡아 Vite HMR 클라이언트를 "파싱 차단 스크립트"로 오판했습니다.
검증 도구도 검증 대상입니다.
10. 긴 마크다운 문서 위임은 형태를 바꿔야 했다
인증 설계 ADR(#189)을 쓰면서 세 번 연속 실패했습니다.
whole 편집 형식은 파일 전체를 코드펜스로 감싸 출력해야 하는데, 내용 자체가 200줄짜리 마크다운 문서면 모델이 그걸 채팅 답변으로 뱉고 펜스를 붙이지 않습니다. Aider는 파싱할 블록을 못 찾아 빈 파일을 남기고 정상 종료(rc=0) 합니다. 실패 신호가 없습니다.
2회차 교정이 역효과였습니다. 명령형(~적어라)을 선언형(~적는다)으로 바꿨더니 지시문이 더 문서 본문처럼 보여서 10곳이 그대로 혼입됐습니다. 절 하나가 통째로 빠졌고 제목이 영어로 바뀌었습니다.
문체를 다듬는 방향이 틀렸습니다. 물리적으로 분리해야 했습니다.
@@ 로 시작하는 줄은 너에게 주는 지시다. 결과 파일에 절대 넣지 마라.
@@ 완성된 파일에 @@ 라는 문자열이 하나라도 남으면 실패다.이러면 하네스가 판정할 수 있는 규칙이 됩니다. "지시문을 옮기지 마라"라는 문장과 결정적으로 다릅니다.
그리고 형태를 바꿨습니다. 자리표시자 한 줄을 먼저 파일에 심고, diff 형식의 SEARCH/REPLACE로 교체합니다. diff 형식이 새 파일을 만들지 못한다는 제약(#190에서 확인)은 시드 파일로 우회되고, 구조 강제라는 이점만 남습니다. 한 번에 통과했습니다.
11. 문서 하네스는 무엇을 검사하는가
ADR은 실행할 수 없습니다. 그래서 하네스가 문서의 주장을 저장소에서 다시 세어 대조하게 짰습니다.
db.prepare호출 133곳 / 라우트 파일 18개 — 실제로 세서 일치 확인- 언급된 테이블 11개가 전부
CREATE TABLE에 실재하는지 - 지목한 파일들이 실재하고 문서에도 언급됐는지
csrfGuard가 실제로 폴백에서next()로 통과시키는지 (문서 주장의 근거)package.json에 argon2/bcrypt가 없는지 (문서 전제와 일치)
마지막 항목이 재미있는데, ADR에 "argon2id는 새 네이티브 의존성이라 STOP&ASK 대상"이라고 썼으니 그 전제가 지금도 참인지를 하네스가 확인하는 겁니다.
문서는 실행할 수 없으므로 주장 대조가 유일한 실행 검증입니다. 체크리스트 8번이 여기서 나왔습니다.
12. 재시도 정책을 다시 씁니다
지난 사례에서 얻은 지침은 "동일 산출물에 대한 재시도는 2회까지, 3회차부터는 직접 수행"이었습니다. 이번 관측으로 조건을 나눕니다.
산출물이 지시와 다른 경우(누락·오타·범위 초과)는 횟수 제한이 필요 없습니다. 빠뜨린 부분을 리터럴로 명시해 재위임하면 3~4회차에도 통과율이 높았습니다.
같은 실패가 프롬프트를 고쳐도 반복되는 경우는 도구 구조 제약을 의심합니다. 프롬프트가 아니라 파이프라인 형태를 바꿔야 합니다. 편집 형식 교체, 시드 파일 도입, 파일 단위 분할.
지시 자체가 틀렸음이 드러난 경우는 재위임 전에 지시를 고칩니다. 모델은 잘못이 없습니다. 이걸 "위임 실패"로 집계하면 통계가 왜곡됩니다. 이번 사이클에서 4건이 여기 해당합니다.
위임 도구가 거부한 경우는 사유를 먼저 읽습니다. 실제로 한 번은 Aider가 Skipping edits to client/public/theme-init.js that matches gitignore spec 라며 거부했는데, 이게 .gitignore 의 public/ 패턴에 앵커가 없어 client/public 까지 잡는다는 걸 알려줬습니다. 도구가 막아준 덕에 커밋되지 않는 파일을 만들어놓고 통과 판정할 뻔한 걸 피했습니다.
13. 정리
143회를 돌리고 남은 결론은 하나입니다.
프롬프트가 정밀해질수록 실패 책임이 지시하는 쪽으로 옮겨갑니다.
이건 좋은 신호이면서 동시에 함정입니다. 모델이 지시에 충실할수록 지시의 결함이 그대로 산출물의 결함이 됩니다. 스펙에 "부분 문자열이 나오면 실패시켜라"라고 쓰면 그대로 구현되고, 정작 그 부분 문자열이 대상 파일 주석에 있어서 오탐을 냅니다. 호출 지점을 하나 빠뜨리고 넘기면 그 자리에 잘못된 인자가 들어가 분기가 조용히 뒤집힙니다.
지난 사례에서 위임 신뢰도가 무너졌을 때 저는 "모델이 판단을 못 한다"고 결론지었습니다. 그건 절반만 맞았습니다. 판단을 시키지 않으면 모델은 충분히 신뢰할 수 있고, 대신 판단의 품질이 전부 지시하는 쪽 책임이 됩니다.
그래서 이 사이클에서 제일 많이 늘어난 건 프롬프트 작성 시간이 아니라 위임 전에 값을 확정하는 시간이었습니다. 대비비 44개를 계산하고, 매핑 34규칙을 만들고, 그게 실제 코드와 맞는지 대조하고 나서야 위임을 시작했습니다.
직접 개입 1회로 15건을 끝낸 건 모델이 좋아져서가 아닙니다. 제가 넘기기 전에 더 많이 결정했기 때문입니다.