본문 바로가기
0 to 1

에이전트한테 규칙을 지키게 하는 데 6개월이 걸렸습니다

by J's Note 2026. 9. 7.

 

올해 3월에 저희가 갖고 있던 FastAPI 프로젝트 네 개를 에이전트한테 스캔시켜 봤습니다. 같은 일을 세 가지 방식으로 하고 있더라고요. API prefix가 /api/v1과 /v1과 /api로 갈리고, 서비스 레이어는 ServiceManager와 컨트롤러 클래스와 함수형이 공존하고, 의존성 주입도 세 가지, 인증도 세 가지였습니다. 네 명이 각자 짠 코드면 그럴 수 있죠. 근데 대부분 에이전트가 짠 코드입니다. 에이전트는 세션마다 처음 보는 사람처럼 들어오니까, 그때그때 가장 그럴듯한 패턴을 고르는 것들이 쌓여서 이렇게 된 겁니다.

 

그래서 컨벤션 저장소를 만들었습니다. 한 달 뒤에는 프론트 쪽에서 같은 문제가 보여서 디자인 시스템도 만들었어요. 6개월 동안 컨벤션 저장소에 76번 커밋했고 규칙 파일이 59개, 변경 이력은 시간 단위로 남겼습니다. 이 글은 그 이력을 처음부터 다시 읽으면서 건진 것들입니다. 대부분 제가 틀렸던 기록이라 좀 민망한데, 그래서 쓸 가치가 있는 것 같더라고요.

문서는 처음부터 3순위였습니다

시작할 때 DDD 리팩토링 글[1]을 읽었습니다. CLAUDE.md나 AGENTS.md 같은 자연어 지침의 효과가 -0.5%에서 +4% 사이인데 비용은 20-23% 늘었다는 수치가 있었어요. 그 글의 한 문장이 저희 컨벤션의 뼈대가 됐습니다.

AI가 규칙을 어긴다면, 프롬프트를 고치기 전에 구조와 시스템을 먼저 점검하라

 

그래서 규칙 강제에 우선순위를 뒀습니다. 1순위는 코드로 강제하는 것들이에요. Ruff의 banned-api로 python-jose나 loguru 같은 것들을 import 자체가 안 되게 막고, AST를 파싱하는 아키텍처 테스트로 라우터가 SQLAlchemy를 직접 부르는지 잡습니다. 2순위는 구조로 유도하는 겁니다. 도메인 폴더 하나를 보면 나머지가 다 예측되는 대칭 구조를 만들어서 에이전트가 패턴을 따라가게 합니다. 문서는 3순위, 최후 수단이죠.

 

아키텍처 테스트를 쓸 때 원칙이 하나 있습니다. 실패 메시지에 고치는 방법을 넣는다.

def test_no_direct_db_access_in_routers():
    violations = find_imports_in("src/routers/", pattern="sqlalchemy")
    assert not violations, (
        f"Router files must not import sqlalchemy directly.\n"
        f"Violations: {violations}\n"
        f"Fix: Move DB queries to repositories/ and inject via Depends()."
    )

위반 파일 목록 뒤에 Fix 한 줄이 붙어 있으면 에이전트는 문서를 찾아보지 않고 에러 메시지만 보고 고칩니다. 문서를 읽으라고 하는 것보다 에러를 잘 쓰는 게 되게 효과가 크더라고요.

컨벤션은 구축하며 하루에 서른 번 바뀌었습니다.

CHANGELOG를 처음엔 날짜 단위로 적었습니다. 시작한 날 오후에 시간 단위로 바꿨어요. 3월 15일 하루에 결정이 열아홉 건, 다음 날은 서른다섯 건이 났거든요. 사람이 회의해서 컨벤션을 정하면 한 달에 몇 건인데, 에이전트랑 같이 결정하면 한 시간에 몇 건이 나옵니다. 순서를 추적하려면 HH:MM이 필요했습니다.

엔트리 형식은 여섯 필드로 고정했습니다.

### 2026-04-10 12:30 | [확정] TEST-002 Phase 2.5 Ratchet + TEST-007 속도 기준 원칙

