Split View: 레거시 코드 다루기 — 무섭고 오래된 코드를 두려움 없이 바꾸는 법
레거시 코드 다루기 — 무섭고 오래된 코드를 두려움 없이 바꾸는 법
프롤로그 — 레거시 코드는 당신이 상속받는다
새 프로젝트는 오래가지 않는다. 6개월이면 어제의 새 코드가 오늘의 레거시가 된다. 1년이면 아무도 그 안을 정확히 모르는 모듈이 하나 생긴다. 3년이면 "그건 건드리지 마세요"라는 말이 회의에서 나온다.
레거시 코드의 정의는 사람마다 다르다. 누군가는 "오래된 코드"라 하고, 누군가는 "내가 안 짠 코드"라 한다. 마이클 페더스의 정의가 가장 쓸모 있다. 레거시 코드는 테스트가 없는 코드다. 테스트가 없으면 바꿨을 때 무엇이 깨졌는지 알 길이 없다. 그래서 손대기가 두렵다. 두려우니까 최소한만 바꾸고, 최소한만 바꾸니까 코드는 점점 더 이상해진다.
여기에 더 솔직한 정의를 하나 보태자. 레거시 코드는 당신이 바꾸기 두려워하는 코드다. 두려움이 핵심이다. 코드가 오래됐든 새것이든, 테스트가 있든 없든, 바꿀 때 손이 떨린다면 그건 당신에게 레거시다.
이 글은 그 두려움을 다루는 기술이다. 두려움을 "용기"로 이기는 게 아니다. 두려움을 절차로 바꾼다. 안전하게 바꾸는 순서가 있으면, 코드가 무서워도 손은 떨리지 않는다. 특성화 테스트로 현재 동작을 박제하고, 이음새를 찾아 테스트를 끼워 넣고, 새싹과 감싸기로 위험을 격리하고, 스트랭글러 무화과로 시스템 전체를 점진적으로 교체한다. 그리고 AI 에이전트가 이 작업에서 어떤 가속기이자 어떤 지뢰인지도 본다.
핵심 한 줄: 레거시 코드를 안전하게 바꾸는 비결은 용기가 아니라 절차다. 바꾸기 전에 현재 동작을 고정하라 — 그 동작이 버그라도.
1장 · 레거시 코드의 딜레마 — 닭과 달걀
레거시 코드를 안전하게 바꾸려면 무엇이 필요한가? 테스트다. 바꾸기 전후를 비교할 수 있어야 "안 깨졌다"고 말할 수 있다.
그런데 테스트를 붙이려면 무엇이 필요한가? 코드를 바꿔야 한다. 테스트하기 좋게 함수를 쪼개고, 의존성을 주입할 수 있게 만들고, 전역 상태를 걷어내야 한다.
여기서 딜레마가 생긴다.
| 하고 싶은 것 | 필요한 선행 조건 | 그런데 |
|---|---|---|
| 코드를 안전하게 바꾼다 | 테스트가 있어야 한다 | 테스트가 없다 |
| 테스트를 붙인다 | 코드를 테스트 가능하게 바꿔야 한다 | 그 변경이 안전한지 모른다 |
| 변경이 안전한지 확인한다 | 테스트가 있어야 한다 | 원점으로 |
이게 레거시 코드 작업의 본질적 어려움이다. 닭과 달걀. 테스트 없이는 안전하게 못 바꾸고, 안 바꾸면 테스트를 못 붙인다.
탈출구는 두 가지다.
첫째, 최소한의 위험한 변경을 받아들인다. 테스트를 끼워 넣기 위한 변경은 가능한 한 작고, 기계적이고, 되돌리기 쉬운 것으로 한다. "메서드 추출", "변수를 매개변수로", "생성자에 의존성 받기" — 이런 변경은 IDE가 자동으로 해 주고, 동작을 거의 바꾸지 않는다. 위험이 0은 아니지만 충분히 작다.
둘째, 변경 없이 테스트할 수 있는 지점을 찾는다. 이게 이음새(seam)다. 코드를 다시 쓰지 않고도, 이미 존재하는 경계를 통해 테스트를 끼워 넣는다. 3장과 4장의 주제다.
핵심은 순서다. 먼저 현재 동작을 박제하는 안전망을 친다(특성화 테스트). 그다음 그 안전망 아래에서 코드를 리팩터링한다. 안전망이 빨간불을 켜면 멈춘다. 무모하게 "일단 고치고 보자"가 아니라, 매 걸음마다 발밑을 확인하는 것이다.
레거시 코드 작업은 등반과 같다. 한 손이 확보된 다음에야 다른 손을 뗀다. 절대 두 손을 동시에 떼지 않는다.
2장 · 특성화 테스트 — 현재 동작을 박제하라
특성화 테스트(characterization test)는 레거시 코드 작업의 출발점이다. 이름이 핵심을 담고 있다. 이 테스트는 코드가 무엇을 해야 하는지를 검증하지 않는다. 코드가 현재 무엇을 하는지를 박제한다.
차이가 중요하다. 일반적인 테스트는 명세에서 시작한다 — "이 함수는 X를 반환해야 한다". 특성화 테스트는 명세가 없거나 믿을 수 없는 상황에서 시작한다. 명세 대신 현재 동작 그 자체를 진실로 받아들인다.
왜 "현재 동작"인가, 버그라도
레거시 코드의 현재 동작에는 버그가 섞여 있다. 그런데 특성화 테스트는 그 버그까지 박제한다. 이상하게 들리지만 이유가 있다.
당신은 지금 이 코드의 동작을 이해하려는 것이지 고치려는 것이 아니다. 어떤 동작이 의도된 기능이고 어떤 동작이 버그인지, 지금은 구분할 수 없다. 누군가 그 "버그"에 의존하고 있을지도 모른다(헤이럼의 법칙). 그러니 일단 전부 박제한다. 그다음, 어떤 동작이 버그인지 확인되면 그 테스트를 의도적으로 바꾼다 — 그건 명확한 결정이지, 모르고 깨뜨린 게 아니다.
일반 테스트: 명세 → 테스트 작성 → 코드가 통과하게
특성화 테스트: 코드 실행 → 출력 관찰 → 그 출력을 기대값으로 박제
특성화 테스트를 쓰는 절차
- 테스트 하네스에 코드를 올린다. 함수를 호출할 수 있는 최소한의 환경을 만든다. 입력을 주고 출력을 받을 수 있으면 된다.
- 명백히 틀린 기대값으로 단언을 쓴다.
assertEquals("THIS_IS_WRONG", result)같은 식으로. - 테스트를 돌리고 실패 메시지를 본다. 테스트 러너가 "기대: THIS_IS_WRONG, 실제: 42"라고 알려준다. 이 42가 코드의 현재 동작이다.
- 실제 출력을 기대값으로 박는다.
assertEquals(42, result). 이제 테스트가 통과한다. - 반복한다. 다양한 입력 — 정상값, 경계값, 빈 값, 0, 음수, null — 으로 동작을 박제한다.
// before — 무엇을 하는지 명세가 없는 레거시 함수
function computeDiscount(order) {
let d = 0
if (order.total > 100) d = order.total * 0.1
if (order.coupon === 'VIP') d += 5
if (order.items.length > 10) d = Math.min(d, 20)
return Math.round(d * 100) / 100
}
// after — 현재 동작을 박제한 특성화 테스트
test('characterizes computeDiscount', () => {
// 명세가 아니라 "현재 이렇게 동작한다"의 기록
expect(computeDiscount({ total: 50, coupon: null, items: [] })).toBe(0)
expect(computeDiscount({ total: 150, coupon: null, items: [] })).toBe(15)
expect(computeDiscount({ total: 150, coupon: 'VIP', items: [] })).toBe(20)
// 아래는 버그처럼 보인다 — 일단 박제하고, 나중에 의도적으로 바꾼다
expect(computeDiscount({ total: 50, coupon: 'VIP', items: [] })).toBe(5)
})
이제 이 안전망 아래에서 computeDiscount를 리팩터링할 수 있다. 변수 이름을 고치고, 함수를 쪼개고, 조건을 정리한다. 테스트가 계속 초록불이면 동작은 그대로다. 빨간불이 켜지면 — 무언가 바뀐 것이고, 의도한 게 아니면 되돌린다.
골든 마스터 — 출력이 거대할 때
함수가 단순한 값이 아니라 거대한 출력(HTML 페이지, JSON 문서, 로그 파일, 이미지)을 낸다면, 출력 전체를 파일로 저장한다. 이게 골든 마스터(golden master) 또는 스냅샷 테스트다. 입력 100개를 넣어 출력 100개를 파일로 박제한다. 리팩터링 후 다시 100개를 생성해 파일과 비교(diff)한다. 한 글자라도 다르면 테스트가 잡는다.
골든 마스터는 동작을 한 줄도 이해하지 못한 코드에도 안전망을 칠 수 있다는 게 강점이다. 약점은 diff가 났을 때 "이 변경이 의도된 것인지" 사람이 판단해야 한다는 것이다.
3장 · 이음새 찾기 — 재작성 없이 테스트를 끼워 넣는 곳
이음새(seam)는 페더스의 가장 중요한 개념이다. 이음새란, 그 자리에서 코드를 편집하지 않고도 동작을 바꿀 수 있는 지점이다.
레거시 코드가 테스트하기 어려운 진짜 이유는 로직이 복잡해서가 아니다. 의존성 때문이다. 함수 한가운데서 데이터베이스에 직접 접속하고, 현재 시각을 직접 읽고, 네트워크를 직접 호출하고, 전역 싱글턴을 직접 만진다. 테스트에서 이걸 다 진짜로 띄울 수는 없다.
이음새는 그 의존성을 테스트 시점에 가짜로 바꿔치기할 수 있는 통로다. 이음새가 있으면 코드 본문을 안 고치고도, 그 통로로 가짜를 밀어 넣어 테스트할 수 있다.
이음새의 종류
| 이음새 종류 | 바꿔치기하는 방법 | 예시 |
|---|---|---|
| 객체 이음새 | 인터페이스/클래스를 구현한 가짜 객체를 주입 | 생성자나 setter로 의존성 주입 |
| 매개변수 이음새 | 함수 인자로 의존성을 받게 함 | now() 대신 clock 인자 |
| 함수/모듈 이음새 | 임포트나 함수 참조를 테스트에서 교체 | 모듈 모킹, 함수 포인터 |
| 서브클래스 이음새 | 위험한 메서드를 오버라이드한 서브클래스로 테스트 | 테스트 전용 서브클래스 |
| 빌드 이음새 | 빌드/링크 시점에 다른 구현을 연결 | 테스트 빌드에서 다른 파일 링크 |
가장 흔한 수술 — 의존성을 매개변수로 끌어내기
레거시 코드에서 가장 자주 하는 이음새 작업은 "함수 안에 박힌 의존성을 매개변수로 끌어내는 것"이다. 이건 동작을 바꾸지 않는 기계적 변경이고, 대부분의 IDE가 자동으로 해 준다.
// before — 시간 의존성이 함수 안에 박혀 있다. 테스트 불가능.
function isSubscriptionExpired(subscription) {
const now = Date.now() // ← 숨은 의존성
return subscription.expiresAt < now
}
// after — now를 매개변수로 끌어냈다. 이게 매개변수 이음새.
function isSubscriptionExpired(subscription, now = Date.now()) {
return subscription.expiresAt < now
}
// 이제 테스트가 시간을 통제할 수 있다 — 코드 본문은 그대로
test('expired when expiresAt is in the past', () => {
const sub = { expiresAt: 1000 }
expect(isSubscriptionExpired(sub, 2000)).toBe(true) // now=2000을 주입
expect(isSubscriptionExpired(sub, 500)).toBe(false)
})
기존 호출자는 한 글자도 안 바뀐다 — now에 기본값이 있으니까. 그런데 테스트는 이제 시간을 마음대로 통제한다. 이게 이음새의 힘이다. 호출 지점은 그대로, 테스트 지점만 열린다.
레거시 코드를 테스트하지 못하는 건 거의 항상 의존성 문제다. 이음새를 찾는 일은 곧 "어떤 의존성을 어떻게 가짜로 바꿀까"를 찾는 일이다.
4장 · 보이스카우트 규칙 — 점진적 개선
레거시 코드 전체를 한 번에 깨끗하게 만들 수는 없다. 그럴 시간도 없고, 그래야 할 사업적 이유도 보통 없다. 그래서 필요한 게 점진적 개선의 원칙이다.
보이스카우트 규칙: 처음 왔을 때보다 캠프장을 조금 더 깨끗하게 해놓고 떠나라. 코드에 적용하면 — 어떤 파일을 만질 일이 생기면, 그 일을 하면서 그 주변을 조금 더 낫게 만들고 나온다.
"조금"이 핵심이다. 버그를 고치러 들어간 김에 모듈 전체를 리팩터링하면 안 된다. 그 변경이 커지면 리뷰가 어렵고, 버그 수정과 리팩터링이 한 커밋에 섞이고, 무언가 깨졌을 때 원인을 가리기 힘들다.
| 범위 | 적절한 정도 | 너무 과한 것 |
|---|---|---|
| 이름 | 만진 함수의 모호한 변수명 1-2개 정리 | 파일 전체 변수명 일괄 변경 |
| 구조 | 방금 건드린 함수에서 한 덩어리 추출 | 클래스 계층 전체 재설계 |
| 테스트 | 이번에 고친 버그의 특성화 테스트 추가 | 모듈 전체 테스트 커버리지 작업 |
| 죽은 코드 | 명백히 호출되지 않는 함수 하나 삭제 | "안 쓸 것 같은" 코드 대량 삭제 |
핵심 규칙 두 가지. 첫째, 버그 수정 커밋과 리팩터링 커밋을 분리한다. 리뷰어가 "이건 동작을 바꾸는 변경, 이건 동작을 안 바꾸는 변경"을 따로 볼 수 있어야 한다. 둘째, 리팩터링은 안전망 아래에서만 한다. 정리하려는 그 코드에 특성화 테스트가 없으면, 먼저 테스트부터 친다.
점진적 개선의 누적 효과는 크다. 한 번에 5%씩, 사람들이 자주 만지는 곳부터 좋아진다. 자주 만지는 곳이 좋아진다는 게 중요하다 — 아무도 안 건드리는 코드는 깨끗할 필요가 별로 없다. 변경이 자주 일어나는 곳, 거기가 투자할 가치가 있는 곳이다.
5장 · 새싹 메서드 — 새 코드를 깨끗한 곳에서 시작한다
기능을 추가해야 하는데, 그 자리가 200줄짜리 테스트 불가능한 함수 한가운데라면 어떻게 하는가?
나쁜 답: 그 200줄 한가운데에 새 로직을 또 끼워 넣는다. 함수는 220줄이 되고, 더 테스트 불가능해진다.
좋은 답: 새싹 메서드(sprout method). 새 로직을 기존 함수 안에 쓰지 않는다. 새 메서드로 따로 만들고 — 그 새 메서드는 테스트와 함께 깨끗하게 짠다 — 기존 함수에서는 그 새 메서드를 호출만 한다.
// before — 주문 처리 함수. 길고, 테스트하기 어렵다.
function processOrder(order) {
// ... 150줄의 검증, 재고 처리, 결제 ...
// 여기에 "프리미엄 고객 포인트 적립" 기능을 추가해야 한다
// 나쁜 선택: 이 자리에 20줄을 더 끼워 넣는다
// ... 나머지 50줄 ...
}
// after — 새 로직은 새싹 메서드로. 깨끗하게, 테스트와 함께.
function calculateLoyaltyPoints(order) { // ← 새싹: 새 메서드, 테스트 가능
if (!order.customer.isPremium) return 0
const base = Math.floor(order.total / 10)
return order.hasPromoCode ? base * 2 : base
}
function processOrder(order) {
// ... 150줄은 그대로 — 건드리지 않는다 ...
const points = calculateLoyaltyPoints(order) // ← 기존 함수에서는 호출만
order.customer.points += points
// ... 나머지 50줄도 그대로 ...
}
// 새싹 메서드는 처음부터 테스트가 있다
test('premium customer with promo code earns double points', () => {
const order = { total: 100, hasPromoCode: true, customer: { isPremium: true } }
expect(calculateLoyaltyPoints(order)).toBe(20)
})
핵심 이득이 세 가지다. 첫째, 새 코드는 100% 테스트된다 — 깨끗한 곳에서 새로 짰으니까. 둘째, 기존 200줄은 거의 안 건드린다 — 새싹 메서드를 호출하는 한 줄만 추가했다. 위험이 그 한 줄로 격리된다. 셋째, 레거시 함수가 조금씩 줄어든다 — 새 기능이 바깥에 쌓이지 함수 안에 쌓이지 않는다.
새싹 메서드는 "레거시 코드를 당장 정리할 수는 없지만, 적어도 더 나빠지게 하지는 않겠다"는 타협이다. 그리고 그 타협이 시간이 지나면 레거시 함수를 자연스럽게 작아지게 만든다.
6장 · 새싹 클래스 — 새싹 메서드로도 모자랄 때
새싹 메서드는 새 로직이 메서드 하나에 들어갈 때 쓴다. 그런데 새 기능이 그보다 크다면 — 상태가 있고, 여러 동작이 얽혀 있고, 자체적인 협력 객체가 필요하다면 — 메서드 하나로는 모자란다.
이때는 **새싹 클래스(sprout class)**다. 새 책임을 통째로 새 클래스로 만든다. 레거시 클래스는 그 새 클래스를 인스턴스화해서 위임할 뿐이다.
새싹 클래스를 쓰는 또 다른 강한 이유: 레거시 클래스 자체가 테스트 하네스에 올라가지 않을 때. 레거시 클래스의 생성자가 데이터베이스에 접속하거나, 거대한 의존성 그래프를 끌고 오면, 그 안에 새싹 메서드를 만들어도 테스트하기 어렵다. 새 클래스는 그 진창 바깥에 있으니, 자유롭게 테스트할 수 있다.
새싹 메서드 vs 새싹 클래스 — 언제 무엇을
새싹 메서드를 쓴다:
- 새 로직이 메서드 하나에 깔끔히 들어간다
- 레거시 클래스를 (어렵게나마) 테스트 하네스에 올릴 수 있다
- 새 로직이 레거시 클래스의 상태를 별로 안 쓴다
새싹 클래스를 쓴다:
- 새 책임이 상태 + 여러 메서드로 이뤄진다
- 레거시 클래스를 테스트 하네스에 도저히 못 올린다
- 새 기능을 독립적으로 테스트하고 재사용하고 싶다
- 새 책임이 레거시 클래스와 개념적으로 분리된다
새싹 클래스의 위험은 시스템에 클래스가 하나 더 생긴다는 것이다. 잘못 쓰면 작은 책임마다 클래스가 폭증한다. 그래서 기준은 "이 책임이 정말 독립적인 개념인가"다. 독립적인 개념이면 새싹 클래스가 설계를 오히려 개선한다 — 거대한 레거시 클래스에서 한 책임을 떼어내 이름을 붙인 것이니까.
새싹 메서드와 새싹 클래스는 같은 철학의 두 크기다. 새 코드를 레거시의 진창 안에 짓지 말고, 그 옆 깨끗한 땅에 짓고 다리만 놓아라.
7장 · 감싸기 메서드 — 기존 동작에 손대지 않고 행동을 덧붙인다
새싹은 "완전히 새로운 로직"을 추가할 때 쓴다. 그런데 다른 상황이 있다. 기존 동작이 일어나는 그 시점에, 무언가를 더 하고 싶을 때다. 예를 들어 "결제할 때마다 감사 로그를 남기고 싶다" — 결제 로직 자체는 바꾸고 싶지 않다.
이때는 감싸기 메서드(wrap method). 기존 메서드의 이름을 바꿔 옆으로 비켜 두고, 원래 이름으로 새 메서드를 만든다. 새 메서드는 옛 메서드를 호출하고, 그 앞이나 뒤에 새 동작을 덧붙인다.
// before — 결제 로직. 호출자가 여기저기 있다. 본문은 건드리고 싶지 않다.
class PaymentService {
processPayment(order) {
const result = this.gateway.charge(order.total, order.card)
order.status = result.success ? 'paid' : 'failed'
return result
}
}
// after — 감싸기 메서드. 원래 메서드는 이름만 바꿔 비켜 두고,
// 원래 이름으로 "감싸는" 메서드를 만든다.
class PaymentService {
// 원래 본문 — 한 글자도 안 바꿨다. 이름만 private으로.
_processPaymentCore(order) {
const result = this.gateway.charge(order.total, order.card)
order.status = result.success ? 'paid' : 'failed'
return result
}
// 새 메서드가 원래 이름을 차지한다 — 호출자는 아무것도 모른다
processPayment(order) {
this.auditLog.record('payment_attempt', order.id) // ← 덧붙인 동작 (앞)
const result = this._processPaymentCore(order) // ← 원래 동작 그대로 호출
this.auditLog.record('payment_result', order.id, result.success) // ← 덧붙인 동작 (뒤)
return result
}
}
감싸기 메서드의 핵심은 원래 메서드 본문을 한 글자도 안 건드린다는 것이다. _processPaymentCore는 옛날 코드 그대로다 — 그래서 옛 동작이 바뀔 위험이 없다. 새 동작은 전부 감싸는 층에 있고, 그 감싸는 층은 따로 테스트할 수 있다.
비슷한 친척으로 감싸기 클래스(wrap class) — 데코레이터 패턴 — 가 있다. 메서드 하나가 아니라 인터페이스 전체를 감싸고 싶을 때, 같은 인터페이스를 구현하면서 안에 원래 객체를 품는 클래스를 만든다. 새싹과 감싸기를 합치면 레거시 코드를 거의 안 건드리고도 기능을 더하고 행동을 바꿀 수 있는 도구 세트가 완성된다.
| 기법 | 언제 | 레거시 코드를 |
|---|---|---|
| 새싹 메서드 | 새 로직 추가, 메서드 크기 | 호출 한 줄만 추가 |
| 새싹 클래스 | 새 책임 추가, 클래스 크기 | 위임 한 줄만 추가 |
| 감싸기 메서드 | 기존 동작 시점에 행동 덧붙임 | 이름만 변경, 본문 보존 |
| 감싸기 클래스 | 인터페이스 전체에 행동 덧붙임 | 전혀 안 건드림 |
8장 · 스트랭글러 무화과 패턴 — 새 시스템을 옛 시스템 둘레에 키운다
지금까지는 함수와 클래스 수준의 기법이었다. 그런데 레거시가 시스템 전체라면 — 모놀리스 하나, 낡은 서비스 하나를 통째로 교체해야 한다면 — 어떻게 하는가?
빅뱅 재작성의 유혹이 여기서 온다. "새로 다 짜서 어느 날 한 번에 갈아끼우자." 이건 거의 항상 실패한다(10장에서 자세히). 대안이 **스트랭글러 무화과 패턴(strangler fig pattern)**이다.
이름은 마틴 파울러가 열대우림의 스트랭글러 무화과에서 따왔다. 이 나무는 숙주 나무 위에서 싹을 틔워, 뿌리를 아래로 뻗으며 숙주를 천천히 감싼다. 수십 년 뒤 숙주 나무는 죽어 사라지고, 무화과는 숙주의 모양 그대로 홀로 선다. 갑작스러운 교체가 아니라, 점진적 대체다.
소프트웨어에 적용하면 이렇다.
스트랭글러 무화과 — 단계
1. 가로채는 층(facade/proxy)을 옛 시스템 앞에 세운다.
모든 트래픽이 이 층을 거친다. 처음엔 100% 옛 시스템으로 흘려보낸다.
2. 기능을 하나 골라, 그 기능만 새 시스템으로 구현한다.
가로채는 층이 그 기능의 트래픽만 새 시스템으로 라우팅한다.
3. 검증한다. 새 경로가 옛 경로와 같은 결과를 내는가?
필요하면 한동안 양쪽에 다 보내고 결과를 비교한다(섀도잉).
문제가 생기면 라우팅을 옛 시스템으로 즉시 되돌린다 — 롤백이 한 줄.
4. 다음 기능으로 반복한다. 옛 시스템의 책임이 한 조각씩 새 시스템으로 옮겨간다.
5. 옛 시스템에 트래픽이 0이 되면 — 삭제한다.
가로채는 층도 더 이상 라우팅할 게 없으면 걷어낸다.
스트랭글러 무화과의 장점을 빅뱅 재작성과 비교하면 명확하다.
| 항목 | 빅뱅 재작성 | 스트랭글러 무화과 |
|---|---|---|
| 위험 노출 | 출시일 하루에 전부 | 기능마다 조금씩 분산 |
| 피드백 | 다 끝난 뒤에야 | 첫 기능부터 즉시 |
| 롤백 | 사실상 불가능 | 라우팅 한 줄로 |
| 비즈니스 가치 | 끝날 때까지 0 | 첫 조각부터 발생 |
| 옛/새 코드 공존 | 안 함 (그래서 위험) | 함 (그래서 안전) |
| 일정이 밀리면 | 전부 위험 | 옛 시스템이 계속 돈다 |
가로채는 층을 세우는 것이 첫 번째이자 가장 중요한 단계다. 그 층이 없으면 점진적 라우팅이 불가능하다. HTTP 서비스라면 리버스 프록시나 API 게이트웨이, 라이브러리라면 파사드 클래스, 데이터베이스라면 추상화 계층이 그 역할을 한다. 일단 모든 트래픽이 한 지점을 거치게 만들면, 그 지점에서 한 조각씩 방향을 틀 수 있다.
스트랭글러 무화과의 핵심은 "옛것과 새것이 한동안 공존한다"는 것을 받아들이는 데 있다. 공존이 지저분해 보이지만, 그 지저분함이 곧 안전망이다 — 언제든 옛것으로 돌아갈 수 있으니까.
9장 · 낯선 코드를 빠르게 읽는 법 — 진입점, 콜 그래프, 실행, 로그
레거시 코드를 바꾸려면 먼저 읽어야 한다. 그런데 10만 줄짜리 시스템을 처음부터 끝까지 읽을 수는 없다. 전부 읽으려 하지 마라. 필요한 경로만 추적하라.
진입점에서 시작한다
코드는 어딘가에서 시작된다 — main, HTTP 라우트 핸들러, 이벤트 리스너, 크론 잡, CLI 명령 파서. 당신이 바꾸려는 동작이 사용자에게 어떻게 보이는지를 먼저 정하고, 그 동작의 진입점을 찾는다. 거기서부터 한 겹씩 따라 들어간다.
콜 그래프를 따라간다, 옆길은 무시한다
진입점에서 시작해 호출을 따라가되, 지금 바꾸려는 동작과 관련된 호출만 따라간다. 로깅, 메트릭, 설정 로딩 같은 옆길은 일단 무시한다. IDE의 "호출 계층 보기", "정의로 이동", "사용처 찾기"가 핵심 도구다. 머릿속이 아니라 종이나 화면에 콜 그래프를 그리면서 따라간다.
일단 실행해 본다
읽기만 하면 추측이 쌓인다. 실행해서 확인한다. 디버거를 진입점에 걸고 한 단계씩 밟으면, 어떤 분기가 실제로 타지는지, 변수에 무엇이 들어오는지가 추측이 아니라 사실로 보인다. 코드를 5분 노려보는 것보다 디버거로 한 번 밟는 게 빠를 때가 많다.
이해를 위한 로그를 박는다
디버거를 못 쓰는 환경이라면, 콜 그래프의 핵심 지점에 임시 로그를 박는다. "여기 도달함", "이 변수 값은 이것" — 한 번 흐름을 파악하기 위한 로그다. 흐름을 이해하고 나면 지운다. 이건 디버깅이 아니라 지도 그리기다.
특성화 테스트가 곧 학습 도구다
2장의 특성화 테스트는 안전망이기도 하지만 학습 도구이기도 하다. 입력을 바꿔 가며 출력을 박제하다 보면, 코드가 어떤 입력에 어떻게 반응하는지를 손으로 배우게 된다. "이 입력엔 이게 나오네? 왜지?" 하는 질문이 곧 코드를 읽는 길잡이가 된다.
| 상황 | 빠른 읽기 도구 |
|---|---|
| 동작이 어디서 시작되는지 모름 | 진입점 목록부터 — 라우트, main, 리스너 |
| 함수가 어디로 이어지는지 모름 | IDE 콜 계층, 정의로 이동 |
| 어느 분기가 실제로 타지는지 모름 | 디버거로 진입점부터 한 단계씩 |
| 운영에서만 재현되는 흐름 | 핵심 지점에 임시 로그 |
| 입력-출력 관계를 모름 | 특성화 테스트로 입력 바꿔 가며 관찰 |
10장 · 재작성 대 리팩터링 — 재작성의 함정
레거시 코드를 마주한 모든 엔지니어가 한 번쯤 하는 생각: "이걸 고치느니 새로 짜는 게 빠르겠다." 가끔은 맞다. 하지만 대부분은 함정이다. 이 함정에는 이름이 있다 — 재작성의 함정(the rewrite trap).
왜 재작성은 거의 항상 더 오래 걸리는가
레거시 코드는 추하다. 그래서 그 안에 담긴 가치를 과소평가하기 쉽다. 그러나 그 추한 코드의 구석구석에는 수년간 발견된 버그 수정과 엣지 케이스 처리가 박혀 있다. "이상한 if 문" 하나하나가 보통 누군가 새벽에 장애를 겪고 추가한 것이다. 새로 짜면 그 지식이 전부 사라진다. 그리고 같은 엣지 케이스를 같은 순서로 다시 발견하게 된다 — 이번엔 운영 환경에서.
게다가 재작성하는 동안 옛 시스템은 멈추지 않는다. 옛 시스템에 새 기능이 들어가고 버그가 고쳐진다. 새 시스템은 움직이는 표적을 쫓는다. 따라잡았다 싶으면 표적이 또 움직여 있다.
재작성 대 리팩터링 — 판단 기준
| 신호 | 리팩터링이 맞다 | 재작성을 고려할 만하다 |
|---|---|---|
| 코드가 동작하긴 하는가 | 동작은 한다, 바꾸기가 무서울 뿐 | 핵심 기능이 실제로 망가져 있다 |
| 도메인 지식 | 코드에만 있다, 문서·사람에 없다 | 도메인이 단순하고 잘 이해돼 있다 |
| 기술 스택 | 낡았지만 지원은 된다 | 보안 패치가 끊겼다, 인력을 못 구한다 |
| 점진적 경로 | 이음새를 찾을 수 있다 | 스트랭글러 층조차 세울 수 없는 구조 |
| 규모 | 크다 | 작아서 한 스프린트에 다시 짤 수 있다 |
| 변경 빈도 | 자주 바뀐다 (그래서 개선 가치 큼) | 거의 안 바뀐다 (그냥 둬도 됨) |
핵심 통찰 두 가지. 첫째, "재작성"이라 부르는 것의 안전한 형태가 바로 스트랭글러 무화과다. 새로 짜고 싶다면, 빅뱅으로 짜지 말고 옛 시스템 둘레에 점진적으로 키워라. 그러면 그건 재작성이 아니라 "점진적 대체"이고, 함정이 아니다.
둘째, 재작성하고 싶은 충동은 대개 코드를 이해하지 못한 데서 온다. 9장의 방법으로 코드를 충분히 읽고, 2장의 특성화 테스트로 동작을 박제하고 나면, "새로 짜야 한다"던 코드가 사실은 몇 군데 리팩터링으로 충분했음을 알게 되는 경우가 많다. 재작성을 결정하기 전에, 먼저 이해부터 하라.
11장 · AI 시대의 레거시 코드 — 에이전트는 가속기이자 지뢰다
AI 코딩 에이전트는 레거시 코드 작업의 양면을 동시에 바꿨다. 잘 쓰면 가장 지루한 부분을 몇 분으로 줄여 주고, 잘못 쓰면 이해하지 못한 코드를 자신 있게 망가뜨린다.
에이전트가 잘하는 것
| 작업 | 왜 에이전트가 강한가 |
|---|---|
| 특성화 테스트 대량 생성 | 입력을 다양하게 만들고 출력을 박제하는 건 지루하지만 기계적 — 에이전트가 빠르다 |
| 콜 그래프 추적 | 거대한 코드베이스에서 "이게 어디서 호출되나"를 빠르게 훑는다 |
| 이음새 후보 찾기 | "이 함수의 숨은 의존성"을 찾아 매개변수로 끌어내는 패턴을 안다 |
| 낯선 코드 요약 | 10만 줄 모듈의 진입점과 흐름을 빠르게 설명한다 |
| 기계적 리팩터링 | 메서드 추출, 이름 변경 같은 동작 보존 변경을 안전하게 한다 |
특히 특성화 테스트 생성은 에이전트의 킬러 유스케이스다. 사람이 하면 지루해서 몇 개만 쓰고 마는 일을, 에이전트는 수십 개의 입력 케이스로 빠르게 채운다. 안전망이 두꺼워진다.
에이전트가 위험한 것
문제는 에이전트가 이해하지 못한 것을 이해한 것처럼 말한다는 데 있다. 레거시 코드의 "이상한 if 문"은 보통 중요한 엣지 케이스 처리다. 에이전트는 그걸 "불필요해 보이는 코드"로 판단하고 자신 있게 삭제한다. 그 if 문이 왜 거기 있는지는 코드 어디에도 안 적혀 있고 — 5년 전 장애 회고에만 있다.
AI 에이전트와 레거시 코드 — 안전 규칙
먼저 안전망부터:
- "코드를 바꾸기 전에, 먼저 특성화 테스트로 현재 동작을 박제해"
- 에이전트가 만든 특성화 테스트를 사람이 검토 — 정말 현재 동작을 잡나?
- 골든 마스터가 있으면 에이전트 변경 전후로 반드시 diff
변경의 크기와 종류를 통제:
- "동작을 바꾸지 마. 동작 보존 리팩터링만." 이라고 명시
- 한 번에 한 가지 — "리팩터링과 기능 추가를 같은 변경에 섞지 마"
- "이상해 보이는 코드"를 삭제하기 전에 "왜 거기 있는지" 먼저 설명하게 시킴
이해를 강제:
- 에이전트가 "이 코드는 불필요하다"고 하면 — git blame, 관련 이슈를 확인
- 큰 구조 변경은 스트랭글러 무화과로 — 에이전트에게 빅뱅 재작성을 시키지 마
- 에이전트의 "이건 이렇게 동작합니다" 설명은 검증 전엔 가설
핵심은 1장의 절차가 사람에게든 에이전트에게든 똑같이 적용된다는 것이다. 먼저 안전망(특성화 테스트), 그다음 작고 동작 보존적인 변경, 매 걸음 검증. 에이전트는 이 절차의 지루한 단계 — 특히 테스트 생성과 콜 그래프 추적 — 를 극적으로 빠르게 만든다. 하지만 "이 변경이 안전한가"의 최종 판단은 여전히 사람이 한다. 에이전트의 자신감은 정확성의 증거가 아니다.
에이전트는 레거시 코드의 안전망을 치는 일을 10배 빠르게 해 준다. 그러나 안전망 없이 레거시 코드를 바꾸는 일도 10배 빠르게 한다. 어느 쪽을 시킬지는 당신이 정한다.
에필로그 — 체크리스트와 안티패턴
레거시 코드를 다루는 기술의 핵심은 한 문장이다. 바꾸기 전에 현재 동작을 고정하라. 특성화 테스트로 안전망을 치고, 이음새로 테스트를 끼워 넣고, 새싹과 감싸기로 위험을 격리하고, 스트랭글러 무화과로 시스템을 점진적으로 교체한다. 두려움을 용기로 이기는 게 아니라, 절차로 바꾸는 것이다.
레거시 코드 변경 체크리스트
- 무엇을 바꾸려는지 한 문장으로 말했는가? — 범위가 명확한가, 아니면 "이 모듈을 정리"처럼 모호한가?
- 현재 동작을 박제했는가? — 손대려는 코드에 특성화 테스트(또는 골든 마스터)가 있는가?
- 그 동작이 버그라도 박제했는가? — 지금은 이해가 목적이다. 버그 수정은 나중에 의도적으로.
- 이음새를 찾았는가? — 어떤 의존성을, 어떤 종류의 이음새로 가짜로 바꿀 것인가?
- 테스트를 위한 변경이 작고 기계적인가? — 메서드 추출, 매개변수 추가 — IDE가 해 줄 수 있는 수준인가?
- 새 코드를 깨끗한 곳에서 시작했는가? — 새싹 메서드/클래스로, 레거시 진창 바깥에서?
- 기존 동작에 행동을 덧붙일 땐 감쌌는가? — 원래 본문을 한 글자도 안 건드렸는가?
- 리팩터링 커밋과 동작 변경 커밋을 분리했는가? — 리뷰어가 둘을 따로 볼 수 있는가?
- 시스템 교체라면 스트랭글러인가? — 가로채는 층을 세웠는가, 롤백이 한 줄인가?
- 재작성을 결정하기 전에 충분히 이해했는가? — 추함을 무가치로 착각하고 있지 않은가?
- 매 걸음 안전망이 초록불인가? — 한 손이 확보된 다음에야 다른 손을 뗐는가?
- 에이전트에게 시켰다면 — 안전망부터, 동작 보존만, "왜 거기 있는지"를 먼저 설명하게.
안티패턴
| 안티패턴 | 왜 나쁜가 | 대신 |
|---|---|---|
| 테스트 없이 "일단 고치기" | 무엇이 깨졌는지 알 수 없다 | 특성화 테스트로 안전망부터 |
| 버그를 발견하고 즉시 "고쳐서" 박제 | 의도된 동작인지 모른 채 바꿈 | 일단 박제, 버그 수정은 별도 의도적 결정 |
| 200줄 함수 한가운데 새 로직 끼우기 | 함수가 더 테스트 불가능해진다 | 새싹 메서드 — 새 코드는 깨끗한 곳에 |
| 버그 수정 김에 모듈 전체 리팩터링 | 리뷰 불가, 원인 추적 불가 | 보이스카우트 규칙 — "조금만" |
| 빅뱅 재작성 | 도메인 지식 소실, 롤백 불가, 가치 0 | 스트랭글러 무화과 — 점진적 대체 |
| "추하니까 새로 짜자" | 추함과 무가치를 혼동 | 먼저 이해 — 대개 리팩터링이면 충분 |
| 옛 메서드 본문을 직접 수정해 행동 추가 | 옛 동작이 깨질 위험 | 감싸기 메서드 — 본문 보존 |
| 10만 줄을 처음부터 끝까지 읽기 | 시간 낭비, 옆길에서 길 잃음 | 진입점부터 필요한 경로만 |
| 두 손을 동시에 떼기 | 깨지면 무엇 때문인지 모름 | 한 손 확보 후 다른 손 — 매 걸음 검증 |
| 에이전트의 "불필요해 보임"을 그대로 믿기 | 엣지 케이스 처리를 삭제 | git blame·이슈 확인, 이유부터 설명 |
다음 글 예고
다음 글은 **"테스트 더블 제대로 쓰기 — 목, 스텁, 페이크, 그리고 과한 모킹의 함정"**이다. 이 글에서 이음새로 의존성을 "가짜로 바꿔치기한다"고 여러 번 말했는데, 그 가짜에도 종류가 있고 잘못 쓰면 테스트가 구현에 들러붙어 오히려 리팩터링을 막는다. 스텁과 목과 페이크의 차이, 무엇을 모킹하고 무엇을 모킹하지 말아야 하는지, 모킹이 과한지 알아보는 신호, 그리고 "런던파 대 고전파" 논쟁을 실용적으로 정리한다. 안전망을 칠 줄 알게 됐다면, 다음은 그 안전망이 진짜로 안전한지 보는 법이다.
Working with Legacy Code — How to Change Old, Scary, Untested Code Without Fear
Prologue — You Will Inherit Legacy Code
New projects do not stay new for long. Six months in, yesterday's new code is today's legacy. A year in, there is a module nobody knows the inside of precisely. Three years in, the phrase "don't touch that one" shows up in meetings.
Everyone defines legacy code differently. Some say "old code," some say "code I didn't write." Michael Feathers' definition is the most useful: legacy code is code without tests. Without tests, there is no way to know what broke when you changed something. So touching it is scary. Because it is scary you change as little as possible, and because you change as little as possible the code gets stranger and stranger.
Add one more, more honest definition: legacy code is code you are afraid to change. The fear is the core. Old or new, tested or not, if your hands shake when you change it, it is legacy to you.
This post is the craft of handling that fear. Not beating the fear with "courage." You turn the fear into procedure. When there is a safe order for changing things, the code can be scary and your hands still will not shake. You pin current behavior with characterization tests, find seams to insert tests, isolate risk with sprout and wrap, and replace whole systems gradually with the strangler fig. And you see where AI agents are an accelerant and where they are a landmine in this work.
One line: the secret to changing legacy code safely is not courage but procedure. Pin the current behavior before you touch it — even if that behavior is a bug.
Chapter 1 · The Legacy Code Dilemma — Chicken and Egg
What do you need to change legacy code safely? Tests. You have to be able to compare before and after to say "nothing broke."
But what do you need to add tests? You have to change the code. You split functions to make them testable, make dependencies injectable, and rip out global state.
Here is the dilemma.
| What you want | What it requires first | But |
|---|---|---|
| Change the code safely | Tests must exist | There are no tests |
| Add tests | Change the code to be testable | You do not know that change is safe |
| Confirm the change is safe | Tests must exist | Back to start |
This is the essential difficulty of legacy work. Chicken and egg. Without tests you cannot change safely, and without changing you cannot add tests.
There are two ways out.
First, accept a minimal risky change. Make the change needed to insert a test as small, mechanical, and reversible as possible. "Extract method," "variable to parameter," "take a dependency in the constructor" — your IDE does these automatically, and they barely change behavior. The risk is not zero, but it is small enough.
Second, find points you can test without changing. That is a seam. Without rewriting the code, you insert a test through a boundary that already exists. That is the subject of Chapters 3 and 4.
The key is order. First, throw up a safety net that pins current behavior (characterization tests). Then refactor the code under that net. If the net turns red, you stop. Not a reckless "fix it and see," but checking your footing at every step.
Legacy work is like climbing. You move one hand only after the other is secured. You never let go with both hands at once.
Chapter 2 · Characterization Tests — Pin the Current Behavior
The characterization test is the starting point of legacy work. The name holds the core. This test does not verify what the code should do. It pins what the code currently does.
The difference matters. A normal test starts from a spec — "this function should return X." A characterization test starts where there is no spec, or the spec cannot be trusted. Instead of a spec, it accepts the current behavior itself as the truth.
Why "current behavior," even if it is buggy
The current behavior of legacy code has bugs mixed in. And yet the characterization test pins those bugs too. It sounds odd, but there is a reason.
Right now you are trying to understand this code's behavior, not fix it. Which behavior is the intended feature and which is a bug — you cannot tell yet. Someone may be depending on that "bug" (Hyrum's Law). So pin all of it for now. Then, once you have confirmed which behavior is a bug, you change that test deliberately — that is a clear decision, not something you broke unknowingly.
Normal test: spec -> write test -> make code pass
Characterization: run code -> observe output -> pin that output as expected
The procedure for writing a characterization test
- Get the code onto a test harness. Build the minimal environment that can call the function. If you can give it input and receive output, that is enough.
- Write an assertion with an obviously wrong expected value. Something like
assertEquals("THIS_IS_WRONG", result). - Run the test and look at the failure message. The test runner tells you "expected: THIS_IS_WRONG, actual: 42." That 42 is the code's current behavior.
- Pin the actual output as the expected value.
assertEquals(42, result). Now the test passes. - Repeat. Pin behavior with varied inputs — normal values, boundary values, empty, 0, negative, null.
// before — a legacy function with no spec for what it does
function computeDiscount(order) {
let d = 0
if (order.total > 100) d = order.total * 0.1
if (order.coupon === 'VIP') d += 5
if (order.items.length > 10) d = Math.min(d, 20)
return Math.round(d * 100) / 100
}
// after — a characterization test that pins the current behavior
test('characterizes computeDiscount', () => {
// not a spec, a record of "this is how it currently behaves"
expect(computeDiscount({ total: 50, coupon: null, items: [] })).toBe(0)
expect(computeDiscount({ total: 150, coupon: null, items: [] })).toBe(15)
expect(computeDiscount({ total: 150, coupon: 'VIP', items: [] })).toBe(20)
// this one looks like a bug — pin it for now, change it deliberately later
expect(computeDiscount({ total: 50, coupon: 'VIP', items: [] })).toBe(5)
})
Now you can refactor computeDiscount under this safety net. Fix variable names, split the function, clean up the conditions. As long as the test stays green, the behavior is unchanged. If it turns red — something changed, and if it was not intended, you revert.
Golden Master — when the output is huge
If a function produces not a simple value but a huge output (an HTML page, a JSON document, a log file, an image), save the whole output to a file. This is the golden master, or snapshot test. Feed 100 inputs, pin the 100 outputs to files. After refactoring, generate the 100 again and diff against the files. If even one character differs, the test catches it.
The strength of the golden master is that it can throw a safety net over code whose behavior you do not understand one bit. The weakness is that when a diff appears, a human has to judge whether that change was intended.
Chapter 3 · Finding Seams — Places to Insert Tests Without Rewriting
The seam is Feathers' most important concept. A seam is a place where you can change behavior without editing the code in that spot.
The real reason legacy code is hard to test is not that the logic is complex. It is the dependencies. In the middle of a function it connects to a database directly, reads the current time directly, calls the network directly, touches a global singleton directly. You cannot stand all of that up for real in a test.
A seam is a channel through which you can swap that dependency for a fake at test time. With a seam, without touching the function body, you can push a fake through that channel and test.
Kinds of seams
| Seam kind | How you swap | Example |
|---|---|---|
| Object seam | Inject a fake object implementing the interface/class | Dependency injection via constructor or setter |
| Parameter seam | Make the function take the dependency as an argument | A clock argument instead of now() |
| Function/module seam | Replace an import or function reference in the test | Module mocking, function pointer |
| Subclass seam | Test with a subclass that overrides the risky method | Test-only subclass |
| Build seam | Wire a different implementation at build/link time | Link a different file in the test build |
The most common surgery — pulling a dependency out into a parameter
The most frequent seam work in legacy code is "pulling a dependency buried inside a function out into a parameter." It is a mechanical change that does not alter behavior, and most IDEs do it automatically.
// before — the time dependency is buried inside the function. Untestable.
function isSubscriptionExpired(subscription) {
const now = Date.now() // hidden dependency
return subscription.expiresAt < now
}
// after — now pulled out into a parameter. This is the parameter seam.
function isSubscriptionExpired(subscription, now = Date.now()) {
return subscription.expiresAt < now
}
// now the test can control time — the function body is unchanged
test('expired when expiresAt is in the past', () => {
const sub = { expiresAt: 1000 }
expect(isSubscriptionExpired(sub, 2000)).toBe(true) // inject now=2000
expect(isSubscriptionExpired(sub, 500)).toBe(false)
})
The existing callers do not change one character — because now has a default value. But the test now controls time freely. That is the power of a seam. The call site stays the same; only the test site opens up.
Not being able to test legacy code is almost always a dependency problem. Finding seams is the same as finding "which dependency to fake, and how."
Chapter 4 · The Boy Scout Rule — Incremental Improvement
You cannot make an entire legacy codebase clean in one shot. There is no time, and usually no business reason to. So you need the principle of incremental improvement.
The Boy Scout Rule: leave the campground a little cleaner than you found it. Applied to code — when you have a reason to touch a file, you make its surroundings a little better while you are doing that work, and you leave.
"A little" is the core. Do not refactor an entire module just because you went in to fix a bug. When that change gets large, review gets hard, the bug fix and the refactor get mixed into one commit, and when something breaks it is hard to tell what caused it.
| Scope | The right amount | Way too much |
|---|---|---|
| Names | Clean up 1-2 vague variable names in the function you touched | Bulk-rename variables across the whole file |
| Structure | Extract one chunk from the function you just touched | Redesign the entire class hierarchy |
| Tests | Add a characterization test for the bug you fixed this time | Do test coverage work for the whole module |
| Dead code | Delete one function that is obviously never called | Mass-delete code that "seems unused" |
Two key rules. First, separate the bug-fix commit from the refactor commit. A reviewer should be able to see "this is a behavior-changing change, this is a non-behavior-changing change" separately. Second, only refactor under a safety net. If the code you want to clean up has no characterization test, throw up the test first.
The cumulative effect of incremental improvement is large. 5 percent at a time, the places people touch often get better. That the often-touched places get better matters — code nobody touches barely needs to be clean. Where change happens often, that is where the investment is worth it.
Chapter 5 · Sprout Method — Start New Code in a Clean Place
You have to add a feature, but the spot is the middle of a 200-line untestable function. What do you do?
Bad answer: cram the new logic into the middle of those 200 lines too. The function becomes 220 lines and even more untestable.
Good answer: sprout method. You do not write the new logic inside the existing function. You make it a separate new method — and you write that new method cleanly, with tests — and the existing function only calls that new method.
// before — an order processing function. Long, hard to test.
function processOrder(order) {
// ... 150 lines of validation, inventory, payment ...
// you have to add a "premium customer loyalty points" feature here
// bad choice: cram 20 more lines into this spot
// ... the remaining 50 lines ...
}
// after — the new logic goes into a sprout method. Clean, with tests.
function calculateLoyaltyPoints(order) { // sprout: new method, testable
if (!order.customer.isPremium) return 0
const base = Math.floor(order.total / 10)
return order.hasPromoCode ? base * 2 : base
}
function processOrder(order) {
// ... the 150 lines stay as is — you do not touch them ...
const points = calculateLoyaltyPoints(order) // the existing function only calls
order.customer.points += points
// ... the remaining 50 lines stay as is too ...
}
// the sprout method has tests from the start
test('premium customer with promo code earns double points', () => {
const order = { total: 100, hasPromoCode: true, customer: { isPremium: true } }
expect(calculateLoyaltyPoints(order)).toBe(20)
})
There are three key gains. First, the new code is 100 percent tested — because you wrote it fresh in a clean place. Second, you barely touch the existing 200 lines — you added one line that calls the sprout method. The risk is isolated to that one line. Third, the legacy function shrinks bit by bit — new features pile up outside the function, not inside it.
The sprout method is a compromise: "I cannot clean up the legacy code right now, but at least I will not make it worse." And over time that compromise naturally makes the legacy function smaller.
Chapter 6 · Sprout Class — When a Sprout Method Is Not Enough
You use a sprout method when the new logic fits in a single method. But if the new feature is bigger than that — it has state, several intertwined behaviors, and needs collaborators of its own — a single method is not enough.
This is where you use a sprout class. You make the new responsibility a whole new class. The legacy class only instantiates and delegates to that new class.
Another strong reason to use a sprout class: when the legacy class itself will not go onto a test harness. If the legacy class's constructor connects to a database, or drags in a huge dependency graph, even a sprout method inside it is hard to test. The new class is outside that swamp, so you can test it freely.
Sprout method vs sprout class — when to use which
Use a sprout method:
- the new logic fits cleanly in one method
- you can get the legacy class onto a test harness (even if with effort)
- the new logic barely uses the legacy class's state
Use a sprout class:
- the new responsibility is state + several methods
- you simply cannot get the legacy class onto a test harness
- you want to test the new feature independently and reuse it
- the new responsibility is conceptually separate from the legacy class
The risk of a sprout class is that there is one more class in the system. Used badly, classes explode for every tiny responsibility. So the criterion is "is this responsibility really an independent concept." If it is an independent concept, a sprout class actually improves the design — you have peeled one responsibility off a giant legacy class and given it a name.
The sprout method and the sprout class are two sizes of the same philosophy. Do not build new code inside the legacy swamp; build it on the clean ground next to it and just lay a bridge.
Chapter 7 · Wrap Method — Add Behavior Without Touching the Existing Behavior
Sprout is for adding "completely new logic." But there is another situation. At the exact moment the existing behavior happens, you want to do something more. For example, "I want to leave an audit log every time a payment happens" — you do not want to change the payment logic itself.
This is where you use a wrap method. You rename the existing method and set it aside, and make a new method with the original name. The new method calls the old method, and adds new behavior before or after it.
// before — payment logic. Callers are all over the place. You do not want to touch the body.
class PaymentService {
processPayment(order) {
const result = this.gateway.charge(order.total, order.card)
order.status = result.success ? 'paid' : 'failed'
return result
}
}
// after — wrap method. The original method is set aside with just a rename,
// and a "wrapping" method takes the original name.
class PaymentService {
// the original body — not one character changed. Just made private.
_processPaymentCore(order) {
const result = this.gateway.charge(order.total, order.card)
order.status = result.success ? 'paid' : 'failed'
return result
}
// the new method takes the original name — callers know nothing
processPayment(order) {
this.auditLog.record('payment_attempt', order.id) // added behavior (before)
const result = this._processPaymentCore(order) // call the original behavior as is
this.auditLog.record('payment_result', order.id, result.success) // added behavior (after)
return result
}
}
The core of the wrap method is that you do not touch the original method body by one character. _processPaymentCore is the old code exactly as it was — so there is no risk the old behavior changed. The new behavior is all in the wrapping layer, and that wrapping layer can be tested separately.
A close relative is the wrap class — the decorator pattern. When you want to wrap not one method but a whole interface, you make a class that implements the same interface while holding the original object inside. Combine sprout and wrap and you have a complete tool set for adding features and changing behavior while barely touching the legacy code.
| Technique | When | The legacy code is |
|---|---|---|
| Sprout method | Add new logic, method-sized | One call line added |
| Sprout class | Add new responsibility, class-sized | One delegation line added |
| Wrap method | Add behavior at the moment of existing behavior | Renamed only, body preserved |
| Wrap class | Add behavior to a whole interface | Not touched at all |
Chapter 8 · The Strangler Fig Pattern — Grow the New System Around the Old
So far the techniques have been at the function and class level. But what if the legacy is a whole system — you have to replace one monolith, one aged service, wholesale? What do you do?
The temptation of the big-bang rewrite comes from here. "Let's write the whole thing fresh and swap it in one day." This almost always fails (more in Chapter 10). The alternative is the strangler fig pattern.
Martin Fowler took the name from the strangler fig of the rainforest. This tree sprouts on top of a host tree, sends roots down, and slowly wraps the host. Decades later the host tree dies and disappears, and the fig stands alone in the host's exact shape. Not a sudden swap, but gradual replacement.
Applied to software, it goes like this.
Strangler fig — the steps
1. Stand an intercepting layer (facade/proxy) in front of the old system.
All traffic goes through this layer. At first it routes 100 percent to the old system.
2. Pick one feature, and implement only that feature in the new system.
The intercepting layer routes only that feature's traffic to the new system.
3. Verify. Does the new path produce the same result as the old path?
If needed, send to both for a while and compare results (shadowing).
If something goes wrong, route back to the old system immediately — rollback is one line.
4. Repeat with the next feature. The old system's responsibilities move to the new
system one piece at a time.
5. When traffic to the old system reaches 0 — delete it.
When the intercepting layer has nothing left to route, take it out too.
The advantages of the strangler fig are clear when compared to the big-bang rewrite.
| Item | Big-bang rewrite | Strangler fig |
|---|---|---|
| Risk exposure | All on one launch day | Spread a little per feature |
| Feedback | Only after it is all done | Immediately from the first feature |
| Rollback | Effectively impossible | One routing line |
| Business value | 0 until it is done | Starts from the first piece |
| Old/new coexisting | Does not (so it is risky) | Does (so it is safe) |
| If the schedule slips | Everything is at risk | The old system keeps running |
Standing the intercepting layer is the first and most important step. Without that layer, gradual routing is impossible. For an HTTP service it is a reverse proxy or API gateway, for a library a facade class, for a database an abstraction layer. Once you make all traffic go through one point, you can turn one piece at a time at that point.
The core of the strangler fig is accepting that "the old and the new coexist for a while." Coexistence looks messy, but that messiness is the safety net — because you can go back to the old at any time.
Chapter 9 · Reading Unfamiliar Code Fast — Entry Points, Call Graph, Run It, Logging
To change legacy code you first have to read it. But you cannot read a 100,000-line system from start to finish. Do not try to read all of it. Trace only the path you need.
Start from the entry point
Code starts somewhere — main, an HTTP route handler, an event listener, a cron job, a CLI command parser. First decide how the behavior you want to change appears to the user, and find that behavior's entry point. From there you follow inward layer by layer.
Follow the call graph, ignore the side roads
Start from the entry point and follow the calls, but follow only the calls related to the behavior you want to change now. Ignore side roads like logging, metrics, and config loading for now. Your IDE's "view call hierarchy," "go to definition," and "find usages" are the key tools. You follow the call graph drawing it on paper or screen, not in your head.
Just run it
Reading alone piles up guesses. Run it and confirm. Set a debugger on the entry point and step through, and which branch is actually taken, what comes into a variable — that becomes fact, not a guess. Stepping through with a debugger once is often faster than glaring at the code for five minutes.
Add logging to understand
In an environment where you cannot use a debugger, drop temporary logs at key points in the call graph. "Reached here," "this variable's value is this" — logs to grasp the flow once. Once you understand the flow, you delete them. This is not debugging, it is drawing a map.
Characterization tests are a learning tool
The characterization tests from Chapter 2 are a safety net, but also a learning tool. As you pin outputs while varying inputs, you learn by hand how the code reacts to which input. The question "this input gives this? why?" becomes the guide for reading the code.
| Situation | Fast-reading tool |
|---|---|
| You do not know where the behavior starts | Start with the entry-point list — routes, main, listeners |
| You do not know where a function leads | IDE call hierarchy, go to definition |
| You do not know which branch is actually taken | Step from the entry point with a debugger |
| A flow that only reproduces in production | Temporary logs at key points |
| You do not know the input-output relationship | Observe with characterization tests while varying inputs |
Chapter 10 · Rewrite vs Refactor — The Rewrite Trap
A thought every engineer facing legacy code has at least once: "it would be faster to write this fresh than to fix it." Sometimes that is right. But most of the time it is a trap. This trap has a name — the rewrite trap.
Why a rewrite almost always takes longer
Legacy code is ugly. So it is easy to underestimate the value held inside it. But in every corner of that ugly code, bug fixes and edge-case handling discovered over years are embedded. Each "weird if statement" was usually added by someone who suffered an outage at 3 a.m. Write it fresh and all of that knowledge is gone. And you will rediscover the same edge cases in the same order — this time in production.
On top of that, the old system does not stop while you rewrite. New features go into the old system and bugs get fixed. The new system chases a moving target. The moment you think you have caught up, the target has moved again.
Rewrite vs refactor — the criteria
| Signal | Refactor is right | A rewrite is worth considering |
|---|---|---|
| Does the code work | It works, you are just afraid to change it | The core feature is actually broken |
| Domain knowledge | It lives only in the code, not docs or people | The domain is simple and well understood |
| Tech stack | Old but still supported | Security patches have stopped, you cannot hire for it |
| Incremental path | You can find seams | A structure where you cannot even stand a strangler layer |
| Size | Large | Small enough to rewrite in one sprint |
| Change frequency | Changes often (so the improvement value is high) | Barely changes (leaving it alone is fine) |
Two key insights. First, the safe form of what you call a "rewrite" is exactly the strangler fig. If you want to write fresh, do not write it as a big bang; grow it gradually around the old system. Then it is not a rewrite but "gradual replacement," and it is not a trap.
Second, the urge to rewrite usually comes from not understanding the code. Once you have read the code enough with the methods of Chapter 9 and pinned the behavior with the characterization tests of Chapter 2, you often find that the code you said "must be rewritten" actually just needed a few refactors. Before you decide to rewrite, understand it first.
Chapter 11 · Legacy Code in the AI Era — The Agent Is an Accelerant and a Landmine
AI coding agents have changed both sides of legacy work at once. Used well, they shrink the most tedious parts to minutes; used badly, they confidently break code they do not understand.
What agents are good at
| Task | Why the agent is strong |
|---|---|
| Mass-generating characterization tests | Making varied inputs and pinning outputs is tedious but mechanical — the agent is fast |
| Tracing the call graph | It quickly scans "where is this called" in a huge codebase |
| Finding seam candidates | It knows the pattern of finding "this function's hidden dependency" and pulling it into a parameter |
| Summarizing unfamiliar code | It quickly explains the entry points and flow of a 100,000-line module |
| Mechanical refactoring | It safely does behavior-preserving changes like extract method and rename |
Characterization test generation in particular is the agent's killer use case. The work a human does tediously and stops after writing only a few, the agent fills quickly with dozens of input cases. The safety net gets thicker.
What agents are dangerous at
The problem is that the agent speaks of what it does not understand as if it understands it. The "weird if statement" of legacy code is usually important edge-case handling. The agent judges it as "code that looks unnecessary" and confidently deletes it. Why that if statement is there is written nowhere in the code — it is only in an outage retro from five years ago.
AI agents and legacy code — safety rules
Safety net first:
- "Before changing the code, first pin the current behavior with characterization tests"
- Have a human review the agent's characterization tests — do they really catch the current behavior?
- If there is a golden master, always diff before and after the agent's change
Control the size and kind of the change:
- State explicitly: "Do not change behavior. Behavior-preserving refactoring only."
- One thing at a time — "Do not mix refactoring and feature addition in the same change"
- Before deleting "code that looks weird," make it explain "why it is there" first
Force understanding:
- When the agent says "this code is unnecessary" — check git blame and the related issue
- Do large structural changes with the strangler fig — do not make the agent do a big-bang rewrite
- The agent's "this is how it works" explanation is a hypothesis until verified
The key is that the procedure from Chapter 1 applies the same to humans and to agents. Safety net first (characterization tests), then small behavior-preserving changes, verify at every step. The agent makes the tedious steps of this procedure — especially test generation and call-graph tracing — dramatically faster. But the final judgment of "is this change safe" is still a human's. The agent's confidence is not evidence of correctness.
The agent does the work of throwing up a safety net over legacy code 10 times faster. But it also does the work of changing legacy code without a safety net 10 times faster. Which one you set it to is up to you.
Epilogue — Checklist and Anti-Patterns
The core of the craft of handling legacy code is one sentence. Pin the current behavior before you change it. Throw up a safety net with characterization tests, insert tests with seams, isolate risk with sprout and wrap, and replace the system gradually with the strangler fig. Not beating the fear with courage, but turning it into procedure.
Legacy code change checklist
- Did you say in one sentence what you are changing? — Is the scope clear, or vague like "clean up this module"?
- Did you pin the current behavior? — Does the code you are touching have characterization tests (or a golden master)?
- Did you pin that behavior even if it is a bug? — Right now the goal is understanding. The bug fix comes later, deliberately.
- Did you find a seam? — Which dependency, with which kind of seam, will you fake?
- Is the change for testing small and mechanical? — Extract method, add parameter — at a level the IDE can do?
- Did you start new code in a clean place? — With a sprout method/class, outside the legacy swamp?
- When adding behavior to existing behavior, did you wrap? — Did you not touch the original body by one character?
- Did you separate the refactor commit from the behavior-change commit? — Can a reviewer see the two separately?
- If it is a system replacement, is it a strangler? — Did you stand an intercepting layer, is rollback one line?
- Did you understand it enough before deciding to rewrite? — Are you not mistaking ugliness for worthlessness?
- Is the safety net green at every step? — Did you let go with the other hand only after one was secured?
- If you set an agent to it — Safety net first, behavior-preserving only, make it explain "why it is there" first.
Anti-patterns
| Anti-pattern | Why it is bad | Instead |
|---|---|---|
| "Just fix it" without tests | You cannot know what broke | Safety net first, with characterization tests |
| Find a bug and immediately pin it "fixed" | You changed it without knowing if it was intended | Pin it first, the bug fix is a separate deliberate decision |
| Cram new logic into the middle of a 200-line function | The function gets even more untestable | Sprout method — new code in a clean place |
| Refactor the whole module while fixing a bug | Unreviewable, cause untraceable | Boy Scout Rule — "just a little" |
| Big-bang rewrite | Domain knowledge lost, rollback impossible, value 0 | Strangler fig — gradual replacement |
| "It's ugly, let's write it fresh" | Confusing ugliness with worthlessness | Understand first — usually a refactor is enough |
| Edit the old method body directly to add behavior | Risk the old behavior breaks | Wrap method — preserve the body |
| Read 100,000 lines from start to finish | Wasted time, lost on side roads | From the entry point, only the path you need |
| Let go with both hands at once | If it breaks you do not know what caused it | One hand secured, then the other — verify at every step |
| Trust the agent's "looks unnecessary" as is | You delete edge-case handling | Check git blame and the issue, explain the reason first |
Next post teaser
The next post is "Using Test Doubles Right — Mocks, Stubs, Fakes, and the Trap of Over-Mocking." In this post we said many times that a seam "swaps a dependency for a fake," but that fake has kinds too, and used badly the test sticks to the implementation and actually blocks refactoring. The difference between a stub, a mock, and a fake, what to mock and what not to mock, the signs that mocking has gone too far, and a practical take on the "London school vs classicist" debate. Now that you can throw up a safety net, next is how to see whether that safety net is really safe.