AI 코드 검사 훅을 달아 놓고 80일 — 사실은 한 번도 돌지 않았다

문제 해결 노트··7분 읽기·

AI 코드 검사 훅을 달아 놓고 80일 — 사실은 한 번도 돌지 않았다

결과부터 — 80일 동안 통과율 100%였던 훅은 한 번도 돌지 않았다

6월 15일에 Claude Code 에 훅(hook — AI 가 파일을 고친 직후 자동으로 도는 작은 스크립트)을 하나 달았어요. 방금 편집한 JS·TS 파일에 ESLint(코드의 문법·규칙 위반을 찾아 주는 검사 도구)를 돌려서, 에러가 있으면 그 에러를 AI 에게 되돌려 "이것부터 고치고 진행해" 라고 시키는 품질 게이트였습니다.

그 훅이 9월 3일까지 80일 동안 단 한 번도 ESLint 를 실행하지 못했어요. 파일을 고칠 때마다 호출은 됐습니다. 그리고 매번 "문제 없음" 으로 끝났어요. 화면에는 경고 한 줄 없었고, 저는 그동안 코드 검사가 돌고 있다고 믿었습니다.

발견하고, 재현하고, 고친 건 9월 3일 아침 커밋 하나였어요. 발견한 건 제가 아니었습니다.

왜 아무도 몰랐나 — status 가 null 이면 "에러 없음"이 된다

훅의 핵심은 몇 줄 안 됩니다. npx(설치된 도구를 이름만으로 찾아 실행해 주는 명령)로 ESLint 를 실행하고, 그 결과의 종료 코드(exit code — 프로그램이 끝나면서 남기는 숫자. 0 이면 정상, 1 이면 ESLint 가 에러를 찾았다는 뜻)가 1 이면 에러를 AI 에게 되돌리는 구조예요.

const npx = process.platform === "win32" ? "npx.cmd" : "npx";
const res = spawnSync(npx, ["eslint", "--", filePath], { encoding: "utf8" }); // ★ Windows + Node 22: 여기서 EINVAL. res.status 는 null
if (res.status === 1) {   // null === 1 은 false → 아무 일도 없었던 것처럼 통과
  process.exit(2);
}

Windows 에서 npx.cmd 같은 .cmd 파일을 shell(명령줄 해석기 — 명령 프롬프트가 하는 일) 없이 직접 실행하면 Node 22 는 EINVAL(잘못된 인자) 오류를 던져요. 2024년의 보안 패치(CVE-2024-27980) 이후 동작입니다. 이때 spawnSync(외부 명령을 실행하고 끝날 때까지 기다리는 함수)의 결과는 두 칸으로 나뉘어요. status 는 프로그램이 끝나며 남긴 숫자인데, 프로그램이 애초에 시작을 못 했으니 null 이고, 실패 이유는 error 칸에 따로 실립니다. 훅은 status 만 봤어요. null 은 1 이 아니니까, 검사를 못 한 것과 검사를 통과한 것이 같은 결과가 됐습니다.

여기에 역설이 하나 있어요. 이 코드는 훅을 만든 지 3분 뒤에 들어왔습니다. 처음 버전은 shell: true 로 명령을 실행했는데, AI 가 방금 만든 자기 코드를 검토하다 "shell 을 거치면 명령 주입 공격에 취약하다" 고 짚었고, shell 을 빼고 npx.cmd 를 직접 부르는 방식으로 바꿨어요. 보안 판단은 맞았습니다. 그런데 바꾼 뒤에 "이 훅이 아직 살아 있나" 를 확인한 기록이 없어요. 안전을 더하다가 안전장치를 죽인 거예요. 그 파일은 그 뒤로 9월 3일까지 한 번도 수정되지 않았습니다. git 이력에 커밋이 딱 세 개예요 — 만든 것, 3분 뒤 고친 것, 80일 뒤 살린 것.

어떻게 발견됐나 — 내가 아니라 다른 AI 가 짚었다

9월 3일에 블로그 발행 파이프라인을 만들면서 "이 코드는 이렇게 동작한다" 는 주장 14개를 적어 다른 AI(Codex)에게 반증시켰는데, 그중 하나가 ".cmd 를 shell 없이 직접 실행하면 Windows 의 Node 22 에서 EINVAL 이 난다" 였어요. 직접 돌려 보니 그대로였습니다 — status: null, error: EINVAL, Node v22.18.0. 그리고 같은 결함이 그날 새로 만든 교차검증 스크립트에도 있었어요. 그쪽은 codex.cmd 를 같은 방식으로 부르고 있어서, 그대로 뒀으면 Codex 를 찾지 못했다며 영원히 판정불가로 끝났을 겁니다. 같은 날, 같은 실수, 두 곳.

어떻게 시험했어야 했나 — 거부 케이스 하나면 첫날 알았다

고친 건 두 가지예요.

첫째, npx 를 거치지 않습니다. 프로젝트 안의 node_modules/eslint/bin/eslint.js 를 지금 돌고 있는 node 로 직접 실행해요. shell 도 없고 .cmd 도 없으니 EINVAL 이 날 자리가 없습니다.

둘째, status 를 보기 전에 error 를 먼저 봅니다. 실행 자체가 실패하면 "eslint 실행 실패 — 검사 생략" 을 stderr(오류 메시지가 나오는 출력 칸)에 남기고 끝나요. 이 경우엔 exit 2(훅이 '이 에러를 AI 에게 보여 줘라' 는 뜻으로 남기는 종료 코드)로 되돌리지도 않습니다 — 훅이 자기 문제로 AI 를 영영 붙들어 두는 건 다른 종류의 사고니까요. 하지만 적어도 아무 말 없이 통과하지는 않아요. 검사를 못 한 것과 검사를 통과한 것이 이제는 다른 결과입니다.

그리고 검증 방법을 바꿨어요. 6월 15일의 기록을 뒤져 봐도 훅을 달고 나서 일부러 틀린 파일을 넣어 본 흔적이 없습니다. 훅을 달았고, 에러 메시지가 안 떴고, 그걸로 됐다고 믿은 거예요. 그런데 "에러가 안 난다" 는 훅이 살아 있다는 증거가 아니라, 훅이 아무것도 안 해도 똑같이 나오는 결과입니다. 이번엔 일부러 에러가 있는 파일로 훅이 exit 2 로 에러를 되돌리는 것까지 확인했어요. 파일을 손으로 만들 필요도 없습니다 — AI 에게 "일부러 틀린 파일을 하나 만들어서 훅이 잡는지 보여 줘" 라고 시키면 돼요. 이 한 번이 6월 15일에 있었으면 3분 만에 알았을 일입니다.

안전장치는 통과로 검증할 수 없어요. 일부러 틀린 것을 넣어 거부당하는 것을 본 날이, 그 장치가 살아 있는 걸 확인한 첫날입니다.

공유

댓글

0/2000

아직 댓글이 없습니다. 첫 번째 댓글을 남겨보세요.