Skip to content
Published on

레거시 코드 다루기 — 무섭고 오래된 코드를 두려움 없이 바꾸는 법

Authors

프롤로그 — 레거시 코드는 당신이 상속받는다

새 프로젝트는 오래가지 않는다. 6개월이면 어제의 새 코드가 오늘의 레거시가 된다. 1년이면 아무도 그 안을 정확히 모르는 모듈이 하나 생긴다. 3년이면 "그건 건드리지 마세요"라는 말이 회의에서 나온다.

레거시 코드의 정의는 사람마다 다르다. 누군가는 "오래된 코드"라 하고, 누군가는 "내가 안 짠 코드"라 한다. 마이클 페더스의 정의가 가장 쓸모 있다. 레거시 코드는 테스트가 없는 코드다. 테스트가 없으면 바꿨을 때 무엇이 깨졌는지 알 길이 없다. 그래서 손대기가 두렵다. 두려우니까 최소한만 바꾸고, 최소한만 바꾸니까 코드는 점점 더 이상해진다.

여기에 더 솔직한 정의를 하나 보태자. 레거시 코드는 당신이 바꾸기 두려워하는 코드다. 두려움이 핵심이다. 코드가 오래됐든 새것이든, 테스트가 있든 없든, 바꿀 때 손이 떨린다면 그건 당신에게 레거시다.

이 글은 그 두려움을 다루는 기술이다. 두려움을 "용기"로 이기는 게 아니다. 두려움을 절차로 바꾼다. 안전하게 바꾸는 순서가 있으면, 코드가 무서워도 손은 떨리지 않는다. 특성화 테스트로 현재 동작을 박제하고, 이음새를 찾아 테스트를 끼워 넣고, 새싹과 감싸기로 위험을 격리하고, 스트랭글러 무화과로 시스템 전체를 점진적으로 교체한다. 그리고 AI 에이전트가 이 작업에서 어떤 가속기이자 어떤 지뢰인지도 본다.

핵심 한 줄: 레거시 코드를 안전하게 바꾸는 비결은 용기가 아니라 절차다. 바꾸기 전에 현재 동작을 고정하라 — 그 동작이 버그라도.


1장 · 레거시 코드의 딜레마 — 닭과 달걀

레거시 코드를 안전하게 바꾸려면 무엇이 필요한가? 테스트다. 바꾸기 전후를 비교할 수 있어야 "안 깨졌다"고 말할 수 있다.

그런데 테스트를 붙이려면 무엇이 필요한가? 코드를 바꿔야 한다. 테스트하기 좋게 함수를 쪼개고, 의존성을 주입할 수 있게 만들고, 전역 상태를 걷어내야 한다.

여기서 딜레마가 생긴다.

하고 싶은 것필요한 선행 조건그런데
코드를 안전하게 바꾼다테스트가 있어야 한다테스트가 없다
테스트를 붙인다코드를 테스트 가능하게 바꿔야 한다그 변경이 안전한지 모른다
변경이 안전한지 확인한다테스트가 있어야 한다원점으로

이게 레거시 코드 작업의 본질적 어려움이다. 닭과 달걀. 테스트 없이는 안전하게 못 바꾸고, 안 바꾸면 테스트를 못 붙인다.

탈출구는 두 가지다.

첫째, 최소한의 위험한 변경을 받아들인다. 테스트를 끼워 넣기 위한 변경은 가능한 한 작고, 기계적이고, 되돌리기 쉬운 것으로 한다. "메서드 추출", "변수를 매개변수로", "생성자에 의존성 받기" — 이런 변경은 IDE가 자동으로 해 주고, 동작을 거의 바꾸지 않는다. 위험이 0은 아니지만 충분히 작다.

둘째, 변경 없이 테스트할 수 있는 지점을 찾는다. 이게 이음새(seam)다. 코드를 다시 쓰지 않고도, 이미 존재하는 경계를 통해 테스트를 끼워 넣는다. 3장과 4장의 주제다.

핵심은 순서다. 먼저 현재 동작을 박제하는 안전망을 친다(특성화 테스트). 그다음 그 안전망 아래에서 코드를 리팩터링한다. 안전망이 빨간불을 켜면 멈춘다. 무모하게 "일단 고치고 보자"가 아니라, 매 걸음마다 발밑을 확인하는 것이다.

레거시 코드 작업은 등반과 같다. 한 손이 확보된 다음에야 다른 손을 뗀다. 절대 두 손을 동시에 떼지 않는다.


2장 · 특성화 테스트 — 현재 동작을 박제하라

특성화 테스트(characterization test)는 레거시 코드 작업의 출발점이다. 이름이 핵심을 담고 있다. 이 테스트는 코드가 무엇을 해야 하는지를 검증하지 않는다. 코드가 현재 무엇을 하는지를 박제한다.

차이가 중요하다. 일반적인 테스트는 명세에서 시작한다 — "이 함수는 X를 반환해야 한다". 특성화 테스트는 명세가 없거나 믿을 수 없는 상황에서 시작한다. 명세 대신 현재 동작 그 자체를 진실로 받아들인다.

왜 "현재 동작"인가, 버그라도

레거시 코드의 현재 동작에는 버그가 섞여 있다. 그런데 특성화 테스트는 그 버그까지 박제한다. 이상하게 들리지만 이유가 있다.

당신은 지금 이 코드의 동작을 이해하려는 것이지 고치려는 것이 아니다. 어떤 동작이 의도된 기능이고 어떤 동작이 버그인지, 지금은 구분할 수 없다. 누군가 그 "버그"에 의존하고 있을지도 모른다(헤이럼의 법칙). 그러니 일단 전부 박제한다. 그다음, 어떤 동작이 버그인지 확인되면 그 테스트를 의도적으로 바꾼다 — 그건 명확한 결정이지, 모르고 깨뜨린 게 아니다.