- 대상: test/02-architecture-tests.md, test/07-tdd-process.md
- 유형: 규칙 추가
- 변경 전: Phase 1 WARNING과 Phase 3 ERROR 사이에 큰 공백
- 변경 후: 위반 개수를 baseline 파일에 고정, 증가 시 CI 실패
- 사유: 거짓 초록. 검사 범위를 tests로 좁혀 CI를 통과시킨 사건
- 결정자: 사용자 지적, 정정 승인

 

여기서 하나 더 배운 게 있습니다. 에이전트는 확인하지 않으면 그럴듯한 시간을 적습니다. 그래서 기록 전에 date 명령으로 실제 시간을 확인하라는 규칙이 CHANGELOG 상단에 박혀 있어요. 결정자 필드도 따로 뒀습니다. 사용자가 결정했는지 에이전트가 제안했는지, 그리고 확정인지 제안인지 기각인지를 남깁니다.

 

이 형식이 나중에 예상 못 한 곳에서 값어치를 했습니다. 엔트리마다 변경 전과 변경 후와 사유 같은 것들이 있으니까, 신규 입사자 온보딩 교안을 만들 때 왜 이 규칙인가를 CHANGELOG에서 그대로 꺼내 쓸 수 있었어요. 결국에는 컨벤션의 Why가 어디 따로 있는 게 아니고 변경 이력 안에 있었던 겁니다.

리서치로 부풀린 규칙은 다 지워야 했습니다

초안은 업계 표준 리서치로 보강했습니다. tiangolo 템플릿이나 Netflix Dispatch 같은 것들을 보면서 저희 코드에 없는 항목을 채웠어요. 캐싱 전략, 분산 락, Circuit Breaker, SAGA 패턴, Secrets Manager, K8s 배포, 벡터 DB 세 종 비교. 문서는 두꺼워졌고 그럴듯했습니다.

 

확정 단계에서 그중 절반을 지웠습니다. CHANGELOG의 사유 필드를 보면 같은 말이 반복돼요. 현재 규모에서 오버다. 실제로 사용한 적이 없다. 실제 경험과 맞는 컨벤션이어야 한다. SAGA는 단일 DB 모놀리식에서 범위 밖으로 뺐고, Secrets Manager는 .env로 되돌렸고, Qdrant는 써본 적이 없어서 뺐고, K8s와 Terraform은 별도 레포로 보냈습니다. 가드레일 문서는 PII 탐지와 ML 분류기와 다층 방어를 전부 확정 규칙으로 적어놨었는데, 실제로 쓰는 건 SLM 프롬프트 라우팅 하나였어요. 확정과 향후 방향을 갈라서 다시 썼습니다. RAG 파이프라인은 아예 WIP로 남겼습니다. 개발을 해보고 나서 쓰는 게 더 정확하다는 판단이었어요.

 

이걸 왜 굳이 지웠냐면, 에이전트는 문서에 있으면 씁니다. 사람은 우리 규모엔 오버라고 넘어가는데 에이전트는 SAGA 섹션이 있으면 SAGA를 구현해요. 안 쓰는 것을 컨벤션에 두는 건 중립이 아니라 오염이더라고요.

규칙 파일만 고치면 에이전트는 옛 규칙을 읽습니다

저장소는 루트 AGENTS.md, 팀별 AGENTS.md, 세부 규칙 파일의 3단계입니다. 3월 16일에 세부 파일 결정을 한참 바꾸고 나서 전체 일관성 검사를 돌렸는데 불일치가 10건 나왔습니다. 세부 파일은 ServiceManager를 폐기했는데 루트 문서는 여전히 ServiceManager를 권장하고 있는 식이었어요. 에이전트는 루트부터 읽으니까 옛 규칙을 먼저 만납니다.

 

그 뒤로 변경은 항상 3단계를 같이 검수합니다. 이 교훈은 6월에 한 번 더 돌아왔어요. 규칙 사이에 관계 그래프를 넣었는데, 규칙 하나를 고치면 그 규칙을 가리키는 다른 파일들의 엣지도 함께 고쳐야 했습니다. 엣지 한쪽만 고치는 건 그래프를 끊는 겁니다. 결국에는 문서가 여러 층이면 어느 층을 고치든 나머지 층에 전파하는 절차가 규칙 자체보다 중요하더라고요.

토큰 아끼려다 하루 만에 되돌린 것

