레거시 재작성(Legacy Rewrites)에 필요한 것은 더 나은 프롬프트가 아니라 판사(Judge)이다
요약
레거시 시스템 재작성 시 코드 분석보다 기존 시스템의 동작을 테스트 스위트로 기록하여 명세로 활용하는 '테스트 주도 현대화' 방식의 중요성을 강조합니다. 코딩 에이전트 자체보다 결과의 정합성을 검증할 수 있는 테스트 하네스(harness) 구축이 핵심 자산임을 설명합니다.
핵심 포인트
- 소스 코드 분석은 해석을 낳지만, 실행은 비교 가능한 증거를 생성함
- 코딩 에이전트는 교체 가능한 부품이며, 이를 심판할 하네스가 핵심 자산임
- 재작성의 핵심 결과물은 새로운 코드가 아닌 전환을 승인할 수 있는 근거임
- 기존 시스템의 동작을 실행 가능한 테스트 스위트로 기록하여 명세로 활용해야 함
최근 들어 레거시 교체(legacy replacements)에 대한 질문을 계속 받고 있습니다.
이는 제가 나누는 대화 중 가장 흔한 주제 중 하나가 되었으며, 질문의 틀은 거의 항상 동일합니다. "이제 AI가 이것을 재작성할 수 있지 않나요?" 수요는 분명히 증가하고 있으며, 그 전제가 틀렸다고 생각하지는 않습니다. 코딩 에이전트(coding agent)는 레거시 애플리케이션을 놀라울 정도로 빠르게 재작성할 수 있습니다. 새로운 코드를 생성하는 것은 더 이상 병목 현상(bottleneck)이 아닙니다.
변하지 않은 것은 _"새로운 시스템이 기존 시스템과 동일하게 동작한다"_라고 말할 수 있는 능력입니다. 이는 결과적으로 두 시스템 모두에 대해 누구도 증거를 제시할 수 없는 주장임이 드러났습니다.
그래서 이제 이런 상황이 발생하면, 저는 더 이상 기존 코드를 분석하는 것부터 시작하지 않습니다. 그것은 당연한 조치가 아닙니다. 기본적으로는 기존 시스템을 먼저 이해한 다음, 그 이해를 바탕으로 재구축하는 것이 일반적입니다. 제가 본 모든 계획은 이 순서로 시작되었고, 코딩 에이전트가 분석을 수행하도록 하여 이 순서대로 시도해 보기도 했습니다. 솔직한 요약은, 그것이 수렴(converge)하지 않았다는 것입니다.
대신 제가 옮겨가게 된 접근 방식은 다음과 같습니다:
기존 시스템의 관찰 가능한 동작(observable behavior)을 실행 가능한 스위트(executable suite)로 기록하고, 해당 스위트를 마이그레이션 범위(migration scope)에 대한 명세(specification)로 받아들인 뒤, 테스트가 통과(green)될 때까지 코딩 에이전트가 이를 바탕으로 구현하게 한다.
레거시 시스템이 테스트를 제공하는 것이 아니라, 오라클(oracle) 역할을 수행하는 테스트 주도 현대화(Test-driven modernization) 방식입니다. 시나리오, 관찰 지점(observation points), 그리고 정규화 규칙(normalization rules)은 여전히 제가 설계해야 할 몫입니다. 기존 시스템이 제공하는 것은 기대값(expected values)뿐입니다.
이것은 조언도 아니고, 이 문제가 해결되었다는 주장도 아닙니다. 당연해 보이는 접근 방식이 수렴하지 못하는 것을 지켜본 후 제가 얻은 가장 실질적인 가설입니다.
현재 제가 도달한 결론 (그리고 계속해서 수정해 나갈 것으로 예상되는 부분):
- 소스 분석은 **해석 (interpretations)**을 생성합니다. 실행은 **비교 가능한 증거 (comparable evidence)**를 생성합니다.
- 선언된 범위(scope)에 대해, 커버리지 계약(coverage contract)과 기록된 결과, 그리고 테스트가 곧 명세(specification)입니다. 이것들이 완벽해서가 아니라, 수용되었기 때문입니다.
- 코딩 에이전트(coding agent)는 교체 가능한 부품입니다. 그것을 심판하는 **하네스 (harness)**가 자산입니다.
- 중요한 결과물은 새로운 코드가 아닙니다. 누군가가 전환(cutover)을 승인할 수 있는 근거(evidence)입니다.
처음에는 단순히 에이전트를 대상으로 지정해 보았습니다
이전 버전에 구축했던 모델에는 하네스(harness)가 전혀 포함되어 있지 않았습니다.
Claude Code를 레거시 코드베이스에 직접 지정했습니다. 저는 스캐폴딩(scaffolding)에 상당한 노력을 기울였습니다. 반복적인 분석 작업을 위한 기술, 세션 전반에 걸쳐 유지되는 프롬프트 구조(prompt structures), 시스템을 순차적인 단위로 분해하는 작업 계획(work plans), 그리고 루프(loop): 기존 코드의 특정 영역을 분석하고, 새로운 스택으로 동일한 기능을 작성하고, 검토한 뒤 다음으로 넘어가는 방식입니다. 순진한 설정은 아니었으며, 여전히 그것이 가장 먼저 시도해야 할 올바른 방법이었다고 생각합니다.
부분적으로는 작동했습니다. 그게 난처한 부분입니다. 무언가는 만들어내니까요. 기존과 명확히 동일한 애플리케이션임을 알 수 있는 새로운 구현체를 얻게 되고, 운이 좋은 날에는 검토자가 고개를 끄덕이기도 합니다.
하지만 그것이 만들어내지 못한 것은, 그것을 믿을 수 있는 근거였습니다. 세 가지 문제가 계속 발생했는데, 알고 보니 그것들은 모두 다른 모습만 하고 있을 뿐 결국 같은 문제였습니다.
확인된 동작 단위당 토큰 비용이 형편없었습니다. 루프는 이미 읽었던 코드를 다시 읽고, 이미 도달했던 결론을 다시 도출하며, 다음 단계에서 소비할 수 있도록 긴 분석 산문을 작성하는 데 예산의 대부분을 소비했습니다. 엄청난 입력(input)이 발생했지만, 출력은 단 한 단락의 해석뿐이었습니다. 저장 프로시저(stored procedure)에 대해 그럴듯한 해석을 얻기 위해 값비싼 토큰을 소모하고 있었던 것입니다.
충실도(Fidelity)는 우리가 눈으로 확인하던 것이었습니다. 두 개의 화면을 나란히 띄워놓고, 두 세트의 레코드를 정렬한 뒤, 눈을 가늘게 뜨고 쳐다보는 식이었죠. "맞는 것 같네." 이 문장에는 어떤 숫자도 들어있지 않습니다. 어떤 부작용(side effects)이 검증되었고 어떤 것이 검증되지 않았는지, 혹은 어제의 슬라이스(slice)가 여전히 통과되고 있는지 아무도 말할 수 없었습니다. 그리고 데이터의 양은 에이전트(agent)를 압도하기 훨씬 전부터 주의력을 무너뜨립니다. 아무도 400번째 주문 상태 전환(order-status transition)을 주의 깊게 눈으로 확인하지 않습니다.
래칫(ratchet, 단계적 개선)되는 것이 아무것도 없었습니다. 검토를 마치고 승인된 슬라이스가 다음 슬라이스가 진행되는 동안 조용히 퇴보(regress)할 수 있었고, 우리는 이를 통합(integration) 단계나 그 이후에야 알게 되었습니다. 수동 검토는 스스로 다시 실행될 수 있는 그 어떤 흔적도 남기지 않습니다.
결국 제가 내린 진단은 이랬습니다. 저는 더 나은 프롬프트(prompt)로 측정(measurement) 문제를 해결하려 하고 있었습니다. 기술과 계획의 모든 반복은 에이전트의 해석을 더 설득력 있게 만들려는 시도였습니다. 제게 필요했던 것은 더 설득력 있는 해석이 아니었습니다. 사람이 두 화면을 눈을 가늘게 뜨고 쳐다보지 않고도 에이전트의 의견에 반대할 수 있는 메커니즘이었습니다.
그래서 저는 분석(analysis)을 개선하는 것을 멈추고 측정(measurement)을 구축하기 시작했습니다. 다음에 이어지는 내용은 미리 논리적으로 설계된 디자인이 아니라, 첫 번째 접근 방식의 실패가 가리키고 있던 방향입니다.
동작(Behavior)은 결코 한 곳에 있지 않았다
분석 우선(analysis-first) 접근 방식은 수렴하지 않습니다. 이는 에이전트가 글을 못 읽어서가 아닙니다. 동작(behavior)이 어디에도 모여 있지 않기 때문입니다.
일부는 애플리케이션 코드(application code)에 있습니다. 나머지는 저장 프로시저(stored procedure), 트리거(trigger), 야간 배치 작업(nightly batch jobs), 세 개의 화면이 서로 다르게 읽는 권한 테이블(permissions table), 아무도 관리하지 않는 스케줄러 항목(scheduler entry), 문서에는 언급되지 않은 형태를 반환하는 외부 서비스(external service), 그리고 사람의 머릿속에 존재하는 운영 규칙(operational rules)에 흩어져 있습니다. Java / JSP + Oracle + nightly batch + mail 시스템은 명세서(specification)를 가지고 있는 것이 아니라, 축적된 일련의 효과(effects)를 가지고 있는 것입니다. 이 모든 것을 다 읽더라도 실행 순서를 알 수 없거나, 프로덕션(production) 환경에서 어떤 분기(branch)가 실행되는지, 혹은 송장 반올림(invoice rounding)이 경로에 따라 다른지 여부를 여전히 알 수 없습니다.
하지만 여러분은 그것을 _실행(run)_할 수는 있습니다. 실행 중인 시스템을 대상으로 비즈니스 시나리오를 구동하고 그 결과로 무엇이 나오는지 기록할 수 있습니다. 그러나 기록하는 것만으로는 그것이 권위(authoritative)를 갖지는 못합니다. 출처(provenance)(어떤 빌드인지, 어떤 데이터 세트인지, 어떤 실행인지), 반복된 실행 전반에 걸친 안정적인 의미론(semantics), 그리고 해당 실행이 기준점(baseline) 역할을 할 수 있도록 허용하는 승인된 정책(policy)이 필요합니다. 인간의 판단은 범위(scope), 정책(policy), 그리고 전환(cutover) 경계에 위치해야지, 모든 실행의 모든 필드에 위치해서는 안 됩니다. 사람이 내부 루프(inner loop)에 들어가는 순간, 이 프로세스는 문제가 중요해지기 시작하는 바로 그 시점의 규모에서 확장이 멈추게 됩니다.
테스트 스위트(suite)가 통과(green)되었다는 사실은 또한 그 스위트가 포함하고 있는 시나리오만을 증명할 뿐입니다. 이는 명백한 반론이며, 글의 마지막이 아닌 여기서 답변할 가치가 있습니다. 따라서 루프에는 두 번째 게이트(gate)가 필요합니다. 즉, 범위(scope)가 요구하는 표면(surfaces), 여정(journeys), 데이터 효과(data effects), 오류 경로(error paths), 그리고 경계(boundaries)가 실제로 표현되어 있다는 증거가 필요합니다. 여기서 커버리지(coverage)는 퍼센트(%)가 아니라, 무엇이 반드시 존재해야 하는지에 대한 계약(contract)입니다. 이로 인해 "스위트가 곧 사양(specification)이다"라는 말은 슬로건에 불과하게 됩니다. 정확한 버전은, 선언된 범위(scope)에 대해 커버리지 계약(coverage contract), 테스트, 그리고 증명된 레거시 결과(attested legacy results)가 결합하여 실행 가능한 사양(executable specification)을 형성한다는 것입니다.
비교기(comparator)는 표현(representation)을 정규화할 수는 있지만, 동작(behavior)을 용서할 수는 없습니다.
테스트 하네스(harness)는 각 시나리오를 두 시스템 모두에 대해 실행하고 비교합니다. 이는 서로 달라야만 하는 두 시스템을 비교하려고 시도하기 전까지는 당연하게 들립니다. ID는 새로 생성되었습니다. 타임스탬프(timestamps)도 새로 생성되었습니다. 행 순서(row ordering)는 지정되지 않았습니다. 새로운 UI의 구현과 DOM은 사용자에게 보이는 동작이 동일하게 유지되어야 하는 경우에도 의도적으로 다릅니다.
따라서 동일성을 요구하는 비교기(comparator)는 쓸모가 없습니다. 하지만 해결책은 무언가를 용서하는(forgives) 비교기를 만드는 것이 아닙니다. 이것이 제가 처음에 틀렸던 부분이며, 전체 장치(apparatus)가 기반을 두고 있는 핵심적인 구분입니다.
표현(Representation)은 생성된 ID, 요청 타임스탬프(request timestamps), 인코딩(encodings), 순서가 없는 컬렉션(unordered collections)의 순서, 그리고 비의미적(non-semantic)임이 '증명된' 메타데이터입니다. 여기서 '증명된'이라는 점에 주목하십시오. "이 필드는 부수적인 것으로 보였다"는 식의 판단은 비즈니스 규칙이 폐기되는 방식입니다. 비교기(comparator)가 할 수 없는 일은 특정 동작이 변경되거나 사라질 수 있다고 결정하는 것입니다. 그러한 결정은 존재하며 빈번하게 일어나지만, 이는 엄격한 번역(strict translation) 범위를 벗어나 마이그레이션 정책(migration-policy) 결정으로 다루어져야 하며, 무엇이 누구에 의해 결정되었는지 기록되어야 합니다.
| 계층 (Layer) | 결정할 수 있는 사항 |
|---|---|
| 정규화 (Normalization) | 표현(representation)만 — 동일한 동작을 다르게 표현하는 것 |
| 마이그레이션 정책 (Migration policy) | 의도적인 변경 또는 제거, 엄격한 번역 모드 외부의 사항 |
의도적으로 두 행으로 나누었습니다. 허용적인 모드(permissive mode)는 좁은 범위의 예외를 수용할 수 있지만, 결코 통과(green)를 위한 일반적인 경로가 되어서는 안 됩니다. "이 차이점은 무시하자"가 일상적인 어휘가 되는 순간, 통과(green) 결과는 협상을 통해 얻어낼 수 있는 무언가로 변질되며, 이 장치(apparatus)는 그저 안도감을 느끼기 위한 값비싼 수단이 되어버립니다.
하나의 시나리오, 엔드 투 엔드 (end to end)
추상적으로 들으면 이 모든 것이 깔끔해 보이지만, 실제로 진행되는 단일 사례를 살펴보겠습니다. 주문 생성(Order creation), 표준 경로(standard path), 재고 있음(item in stock).
기록된 레거시 실행(legacy run):
scenario order.create / standard / in-stock
legacy build=legacy-2019.11 data=masked-set-3 run=8f2c
...
새로운 구현(new implementation), 첫 번째 패스:
PASS screen, api status, orders, order_lines, permission, batch, mail
DIFF api.order_no expected O-2024-000123 actual O-2026-000001
DIFF inventory_reservations expected 1 (qty 10) actual 0
두 개의 차이점(diffs)이 있으며, 이들은 서로 다른 종류입니다:
order_no generated id → normalized (shape asserted: O-YYYY-NNNNNNN)
qty on inventory reserve → semantic; NOT normalizable
첫 번째는 해결됩니다. 두 번째는 동작(behavior)이므로, 규칙(rule)이 아닌 작업(task)이 됩니다.
기대값: inventory reservation created: 1 (qty 10)
실제값: inventory reservation created: 0
수정사항: 주문 생성 시 재고 예약(inventory reservation)을 추가할 것.
...
에이전트(Agent)가 구현하고, 테스트 하네스(harness)가 재실행하며, 사람이 승인할 수 있는 결과와 함께 슬라이스(slice)가 종료됩니다:
order.create / standard / in-stock PRESERVED
evidence legacy run 8f2c, new run c41a, 7 observed effects, 0 unresolved
normalized order_no, created_at, DOM structure
...
마지막 블록이 이 연습의 실제 산출물입니다. 코드가 아니라, 어떤 실행(run)이 있었는지, 효과(effects)가 몇 개인지, 무엇이 정규화(normalized)되어 사라졌는지, 그리고 이 결과에 도달하기 위해 누군가 규칙을 변경했는지 여부를 나타내는 행(row)입니다.
엄격함(Rigor)은 다시 한번 압축합니다
에이전트가 무엇을 받았는지 주목하십시오. 시스템에 대한 설명이 아니라, 차이점(diff)과 범위 제약(scope constraint)입니다. 첫 번째 접근 방식에서 그에 상응하는 입력값은 레거시 재고 모듈이 아마도 어떻게 작동하는지에 대한 긴 문단들이었습니다. 길고, 확신이 없으며, 비용이 많이 들고, 부분적으로 틀린 내용들이었죠. 여기서는 두 개의 숫자뿐입니다. 더 짧고, 관찰된 실행(run)에 근거합니다.
그래프 포스트에서 다른 방향으로 도달한 것과 동일한 격언입니다. 동작(behavior)이 더 정밀하게 포착될수록, 무엇이 잘못되었는지 말하는 데 필요한 토큰(token)은 줄어듭니다. 초기에 포착 비용이 발생하지만, 그 이후에는 매 반복(iteration)마다 비용이 발생하지 않습니다. 이는 매 라운드마다 코드베이스를 다시 읽는 것과는 정반대입니다.
결과적으로 루프 자체는 특별할 것이 없으며, 그것이 핵심입니다. 레드-그린-리팩터(red-green-refactor) 방식이지만, 여기서 그린(green)은 이미 존재하는 시스템으로부터 온 것입니다.
에이전트는 코드를 작성하고, 하네스는 그것이 맞는지 결정합니다
이 규율은 신뢰 경계(trust-boundary)의 문제입니다. 에이전트는 자신의 작업물을 스스로 판단하는 존재가 되어서는 안 됩니다.
Claude Code, Codex, 혹은 다음에 무엇이 나오든 — 이들은 서로 교체 가능한 것들입니다. 변하지 않는 자산은 그 주변의 모든 것들입니다: 시나리오 정의(scenario definitions), 스택별 실행 어댑터(execution adapters), 두 종류의 러너(runners), 결과 정규화(result normalization), 동작 비교(behavior comparison), 복구 작업 생성(repair-task generation), 증거 저장(evidence storage) 등이 그것입니다. 에이전트들은 그럴싸하게 "모든 테스트 통과"라는 결과를 만들어내는 데 놀라울 정도로 능숙합니다. 따라서 비교기(comparator), 정규화 규칙(normalization rules), 그리고 증거 저장소(evidence store)는 제가 의도적으로 직접 작성하고, 검토할 수 있을 만큼 충분히 작으며, 지루할 정도로 단순한 부분으로 남겨두고 싶은 요소들입니다. 에이전트의 도움을 받는 맞춤형(bespoke) 부분은 실수가 조용한 통과(silent pass)가 아닌 실패한 시나리오로 드러나는 쪽에 머물러야 합니다. 그렇지 않으면 당신은 안전해 보이지만 실제로는 불안정한(unsoundness) 시스템을 구축하게 될 것입니다.
캡처 레이어(capture layers)는 대부분 배관(plumbing) 작업에 가깝습니다: 화면 동작을 위한 브라우저 구동(Playwright), API를 위한 경계 캡처(boundary capture), 데이터베이스를 위한 전/후 스냅샷(before/after snapshots), 실행 경로 및 부수 효과(side effects)를 위한 트레이싱(tracing), 배치/메일/이벤트를 위한 스텁 처리된 싱크(stubbed sinks), 증거를 위한 JSONL 및 Parquet, 작업 상태 및 차이 분석(diff analysis)을 위한 SQLite 및 DuckDB 등이 있습니다. 이 중 어떤 것이든 귀하의 환경에 맞는 도구로 교체하십시오. 도구는 보이는 것만큼 중요하지 않습니다. 중요한 것은 출력이 재현(replay) 및 비교가 가능한 정규화된 기록(normalized record)이어야 한다는 점입니다. 시스템이 이런 방식으로 마이그레이션될 수 있는지 여부를 결정하는 것은 언어나 스택이 아니라 바로 이 점입니다.
여기에 두 가지 솔직한 주의사항이 있습니다. 스텁 처리된 싱크(stubbed sink)는 시스템이 경계에서 올바른 요청을 보냈음을 증명할 뿐, 메일이 도착했거나 결제가 완료되었음을 증명하지는 않습니다. 또한 트레이스(traces)는 동등성(equivalence)의 증거가 아닙니다. 재작성(rewrite) 시에는 내부 경로(internal routes)가 변해야만 합니다. 트레이스를 통해 얻을 수 있는 것은 설명입니다. 즉, 왜 차이(diff)가 발생했는지, 어떤 관찰(observations)이 누락되었는지, 어떤 부수 효과를 아무도 기록할 생각을 못 했는지에 대한 설명 말입니다.
"완전함"이 의미해야 하는 것
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기