테스트 없는 레거시 함수에 스펙과 단위 테스트를 함께 심는 흐름 — Codex CLI, AGENTS.md, dry-run 검증 루프
레거시 코드베이스를 넘겨받은 사람이라면 다들 비슷한 감정을 겪었을 겁니다. git blame을 돌려봐도 왜 이 코드가 이렇게 생겼는지 알 수 없고, 테스트는 한 줄도 없으며, "그냥 건드리지 말자"는 팀의 암묵적 합의만 남아 있는 그 함수. 저도 처음엔 눈감고 지나쳤다가, 어느 배포 직후에 바로 그 함수에서 이상한 동작이 터졌습니다. 결국 마주해야 했고, 이 글에서 정리하는 흐름 — 스펙을 먼저 뽑고, AGENTS.md로 경계를 걸고, dry-run 훅으로 검증하는 루프 — 로 겨우 그 함수를 손댈 수 있는 상태까지 끌어올렸습니다. 완벽하진 않았지만, 최소한 다음 변경에서 "무엇이 깨졌는지"를 알 수 있는 안전망은 생겼습니다.
이 글에서 다루는 건 Codex CLI로 테스트 없는 레거시 함수를 분석하고, 스펙 문서와 단위 테스트를 동시에 생성하되, 오탐(false positive) 없이 실질적인 커버리지를 올리는 방법입니다. 세 가지 축을 중심으로 이야기하려 합니다. 스펙을 먼저 고정하는 Spec-First 접근, AGENTS.md로 에이전트 행동에 구체적 경계를 거는 방식, 그리고 dry-run 검증 루프로 "완료처럼 보이지만 실제로는 틀린" 상태를 막는 구조입니다.
Codex CLI는 OpenAI가 2025년에 공개한 터미널 기반 코딩 에이전트입니다(정확한 최초 공개월은 공식 릴리스 노트를 확인하기 바랍니다). 단순 코드 생성뿐 아니라 리뷰, 리팩터링, 테스트 작성을 수행하는데, 문서도 테스트도 없는 레거시 함수를 다룰 때 특히 쓸모가 있습니다.
AGENTS.md — 이 워크플로의 계약서
AGENTS.md는 프로젝트 개요, 빌드/테스트 명령, 코드 스타일, 아키텍처 제약, 보안 고려사항을 담는 에이전트 지시 파일입니다. 글로벌 → 프로젝트 루트 → 하위 디렉터리의 3계층으로 동작해서 모듈마다 다른 테스트 전략을 적용할 수 있습니다.
이 워크플로에서 AGENTS.md가 왜 스펙과 테스트 생성만큼 독립적인 축인지 먼저 짚고 넘어가는 게 좋습니다. 스펙은 "코드가 무엇을 하는가", AGENTS.md는 "에이전트가 무엇을 해도 되는가"입니다. 두 문서는 서로 다른 질문에 답합니다. 스펙만 있고 AGENTS.md가 없으면 에이전트는 스펙을 참조하되 자기 편한 방식(과잉 모킹, 내부 세부사항 검증 등)으로 테스트를 짜기 쉽습니다.
한 가지 강한 원칙이 있습니다. AGENTS.md는 팀이 직접 손으로 쓰는 게 좋습니다. Daniel Vaughan의 블로그 포스트 Over-Mocked Tests and Coding Agents에서 개발자 작성 AGENTS.md와 LLM 자동 생성 AGENTS.md의 효과를 비교하며 전자를 강하게 권장하는데, 구체 수치는 원 자료에서 직접 확인하기 바랍니다. 요지는 이 파일이 "팀이 합의한 계약서"에 가까워야 한다는 점이고, 그 계약을 AI에게 대신 작성시키는 순간 계약의 의미가 사라진다는 겁니다.
스펙을 먼저 뽑는 이유, 그리고 그 함정
에이전트에게 테스트 없는 레거시 함수를 던지면서 "테스트 써줘"라고만 하면, 에이전트는 함수의 의도를 모르므로 현재 구현만 보고 테스트를 만들어냅니다. 그러면 버그가 있는 구현조차 "올바른 동작"으로 굳혀버립니다.
레거시에서 안전망 확보의 고전적 기법은 Michael Feathers가 『Legacy Code Working Effectively』에서 정리한 Characterization Testing(특성화 테스트) 입니다. 현재 입력값을 넣어 실제 출력을 기록하고, 그 출력을 검증 기준으로 삼는 방식입니다.
여기에 자기 모순처럼 보이는 지점이 있습니다. 앞서 "버그를 굳히면 안 된다"고 했는데, Characterization Testing은 정확히 현재 동작을 그대로 고정하는 기법입니다. 이 긴장을 어떻게 다룰지가 이 워크플로의 성패를 가릅니다. 제가 쓰는 방식은 두 단계로 나누는 겁니다.
핵심은 에이전트가 뽑은 스펙 초안은 그대로 진리가 아니라 리뷰 대상이라는 점입니다. 사람이 한 번 훑으면서 "이건 명백한 버그로 보인다"는 항목을 골라내는 절차가 필요합니다. 그 항목은 테스트로는 현재 동작을 고정하되(안 그러면 리팩터링 시 감지 불가), 별도 이슈로 남깁니다. 이 리뷰 단계 없이 스펙을 앵커로 쓰면 그건 Spec-First가 아니라 "AI가 자동 생성한 Characterization Test"에 지나지 않습니다.
실제로 해보기 — rate_limiter.py를 예시로
토큰 버킷 알고리즘으로 구현된 레거시 rate_limiter.py를 예시로 흐름을 따라가 보겠습니다.
1단계: 스펙 초안 뽑기
먼저 Codex CLI에게 함수의 현재 동작을 자연어로 분석해 스펙 파일로 저장하도록 지시합니다. 아래 명령어 형식(따옴표 안에 자연어를 직접 넣는 방식)은 Codex CLI 버전에 따라 codex exec 같은 서브커맨드가 필요할 수 있으니, 실제 설치된 버전의 codex --help 출력을 먼저 확인하기 바랍니다.
codex "rate_limiter.py의 모든 함수를 분석해서 현재 동작을 자연어 스펙으로 작성하고 spec/rate_limiter.md에 저장해줘. 추측하지 말고 코드에서 관찰되는 동작만 기술해."생성된 spec/rate_limiter.md는 대략 이런 형태가 됩니다.
# rate_limiter.py 동작 스펙
## RateLimiter.__init__(capacity, refill_rate)
- capacity: 버킷 최대 토큰 수
- refill_rate: 초당 추가되는 토큰 수
- 초기화 시 버킷은 capacity만큼 가득 찬 상태
## RateLimiter.consume(tokens=1)
- 토큰이 충분하면 tokens 수만큼 차감 후 True 반환
- 토큰 부족 시 False 반환, 버킷 상태 변경 없음
- 호출 시마다 경과 시간을 계산해 토큰을 보충 (최대 capacity까지)
## 경계 조건
- tokens=0 호출 시 항상 True
- capacity=0 초기화 시 consume은 항상 False이 스펙 초안을 팀이 훑어보고, 의심 가는 동작(예: tokens=0이 항상 True인 게 정말 의도인지)에 표시를 남긴 뒤 확정합니다. 이후 구현을 변경해도 테스트는 이 스펙을 기준으로 검증합니다.
2단계: AGENTS.md에 테스트 경계 명시
여기가 가장 중요한 단계입니다. 막연한 지시는 효과가 없습니다. "좋은 테스트를 써줘"는 의미가 없고, 무엇을 모킹해도 되고 무엇을 모킹하면 안 되는지 구체적으로 못 박아야 합니다.
# AGENTS.md
## 테스트 정책
### 모킹 규칙
- HTTP API 호출과 데이터베이스 연결만 mock 허용
- 내부 로직, 내부 헬퍼 함수, 순수 계산 함수는 반드시 실제 객체 사용
- 시간 의존성은 freezegun 또는 unittest.mock.patch로 제어하되,
patch 대상은 반드시 사용처 모듈 경로 (예: rate_limiter.time.time)
- RateLimiter 클래스 자체를 mock하는 것은 금지
### 테스트 구조
- 각 테스트는 단일 동작만 검증 (AAA 패턴 유지)
- spec/rate_limiter.md의 경계 조건을 반드시 커버
- 테스트 실행: pytest tests/ -v --tb=short
### 금지 사항
- 테스트 내에서 assert 없이 pass만 있는 빈 테스트 작성 금지
- 구현 세부사항(내부 변수명 등)을 직접 검증하는 테스트 금지앞서 인용한 Daniel Vaughan의 블로그 포스트에는 browser-use 오픈소스 저장소에서 Never mock anything in tests, always use real objects!라는 한 줄 지시를 넣은 뒤 에이전트가 생성한 모킹 커밋이 크게 줄었다는 사례가 나옵니다. 감소 폭의 절대 수치나 베이스라인은 원 포스트에서 직접 확인하기 바랍니다. 이 사례에서 배울 지점은 정확한 숫자가 아니라 한 줄짜리 구체적 지시가 애매한 여러 문단보다 낫다는 원칙입니다.
3단계: 스펙 기반 단위 테스트 생성
codex "spec/rate_limiter.md를 읽고 AGENTS.md의 테스트 정책에 따라 rate_limiter.py에 대한 단위 테스트를 tests/test_rate_limiter.py에 작성해줘. 스펙의 모든 경계 조건을 커버해야 해."생성된 테스트를 그대로 받으면 안 됩니다. 특히 시간 모킹은 경로를 자주 틀립니다. 아래는 검토를 거친 예시입니다.
# tests/test_rate_limiter.py
from unittest.mock import patch
import pytest
from rate_limiter import RateLimiter
class TestRateLimiterInit:
def test_bucket_full_on_init(self):
# refill을 0으로 고정해서 초기 토큰 수를 순수하게 검증
limiter = RateLimiter(capacity=3, refill_rate=0)
assert limiter.consume(tokens=3) is True
# capacity를 넘는 요청은 실패해야 초기값이 정확히 capacity임이 증명됨
assert limiter.consume(tokens=1) is False
def test_zero_capacity_always_rejects(self):
limiter = RateLimiter(capacity=0, refill_rate=0)
assert limiter.consume() is False
class TestConsume:
def test_sufficient_tokens_returns_true(self):
limiter = RateLimiter(capacity=5, refill_rate=0)
assert limiter.consume(tokens=3) is True
def test_insufficient_tokens_returns_false(self):
limiter = RateLimiter(capacity=2, refill_rate=0)
assert limiter.consume(tokens=3) is False
def test_zero_tokens_always_true(self):
limiter = RateLimiter(capacity=0, refill_rate=0)
assert limiter.consume(tokens=0) is True
def test_bucket_unchanged_on_rejection(self):
limiter = RateLimiter(capacity=2, refill_rate=0)
limiter.consume(tokens=3) # 실패 케이스
assert limiter.consume(tokens=2) is True
def test_token_refill_over_time(self):
# rate_limiter 모듈이 사용하는 time.time을 패치해야 함
with patch('rate_limiter.time.time') as mock_time:
mock_time.return_value = 0.0 # init 호출 전에 반환값 고정
limiter = RateLimiter(capacity=10, refill_rate=5)
limiter.consume(tokens=10) # 버킷 비움
mock_time.return_value = 2.0 # 2초 경과
# 2초 × 5토큰/초 = 10토큰 보충
assert limiter.consume(tokens=10) is True수정된 지점은 두 가지입니다. 첫째, test_bucket_full_on_init은 원래 consume()을 10번 호출해서 통과 여부만 보고 있었는데, refill이 사이사이 끼어들면 초기 상태가 잘못돼도 테스트가 통과할 수 있습니다. refill_rate=0으로 고정한 뒤 "capacity+1번째 요청이 실패"를 확인하는 편이 정확합니다. 둘째, patch('time.time')은 time 모듈 자체를 패치할 뿐 rate_limiter 안에서 참조하는 이름을 바꾸지 못하는 경우가 흔합니다. patch('rate_limiter.time.time')처럼 사용처 경로를 지정해야 합니다. 그리고 return_value는 반드시 RateLimiter 생성 이전에 세팅해야 init에서의 time.time() 호출이 0.0을 돌려받습니다.
4단계: dry-run 검증 루프
이 워크플로의 마지막 안전장치입니다. 훅으로 두 층의 검증을 걸어서 "완료처럼 보이지만 실제로는 틀린" 상태가 커밋에 흘러들지 못하게 합니다.
훅 설정 파일은 개념적 예시로만 봐주기 바랍니다. 실제 훅 이름, 설정 형식, 지원 값은 사용 중인 Codex CLI 버전 문서를 확인해야 합니다.
# 개념적 예시 - 실제 키 이름과 값은 사용 중인 Codex CLI 버전 문서 참조
[hooks.pre_tool_use]
command = "pytest tests/ -q --tb=short"
on_failure = "abort"
[hooks.stop]
command = "pytest tests/ -v --cov=. --cov-fail-under=80"
on_failure = "reject"한 가지 현실적 주의점이 있습니다. 파일 저장마다 전체 pytest를 돌리면 테스트 스위트가 커질수록 저장 한 번에 수십 초에서 분 단위로 벌어집니다. 실무에서는 사전 훅은 변경 파일과 관련된 테스트만 실행(예: pytest tests/test_rate_limiter.py -q)하고, 종료 훅에서만 전체 스위트를 돌리는 식으로 나누는 게 현실적입니다. 그렇지 않으면 에이전트가 "저장 → 대기 → 저장 → 대기"로 시간을 다 씁니다.
트레이드오프 — 솔직하게 짚어야 할 것들
과잉 모킹은 커버리지 숫자를 속입니다
앞서 인용한 Daniel Vaughan의 블로그 포스트가 참조한 Hora & Robbes의 연구 관련 논의에서, 에이전트 생성 테스트에서 모킹 비율이 인간 작성 테스트보다 높다는 경향이 반복적으로 지적됩니다. 정확한 데이터셋 규모, 비교 방법론, 수치는 원 논문을 직접 확인하는 게 안전합니다 — 저는 블로그 포스트를 통해 알게 된 논의이고, 논문 원문을 직접 확인하지 못했기 때문에 구체 수치를 이 글에서 반복하지 않겠습니다.
중요한 건 방향입니다. 커버리지 숫자는 올라가는데 실제로는 배선(wiring)만 검증하는 테스트가 늘어나면, 리팩터링할 때 진짜 회귀는 못 잡고 무해한 리팩터링에서 테스트가 무너집니다. 레거시 코드에서 가장 피해야 하는 상황입니다.
| 항목 | 내용 |
|---|---|
| 커버리지 향상 | 자동 생성 테스트로 커버리지가 오르는 사례는 많지만, 벤더 블로그의 "N배" 수치는 베이스라인과 방법론이 명시된 경우에만 신뢰 |
| 과잉 모킹 위험 | 에이전트 생성 테스트에서 모킹 비율이 높아지는 경향이 지적됨 (구체 수치는 원 자료 참조) |
| AGENTS.md 자동 생성의 역효과 | LLM 자동 생성 지시 파일이 오히려 결과를 악화시킨다는 보고가 있음 |
| 비결정론적 동작 | 동일 프롬프트에도 다른 테스트가 생성될 수 있음 |
흔한 실수들
실수 1: AGENTS.md를 AI에게 쓰게 하기
이 파일은 팀이 직접 논의해 작성해야 하는 계약서입니다. AI가 뽑은 초안을 참고할 수는 있어도 그대로 커밋하면 목적이 사라집니다.
실수 2: 옛날 문서를 근거로 플래그 사용하기
Codex CLI는 활발히 변하고 있어서 옛날 블로그 글의 옵션(예: --full-auto류의 자동화 플래그)이 현재 버전에서 사라졌거나 다른 이름으로 바뀌었을 수 있습니다. 구체적으로 어떤 버전에서 어떤 플래그가 어떻게 변했는지는 공식 changelog를 그때그때 확인하기 바랍니다. 저도 옛날 예시를 그대로 붙였다가 옵션이 없어져 있어서 당황한 적이 있습니다.
실수 3: 구형 설정과 신형 설정 혼용
승인 정책이나 샌드박스 설정이 개편된 버전에서 구형 테이블과 신형 키를 섞으면 조용히 무시되거나 충돌합니다. 설정 파일은 사용 중인 버전 문서의 형식으로 통일해야 합니다.
실수 4: 신뢰하지 않은 프로젝트 설정 로드 방치
낯선 저장소를 열 때 훅과 규칙이 묵시적으로 로드되면 로컬 환경에서 임의 명령이 실행될 수 있습니다. 신뢰 프로젝트 목록 설정을 반드시 확인하기 바랍니다.
비결정론적 동작
같은 프롬프트로 두 번 돌리면 다른 테스트가 나옵니다. 이 비결정성은 회귀 기준 설정을 어렵게 만듭니다. 최근에는 행동 지문(behavioral fingerprinting)으로 이 문제에 접근하려는 실험적 도구들도 등장하고 있는데, 아직 프로덕션 파이프라인에 넣기엔 이르다는 게 제 판단입니다. 현실적으로는 생성된 테스트를 사람이 한 번 리뷰하고, 통과한 테스트는 그대로 커밋해 이후 변경에 대해서만 회귀 검증에 사용하는 방식이 안전합니다.
처음 시도한다면 어떤 함수를 골라야 할까
레거시 함수 100개에 이 흐름을 다 돌리려고 하면 시작조차 못 합니다. 처음 시도할 함수는 다음 세 기준을 만족하는 것을 고르는 편이 좋습니다.
- 최근 6개월 내 두 번 이상 변경된 함수: 변경 빈도가 높다는 건 계속 손을 대야 한다는 뜻이고, 안전망의 투자 회수가 가장 빠릅니다.
- 외부 I/O가 적고 입출력이 결정적인 함수: 시간, 랜덤, 네트워크, DB에 얽힌 함수는 두 번째로 미룹니다. 처음에는 순수 계산에 가까운 함수가 성공률이 높습니다.
- 버그로 인해 이슈 트래커에 최소 한 번 이상 등장한 함수: 이 경우 스펙을 뽑을 때 팀이 "이건 명백한 버그"라고 표시할 항목이 많고, 리뷰 절차가 자연스럽게 붙습니다.
제가 처음에 잘못 골랐던 함수는 이 세 기준을 하나도 만족하지 못하는, 그저 "가장 무섭게 생긴" 함수였습니다. 시간, 캐시, DB, 외부 API가 모두 얽힌 함수를 첫 시도로 잡았다가 며칠을 날렸습니다. 앞서 도입부에서 언급한 배포 사고 함수는 다행히 두 번째 기준을 만족하는 편이라 이 흐름으로 커버가 붙었고, 이후 리팩터링에서 근본 원인을 잡을 수 있었습니다. 첫 시도는 화려한 함수가 아니라 회수가 빠른 함수여야 합니다.
참고 자료
- Michael Feathers, 『Legacy Code Working Effectively』 — Characterization Testing의 원전
- Test-Driven Development with Codex CLI: AGENTS.md Test Gates and Hook-Based Verification (블로그)
- Over-Mocked Tests and Coding Agents (블로그, Hora & Robbes 연구 논의 포함)
- Agent Instruction Files: AGENTS.md, CLAUDE.md, Cross-Tool Portability (블로그)
- AgentAssay: 비결정론적 에이전트 워크플로의 통계적 검증 (블로그)
- Modernizing your Codebase with Codex — OpenAI Cookbook
- Codex CLI 공식 저장소의 릴리스 노트와
--help출력 — 버전별 플래그와 훅 이름은 항상 이쪽으로 확인