4월 초에 토큰 최적화를 했습니다. 캐싱 어떻게 하냐는 질문 하나에 루트에서 팀에서 세부 파일까지 세 번 읽고 710줄을 소비하더라고요. 실제로 필요한 건 캐싱 규칙 몇 줄인데요. 모든 세부 파일에 frontmatter를 달았습니다. Rule ID, 검색용 키워드, 120자 요약. BACK-010, DB-008 같은 ID 체계가 이때 생겼고, 루트에는 캐싱 레이어 추가면 BACK-010과 DB-008을 읽으라는 식으로 작업별 규칙을 묶은 번들 표를 넣었습니다. 같은 질문으로 전후를 재봤는데 710줄이 420줄로, 56개 파일 중 정확히 2개만 읽게 됐습니다. 이건 잘 됐어요.

 

그런데 거기서 한 걸음 더 갔습니다. 파일 안에서도 규칙 섹션만 offset과 limit으로 잘라 읽으라는 프로토콜을 CLAUDE.md에 넣었어요. 규칙과 코드 예시와 금지 사항을 섹션으로 나누고, 줄 번호 계산법까지 적었습니다. 그리고 같은 날 되돌렸습니다. 규칙만 읽은 에이전트가 금지 사항을 놓치고 구현하더라고요. 규칙과 코드 예시와 금지 사항은 하나의 맥락이었던 겁니다. 줄 번호를 미리 계산해 두는 방식도 파일을 고칠 때마다 전부 틀어져서 관리 비용이 이점을 넘었습니다.

 

지금 CLAUDE.md에는 이렇게 적혀 있습니다. 필요하다고 판단한 파일은 통째로 읽어라. 결국에는 절감은 어느 파일을 읽을지 정확히 고르는 데서 나오고, 고른 파일을 얼마나 읽을지에서 나오면 안 되는 거였습니다.

 

하나 더 있어요. AGENTS.md에 읽기 지침을 아무리 잘 써도 에이전트가 AGENTS.md를 먼저 읽는다는 보장이 없었습니다. 그래서 세션 시작 시 자동으로 로딩되는 CLAUDE.md로 프로토콜을 옮겼어요. 지침의 내용보다 지침이 로딩되는 경로가 먼저였습니다.

첫 실전 적용에서 컨벤션이 깨졌습니다

첫 충돌은 3월 말 PDF 처리 파이프라인에서 왔습니다. 모노레포에서 서비스마다 패키지 이름을 src로 두니까 파이썬의 sys.modules 안에서 src가 충돌해서 여러 서비스 테스트를 한 번에 돌릴 수 없었어요. Temporal에서는 child workflow를 시작하고 await를 빼먹으니까 부모가 끝나는 순간 기본 정책이 child를 종료시켜서 임베딩 워크플로우가 조용히 사라졌습니다. 둘 다 그 자리에서 컨벤션이 됐어요.

 

4월에는 운영 중인 백엔드를 컨벤션대로 리팩토링했습니다. 문서로만 있을 때는 몰랐던 런타임 함정이 며칠 사이에 네 개 나왔어요.

 

FastAPI의 Depends 세션은 autobegin이라 컨벤션대로 session.begin()을 부르면 IllegalStateChangeError가 납니다.

라우터에 @router.get("/")을 쓰면 307 리다이렉트가 나면서 Authorization 헤더가 날아가 401이 됩니다.

PyJWT는 leeway 기본값이 0이라 Cognito 토큰의 iat가 서버 시계와 몇 초만 어긋나도 거부해요.

python-jose는 기본으로 leeway가 켜져 있어서 전환 전에는 안 보였던 문제입니다.

pydantic-settings는 .env의 CSV 문자열을 list[str]로 받으면 JSON 파싱을 시도하다 죽습니다.

 

테스트 쪽에서는 6건이 더 나왔습니다. LangGraph 노드를 테스트할 때 get_stream_writer를 전역으로 패치하면 효과가 없고 노드 모듈 경로로 패치해야 하는데, 이거 하나에 30분을 썼어요. 이런 것들이 전부 컨벤션에 추가됐습니다.

 

컨벤션은 문서로 쓸 때 완성되는 게 아니라 첫 프로젝트에 붙일 때 완성된다. CHANGELOG에서 제일 값진 엔트리들은 "실전 적용 후 사후 보고"라고 적힌 것들입니다.