일반 테스트:        명세 → 테스트 작성 → 코드가 통과하게
특성화 테스트:      코드 실행 → 출력 관찰 → 그 출력을 기대값으로 박제

특성화 테스트를 쓰는 절차

  1. 테스트 하네스에 코드를 올린다. 함수를 호출할 수 있는 최소한의 환경을 만든다. 입력을 주고 출력을 받을 수 있으면 된다.
  2. 명백히 틀린 기대값으로 단언을 쓴다. assertEquals("THIS_IS_WRONG", result) 같은 식으로.
  3. 테스트를 돌리고 실패 메시지를 본다. 테스트 러너가 "기대: THIS_IS_WRONG, 실제: 42"라고 알려준다. 이 42가 코드의 현재 동작이다.
  4. 실제 출력을 기대값으로 박는다. assertEquals(42, result). 이제 테스트가 통과한다.
  5. 반복한다. 다양한 입력 — 정상값, 경계값, 빈 값, 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배 빠르게 한다. 어느 쪽을 시킬지는 당신이 정한다.


에필로그 — 체크리스트와 안티패턴

레거시 코드를 다루는 기술의 핵심은 한 문장이다. 바꾸기 전에 현재 동작을 고정하라. 특성화 테스트로 안전망을 치고, 이음새로 테스트를 끼워 넣고, 새싹과 감싸기로 위험을 격리하고, 스트랭글러 무화과로 시스템을 점진적으로 교체한다. 두려움을 용기로 이기는 게 아니라, 절차로 바꾸는 것이다.

레거시 코드 변경 체크리스트

  1. 무엇을 바꾸려는지 한 문장으로 말했는가? — 범위가 명확한가, 아니면 "이 모듈을 정리"처럼 모호한가?
  2. 현재 동작을 박제했는가? — 손대려는 코드에 특성화 테스트(또는 골든 마스터)가 있는가?
  3. 그 동작이 버그라도 박제했는가? — 지금은 이해가 목적이다. 버그 수정은 나중에 의도적으로.
  4. 이음새를 찾았는가? — 어떤 의존성을, 어떤 종류의 이음새로 가짜로 바꿀 것인가?
  5. 테스트를 위한 변경이 작고 기계적인가? — 메서드 추출, 매개변수 추가 — IDE가 해 줄 수 있는 수준인가?
  6. 새 코드를 깨끗한 곳에서 시작했는가? — 새싹 메서드/클래스로, 레거시 진창 바깥에서?
  7. 기존 동작에 행동을 덧붙일 땐 감쌌는가? — 원래 본문을 한 글자도 안 건드렸는가?
  8. 리팩터링 커밋과 동작 변경 커밋을 분리했는가? — 리뷰어가 둘을 따로 볼 수 있는가?
  9. 시스템 교체라면 스트랭글러인가? — 가로채는 층을 세웠는가, 롤백이 한 줄인가?
  10. 재작성을 결정하기 전에 충분히 이해했는가? — 추함을 무가치로 착각하고 있지 않은가?
  11. 매 걸음 안전망이 초록불인가? — 한 손이 확보된 다음에야 다른 손을 뗐는가?
  12. 에이전트에게 시켰다면 — 안전망부터, 동작 보존만, "왜 거기 있는지"를 먼저 설명하게.

안티패턴

안티패턴왜 나쁜가대신
테스트 없이 "일단 고치기"무엇이 깨졌는지 알 수 없다특성화 테스트로 안전망부터
버그를 발견하고 즉시 "고쳐서" 박제의도된 동작인지 모른 채 바꿈일단 박제, 버그 수정은 별도 의도적 결정
200줄 함수 한가운데 새 로직 끼우기함수가 더 테스트 불가능해진다새싹 메서드 — 새 코드는 깨끗한 곳에
버그 수정 김에 모듈 전체 리팩터링리뷰 불가, 원인 추적 불가보이스카우트 규칙 — "조금만"
빅뱅 재작성도메인 지식 소실, 롤백 불가, 가치 0스트랭글러 무화과 — 점진적 대체
"추하니까 새로 짜자"추함과 무가치를 혼동먼저 이해 — 대개 리팩터링이면 충분
옛 메서드 본문을 직접 수정해 행동 추가옛 동작이 깨질 위험감싸기 메서드 — 본문 보존
10만 줄을 처음부터 끝까지 읽기시간 낭비, 옆길에서 길 잃음진입점부터 필요한 경로만
두 손을 동시에 떼기깨지면 무엇 때문인지 모름한 손 확보 후 다른 손 — 매 걸음 검증
에이전트의 "불필요해 보임"을 그대로 믿기엣지 케이스 처리를 삭제git blame·이슈 확인, 이유부터 설명

다음 글 예고

다음 글은 **"테스트 더블 제대로 쓰기 — 목, 스텁, 페이크, 그리고 과한 모킹의 함정"**이다. 이 글에서 이음새로 의존성을 "가짜로 바꿔치기한다"고 여러 번 말했는데, 그 가짜에도 종류가 있고 잘못 쓰면 테스트가 구현에 들러붙어 오히려 리팩터링을 막는다. 스텁과 목과 페이크의 차이, 무엇을 모킹하고 무엇을 모킹하지 말아야 하는지, 모킹이 과한지 알아보는 신호, 그리고 "런던파 대 고전파" 논쟁을 실용적으로 정리한다. 안전망을 칠 줄 알게 됐다면, 다음은 그 안전망이 진짜로 안전한지 보는 법이다.