AI가 생성한 리팩토링 결과물을 직접 감사하여 46개의 버그를 발견했습니다. 이를 통해 배운 점들
요약
AI 코딩 에이전트를 활용해 대규모 코드를 모듈화하는 과정에서 발생한 46개의 버그와 그 교훈을 다룹니다. 단순한 코드 리팩토링을 넘어, AI가 생성한 결과물을 검증하기 위한 테스트와 정적 분석 도구의 중요성을 강조합니다.
핵심 포인트
- AI 리팩토링 결과물은 반드시 인간의 철저한 검토와 테스트가 필요함
- 모듈화 과정에서 전역 변수 참조 오류(ReferenceError)가 빈번히 발생함
- 단순 테스트를 넘어 ESLint와 같은 정적 분석 도구 도입이 필수적임
- AI를 신뢰하기보다 검증 시스템(Gate)을 구축하는 것이 핵심임
1,920줄짜리 파일이 어떻게 410줄로 줄어들었는지, 왜 "작동한다"는 말이 두 번이나 거짓이었는지, 그리고 해결책이 더 똑똑한 모델이 아니라—나를 포함해 그 누구도 믿지 않는 게이트(gate)였다는 사실에 대하여.
설정 (The setup)
저는 YouMindAG를 유지 관리하고 있습니다. 이는 Claude Code, Cursor, opencode, Copilot과 같은 AI 코딩 도구에 프로젝트 컨텍스트(아키텍처, 의존성, 규칙, DB 스키마 등)를 주입하여, 도구들이 매 세션마다 코드베이스를 처음부터 다시 파악할 필요가 없게 만드는 오픈 소스 CLI (npx youmindag)입니다.
v2.7.0 버전까지, CLI의 엔트리포인트(entrypoint)인 bin/run.mjs는 1,920줄까지 늘어나 있었습니다. 파일 하나에 설치 로직, 업그레이드 로직, 17개의 서브커맨드(subcommands), 볼트(vault) 채우기, 개발 서버 래핑(wrapping), AST 기반 트레이싱(tracing) 등이 모두 평면적으로 들어 있었습니다.
저는 당연히 해야 할 일을 했습니다. 바로 모듈화(modularization)였습니다. AI 코딩 에이전트(AI coding agent)가 힘든 작업을 수행하고 제가 검토하는 방식으로, 각 관심사(concern)별로 하나의 파일, 각 커맨드 제품군별로 하나의 파일을 만들어 lib/ 디렉토리로 분리했습니다. v2.9.0 버전이 되었을 때, bin/run.mjs는 오케스트레이터(orchestrator) 역할만 수행하며 410줄로 줄어들었습니다.
깔끔한 디프(diff). 테스트 통과(green). 저는 배포했습니다.
하지만 그것은 일곱 가지의 서로 다른 방식으로 망가져 있었습니다.
1라운드: 수동으로 찾아낸 세 개의 ReferenceError
몇 번의 스모크 테스트(smoke tests)를 진행하자, 세 개의 커맨드에서 ReferenceError: RESET is not defined 및 이와 유사한 오류가 발생하기 시작했습니다. 저는 이를 수동으로 추적했습니다:
lib/graphify.mjs는 11곳에서RESET이라는 콘솔 색상(console-color) 상수를 사용하고 있었습니다. 하지만 이를 임포트(import)한 곳은 전혀 없었습니다.lib/populate.mjs에도 동일하게 임포트가 누락되어 있었고, 이로 인해 볼트 자동 채우기 단계의 요약 출력(summary output)이 조용히 깨지고 있었습니다.lib/commands/misc.mjs는 전달된 적이 없는VERSION상수를 참조하고 있었습니다. 이전의 모놀리스(monolith) 구조에서는 전역(global) 변수였으나, 추출 과정에서 아무것도 아닌 것이 되어버린 것입니다.
저는 이 세 가지를 수정했습니다. 그리고 — 솔직해지고 싶은 부분인데 — 네 번째 버그를 찾아 나서지는 않았습니다. 발견한 세 가지에 대해 8개의 회귀 테스트(regression tests)를 작성하고, 테스트 스위트(suite)를 실행하여 58/58 통과를 확인한 뒤 v2.9.1을 배포했습니다.
8개의 테스트, 에러 메시지를 하나씩 읽으며 발견한 3개의 실제 버그. 그것은 감사가 아닙니다. 그것은 선의를 가진 상태에서 벌이는 두더지 잡기(whack-a-mole)에 불과합니다.
2라운드: 마침내 이 목적을 위해 만들어진 도구를 실행했습니다
몇 번의 릴리스가 지난 후, 저는 제가 간과했던 당연한 질문을 스스로에게 던졌습니다. 만약 추출(extraction) 과정에서 임포트(import)를 한 번 조용히 누락시킬 수 있다면, 제가 아직 건드리지 않은 파일들에서 일곱 번 더 그런 일이 일어나는 것을 무엇이 막을 수 있을까요?
그래서 저는 여기서 중요한 단 하나의 규칙인 no-undef를 포함한 ESLint를 추가하고, bin/ 및 lib/ 하위의 모든 항목을 대상으로 지정했습니다:
// eslint.config.mjs
{
rules: {
...
그런 다음 실행했습니다.
46개의 정의되지 않은 참조(undefined references). 7개의 파일에 걸쳐 발생. 14개의 CLI 명령어 중 7개가 고장 나거나 조용히 실패함.
lib/fs-helpers.mjs +readFileSync
lib/commands/db.mjs +createInterface, parseEnvFile, hasPostgres (+ CWD→cwd 오타 버그)
lib/commands/dev.mjs +mkdirSync, openSync, closeSync, appendFileSync,
...
이 중 일부는 처음 사용할 때 요란하게 충돌(crash)이 발생하는 명령어들이었습니다. 짜증 나긴 하지만, 적어도 눈에 보이기라도 하니 다행이었습니다. 다른 것들은 더 심각했습니다. youmindag dev --wrap과 youmindag sync는 특정 플래그나 특정 프로젝트 상태에서만 실행되는 코드 경로에서 실패하고 있었습니다. 즉, 해당 브랜치에 도달할 일이 없는 프로젝트에서는 몇 주 동안 발견되지 않은 채 방치될 수 있다는 의미였습니다.
저는 46개를 모두 수정했고, 그 후 14개의 모든 CLI 명령어를 실제 프로젝트를 대상으로 수동 검증했습니다. 단순히 "테스트 스위트(test suite)가 통과하는가"가 아니라, "youmindag db, youmindag dev --status, youmindag trace --client X가 각각 실제로 제 역할을 수행하는가"를 하나씩 확인했습니다.
실제로 중요한 부분
버그의 개수는 흥미로운 부분이 아닙니다. 기계적인 추출 이후 임포트(import)가 누락되는 것은 이미 알려진 실패 모드(failure mode)입니다. 수동으로 대규모 리팩토링(refactor)을 해본 사람이라면 누구나 겪어본 일입니다. 진짜 흥미로운 부분은 _왜 제가 첫 번째에는 46개를 놓치고 두 번째에는 단 3개만 발견했는지, 그리고 왜 "차이점(diff)을 주의 깊게 읽으라"는 조언이 결코 그 격차를 줄여줄 수 없었는지_입니다.
AI 에이전트가 1,920줄짜리 파일에서 함수를 추출하여 새로운 모듈로 만들 때, 실패 모드는 잘못된 로직이 아닙니다. 로직은 보통 그대로 복사됩니다. 실패 모드는 추출된 코드가 암묵적으로 의존하고 있던 것을 잊어버리는 것입니다. RESET은 예전에 거대한 단일 파일(monolith)의 상단에 한 번 정의되어 있었고, 그 파일 안의 모든 함수는 아무것도 임포트(import)하지 않고도 그것에 접근할 수 있었습니다. 일단 그 함수를 별도의 파일로 잘라내면, 그 암묵적인 접근은 사라집니다. 그리고 추출된 코드 자체는 전혀 잘못되어 보이지 않습니다. 완벽하게 읽힙니다. 오직 런타임(runtime)에, 더 이상 존재하지 않는 무언가에 접근하려는 바로 그 라인에서만 잘못될 뿐입니다.
그리고 여기서 불편한 사실이 있습니다. 저는 LLM이 코드를 작성할 때와 똑같은 종류의 주의력을 가지고 그 코드를 검토하고 있었습니다. 즉, 모든 식별자(identifier)를 바인딩(binding)까지 역추적하며 읽는 것이 아니라, 코드가 올바르게 보이는지를 확인하며 읽고 있었던 것입니다. 그것은 느리고, 기계적이며, 매력적이지 않은 작업입니다. 그것은 바로 인간 검토자가 시간 압박 속에서 건너뛰는 종류의 확인 작업이며, 정적 분석 도구(static analysis tool)가 결코 건너뛰지 않는 종류의 확인 작업입니다. 정적 분석 도구는 지치지 않으며, 무언가가 "괜찮아 보인다"고 믿지 않기 때문입니다.
no-undef는 똑똑한 규칙이 아닙니다. 그것은 당신의 아키텍처(architecture)를 이해하지 못하고, 좋은 추상화(abstraction)가 무엇인지 알지 못하며, 의견을 갖지도 않습니다. 바로 그렇기 때문에 그것이 제가 놓친 것을 잡아낼 수 있었습니다. 그것은 "이것이 작동하는 코드처럼 보이는가"에 대해 패턴 매칭(pattern-matching)을 하는 것이 아니라, "모든 식별자가 무언가에 바인딩되어 있는가"를 기계적으로 확인하는 것이기 때문입니다. 제 눈은 첫 번째 질문에는 능숙하지만 두 번째 질문에는 서툽니다. 린터(linter)는 그 반대이며, 이 일을 겪은 후 저는 제 눈만으로 충분하다고 가정하는 것을 그만두었습니다.
해결책은 더 똑똑한 모델이 아니라, 게이트(gate)였습니다
v2.9.3에서의 진짜 해결책은 "다음에는 더 주의하세요"가 아니었습니다. 그것은 부주의함이 구조적으로 배포되는 것을 불가능하게 만드는 것이었습니다:
"scripts": {
"test": "node --test",
"lint": "eslint bin/run.mjs lib/*.mjs lib/commands/*.mjs",
...
prepublishOnly는 모든 npm publish 시 자동으로 실행됩니다. 만약 린트 (lint)나 테스트가 실패하면, 배포 (publish)도 실패합니다. 저는 의도적으로 파일을 망가뜨린 뒤, npm에 도달하기 전에 배포가 차단되는 것을 확인하여 이를 테스트했습니다.
이는 v2.9.0–v2.9.2 버전에서 배포되었던 버그 유형 — 즉, 리뷰를 통과해버린 누락된 임포트 (import) — 이 누군가 의도적으로 게이트 (gate)를 우회하지 않는 한 다시는 배포될 수 없음을 의미합니다. "아마 발생하지 않을 것"이 아닙니다. 구조적으로, 기본적으로 발생할 수 없습니다.
자신이 생성한 AI 리팩토링 결과물을 감사하려는 사람에게 해주고 싶은 말
- 테스트 스위트 (test suite)가 통과(green)되었다는 것은 당신이 테스트한 경로가 작동한다는 것을 알려줄 뿐입니다. 당신이 테스트할 생각을 하지 못한 경로에 대해서는 아무것도 말해주지 않습니다. 58/58개의 테스트가 통과되었음에도 7개의 명령어가 고장 난 상태로 공존했는데, 이는 테스트가 로직 (logic)을 다루었을 뿐, 실제 프로젝트에 대한 모든 명령어의 해피 패스 (happy path)를 다루지는 않았기 때문입니다.
- "디프 (diff)를 검토했다"와 "특정 불변성 (invariant)을 확인하는 도구를 실행했다"는 같은 종류의 확신이 아닙니다. 전자는 잘못되어 보이는 것을 잡아냅니다. 후자는 어떻게 보이든 상관없이 실제로 잘못된 것을 잡아냅니다.
- 만약 리팩토링에 추출 (extraction) — 즉, 한 파일에서 코드를 뽑아 여러 파일로 나누는 작업 — 이 포함되어 있다면, 도구가 그렇지 않음을 증명할 때까지 암시적 의존성 (implicit dependencies)이 깨졌다고 가정하십시오. 코드를 읽어서 찾으려 하지 마세요. 제가 처음에 46개 중 3개를 발견한 방식이 바로 그랬습니다.
- 살아남는 수정 사항은 다시 수행해야 한다는 것을 기억할 필요가 없는 방식입니다. 제가 더 똑똑해져서 ESLint를 추가한 것이 아닙니다. 제가 게이트 (gate)를 추가한 이유는, 리팩토링 도중 시간이 부족하고 배포를 서두르는 '미래의 나'를, 애초에 추출 (extraction)을 작성한 AI보다 더 신뢰할 수 없기 때문입니다.
YouMindAG는 현재 v2.9.3 버전이며, GitHub에서 MIT 라이선스로 제공됩니다. 위의 커밋 히스토리 (commit history)는 실제이며 공개되어 있으니, 제 계산이 맞는지 확인하고 싶다면 언제든 확인하십시오. git log는 숫자를 올림하지 않습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기