거짓 초록

가장 크게 배운 건 이겁니다. 리팩토링 중에 한 패키지에서 ruff 위반이 187건 나왔습니다. 에이전트의 첫 반응은 Makefile에 ENFORCED_PATHS := tests를 넣어서 tests 폴더만 검사하게 하는 거였어요. CI는 초록이 됐습니다. src는 그대로였죠.

 

제가 이걸 "테스트의 의미 전복"이라고 지적했고, 그날 컨벤션에 두 가지가 들어갔습니다. 검사 범위를 줄이는 유일한 기준은 속도다. 느려서 빼는 것만 허용하고, 내 작업 범위로 좁히거나 내가 만든 규칙만 검사하는 건 금지입니다.

 

그리고 Ratchet. 기존 위반이 수백 건인 규칙은 바로 에러로 못 올리는데, 경고로 두면 줄어들 압력이 0이에요. 그래서 위반 개수를 baseline 파일에 고정하고, 늘어나면 CI 실패, 줄어들면 갱신을 허용합니다. 숫자가 올라가지 않게만 하는 겁니다. 실제로 ruff는 187에서 15에서 0으로 갔고, mypy는 250건이 남아서 ratchet으로 넘겼습니다.

같은 날 check라는 단일 엔트리포인트도 규칙이 됐습니다. ruff, format, mypy, arch 테스트, unit 테스트를 순서대로 돌리는 명령인데, 이름을 check로 통일한 이유는 에이전트가 프로젝트마다 lint인지 validate인지 ci-check인지 외우게 만들지 않으려는 겁니다. 로컬과 CI가 같은 명령을 쓰니까 로컬은 되는데 CI는 실패하는 일도 없어졌어요.

 

에이전트는 초록불을 만드는 가장 짧은 길을 찾습니다. 그 길이 검사 범위를 줄이는 거라면 줄이죠. 결국에는 그 길을 막는 것도 문서가 아니라 코드여야 했습니다.

검색을 붙였더니 측정이 결정을 대신 해줬습니다

키워드 Grep은 정확하지만 의미 검색이 약합니다. 사용자가 "DB에서 시간 다루는 법"이라고 말하면 타임존 규칙으로 가야 하는데 Grep으로는 안 잡혀요. 4월 중순에 벡터 검색을 붙였습니다. 사람이 던질 법한 질문 171개에 정답 Rule ID를 붙인 골든셋을 먼저 만들었고, 그 뒤 모든 결정을 이 골든셋의 Recall로 했습니다.

 

실험 결과 결정
청킹: 파일 단위 vs 섹션 단위 Recall@1 0.78 vs 0.87 섹션 단위 채택
색인 페이지(AGENTS.md)까지 인덱싱 rule_id 없는 페이지가 상위 노출 인덱스에서 제외
summary 청크 거리 부스트 0.9x MRR 하락 롤백
로컬 임베딩 모델을 Gemini API로 워커 RAM 700MB-1.2GB 해소, 한글 품질 유지 전환
Context7[2]식 resolve/fetch 2단계 Recall@1 0.918에서 0.912, 토큰 절감 채택
LLM이 생성한 발동 상황 청크(enrich) 171 쿼리에서 top-1 변화 0건 미채택, 기본 off

 

여섯 개 실험 중 셋을 기각했습니다. enrich는 특히 아까웠어요. Rule마다 LLM이 이 규칙이 발동하는 상황을 한 줄씩 써서 추가 임베딩하는 건데, 직관적으로는 될 것 같았습니다. 171개 쿼리에서 순위가 바뀐 게 한 건도 없었어요. 골든셋이 없었으면 채택했을 겁니다.

 

6월에는 문서 구조 자체를 두고 큰 실험을 했습니다. 규칙 파일을 개념 단위로 잘게 쪼개는 방향과, 파일은 응집된 채로 두고 파일 사이에 타입이 있는 관계 엣지를 두는 방향이었어요. 쪼개는 쪽은 Recall@5가 1.0에서 0.99로 회귀했고 frontmatter 오버헤드 때문에 토큰도 안 줄었습니다. 관계 그래프 쪽은 캐싱 규칙을 찾을 때 인증 캐시 누출 주의 규칙처럼 의미 검색만으로는 안 나오는 교차 의존을 1홉 전개로 잡아냈는데, 이 deep recall이 0.26에서 0.86으로 올랐어요. 그래서 응집 파일에 requires, related, caution 세 종류 엣지를 달았습니다. 규칙 59개에 엣지 621개, 카테고리 사이를 넘는 엣지가 절반 이상입니다.

