레거시는 거짓말을 하지 않는다
레거시 코드에는 문서가 없다. 있어도 3년 전 것이다. 테스트는 없거나, 있어도 깨진 채 skip 처리되어 있다. 주석은 코드와 모순된다. 원작자는 퇴사했고, 아는 사람은 “건드리면 터진다"는 말만 남겼다.
그런데 그 코드는 지금 이 순간에도 돌아가고 있다. 결제를 처리하고, 로그인을 받고, 주문을 넣는다.
문서는 거짓말을 한다. 주석도 거짓말을 한다. 사람의 기억은 더 심하게 거짓말을 한다. 거짓말을 하지 않는 단 하나는 실제로 흐르고 있는 트래픽이다.
그렇다면 명세를 어디서 찾아야 하는가? 위키가 아니다. 컨플루언스가 아니다. nginx access log다.
닭과 달걀
레거시를 리팩토링하려면 안전망이 필요하다. 뭔가를 바꿨을 때 동작이 달라졌는지 즉시 알아야 한다. 그 안전망이 바로 테스트다.
그런데 레거시에는 테스트가 없다. 테스트를 쓰려면 코드가 무엇을 하는지 알아야 한다. 코드가 무엇을 하는지 알려면 읽어야 한다. 읽어보면 테스트도 문서도 없다.
닭이 먼저냐 달걀이 먼저냐. 마이클 페더스가 Working Effectively with Legacy Code에서 이름 붙인 고전적 교착이다. 그는 답으로 characterization test(특성화 테스트)를 제시했다 — 코드가 옳게 무엇을 해야 하는지가 아니라, 현재 무엇을 하는지를 그대로 박제하는 테스트. 옳고 그름은 나중 문제다. 일단 지금 동작을 고정해야 손을 댈 수 있다.
페더스의 시대에는 이걸 사람이 손으로 짰다. 함수를 호출해 보고, 나온 값을 그대로 expected에 적어 넣는다. 지루하고, 느리고, 그래서 아무도 끝까지 안 했다.
하지만 API 레벨에서는 그 “함수를 호출해 본 결과"가 이미 어딘가에 쌓여 있다. 매일, 수만 건씩. 로그 파일 안에.
한 달치 로그가 명세다
한 달간 수집하면 레거시 API의 현재 동작을 거의 전부 캡처할 수 있다.
nginx access log (1개월):
엔드포인트 · HTTP method · status code · timing
호출 빈도 → 우선순위
에러 패턴 (401, 422, 500 …)
request/response body (미들웨어 또는 reverse proxy로 캡처):
정상 요청/응답 쌍 → 통과해야 하는 동작
에러 요청/응답 쌍 → 깨지면 안 되는 엣지 케이스
이 두 줄기를 합치면 Hurl 통합 테스트로 직역된다. Hurl은 HTTP 요청과 기대 응답을 그대로 평문으로 적는 포맷이다. 트래픽 한 쌍 — “이 요청에 이 응답이 나갔다” — 이 정확히 Hurl 한 블록이다.
# POST /api/orders — 호출 빈도 #3, 하루 1만 2천 건
POST https://api.example.com/orders
Content-Type: application/json
{ "sku": "A-1024", "qty": 2 }
HTTP 201
[Asserts]
jsonpath "$.order_id" exists
jsonpath "$.status" == "pending"
jsonpath "$.total" == 49800
이 테스트는 “주문 API가 어떻게 동작해야 하는가“를 모른다. 다만 “지금 이렇게 동작하고 있다"를 안다. 그거면 충분하다. 리팩토링이 이 응답을 바꾸는 순간, 빨간불이 켜진다.
로그에서 자동으로 도출되는 것:
- 어떤 엔드포인트가 실제로 쓰이는가 → 한 달간 0번 호출된 엔드포인트는 죽은 코드다. 리팩토링 전에 삭제 후보.
- 정상 응답 패턴 → 기본 회귀 테스트.
- 에러 패턴 → 사람이 상상 못 하는 진짜 엣지 케이스. 실제 사용자가 만들어낸 422와 500이다.
- 호출 빈도 → 테스트 우선순위. 하루 1만 2천 건짜리부터 박는다.
마지막 항목이 중요하다. 사람이 테스트를 짤 때는 자기가 기억하는 해피 패스부터 짠다. 트래픽은 그런 편향이 없다. 실제로 부하를 받는 경로가 곧 우선순위다.
두 겹의 안전망
이 접근은 단독으로 쓰는 게 아니라, 레거시를 agent-operable로 끌어올리는 래칫 파이프라인의 한 층이다.
nginx log (1개월) → Hurl 자동 생성 → 레거시 API 현재 동작을 박제
↓
tsma → 함수 단위 안전망 (unit)
↓
filefunc → 코드 구조 정리 (개념 하나에 파일 하나)
↓
리팩토링 → Hurl이 API 동작 보존을 검증 (integration)
핵심은 안전망이 두 겹이라는 것이다.
- tsma = 함수 단위 안전망. 내부 로직이 바뀌었는지 잡는다. 하지만 함수 시그니처가 그대로여도 엔드포인트 전체의 거동은 달라질 수 있다.
- Hurl from traffic = API 단위 안전망. 외부에서 본 계약이 보존되는지 잡는다. 내부를 어떻게 갈아엎든, 밖에서 들어와 밖으로 나가는 것만 같으면 통과다.
리팩토링은 정의상 “외부 동작을 보존하면서 내부 구조를 바꾸는 일"이다. 그렇다면 보존되어야 할 “외부 동작"의 정의가 어딘가에 박제되어 있어야 한다. tsma가 안쪽 경계를, Hurl이 바깥쪽 경계를 잡는다. 두 겹이 함께 있을 때 비로소 에이전트에게 “맘껏 갈아엎어라, 어디가 깨지는지는 기계가 본다"고 말할 수 있다.
아첨할 수 없는 판정관
이게 Symbolic Feedback Loop의 본질과 정확히 맞물린다.
에이전트에게 “리팩토링 잘 했어?“라고 물으면 “네, 깔끔하게 정리했습니다"라고 답한다. 의견을 주면 아첨한다. 하지만 Hurl을 돌리면 POST /orders → expected 201, got 500이 나온다. 숫자와 상태 코드는 아첨하지 못한다. 감정이 없기 때문이다.
트래픽에서 뽑은 Hurl 테스트는 사람의 판단이 개입하지 않은 명세다. 누군가가 “이렇게 동작해야 한다고 생각한다"가 아니라, “관측 결과 이렇게 동작했다"이다. 주장이 아니라 측정이다. 그래서 리팩토링의 옳고 그름을 사람이 아니라 기계가 판정할 수 있다. LLM은 판단자가 아니라 실행자고, 판정은 결정론적 도구가 한다.
전제는 단 하나, 잘 기록된 로그
이 방법이 성립하려면 딱 하나가 필요하다. 잘 기록된 로그 한 달치.
여기서 “잘 기록된"이 전부다. access log만으로는 부족하다. 그건 엔드포인트와 status code와 timing은 주지만, 박제해야 할 핵심 — request body와 response body의 쌍 — 은 안 준다. POST /orders → 201만 알아서는 “이 입력에 이 출력이 나갔다"를 재현할 수 없다. 기능을 고정하려면 들어간 것과 나온 것을 둘 다 들고 있어야 한다.
그래서 진짜 질문은 “테스트를 어떻게 짤까"가 아니라 “내 로그가 명세가 될 만큼 잘 적혀 있는가"이다.
- request/response body가 남는가, 아니면 status code만 남는가.
- 에러 응답도 같이 남는가. 422와 500의 body야말로 사람이 상상 못 하는 엣지 케이스다.
- 로그가 구조화돼 있어 기계가 요청·응답을 쌍으로 묶어낼 수 있는가.
이게 갖춰져 있다면, 당신은 이미 한 달째 명세를 쓰고 있었던 셈이다. 따로 테스트를 짤 필요가 없다. 로그 파이프라인이 대신 짜고 있었으니까. 안 갖춰져 있다면, 지금 미들웨어 한 겹 끼워 한 달만 켜 두면 된다. 한 달 뒤 당신 손에는 레거시의 현재 동작이 통째로 들어온다.
왜 하루가 아니라 한 달인가. 하루치는 해피 패스만 잡는다. 한 달치는 월말 배치, 정산 직전의 트래픽 폭증, 드물게만 호출되는 관리자 엔드포인트, 새벽 3시에 한 번 도는 크론까지 — 시스템의 긴 꼬리를 잡는다. 명세는 평균이 아니라 분포다.
로그를 Hurl로 번역해 기능을 고정한다
로그가 갖춰졌으면 나머지는 기계적이다. 한 달치 request/response 쌍을 도구에 넣고, 각 쌍을 Hurl 블록으로 번역한다. 그렇게 쏟아진 수백 개의 Hurl 파일이 곧 characterization 스위트 — 레거시의 현재 동작을 통째로 박제한 안전망이다. 코드는 한 줄도 안 읽었다. 흘러간 트래픽만 읽었다.
여기서 흔히 멈칫하는 지점 하나를 미리 짚자. “로그에 개인정보·결제·토큰이 들었는데, 그걸 테스트로 박제해도 되나?”
된다. 정확히는, 박제할 필요가 없다. 이 방법론은 본래 값이 필요 없기 때문이다. characterization test가 고정하는 건 값이 아니라 동작이다.
HTTP 201
[Asserts]
jsonpath "$.order_id" exists
jsonpath "$.total" == 49800
여기서 명세로서 중요한 건 49800이라는 숫자가 아니라 “total 필드가 정수로 존재하고, 주어진 입력에 대해 이렇게 계산된다"는 구조다. 값을 마스킹하거나 합성 데이터로 갈아끼워도 명세의 가치는 거의 줄지 않는다. capture → 마스킹 → Hurl 생성, 이 파이프라인 전체가 당신 인프라 안에서 돈다. raw 로그는 어디로도 나갈 일이 없다. 남는 건 값이 가려진 명세, 구조만 보존된 계약뿐이다. 로그를 밖으로 안 보내도 되는 것은 보안상의 양보가 아니라 이 접근의 본질이다 — 애초에 동작만 박제하면 되니까.
생성된 Hurl을 스테이징에 한 번 돌려 보면 그 자리에서 통과/실패가 갈린다. 초록불이 다 들어왔으면, 이제 리팩토링을 시작할 수 있다. 에이전트에게 맘껏 갈아엎으라 하고, 어디가 깨지는지는 Hurl이 본다.
코드 없이 놓는 사다리
그래서 이 접근의 진짜 가치는 “테스트를 빨리 짠다"가 아니다. 진짜 가치는 이것이다.
- 코드를 안 읽어도 시작된다 — 원작자는 떠났고 문서도 없지만, 흘러간 트래픽만으로 안전망이 깔린다. 코드를 이해하기 전에 손댈 자격을 먼저 얻는다.
- 결과물이 즉시 검증 가능 — 생성된 Hurl을 스테이징에 돌리면 그 자리에서 pass/fail이 나온다. “잘 되겠지"가 아니라 “지금 327개 중 327개 통과한다"이다.
- 데이터가 담장을 안 넘는다 — capture부터 Hurl 생성까지 전부 내 인프라 안에서 끝난다. 규제 산업일수록, 아무것도 밖으로 안 내보낸 채 시작할 수 있다는 게 결정적이다.
레거시 현대화의 첫 삽은 보통 “현재 동작이 뭔지 아무도 모른다"라는 절벽에서 멈춘다. 트래픽 → Hurl은 그 절벽에 사다리를 놓는다. 사다리를 놓는 데 코드는 필요 없다. 흘러간 트래픽이면 된다 — 그리고 그 트래픽조차 담장 안에 그대로 둔 채로.
흐름은 이미 명세를 적고 있었다
우리는 명세를 따로 쓰려고 애쓴다. OpenAPI를 손으로 짜고, 위키에 동작을 기술하고, 그것들이 코드와 어긋나면 드리프트라 부르며 한탄한다.
하지만 살아 있는 시스템은 매 순간 자기 명세를 스스로 쓰고 있었다. 요청 하나가 들어오고 응답 하나가 나갈 때마다, 그것은 “나는 이런 시스템이다"라는 한 줄의 자기 기술이다. 로그 파일은 그 자서전이 한 달간 쌓인 것이다.
우리가 안 읽었을 뿐이다.
레거시는 문서가 없는 게 아니다. 문서가 access log 안에 있고, 형식이 사람이 읽기 불편할 뿐이다. Hurl로 번역하면 그것은 돌릴 수 있는 명세, 기계가 판정하는 계약이 된다.
문서는 거짓말을 한다. 트래픽은 거짓말을 하지 않는다.
출처 / 근거
핵심 개념·도구
- Michael Feathers. Working Effectively with Legacy Code. Prentice Hall, 2004. — characterization test 개념의 출처. “코드가 옳게 무엇을 해야 하는가가 아니라 현재 무엇을 하는가를 고정한다.”
- Hurl 프로젝트 (hurl.dev) — 평문 HTTP 요청/응답 테스트 포맷. yongol의 10개 SSOT 중 하나로 통합됨.
- tsma 527개 함수 실증 — 함수 단위 래칫 (tsma).
트래픽·실행에서 테스트를 뽑아낸다 (carving / record-replay)
- Elbaum, Chin, Dwyer, Jorde (2009). “Carving and Replaying Differential Unit Test Cases from System Test Cases.” IEEE TSE 35(1). — 시스템 실행을 record했다가 유닛 단위로 replay하는 differential unit test의 학술 토대.
- Meta 엔지니어링팀 (2024). “Observation-based Unit Test Generation at Meta.” FSE 2024, arXiv:2402.06111. — 앱 실행 관찰값에서 테스트를 carving. CI에서 960만 회 실행, 5,702개 결함 검출. “관측이 곧 테스트"의 산업 규모 실증.
현재 동작을 박제한다 (snapshot / golden master)
- Fujita, Kashiwa, Lin, Iida (2023). “An Empirical Study on the Use of Snapshot Testing.” ICSME 2023. — snapshot(= golden master/characterization) 테스트의 채택 실증. “옳음이 아니라 현재 출력을 고정해 변경을 감지한다.”
리팩토링 안전망
- Kim, Zimmermann, Nagappan (2014). “An Empirical Study of Refactoring Challenges and Benefits at Microsoft.” IEEE TSE 40(7). — 행위 보존을 보장할 테스트가 없으면 리팩토링은 비용이자 위험이라는 실증.
- Yoo, Harman (2012). “Regression Testing Minimization, Selection and Prioritization: A Survey.” STVR 22(2). — 회귀 테스트 = “변경이 기존 행위를 해치지 않는다는 확신"의 표준 정의.
실제 사용 분포가 우선순위다
- John D. Musa (1993). “Operational Profiles in Software-Reliability Engineering.” IEEE Software 10(2). — 사용 빈도순으로 테스트를 배분하면, 일정상 중단해도 가장 많이 쓰이는 기능이 가장 많이 검증된다. “해피패스 편향 대신 트래픽 분포"의 고전적 토대.
기계가 판정해야 하는 이유 (LLM은 판단자가 아니라 실행자)
Huang, Chen, Mishra, et al. (2024). “Large Language Models Cannot Self-Correct Reasoning Yet.” ICLR 2024, arXiv:2310.01798. — 외부 피드백 없이는 LLM이 자기 추론을 교정하지 못한다. 결정론적 외부 검증기가 필요한 이유.
Sharma, Tong, et al. (2024). “Towards Understanding Sycophancy in Language Models.” ICLR 2024, arXiv:2310.13548. — RLHF가 동조를 학습시켜 LLM 자기판정의 신뢰성을 무너뜨린다.
대표 이미지: AI 생성 (Google Gemini)
같이 읽을 거리
- Michael Feathers, “Characterization Testing” — 용어 창시자의 글. “소프트웨어가 프로덕션에 들어가는 순간 그 자체가 명세가 된다(it becomes its own specification).” 이 글 제목과 거의 같은 테제.
- Hurl 공식 튜토리얼, “Your First Hurl File” —
GET / HTTP 200부터--test모드까지. 평문 한 줄이 곧 테스트라는 걸 직접 손에 쥐어 보는 입문. - GitHub Engineering, “Scientist: Measure Twice, Cut Once” — 레거시(control)와 신규(candidate) 코드를 프로덕션에서 동시 실행해 결과를 비교하는 라이브러리. “실제 동작만이 진짜 명세.”
- Twitter Diffy (InfoQ 요약) — 같은 요청을 신/구 서비스에 보내 응답 차이만 회귀로 잡는 프록시. “테스트를 안 쓰고 동작을 박제한다"의 고전적 선례.
- GoReplay — 네트워크 인터페이스에서 라이브 HTTP 트래픽을 캡처해 스테이징으로 리플레이하는 도구. “프로덕션 트래픽을 테스트 입력으로"의 대표 구현.
- Nicolas Carlo, “Characterization vs Approval Tests” — 사실상 같은 기법인 세 용어를 정리하고, 출력에서 민감정보를 스크럽하는 “Printer"의 역할을 강조.
- Pact — consumer-driven contract testing. 트래픽 박제와 대비되는 “명시적 계약” 접근. 두 방식을 같이 보면 균형이 잡힌다.
관련 글
- Hurl이 드리프트를 막는다 — HTTP 계약을 평문으로 선언하고 CI에 잠그는 법. 이 글이 “트래픽 → Hurl"이라면 그건 “Hurl로 드리프트 잠그기”.
- tsma — 레거시 코드의 회귀 방어선 — 두 겹 안전망의 안쪽 경계(함수 단위). Hurl이 바깥 경계라면 tsma가 안쪽.
- Agent Operable Codebase — 레거시를 에이전트가 작업 가능한 코드로 끌어올리는 3단계 파이프라인.
- 코딩 에이전트는 왜 작동하고 왜 무너지는가 — Symbolic Feedback Loop의 구조.
- 제약은 계약이다 — 검증 가능하고 강제 가능한 계약으로서의 테스트.
- 망한 바이브 코딩 살리는 법 — characterization testing으로 레거시를 진단→잠금→수리→추출→전환하는 실전 강의.