두 번의 AI 리뷰를 통과한 내 변경 사항. 올바른 아키텍처는 단 한 파일 떨어진 곳에 문서화되어 있었다.
요약
AI 모델이 작성한 코드와 리뷰 모델이 모두 통과했음에도 불구하고, 로컬과 배포 환경의 모듈 시스템 차이로 인해 런타임 오류가 발생한 사례를 다룹니다. 검증 도구의 스코프 한계와 문서화된 아키텍처의 중요성을 강조합니다.
핵심 포인트
- AI 모델의 검증은 주어진 스코프 내에서만 유효함
- 로컬 환경과 배포 런타임 간의 모듈 시스템(ESM vs CommonJS) 차이 주의
- 단순 구문/로직 테스트를 넘어선 패키징 및 환경 검증 필요
- 문서화된 아키텍처 정보가 문제 해결의 핵심 단서가 될 수 있음
두 개의 별도 모델 — 하나는 코드를 작성하고, 하나는 diff(차이점)를 리뷰하는 모델 — 이 스테이징(staging) 환경에 단 한 단어의 버그를 배포했습니다. 두 모델 모두 로컬 체크를 수행했습니다. 둘 다 통과(green) 판정을 내렸습니다. 그들이 놓친 수정 사항은 기이하거나 미묘한 것이 아니었습니다. 그것은 변경 사항과 동일한 디렉토리에 있는 파일에 평이한 영어로 기록되어 있었습니다.
마지막 그 디테일이 바로 우리가 주목해야 할 부분입니다. 이것은 모델이 틀렸다는 이야기가 아닙니다. 두 모델 모두 자신에게 주어진 작업 범위 내에서는 정확했습니다. 이것은 검증이 어디에서 멈추는지, 그리고 왜 같은 지점에서 검증을 멈추는 두 명의 리뷰어를 쌓아두는 것이 안전해 보이지만 실제로는 그렇지 않은지에 대한 이야기입니다.
무엇이 고장 났는가
cron 타임아웃을 수정하던 중, 저는 Claude Opus에게 작은 JSON 파싱 헬퍼(helper)를 별도의 파일로 분리하여 독립적으로 유닛 테스트(unit-test)를 수행할 수 있도록 요청했습니다. 합리적인 직관이었습니다. 모델은 파일 이름을 jsonExtract.mjs로 지정했고 autoPublish.js에서 이를 임포트(import)했습니다:
import { _firstJsonObject } from './jsonExtract.mjs'; // 완벽하게 합법적으로 보임
이 모든 사건은 바로 그 한 글자 — m에 담겨 있습니다.
autoPublish.js는 ES 모듈(ES module)입니다. 제 컴퓨터에서 Node는 하나의 ESM 파일이 .mjs 파일을 임포트하는 것을 기꺼이 허용했으므로, 모든 로컬 체크를 통과했습니다. 하지만 배포된 런타임(runtime)은 다른 경로를 택했습니다:
Local: ESM importer → .mjs ES module → 작동함
Deployed: CommonJS require() → .mjs ES module → ERR_REQUIRE_ESM
여기서 제가 실제로 알고 있는 바를 정확히 짚고 넘어가고 싶습니다. 왜냐하면 이것이 중요하기 때문입니다. 저는 Vercel의 빌드(build) 과정을 계측(instrument)하지 않았습니다. 제가 관찰한 것은 배포된 함수가 CommonJS require()를 통해 헬퍼에 도달한 반면, 로컬 Node는 호환 가능한 ESM 경로를 실행했다는 점입니다. 어떤 변환이 이를 만들어냈든, 그 결과는 언어 명세(language spec)에 의해 고정되어 있습니다: .mjs 확장자는 파일을 ES 모듈로 강제하며, ES 모듈에 대한 require()는 금지됩니다. 그 결과 호출 시점에 크래시(crash)가 발생했습니다:
Error [ERR_REQUIRE_ESM]: require() of ES Module
/var/task/.../jsonExtract.mjs
from /var/task/.../autoPublish.js not supported.
구문 오류(syntax error)도 아니었습니다. 로직 오류(logic error)도 아니었습니다. 플랫폼이 저장소 하단의 모듈 시스템을 재작성할 때 비로소 실체가 드러나는 패키징 오류(packaging error)였습니다.
아무도 책임지지 않았던 경계선
당신이 실행하는 모든 체크에는 스코프(scope)가 있으며, '통과(green)'라는 결과는 오직 "해당 스코프 내에서 깨끗함"만을 의미합니다. 우리는 이를 잊곤 합니다. 왜냐하면 통과 결과는 실제 커버리지가 얼마나 되든 상관없이 똑같은 색상으로 표시되기 때문입니다.
이 변경 사항에 실행된 체크들을 살펴보고, 이를 합격/불합격이 아닌 스코프로 읽어보십시오:
node --check— 스코프: 구문(syntax). 파일이 파싱되었습니다. 사실이지만, 여기서는 무용지물입니다.- 테스트 스위트 (The test suite) — 스코프: 로컬 런타임(local runtime)에서 테스트가 실행하는 코드 경로. 모두 통과되었습니다. 이 또한 사실입니다. 테스트는 배포 경계(deploy boundary)를 넘어 모듈을 호출한 적이 없었기 때문입니다.
- AI 리뷰 (The AI review) — 스코프: 프롬프트(prompt)에 의해 정의된 로직의 정확성. 경쟁 상태(race conditions), 변수 스코핑(variable scoping), 추출 로직의 건전성 여부 등. 모두 괜찮았습니다. 리뷰어에게는 "이 로직이 올바른가"라고 질문되었고, 리뷰어는 그 질문에 잘 답변했습니다.
- 배포된 런타임 (The deployed runtime) — 스코프: 실제 모듈 시스템. 이 버그가 존재하는 유일한 스코프입니다.
버그는 체크를 통과하며 몰래 빠져나간 것이 아닙니다. 버그는 "두 체크 사이의 경계선(seam)", 즉 "로컬 ESM resolution"과 "배포된 CJS resolution" 사이의 간극에 존재했습니다. 그리고 기존의 어떤 체크도 그 경계선을 자신의 스코프 안에 포함하고 있지 않았습니다. ERR_REQUIRE_ESM은 호출 레벨(invocation-level)의 오류이므로, 정의상 빌드 로그는 통과 상태를 유지했습니다. 빌드 시점에는 이를 트리거할 실행 작업이 아무것도 없었기 때문입니다.
이 지점에서 "한 파일 떨어진 곳에 문서화되어 있다"는 디테일은 아이러니를 넘어 실제 교훈으로 변합니다. 같은 디렉토리에 cluePrompts.js라는 형제 헬퍼 파일이 있었고, 여기에는 이것이 _의도적으로 CommonJS로 작성되었다_는 눈에 띄는 헤더 주석이 달려 있었습니다. 즉, ESM 소비자로부터 기본적으로 임포트(import)되어 바로 이 재작성 작업을 피할 수 있도록 특별히 그렇게 작성된 것이었습니다. 프로젝트 문서 또한 이 컨벤션(convention)을 강화하고 있었습니다. 올바른 아키텍처(architecture)는 알려지지 않았던 것이 아닙니다. 그것은 **수동적(passive)**이었습니다. 산문(prose)으로 인코딩되어 있었고, 산문은 위에 언급된 모든 자동화된 범위(scope) 밖에 있었습니다. 변경 사항(diff)을 훑어보는 인간은 이미 찾아봐야 한다는 것을 알고 있지 않는 한 이를 보지 못했을 것입니다. 모델 또한 마찬가지입니다. 지식은 존재했지만 누구에게도 전파되지 않았습니다.
왜 두 번의 리뷰가 두 번의 리뷰가 아닌가
제가 이 글을 쓰게 만든 함정이 바로 여기에 있습니다. 왜냐하면 이 부분은 Node와 Vercel을 넘어 일반화될 수 있는 내용이기 때문입니다.
두 개의 독립적인 승인(green stamps) — 하나는 작성 모델(author model)로부터, 다른 하나는 테스트를 직접 실행하는 별도의 리뷰어 모델(reviewer model)로부터 — 은 마치 심층 방어(defense in depth)처럼 느껴집니다. 중복성(redundancy)처럼 읽히기도 합니다. 하지만 그렇지 않습니다. 중복성은 리뷰어들이 독립적으로 실패할 때만 커버리지(coverage)를 확보해 줍니다. 이 두 모델은 범위를 공유하고 있었습니다. 둘 다 로컬 런타임(local runtime)에서의 로직 정확성을 평가하고 있었습니다. 두 번의 체크가 범위를 공유하면 사각지대(blind spot)도 공유하게 되며, 이를 쌓아 올리는 것은 커버리지를 단 한 치도 넓히지 못한 채 확신(confidence)만 배가시킬 뿐입니다.
이것이 멀티 모델 파이프라인(multi-model pipelines)의 조용한 위험입니다. 첫 번째 모델과 동일한 방식으로 코드를 생각하는 두 번째 모델을 추가하는 것은 그물을 넓히는 것이 아니라, 단지 더 확신에 찬 오답을 얻게 할 뿐입니다. 진정한 심층 방어(defense in depth)를 위해서는 범위가 상관관계가 없는(uncorrelated) 체크가 필요합니다. 즉, 다른 체크들과는 완전히 다른 신호(signal)를 읽어내는 체크가 필요합니다.
다른 범위를 가진 단 하나의 체크
저는 빌드하는 동안 결정론적인(deterministic) 로컬 훅(hook)인 GroundTruth를 실행합니다. 이것이 무엇을 했고 무엇을 하지 않았는지 정확하게 말씀드리겠습니다. 정직한 버전이 더 유용하기 때문입니다.
그것은 ESM 버그를 잡아내지 못했습니다. 잡아낼 수 없었습니다. 그것은 Vercel의 런타임(runtime)을 들여다볼 수 있는 창이 없으며, 정적 텍스트로부터 require() 실패를 예측하려는 시도도 하지 않습니다. 의미론적(semantic)이고 환경을 인식하는(environment-aware) 평가 기능은 로드맵에는 있지만, 현재 기능에는 포함되어 있지 않습니다. 만약 제가 그것이 충돌을 예견했다고 말했다면, 저는 이 포스트 전체가 다루고 있는 바로 그 잘못된 확신을 당신에게 팔고 있는 셈이 될 것입니다.
그것이 실제로 한 일은 해당 변경 사항이 통과(green)되었다는 것에 동의하기를 거부한 것입니다. 왜냐하면 그것의 범위(scope)는 논리적 정확성이 아니었기 때문입니다. 그것의 범위는 _주장이 관찰 가능한 증거와 일치하는가_였습니다. 제가 테스트 통과를 주장할 때마다 그것은 동일한 경고를 보냈습니다. 증거 내에서 그 주장을 뒷받침할 테스트 실행 기록을 찾을 수 없었기 때문입니다:
[warn] false test/build claim — claimed tests/build pass ("tests pass"),
but a test run looks like it reported failures — double-check
그리고 제가 스테이징(staging) 충돌 내용을 터미널에 붙여넣는 순간, 그것은 태스크를 열고 이를 유지했습니다. 제가 수정되었다고 _말할 때까지_가 아니라, 실제 Node 변경 사항이 디프(diff)에 나타날 때까지 유지했습니다:
[warn] open loop (asked, not delivered) — pending task [tftlx] —
"Staging failed: Error [ERR_REQUIRE_ESM]: require() of ES Module …"
(no Node.js in the diff yet)
CommonJS 수정 사항이 실제로 디프(diff)에 반영되었을 때에야 비로소 경고가 사라졌습니다. 모델이 요청하지 않아도 수행되는 결정론적인 "말한 대로 완료(Told & Done)"였습니다.
이 부분을 주의 깊게 읽으십시오. 도구의 승리로 오해하기 쉽지만 그렇지 않기 때문입니다. 핵심은 GroundTruth가 리뷰어들보다 똑똑하다는 것이 아닙니다. 그것은 의도적으로 리뷰어들보다 멍청합니다. 코드를 전혀 읽지 않기 때문입니다. 그것은 증거에 대비한 주장(claims)을 읽습니다. 그것은 _다른 범위(different scope)_이며, 다른 범위만이 상관관계가 있는 한 쌍(correlated pair)이 놓치는 것을 잡아낼 수 있는 유일한 방법입니다. 중요한 신호는 내내 정확했습니다. 저의 실수는 두 개의 확신에 찬 초록색 승인 도장이, 그들과 사각지대를 공유하지 않았던 하나의 완고한 노란색 경고보다 더 큰 비중을 가졌다는 점입니다.
수정 사항
헬퍼(helper)를 CommonJS로 변환하여, 이웃 파일이 이미 문서화해 둔 패턴과 일치시킵니다:
// jsonExtract.js — 의도적으로 CommonJS로 작성, cluePrompts.js와 일치시킴
function _firstJsonObject(text) { /* … */ }
module.exports = { _firstJsonObject };
...
로컬에서도 작동하고, 배포 환경에서도 작동하며, 단일 진실 공급원 (one source of truth)을 가집니다. 롤백 없이, 프로덕션 다운타임 없이, 단 한 번의 커밋으로 즉시 수정(fixed forward)되었습니다. 스테이징(staging) 단계를 벗어나기 전이었으니까요.
향후 적용할 규칙
api/ 하위의 모든 새로운 모듈은 옆에 이미 구현된 CommonJS 컨벤션 (convention)을 따르거나, 메인(main) 브랜치에 머지(merge)되기 전에 실제 호출 환경에서 실행 검증을 거쳐야 합니다. .mjs가 나빠서가 아닙니다. 주석에만 존재하는 컨벤션은 강제(enforcement)가 아니라 바람(wish)일 뿐이기 때문입니다.
실패의 연쇄 과정을 요약하면 다음과 같습니다:
올바른 아키텍처를 알고 있었음
→ 하지만 그것은 수동적인 산문(passive prose)이었음
→ 그래서 작성자가 이를 적용하지 않았음
...
.mjs와 .js의 차이는 지엽적인 문제입니다. 시스템적인 교훈은 문서화된 지식은 아무것도 강제하지 못하며, 생각이 같은 두 명의 리뷰어는 비용만 두 배로 드는 한 명의 리뷰어와 같다는 점입니다. 만약 어떤 규칙이 배포를 중단시킬 만큼 중요하다면, 테스트(test), 린트(lint), 게이트(gate)와 같이 체크가 가능한 곳에 존재해야 합니다. 그렇지 않다면 코드를 실제로 실행하여 깨지는 상황을 마주해야 합니다. 헤더 주석에 적힌 산문은 좋은 컨벤션들이 정중하게 무시당하러 가는 곳입니다.
그래서 저는 제가 실제로 답을 얻고 싶은 질문으로 바꾸려 합니다: 당신의 코드베이스가 문서화는 해두었지만 강제하지는 않는 컨벤션은 무엇입니까? 그리고 누군가, 혹은 무언가가 그것을 읽지 않았을 때 당신이 치른 대가는 무엇이었습니까?
저는 GroundTruth와 EraPin을 구축하며 이 글을 작성합니다. 이번 사례는 Vercel 스테이징에서 크론(cron)이 실행되는 즉시 발견되었으며, 한 시간 이내에 즉시 수정되었습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기