엣지는 각 규칙 파일의 frontmatter에 들어갑니다. reason 한 줄이 필수라서 에이전트가 따라갈지 말지를 읽고 판단할 수 있어요.

id: BACK-010
title: "캐싱"
relations:
  - type: requires
    target: "DB-008"
    reason: "캐시 backend는 Redis.from_url(settings.REDIS_URL)로 연결. Redis 연결 컨벤션이 선행 필수"
  - type: caution
    target: "BACK-006"
    reason: "인증된 사용자별 응답에 @cache를 적용하면 사용자 간 데이터 누출"

 

 

엣지를 만들 때는 규칙 하나에 에이전트 하나를 붙여 후보를 뽑고, 다른 에이전트가 반대 입장에서 반증을 시도해서 약한 엣지를 강등했습니다.

문서만 보고 쓴 규칙은 SDK에 없는 API를 부릅니다

5월에 Langfuse 관찰성 규칙을 v2 API에서 v4와 OpenTelemetry 기준으로 전면 재작성했습니다. 코드베이스는 이미 넘어갔는데 컨벤션만 v2에 머물러 있었거든요. 그런데 재작성한 규칙에 update_current_trace라는 호출이 있었고, 이게 설치된 langfuse 4.6.1에는 없었습니다. AttributeError가 나요. 40분 뒤에 propagate_attributes 컨텍스트 매니저로 정정했고, 이번엔 실제 프로젝트에서 트레이스를 찍어 session_id와 user_id가 반영되는 걸 보고 확정했습니다.

 

같은 종류의 일이 컨벤션을 소비하는 프로젝트 쪽에서도 나왔습니다. 컨벤션은 Circuit Breaker에 pybreaker를 쓰라고 했는데, 한 프로젝트에서 적용해보니 pybreaker 1.4.1이 asyncio를 지원하지 않더라고요. Tornado 전용이었습니다. 그 프로젝트는 asyncio 네이티브로 직접 구현했고 그 사실이 커밋 메시지에 남았어요.

 

컨벤션의 코드 예시는 설치된 버전에서 한 번은 돌려봐야 합니다. 문서와 기억으로 쓴 규칙은 그럴듯한 API를 부르는데, 에이전트는 그걸 그대로 씁니다.

디자인 시스템도 같은 병을 앓았습니다

4월 말에 디자인 시스템을 시작했습니다. 토스, 당근 SEED[3], 카카오스타일 ZDS, 원티드, shadcn, Geist, W3C DTCG를 리서치해서 플랜을 짰어요. 플랜과 다르게 간 것들도 있습니다. Storybook 대신 Next.js 문서 사이트[4]를 직접 만들었고, Figma 파이프라인 없이 코드 토큰부터 시작했고, 토큰의 단일 진실은 DESIGN.md라는 마크다운 문서인데 빌드가 이 파일을 읽지는 않아서 값을 바꾸면 양쪽을 손으로 맞춰야 합니다. 컴포넌트 티어는 안 만들었어요. 같은 패턴이 세 번 반복되면 그때 승격한다는 원칙만 뒀습니다.

 

npm에 공개 패키지로 올렸더니 대학 고객사 프로젝트 두 곳이 바로 가져다 썼습니다. 그리고 둘 다 첫 커밋에서 브랜드 색을 override했어요. 한 곳은 흰색과 빨강, 한 곳은 빨강과 파랑. 멀티 브랜드는 나중에 필요할 기능이라고 생각했는데 첫 소비자부터 필요했습니다.

 

