규칙이 아닌 레일(Rails): 체크를 통해 코딩 에이전트의 도메인 어휘 강제하기
요약
코딩 에이전트에게 도메인 언어를 산문 형태의 지침으로 전달하는 대신, 체크 스크립트를 통해 기계적으로 강제하는 방법론을 다룹니다. 모델의 지시 이행 능력 문제보다 코드베이스 전반에 남는 용어 잔여물을 관리하는 것이 핵심임을 강조합니다.
핵심 포인트
- 단순한 텍el 지침은 코드베이스의 용어 일관성을 보장하지 못함
- 컴파일러가 인지하지 못하는 영역(주석, 문자열 등)의 용어 잔여물 관리 필요
- 명세 주도(spec-driven) 방식에서 도메인 언어의 기계적 검증 중요성
- 모델의 성능 탓을 하기 전, 규칙을 검증할 도구가 있는지 확인해야 함
왜 내가 코딩 에이전트에게 도메인 언어를 말해주는 것을 그만두고, 이를 강제하기 시작했는지에 대하여.
나는 4월 24일에 프로젝트 용어에 대한 코딩 에이전트의 규칙을 산문(prose) 형태로 작성했습니다. 그리고 5월 2일에 그 규칙들을 _체크(check)_하는 스크립트를 작성했습니다. 첫 실행 결과, 약 150,000줄의 코드베이스에서 737개의 위반 사항이 발견되었습니다. 이 코드베이스는 에이전트가 나의 지시하에 작성한 것이며, 나는 표면적으로는 작업 내내 일관성을 유지해 왔다고 생각했습니다.
그 737개가 무엇이었는지 정확히 짚고 넘어갈 가치가 있습니다. 왜냐하면 '에이전트가 내 규칙을 무시했다'라는 식의 쉬운 해석은 틀렸으며, 진짜 이야기는 훨씬 더 유용하기 때문입니다. 나는 명세 주도(spec-driven) 방식의 작업 방식을 채택했습니다. 도메인 언어(domain language)를 정교하게 다듬어야 할 때면, 용어 사전(glossary)에서 이름 변경(rename)을 시작하여 기존 용어를 폐기하고 새 용어를 정의했습니다. 그 후 변경 사항은 명세(spec) 수정으로 이어졌고, 마지막에야 코드에 도달했습니다. 모든 이름 변경은 완벽하게 이루어져야 했습니다. 하지만 단 하나도 완벽하지 않았습니다. 이름 변경은 컴파일러가 볼 수 있는 부분들을 통해서는 깔끔하게 전파되지만, 컴파일러가 볼 수 없는 곳에는 잔여물(residue)을 남깁니다. 이전 개념의 이름을 그대로 가진 디렉토리, 프론트엔드(frontend)의 문자열, 주석 속의 용어, 그리고 명세(specs) 자체에서도 자주 발견되었습니다. 명세는 코드보다 훨씬 규모가 작고 이름 변경의 일부로 활발히 재작성되고 있었음에도 말입니다. 나는 그 잔여물들을 눈으로 하나씩 잡아내면서도, 내가 보고 있는 것은 극히 일부일 뿐이라는 불안한 느낌을 지울 수 없었습니다. 737이라는 숫자는 내가 나머지 잔여물들을 셀 수 있는 무언가를 처음으로 구축했을 때 나온 결과였습니다. 파일에 적힌 규칙들은 틀리지 않았고, 무시된 것도 아니었습니다. 단지 규칙들이 관장하는 산물(artifacts)들을 볼 수 없었을 뿐입니다. 코드도 볼 수 없었고, 코드와 함께 재작성되고 있는 명세조차 볼 수 없었습니다. 마크다운(markdown) 파일에 적힌 문장은 하나의 바람(wish)일 뿐입니다. 체크(check)는 그 바람이 진실로부터 얼마나 떨어져 있는지를 나에게 알려줄 수 있는 프로젝트의 첫 번째 도구였습니다.
이 에세이는 그 숫자가 강제한 변화, 즉 도메인 언어 (domain language)를 단순히 글로 쓰는 것에서 기계적으로 강제하는 것으로의 이동에 관한 것입니다. 이는 생산성 향상을 위한 꼼수가 아니라, 빠르게 변화하며 에이전트(agent)에 의해 구축되는 코드베이스를 그 자체의 어휘 (vocabulary)와 일관되게 유지해 주는 실제적인 수단으로 밝혀진 것에 관한 이야기입니다. 만약 여러분이 AI 코딩 도구를 위해 세심한 지침 파일 (instructions file)을 작성했음에도 불구하고 코드가 결국 지침에서 벗어나는 것을 목격했다면, 이 글은 그 현상에 대한 영수증이 첨부된 동일한 이야기이며, "모델이 말을 듣지 않는다"라는 진단보다 더 유용한 진단을 제공할 것입니다.
산문(Prose)은 구속력이 없다
이와 관련하여 익숙한 버전의 이야기가 있습니다. "나는 200줄의 규칙을 작성했지만 모델은 그것을 모두 무시했다." 일반적인 설명으로는 컨텍스트 윈도우 (context-window) 압박, 지시 이행 (instruction-following)의 한계, 혹은 규칙이 프롬프트 (prompt) 내의 다른 모든 요소와 경쟁하는 상황 등을 듭니다. 아마 모두 사실일 것입니다. 하지만 이를 모델의 잘못된 행동으로 규정하는 것은 더 일반적인 메커니즘을 놓치는 것입니다. 이 메커니즘은 에이전트가 준수하려고 노력하는지 여부와는 아무런 상관이 없습니다. 인간이든 모델이든 완벽하게 성실한 협업자라 할지라도, 변화했다는 사실조차 알지 못하는 표면(surface)에는 규칙을 적용할 수 없습니다. 프롬프트에 규칙이 존재한다는 사실과 코드베이스에 규칙이 미치는 효과는 서로 다른 두 가지 사실이며, 그 사이의 간극은 아무것도 측정하지 않는 곳에서 정확히 보이지 않는 상태로 남게 됩니다.
제가 기록한 것 중 가장 인상적인 사례는 에이전트 본인으로부터 나왔습니다. 프로젝트 중간에, 글로 된 주의 사항을 한 번 더 전달했음에도 효과가 없자, 저는 에이전트에게 그 규칙을 영구적인 지침 (persistent instructions)에 넣는 것만으로 충분할지 단도직입적으로 물었습니다. 그 답변은 자신의 편의성에 반하는 논리를 펼쳤습니다:
솔직한 답변을 드리자면, 아니요. … 그것은 단지 단계가 더 추가된 규율 (discipline)일 뿐입니다. … 그것은 제가 그것을 읽고 그 순간에 올바르게 _선택_할 때만 작동합니다.
이것이 바로 이 논문의 핵심 주제이며, 이 주제의 대상인 시스템이 스스로 자원하여 내놓은 결론입니다. 실행하지 않을 자유가 있는 서면 지침은, 아무리 단호하게 표현되더라도 제약 (constraint)이 아닙니다. 그것은 선호 사항 (preference)일 뿐입니다. 해결책은 더 정교한 문장으로 다듬은 선호 사항이 되어서는 안 됩니다. 그것은 에이전트가 그 순간에 올바른 선택을 하는지에 의존하지 않는 무언가여야 합니다.
그 사후 결과물(retrospective receipt)은 같은 주에 도착했습니다. 저는 에이전트에게 자신의 지침 파일(instructions file)을 두 개로 분리하고, 순수하게 산문(prose)으로만 되어 있던 규칙들을 기계화(mechanize)하도록 시켰습니다. 커밋 메시지(이 프로젝트의 거의 모든 메시지와 마찬가지로, 자신의 행동을 되돌아본 에이전트가 작성함)는 제가 계속해서 되새기게 되는 다섯 단어로 그 이유를 설명합니다.
misses clustered on un-gated prose rules.
(게이트가 없는 산문 규칙들에 오류가 집중됨)
코드베이스가 실제 자신의 규칙과 어긋난 지점을 매핑했을 때, 오류(misses)는 무작위로 분포되어 있지 않았습니다. 그것들은 오직 기록되어 있다는 사실 외에는 아무런 근거가 없는 규칙들에 집중되어 있었습니다. 체크(check), 스크립트(script), 훅(hook), 또는 실행을 실패하게 만들 수 있는 무언가가 뒷받침된 규칙들에서는 드리프트(drift, 이탈)가 발생하지 않았습니다. 이 패턴은 반항과는 아무런 관련이 없습니다. 그것은 결과(consequence)에 관한 문제입니다. 기계적인 결과가 따르지 않는 규칙은, 작업 내부의 관점에서 볼 때 규칙이 아예 없는 것과 구별할 수 없습니다.
한 가지 에피소드가 이를 구체적으로 보여주었습니다. 지침에는 이름 변경(rename) 시 모든 표면을 발맞추어 훑어야 한다는 점이 평이한 언어로 명시되어 있었습니다: 이전 이름은 어디서든 폐기되어야 하며, 낙오자(stragglers, 에이전트 자신의 표현)가 없어야 한다고 말이죠. 이보다 더 명확한 규칙을 쓰기는 어렵습니다. 그럼에도 불구하고 이름 변경 작업이 수행되었을 때, 일부 사이트들이 훑어지지 않은 채 남겨졌습니다. 컴파일러가 절대 읽지 않는 코드의 문자열 구석진 곳뿐만 아니라, 이름 변경을 유도하기 위해 다시 작성되고 있는 문서인 스펙(specs) 자체에서도 말입니다. 이를 잡아낸 것은 규칙을 다시 읽어본 사람이 아니었습니다. 이름 변경이 완료되었다고 간주된 지 두 단계의 작업(work-slices)이 지난 후, 용어 체크(terminology check)가 남아있는 이전 용어의 발생 사례를 찾아내어 경고를 보낸 것이었습니다. 지속 가능한 해결책은 규칙을 더 단호하게 재진술하는 것이 아니었습니다. 이름 변경이 완료되었다고 선언하기 전에 모든 추적된 표면을 훑는 '이름 변경 완료 게이트(rename-completeness gate)'를 추가하는 것이었습니다. 산문의 규율(prose discipline)은 실패했지만, 게이트는 버텨냈습니다. 모호함이 없다고 확신했던 규칙에 이런 일이 일어나는 것을 한 번 보고 나면, "그냥 더 명확하게 적어라"라는 말은 더 이상 신뢰할 수 있는 계획이 되지 않습니다.
생성 과정을 실제로 무엇이 조종하고 있는지를 생각해보면, 당신도 똑같이 예상할 것입니다. 모델은 그럴듯하고 작동하는 것처럼 보이는 코드를 향해 최적화(optimize)합니다. 그만큼 중요한 점은, 모델이 이를 '관리 가능한 (manageable)' 단위로 최적화한다는 것입니다. 모델은 자신의 컨텍스트 예산(context budget)에 맞는 의미 있는 덩어리로 각 작업을 범위화(scope)하며, 약속하지도 않은 철저한 전수 조사를 수행하기보다는 합리적인 증분(increment)을 목표로 합니다. 또한 모델은 행동하기 전에 모든 명세(spec) 라인과 모든 파일을 읽지 않습니다. 모든 표면에서의 완결성(Completeness)은 모델이 제공하지 않는 바로 그 보장 사항입니다. 이에 반해, "outcome_code가 아니라 disposition이라고 부를 것"이라고 명시된 용어집(glossary) 항목은 기울기(gradient)가 없는 소프트 제약 조건(soft constraint)일 뿐입니다. 이를 놓쳐도 아무런 반작용이 없습니다. 컴파일러(compiler)는 상관하지 않습니다. 테스트(test)는 어느 쪽이든 통과(green) 상태를 유지합니다. 따라서 이 제약 조건은 주변의 더 강력한 신호들에 밀려 조용히 패배하며, 그 격차는 훨씬 나중에, 눈으로 확인하는 과정에서 엉뚱한 용어들이 하나씩 나타날 때 비로소 드러납니다.
결론은 불편하지만, 제 생각에는 옳습니다: 협상 불가능한 규칙은 프롬프트(prompt) 안에 머물러서는 안 됩니다. 규칙이 깨지는 즉시 실패하는 체크(check)가 되어야 합니다. 이 프로젝트에서 체크는 프리 커밋 훅(pre-commit hooks)으로 실행되므로, 변경 사항이 커밋(commit)되기 전에 게이트(gate)가 작동합니다. 코드 변경의 경우 컴파일러는 대개 이미 실행되어 통과된 상태이며, 그것은 애초에 논점이 아니었습니다. 게이트는 컴파일러가 할 수 없는 것을 체크합니다. 하지만 핵심은 원칙에 있습니다: 만약 어떤 규칙이 실패할 수 있는 무언가에 의해 강제(enforce)되지 않는다면, 그것은 규칙이 아닙니다. 그것은 당신이 유지하기 위해 주의력 세금(attention-tax)을 지불하고 있는 선호 사항(preference)일 뿐입니다.
타인의 어깨 위에 올라서기
"제안하지 말고 강제하라"라는 결론에 도달한 사람이 저뿐만이 아니며, 먼저 그곳에 도달한 사람들에게 공로를 돌리고 싶습니다. 왜냐하면 이 아이디어는 진정으로 그들의 것이며, 제가 추가하는 부분은 그 위에 놓여 있기 때문입니다.
Factory.ai의 Alvin Sng는 린터(linters)를 사용하여 에이전트를 유도하는 방안에 대해 주장했습니다. 즉, 에이전트가 기억해야 할 산문(prose) 형태가 아니라, 에이전트가 실행할 린트 규칙(lint rules)으로서 관례를 인코딩하자는 것입니다. InfoQ는 피트니스 함수(fitness functions)와 기계적으로 실행 가능한 아키텍처 의도 선언을 통해, 감독이 생성 속도를 따라잡을 수 있도록 하는 [
따라서 첫 번째 모델을 감시하기 위해 두 번째 모델을 사용하는 대신, 저는 모델을 공식적이고 결정론적인(deterministic) 도구인 스크립트로 밀어 넣습니다. 이는 모델에게 기억하도록 요청하는 것보다 단순히 더 저렴하고 신뢰할 수 있을 뿐만 아니라, 모델의 헤드룸(headroom)을 오직 모델만이 할 수 있는 작업에 사용할 수 있도록 해줍니다. 확률적 평면(probabilistic plane)에서 공식적 평면(formal plane)으로 옮기는 모든 규칙은, 장부 정리(bookkeeping)에 소비하던 창의적 역량을 다시 확보해 줍니다.
그렇다면 기여(contribution)의 지점은 어디일까요? 기존 기술(prior art)은 이러한 강제성을 주로 아키텍처(architecture), 즉 레이어링(layering), 의존성 방향(dependency direction), 시스템의 형태에 초점을 맞춥니다. 금지된 임포트(import)는 한 줄의 grep으로 찾아낼 수 있고 아키텍처 위반은 명확하게 드러나기 때문에, 이는 자연스러운 첫 번째 목표가 됩니다. 제가 발견한 것은 동일한 강제성이 한 단계 더 안쪽, 즉 도메인 주도 설계 (DDD, Domain-Driven Design)에서 실제로 의미를 담고 있다고 말하는 요소에 적용되어야 한다는 것입니다. 그것은 바로 도메인 전문가와의 대화에서부터 클래스 이름에 이르기까지 관통하는 공유 어휘인 **보편적 언어 (ubiquitous language, UL)**입니다. 그리고 에릭 에반스(Eric Evans)는 그의 저서 Domain-Driven Design (Addison-Wesley, 2003)에서 이 언어가 한 번 작성하고 끝내는 용어집이 아니라는 점을 강조합니다. 그는 보편적 언어가 지속적으로 압축되고 정제되어야 하며, 팀의 이해도가 높아짐에 따라 반드시 _진화(evolve)_해야 한다고 기술했습니다. 그 진화가 제가 수행한 모든 이름 변경(rename)의 엔진이었습니다.
에이전트와 함께 DDD(도메인 주도 설계)를 수행할 때 발생하는 특유의 반전이 있습니다. 어떤 의미에서 LLM은 Evans의 프로그램에 있어 선물과 같습니다. 언어를 진화시키는 과정을 극적으로 더 빠르고 저렴하게 만들어주기 때문에, 과거에는 일주일 동안 신중하게 리팩터링(refactor)해야 했던 작업인 명세(specs)와 코드 전반에 걸친 이름 변경(rename)을 단 한나절 만에 끝낼 수 있습니다. 하지만 이러한 속도는 실패 모드(failure mode) 또한 배가시킵니다. 에이전트가 주도하는 모든 빠른 이름 변경은 에이전트가 미처 청소할 생각을 하지 못한 표면에 잔여물을 남기며, 그 속도는 사람이 눈으로 추적할 수 있는 것보다 더 빠릅니다. 지속적인 증류(continuous distillation)를 실질적으로 가능하게 만드는 도구는, 그 불완전함을 위험하게 만드는 바로 그 도구이기도 합니다. 이것이 제가 맞닥뜨린 한계이며, 특히 이 상황에서 정적인 지침 파일(static instructions file)이 부담(liability)이 되는 이유입니다. 그 파일이 고정해둔 단어들이야말로 바로 당신이 이제 일주일에 여러 번씩 바꾸고 있는 그 단어들이기 때문입니다. 제가 가장 먼저 시도했던 당연한 방법은 용어집(glossary)을 컨텍스트(context)로 모델에게 제공하고 모델이 이를 준수할 것이라고 믿는 것이었습니다. 그것은 '컨텍스트로서의 UL(UL-as-context)' 방식이며, 바로 그 산문 규칙(prose-rule)이 저의 737 오류를 만들어냈습니다. 핵심은 "아키텍처 대신 UL을 강제하는 것"이 아닙니다. 강제(enforcement)는 UL 계층에서도 이루어져야 하며, 일단 습관이 들면 두 산출물(artifacts)이 서로 일치해야 하는 모든 계층에서 이루어져야 한다는 것입니다.
사례 연구: 의미를 위한 린터(linter)
첫 번째 관문은 737 오류를 발생시켰던 용어 체크(terminology check)였습니다. 제가 이를 구축한 이유는 용어의 불일치가 이미 알려진, 성가신 문제였기 때문이며, 이는 우리가 고군분투하고 있는 문제로 제가 직접 기록해 두었던 것이기도 합니다. 동일한 개념이 코드와 문서 전반에서 두 가지 이름으로 계속 나타났고, 제가 이를 추적할 수 있는 속도보다 더 빠르게 쌓여갔습니다. 따라서 737 오류는 갑작스러운 함정이 아니었습니다. 그것은 제가 이미 지고 있다는 것을 알고 있었던 부채(debt)에 대한 첫 번째 정직한 측정치였습니다. 기계적인 측면에서 이 체크는 화려하지 않습니다. 소스(Rust, markdown, config, protos, TypeScript 프론트엔드)를 스캔하여 폐기된 이름이나 용어집과 일치하지 않는 용어를 찾아냅니다. 사실상 즉각적인 grep 작업이며, 에이전트가 명세를 읽을 때 발생하는 훅(hook)에서 한 번, 그리고 어떤 커밋(commit)이 반영되기 전에 다시 한 번 자동으로 실행됩니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기