7월에 큰 개편을 했습니다. 계기가 좀 재미있어요. 에이전트가 화면을 만들 때마다 같은 형태로 수렴하더라고요. 좌측 사이드바에 카드 그리드. 어떤 화면이든 그렇게 나옵니다. 처음엔 프롬프트 문제인가 싶었죠. 아니죠, 골격을 고르는 프리미티브가 없어서 매번 즉흥으로 짜다 보니 가장 익숙한 형태로 가는 거였습니다. 그래서 Box, Flex, HStack, VStack, Grid, AppShell 같은 레이아웃 프리미티브를 만들고, AppShell의 nav prop에 기본값을 두지 않았습니다. top인지 side인지 none인지 매번 명시해야 타입 체크가 통과합니다. 목적지가 5개 이하면 top, 8개 이상이면 side, 이동할 곳이 없으면 none이라는 기준표도 같이 넣었어요.

 

이게 컨벤션 저장소의 2순위, 구조로 유도한다는 원칙과 같은 해법이었습니다. 에이전트가 매번 같은 답을 내면 문서에 다르게 하라고 쓰는 게 아니라 결정을 강제하는 구조를 만든다. 간격 토큰도 같은 이유로 바꿨습니다. gap-x4는 16px이라는 크기만 말하는데 gap-component는 컴포넌트 사이라는 의도를 코드에 남기거든요. 얼마나가 아니라 어디에로 고르게 한 겁니다.

<AppShell nav="side">          {/* nav 는 필수 prop. top, side, none 중 하나 */}
  <VStack gap="section">       {/* 48px 이 아니라 섹션 사이 */}
    {children}
  </VStack>
</AppShell>

컨벤션이 저장소 밖으로 나갔습니다

Rule ID를 붙이고 벡터 인덱스를 커밋해 두니까 다른 시스템이 컨벤션을 인용할 수 있게 됐습니다. PR 리뷰 게이트를 하나 만들었는데, 에디터에서는 Claude가 코드를 쓰고 PR이 올라오면 다른 모델인 Codex가 diff를 컨벤션 벡터 검색 결과와 대조해서 approve나 block을 커밋 상태로 남깁니다. 모든 지적에 BACK-006 같은 Rule ID가 붙어요. 쓰는 모델과 검사하는 모델을 갈라놓은 겁니다.

 

신규 입사자 온보딩 커리큘럼도 컨벤션 위에 올렸습니다. 4주 교안 하나하나가 어떤 Rule ID를 다루는지 매핑표가 있고, 원칙은 컨벤션 원문이 언제나 정답이고 교안은 그것의 Why라는 겁니다. 차이가 나면 컨벤션을 정정하거나 교안을 고칩니다.

문서에 주소가 생기면 다른 시스템이 그 주소를 부를 수 있다. Rule ID는 토큰 절감용으로 만들었는데 결국에는 컨벤션을 인프라로 만든 게 그 ID였습니다.

그래서 6개월 뒤에 남은 것

말이 좀 돌아갔는데, 정리하면 이렇습니다. 처음엔 컨벤션을 잘 쓰는 일이라고 생각했습니다. 규칙을 정확하게 적으면 에이전트가 따를 거라고요. 6개월 이력을 다시 읽어보니 잘 된 것들은 전부 문서가 아니었어요. banned-api, 아키텍처 테스트의 에러 메시지, ratchet baseline, check 엔트리포인트, 기본값 없는 nav prop, 골든셋 171개. 문서는 그 구조들이 왜 그렇게 생겼는지 설명하는 역할로 물러났습니다.

 

그리고 컨벤션은 첫 실전 적용에서 깨지고, 설치된 SDK에서 깨지고, 소비하는 프로젝트에서 깨집니다. 깨진 자리를 CHANGELOG에 사유와 함께 남기는 것까지가 컨벤션이었어요.

 

결국에는 규칙을 문서에 쓰는 일이 아니라, 에이전트가 규칙을 어길 수 없는 구조를 만드는 일이었던 거죠.

삽질에서 건진 것

프로그램으로 frontmatter를 고칠 때 키 순서를 가정하지 마세요. 파일 네 개에서 키 순서가 달라 오펀 블록이 생겼고, 결국 손으로 수리했습니다.

주석

[1] 채널코퍼레이션, AI Native DDD Refactoring. https://channel.io/ko/team/blog/articles/ai-native-ddd-refactoring-98c23cdb

[2] Context7. 라이브러리 문서를 resolve-library-id와 query-docs 두 단계로 찾는 MCP 서버. https://context7.com

[3] 당근 SEED Design. https://seed-design.io

[4] NxtGen Design System 문서 사이트. https://design.nxtgen.co.kr