Skip to content

Split View: 리팩터링 완전 가이드: 동작을 지키면서 구조를 바꾸는 절차

|

리팩터링 완전 가이드: 동작을 지키면서 구조를 바꾸는 절차

들어가며

이 블로그에는 이미 리팩터링의 경제학, 언제 비용이 회수되나가 있습니다. 그 글은 변경 빈도와 수정 비용에서 출발해 언제 리팩터링이 이익이 되는지를 계산합니다. 즉 투자 판단에 관한 글입니다.

이 글은 나머지 절반입니다. 하기로 결정한 다음에 어떻게 안전하게 하느냐를 다룹니다. 동작 보존을 주장할 근거를 어떻게 확보하는지, 손댈 수 없는 코드에 어떻게 처음 손을 대는지, 인터페이스를 바꾸면서 어떻게 항상 배포 가능한 상태를 유지하는지, 그리고 큰 변경을 어떤 축으로 잘라야 각 조각이 되돌릴 수 있는 단위가 되는지가 주제입니다. 두 글은 순서대로 읽으면 하나의 결정과 하나의 실행이 됩니다.


1. 정의부터 — 무엇이 리팩터링이 아닌가

1-1. 명사와 동사

Martin Fowler는 리팩터링을 두 가지 품사로 정의합니다. 명사로서의 리팩터링은 "소프트웨어를 이해하기 쉽고 수정 비용이 덜 들게 만들기 위해, 관찰 가능한 동작을 바꾸지 않으면서 내부 구조에 가하는 변경"입니다. 동사로서의 리팩터링은 "일련의 리팩터링을 적용해 관찰 가능한 동작을 바꾸지 않으면서 소프트웨어를 재구조화하는 것"입니다.

두 정의의 공통 조건은 하나뿐입니다. 관찰 가능한 동작의 보존입니다. 이 조건이 리팩터링을 재구조화나 재작성과 구분합니다.

1-2. "관찰 가능한 동작"의 경계는 팀이 정해야 합니다

정의는 명확하지만 경계는 그렇지 않습니다. 다음은 관찰 가능한 동작인가요?

  • 응답 시간: 사용자에게는 관찰됩니다. 성능이 두 배 느려진 리팩터링은 동작을 보존했다고 말하기 어렵습니다.
  • 로그 형식: 사람에게는 잘 안 보이지만 로그를 파싱하는 알림 규칙에는 계약입니다.
  • 에러 메시지 문구와 정렬되지 않은 목록의 실제 순서: 명세에 없지만 소비자가 문자열로 분기하거나 순서에 의존하고 있을 수 있습니다.

그래서 실무에서 첫 단계는 이번 리팩터링에서 무엇을 보존할 것인지 한 줄로 적는 일입니다. "공개 HTTP 응답 본문과 상태 코드는 보존한다, 로그 형식은 보존하지 않는다"처럼 적어 두면 리뷰어와 다투지 않아도 됩니다.

1-3. 리팩터링이 아닌 것들

  • 버그 수정: 정의상 동작을 바꿉니다. 리팩터링 커밋에 섞어 넣으면 diff에서 구분이 사라집니다.
  • 성능 최적화: 관찰 가능한 특성 하나를 의도적으로 바꾸는 작업입니다. 구조 개선을 동반하더라도 별도 작업으로 다룹니다.
  • 재작성: 동작 보존을 보장하지 않고 처음부터 다시 만드는 일입니다.
  • 의존성 대규모 업그레이드: 라이브러리의 동작 변화를 함께 흡수하므로 순수 리팩터링이 아닙니다.

구분이 중요한 이유는 취향 문제가 아닙니다. 리뷰 방식과 롤백 위험이 다르기 때문입니다. 리팩터링 PR을 읽는 리뷰어는 "구조가 좋아졌는가"를 봅니다. 그 안에 동작 변경이 숨어 있으면 아무도 그 부분을 검토하지 않은 채 승인됩니다.

실무 규칙 하나로 줄이면 이렇습니다. 한 커밋은 구조 변경이거나 동작 변경이거나, 둘 중 하나입니다. 두 모자를 동시에 쓰지 않는다는 오래된 조언이 이 이야기입니다.


2. 안전망 없이는 시작하지 않는다 — 특성화 테스트

2-1. 동작 보존은 주장이 아니라 관측입니다

"동작을 안 바꿨습니다"는 검증 가능한 문장이어야 합니다. 그러려면 변경 전후의 동작을 비교할 수단이 필요합니다. 레거시 코드를 다루는 문헌에서 오래 쓰여 온 이름이 특성화 테스트(characterization test)입니다. Michael Feathers의 레거시 코드 저작에서 널리 알려진 용어이며, 요지는 단순합니다.

특성화 테스트는 코드가 무엇을 해야 하는지가 아니라 지금 무엇을 하는지를 고정합니다. 그래서 버그도 함께 고정합니다. 이것은 실수가 아니라 의도입니다. 리팩터링 도중에 버그를 고치면 실패한 테스트가 리팩터링 실수인지 버그 수정인지 구분할 수 없게 됩니다.

# 예시 — 특성화 테스트를 만드는 절차
# 1) 입력을 채집한다. 프로덕션 로그, 샘플 요청, 경계값을 모은다.
# 2) 현재 구현에 통과시켜 출력을 기록한다. 기대값을 손으로 쓰지 않는다.
# 3) 기록한 출력을 골든 파일로 고정한다.
# 4) 이상해 보이는 결과도 그대로 고정하고, 주석으로만 표시한다.

def test_pricing_characterization(golden):
    for case in load_cases("fixtures/pricing_inputs.jsonl"):
        actual = calculate_price(**case)
        # 아래 값이 '옳은' 값이라는 뜻이 아니라 '현재' 값이라는 뜻이다.
        golden.assert_match(case["id"], actual)

# 알려진 이상 동작: 할인율이 100%를 넘으면 음수 가격이 나온다.
# 리팩터링 중에는 이 동작도 그대로 보존한다. 수정은 별도 커밋에서.

2-2. 커버리지 숫자는 안전망이 아닙니다

전체 라인 커버리지 90%는 지금 손댈 파일이 테스트되고 있다는 뜻이 아닙니다. 필요한 것은 전역 지표가 아니라 변경 대상 코드가 실제로 실행되는 테스트입니다. 시작 전에 대상 파일만 커버리지를 측정하고, 실행되지 않는 분기부터 특성화 테스트를 채우십시오.

또 하나의 함정은 테스트가 통과한다는 사실과 테스트가 무언가를 검증한다는 사실이 다르다는 점입니다. 한 번은 고의로 코드를 망가뜨려 테스트가 실패하는지 확인하십시오.

2-3. 어느 계층에 안전망을 두는가 — 여기서 의견이 갈립니다

리팩터링의 안전망을 단위 테스트로 둘지 더 굵은 경계 테스트로 둘지는 합의된 답이 없습니다. 문제는 내부 구조에 결합된 단위 테스트는 리팩터링과 함께 죽는다는 점입니다. 클래스를 쪼개면 그 클래스를 대상으로 하던 테스트도 함께 다시 써야 하고, 그러면 안전망이 사라진 채로 작업하게 됩니다.

Kent C. Dodds는 테스팅 트로피를 설명하면서 "테스트가 소프트웨어가 실제로 사용되는 방식과 닮을수록 더 많은 확신을 줄 수 있다"고 정리합니다. 이 관점에서는 리팩터링 안전망을 통합 계층에 두는 편이 자연스럽습니다.

다만 이 논쟁의 밑바닥에는 용어 문제도 있습니다. Fowler는 실용적 테스트 피라미드 글에서 "단위가 무엇을 뜻하는지 세 사람에게 물으면 네 가지의 미묘하게 다른 답을 듣게 될 것"이라고 적었고, Dodds도 단위 테스트의 정의가 스물네 가지쯤 존재한다고 인정하면서 Justin Searls의 말을 인용합니다. 요지는 사람들이 테스트 종류의 비율을 두고 논쟁하는 것 자체가 주의를 분산시킨다는 것입니다.

  • 단위 테스트 안전망: 빠릅니다. 작은 단계를 유지하려면 피드백 루프가 초 단위여야 합니다. 대신 구조에 결합되어 있으면 리팩터링 대상과 함께 무너집니다.
  • 경계/통합 테스트 안전망: 내부 구조를 바꿔도 살아남습니다. 대신 느립니다. Fowler는 종단 간 테스트가 "악명 높게 불안정하며 예상하지 못한 이유로 자주 실패한다"고, 그리고 "유지보수 비용이 크고 상당히 느리게 동작한다"고 적습니다.

실무 절충은 대개 이렇습니다. 바꿀 구조의 바깥 경계에 안전망을 두십시오. 클래스 세 개를 재배치할 계획이면 그 세 개를 감싸는 모듈 경계에 테스트를 두는 것입니다. 이 판단은 위 자료들이 알려 주는 것이 아니라 이 글에서 제안하는 규칙입니다.

2-4. 테스트 없이 동작 보존이 가능한가

이것도 갈리는 주제입니다. 가능하다는 쪽은 정적 타입 시스템과 IDE의 검증된 자동 리팩터링만 쓰면 기계적으로 안전하다고 봅니다. 불가능하다는 쪽은 어떤 언어에서도 동적 참조와 부수효과가 남아 있으며 "안전한 변환"이라는 믿음이 가장 위험하다고 봅니다. 축은 세 개입니다. 언어의 정적 검사 강도, 변환이 순수하게 기계적인지 판단을 포함하는지, 그리고 코드의 부수효과 밀도입니다.


3. 접합부 만들기 — 손댈 수 없는 코드에 손대기

3-1. 닭과 달걀

레거시 코드에서 가장 흔한 교착은 이렇습니다. 테스트를 쓰려면 의존성을 끊어야 하고, 의존성을 끊으려면 코드를 바꿔야 하고, 코드를 바꾸려면 테스트가 있어야 합니다. 이 순환을 깨는 개념이 접합부(seam)입니다. 해당 지점의 코드를 편집하지 않고도 동작을 바꿔 끼울 수 있는 자리를 뜻하며, 이 용어 역시 Michael Feathers의 레거시 코드 저작에서 널리 알려졌습니다.

탈출 방법은 하나뿐입니다. 위험이 가장 낮은 변경만 먼저 하는 것입니다. 접합부를 내는 변경은 그 자체로는 거의 아무 일도 하지 않아야 합니다.

// 예시 — 위험도 순서대로 접합부를 내는 세 단계

// 0단계: 손댈 수 없는 상태. 시계와 네트워크가 함수 안에 박혀 있다.
async function expireSessions() {
  const now = new Date()
  const rows = await db.query('SELECT * FROM sessions')
  return rows.filter((r) => r.expires_at < now)
}

// 1단계: 매개변수 추가. 기본값을 주면 모든 호출처가 그대로 동작한다.
async function expireSessions({ now = new Date(), query = db.query } = {}) {
  const rows = await query('SELECT * FROM sessions')
  return rows.filter((r) => r.expires_at < now)
}

// 2단계: 이제 테스트가 가능하다. 여기서부터 진짜 리팩터링을 시작한다.
// expireSessions({ now: new Date('2026-01-01'), query: fakeQuery })

3-2. 비결정성 세 가지가 대개 첫 번째 접합부입니다

레거시 코드를 테스트 가능하게 만들 때 가장 먼저 걸리는 것은 대체로 시계, 난수, 네트워크입니다. 이 셋을 주입 가능하게 만들면 그 다음 작업이 급격히 쉬워집니다. 파일 시스템과 환경 변수가 그 다음 순서입니다.

3-3. 접합부의 종류와 비용

  • 매개변수 접합부: 인자를 추가하고 기본값을 줍니다. 가장 저렴하고 안전합니다.
  • 생성자 접합부: 의존성을 생성자로 옮깁니다. 호출처가 많으면 병렬 변경이 필요합니다.
  • 서브클래스 접합부: 메서드를 추출한 뒤 테스트용 서브클래스에서 재정의합니다. 빠른 임시방편입니다.
  • 모듈 접합부: 로딩 시점에 구현을 치환합니다. 강력하지만 테스트 간 격리가 깨지기 쉽습니다.
  • 프로세스 접합부: 스텁 서버로 프로세스 밖에서 가로챕니다. 가장 현실적이지만 가장 느립니다.

3-4. 접합부라고 부르면 안 되는 것

프로덕션 코드에 테스트 여부를 묻는 분기를 넣는 것은 접합부가 아닙니다. if (isTest) 같은 조건은 테스트되는 경로와 실제로 실행되는 경로를 다르게 만들기 때문에, 안전망의 목적 자체를 무너뜨립니다. 접합부는 같은 코드가 다른 협력자와 함께 실행되게 하는 장치이지, 다른 코드가 실행되게 하는 장치가 아닙니다.


4. 작은 단계의 규율 — 항상 초록인 상태 유지

4-1. 규율의 정의

작은 단계의 규율은 "자주 커밋하기"가 아닙니다. 어느 시점에 멈추더라도 배포 가능한 상태를 유지하는 것입니다. 이 조건을 지키면 리팩터링을 중단하는 비용이 거의 0이 되고, 중단 비용이 0이면 시작 문턱도 낮아집니다.

[리팩터링 루프 — 한 바퀴가 몇 분을 넘지 않게]
1. 초록 확인      테스트를 돌려 지금이 초록인지 먼저 본다
2. 한 단계 변경   이름 하나, 추출 하나, 이동 하나. 두 개를 묶지 않는다
3. 테스트         빨간불이면 즉시 되돌린다. 고치려 들지 않는다
4. 커밋           메시지에 무엇을 왜 바꿨는지 한 줄
5. 반복

[중단 규칙]
- 빨간 상태가 10분을 넘으면 되돌리고, 단계를 더 작게 쪼개 다시 시작한다
- 되돌리는 비용이 다시 하는 비용보다 커졌다면, 그 단계는 이미 너무 컸다

4-2. 단계 크기를 정하는 기준

단계가 적절한지 판단하는 실용적 기준은 하나입니다. 실패했을 때 그냥 버리고 다시 할 수 있는가. 버리기 아깝다는 느낌이 들면 그 단계는 이미 너무 큽니다. 초심자에게 흔한 실패는 30분 동안 빨간 상태로 있다가 "거의 다 됐다"고 말하는 상황인데, 그 시점이 되돌리기 비용이 최대인 지점입니다.

4-3. 피드백 루프 속도가 규율을 결정합니다

테스트가 15분 걸리면 아무도 한 단계마다 돌리지 않습니다. 그러면 여러 변경이 한 덩어리로 묶이고, 실패했을 때 원인을 좁힐 수 없게 됩니다. 즉 느린 테스트는 리팩터링 절차 전체를 무너뜨립니다. 시작하기 전에 대상 범위만 빠르게 돌리는 방법을 확보하고, 전체 스위트는 커밋 뒤에 돌리십시오.

4-4. 커밋 기록도 안전망입니다

기계적 변경과 판단이 들어간 변경을 같은 커밋에 넣지 마십시오. 이름 변경 3,000줄과 로직 수정 8줄이 한 커밋에 있으면 리뷰어는 8줄을 찾지 못합니다. 커밋을 분리해야 "이 커밋만 읽으면 됩니다"라고 말할 수 있습니다.


5. 병렬 변경으로 인터페이스 바꾸기

5-1. 확장·이행·축소

인터페이스를 바꾸면서 항상 초록을 유지하는 표준 방법은 Martin Fowler가 정리한 Parallel Change입니다. Fowler는 확장 단계를 "인터페이스를 늘려 옛 버전과 새 버전을 모두 지원하게 만드는 것", 이행 단계를 "옛 버전을 쓰던 모든 클라이언트를 새 버전으로 옮기는 것이며 점진적으로 할 수 있다"고 설명하고, 모든 사용처가 옮겨간 뒤 축소 단계에서 옛 버전을 제거한다고 적습니다. 이 패턴은 Joshua Kerievsky에게 귀속됩니다.

// 예시 — 함수 시그니처를 병렬 변경으로 바꾸기

// 확장(expand): 새 형태를 추가하되 옛 형태를 그대로 유지한다.
export function createOrder(userId, items, options) {
  return createOrderV2({ userId, items, ...options })
}
export function createOrderV2(input) {
  /* 새 구현 */
}

// 이행(migrate): 호출처를 한 번에 하나씩 옮긴다. 각 이동이 독립 커밋이다.
// 옛 함수에는 사용을 감지할 수 있는 신호를 남긴다.
export function createOrder(userId, items, options) {
  logger.warn('createOrder is deprecated', { caller: new Error().stack })
  return createOrderV2({ userId, items, ...options })
}

// 축소(contract): 호출이 0이 된 것을 로그로 확인한 뒤 제거한다.

5-2. 폐기 신호는 코드에 남겨야 합니다

문서에만 적은 폐기 예고는 아무도 읽지 않습니다. 실제로 작동하는 신호는 호출을 감지하거나 빌드를 시끄럽게 만드는 것들입니다.

  • 런타임 로그: 옛 경로의 호출 횟수를 셉니다. 축소 시점을 데이터로 정하는 유일한 방법입니다.
  • 타입 수준 표시와 컴파일 경고: 새로 작성되는 코드가 옛 경로를 쓰지 못하게 막습니다.
  • 린트 규칙: 신규 사용을 금지하고 기존 사용처만 예외 목록에 둔 뒤, 목록이 줄어드는 것을 지표로 삼습니다.

5-3. 소비자를 아는가가 갈림길입니다

호출처를 전부 알고 한 커밋에서 고칠 수 있다면 병렬 변경은 과합니다. 병렬 변경이 필요한 경우는 소비자가 다른 저장소에 있거나, 배포 시점이 다르거나, 외부에 공개된 경우입니다. 판별 질문은 하나입니다. "내가 지금 바꾸면 컴파일이 깨지는 코드를 전부 볼 수 있는가?" 볼 수 없다면 병렬 변경입니다.

5-4. 축소 단계를 잊으면 그것이 부채가 됩니다

확장과 이행만 하고 축소를 건너뛰면 코드베이스에는 영구히 두 경로가 남습니다. 이 상태가 누적된 것이 흔히 말하는 기술 부채의 큰 부분입니다. 축소 작업을 이행 시작과 동시에 티켓으로 만들어 두는 것이 유일하게 작동하는 예방책입니다. 자세한 관리 방법은 기술 부채 완전 가이드에서 다룹니다.


6. 큰 변경을 되돌릴 수 있는 조각으로 자르기

6-1. 자르는 네 가지 축

축 A. 계층으로 자르기     저장소 계층만 → 서비스 계층만 → 컨트롤러만
축 B. 호출처로 자르기     한 번에 한 호출처씩 새 인터페이스로 이동
축 C. 데이터 방향으로     읽기 경로 먼저 → 쓰기 경로 나중 (또는 반대)
축 D. 런타임 분기로       플래그로 새 구현을 일부 트래픽에만 노출

각 조각이 만족해야 하는 조건
- 독립적으로 배포할 수 있다
- 독립적으로 되돌릴 수 있다
- 그 자체로 해롭지 않다 (가치가 없어도 되지만 손해여서는 안 된다)

6-2. 긴 브랜치가 리팩터링을 죽이는 방식

리팩터링 전용 브랜치를 3주 유지하면 두 가지 일이 동시에 일어납니다. 다른 사람들이 그동안 계속 옛 구조 위에 코드를 쓰고, 병합 충돌이 시간에 비례해서가 아니라 그보다 빠르게 늘어납니다. 결국 병합 자체가 안전망 없는 대규모 변경이 됩니다.

그래서 큰 리팩터링일수록 브랜치가 아니라 주 브랜치 위에서 조각으로 진행해야 합니다. 시스템 규모의 점진적 대체가 필요하다면 Strangler Fig 패턴 완벽 가이드의 구조가 그대로 적용됩니다.

6-3. 조각의 순서를 정하는 법

  • 가장 많은 정보를 주는 조각 먼저. 설계 가정이 틀렸다면 일찍 알아야 합니다.
  • 되돌리기 가장 어려운 조각은 마지막에. 데이터 형식 변경이 대개 여기 해당합니다.
  • 다른 사람을 막는 조각은 빨리 통과. 광범위한 이름 변경이 대표적입니다.
  • 조각 사이에는 실제 배포를 끼웁니다. 배포 없이 쌓은 조각은 하나의 큰 변경과 같습니다.

배포 단위의 가역성을 판별하는 기준은 배포 전략 완전 가이드의 체크리스트와 같습니다. 특히 데이터 형식을 건드리는 조각은 그 자체로 조건부 가역이므로, 확장·이행·축소를 별도 배포로 분리해야 합니다.


7. 리팩터링과 기능 개발을 섞을 것인가

7-1. 여기서 의견이 갈립니다

리팩터링을 별도 티켓으로 관리할지 기능 작업에 포함할지는 오래된 논쟁입니다.

  • 별도 티켓 쪽: 리뷰가 쉬워지고, 롤백 단위가 분리되고, 투자량을 추적할 수 있습니다.
  • 기능 작업 포함 쪽: 별도 티켓은 우선순위 경쟁에서 언제나 밀리고, 문맥을 이미 파악한 상태에서 하는 편이 훨씬 쌉니다.
  • 보이스카우트 규칙 쪽: 손댄 자리를 조금씩 낫게 만들면 부채가 자연스럽게 줄어듭니다.
  • 계획된 리팩터링 쪽: 조금씩 고치는 방식으로는 구조적 문제를 해결하지 못하며, 큰 변경에는 합의가 필요합니다.

축은 세 가지입니다. 코드 소유권 모델, 리뷰 처리 속도, 그리고 변경 빈도의 분포입니다. 소유자가 명확하고 리뷰가 빠른 조직에서는 섞는 편이 잘 작동합니다. 리뷰가 느린 조직에서는 섞을수록 PR이 커지고 더 느려지는 악순환이 생깁니다.

7-2. 리뷰 속도가 리팩터링의 양을 결정합니다

Google의 엔지니어링 관행 문서는 이 인과를 명시적으로 적어 두었습니다. 느린 리뷰는 "코드 정리, 리팩터링, 그리고 기존 변경에 대한 추가 개선을 위축시킨다"는 것입니다. 같은 문서는 코드 리뷰 요청에 응답하기까지 걸려도 되는 최대 시간이 영업일 하루라고 못박습니다.

리뷰 기준에 대해서도 같은 문서는 이렇게 정리합니다. 리뷰어는 변경이 완벽하지 않더라도 작업 대상 시스템의 전반적인 코드 건강을 확실히 개선하는 상태라면 승인하는 쪽을 택해야 한다는 것입니다. 또한 필수가 아닌 다듬기 제안에는 "Nit: "을 붙여 작성자가 무시할 수 있게 하라고 권합니다. 취향 논쟁이 붙으면 리팩터링은 그대로 멈추므로, 이 관행이 특히 중요합니다.

7-3. 실무 절충

  • 커밋은 분리하고 PR은 합쳐도 됩니다. "이 커밋은 순수 리팩터링, 다음 커밋이 기능"이라고 나누면 리뷰어가 읽는 방식을 고를 수 있습니다.
  • 순서는 리팩터링 먼저입니다. 기능을 먼저 넣고 정리하면, 정리 커밋은 대개 영원히 오지 않습니다.
  • 크기 상한을 정합니다. 리팩터링 PR이 커질수록 리뷰어는 읽지 않고 승인하게 됩니다. 이 경향은 위 자료가 측정한 것이 아니라 이 글의 경험칙이므로, 상한값은 팀이 정해야 합니다.
  • 기계적 변경은 별도 PR로 분리합니다. 8절의 코드모드가 여기 해당합니다.

리뷰 대화를 다루는 방법은 코드 리뷰의 대화법에 더 자세히 있습니다.


8. 자동 리팩터링 도구와 대규모 변경

8-1. IDE 자동 리팩터링이 안전한 조건

IDE의 이름 변경이나 메서드 추출은 구문 트리를 이해하고 동작하므로 문자열 치환보다 훨씬 안전합니다. 하지만 정적으로 추적되지 않는 참조 앞에서는 조용히 실패합니다.

  • 리플렉션과 동적 디스패치: 문자열로 만든 이름은 도구가 볼 수 없습니다.
  • 직렬화된 식별자: DB나 큐에 저장된 클래스 이름, 이벤트 타입 이름은 코드가 아니라 데이터입니다.
  • 설정 파일, 템플릿, 다른 저장소의 소비자: 모두 도구의 검색 범위 밖입니다.

그래서 자동 리팩터링을 돌린 뒤에도 저장소 전체 문자열 검색을 한 번 하는 것이 값싼 보험입니다.

8-2. 코드모드의 절차

[코드모드 진행 순서]
1. 변환 규칙을 구문 트리 기반으로 작성한다 (정규식 치환은 주석과 문자열까지 바꾼다)
2. 파일 20개 정도의 표본에 적용하고 결과를 사람이 직접 읽는다
3. 규칙을 고친다. 2번과 3번을 결과가 지루해질 때까지 반복한다
4. 전체에 적용하고 전체 테스트를 돌린다
5. 리뷰 요청 시 '무엇을 리뷰해야 하는지'를 함께 적는다

[리뷰 대상 — 결과 diff 전체가 아니다]
- 변환 스크립트 자체
- 도구가 건드리지 못한 예외 목록
- 무작위로 뽑은 파일 10개의 변환 결과
- 테스트 결과와 커버리지 변화

8-3. 대규모 변경은 쪼갤수록 위험할 수 있습니다

일반적으로는 작게 쪼개는 것이 안전하지만, 기계적 변환은 예외인 경우가 있습니다. 이름 변경을 여러 PR로 쪼개면 중간 상태에서 코드가 컴파일되지 않거나 두 이름이 공존하는 기간이 길어집니다. 선택지는 전체를 한 번에 병합하거나, 5절의 병렬 변경으로 중간 상태를 합법으로 만드는 것입니다. 쪼갤 수 없는 변경을 억지로 쪼개면 안전이 아니라 불안정한 중간 상태를 얻습니다.

8-4. 결과가 아니라 동작을 비교하십시오

대규모 변경의 최종 검증은 diff 읽기가 아닙니다. 같은 입력에 대해 변경 전후의 출력이 같은지 비교하는 편이 훨씬 강합니다. 골든 파일 비교, 그리고 프로덕션 트래픽 사본을 두 구현에 함께 흘려 결과를 대조하는 방식이 대표적입니다. 후자는 배포 전략의 섀도 기법과 같은 구조이므로 부수효과 격리가 전제입니다.


9. 멈출 때를 정하기

9-1. 종료 조건을 먼저 씁니다

리팩터링은 본질적으로 끝이 없습니다. 그래서 시작 전에 종료 조건을 한 문장으로 적어야 합니다. 좋은 종료 조건은 구조 지표가 아니라 다음 변경의 비용으로 씁니다.

  • 약한 목표: "순환 복잡도를 15 이하로 낮춘다"
  • 강한 목표: "결제 수단을 하나 추가할 때 고쳐야 하는 파일이 세 개 이하가 된다"

강한 목표는 다음 기능을 실제로 넣어 보면 달성 여부가 즉시 드러나므로 검증할 수 있습니다.

9-2. 멈춰야 한다는 신호

- diff에 원래 목적과 무관한 파일이 등장하기 시작한다
- 병합 충돌 해결 시간이 리팩터링 자체보다 길어진다
- 다른 사람의 작업을 막고 있다는 이야기가 두 번 이상 나온다
- "이것만 마저 하면"이 세 번 반복된다
- 안전망이 계속 빨간 상태이고, 그 원인이 리팩터링인지 아닌지 모르겠다
- 애초에 이 리팩터링이 어떤 다음 변경을 싸게 만들려던 것인지 대답하지 못한다

9-3. 중단도 하나의 결과입니다

중간에 멈추는 것 자체는 실패가 아닙니다. 실패는 멈춘 상태를 기록하지 않고 떠나는 것입니다. 멈출 때는 지금까지 한 부분을 배포 가능한 상태로 마감하고, 남은 부분을 부채 목록에 올리고, 무엇을 알게 되었는지 한 문단으로 남깁니다.

9-4. 효과를 측정하기

리팩터링이 실제로 도움이 되었는지는 코드 지표가 아니라 변경 비용으로 확인하는 편이 정직합니다. DORA는 변경 리드 타임을 "변경이 버전 관리에 커밋된 시점부터 프로덕션에 배포되기까지 걸리는 시간"으로, 변경 실패율을 "배포 이후 즉각적인 개입이 필요했던 배포의 비율"로 정의합니다. 리팩터링한 영역의 작업에서 이 두 지표가 개선되지 않는다면, 구조는 예뻐졌지만 목적은 달성되지 않은 것입니다. 지표가 나빠졌다면 되돌리는 것도 선택지에 남겨 두십시오.


퀴즈: 실력을 확인해 보세요

퀴즈 1: 동료가 "리팩터링하면서 발견한 버그도 같이 고쳤다"며 PR을 올렸습니다. 무엇을 요청해야 할까요?

정답: 버그 수정을 별도 커밋 또는 별도 PR로 분리해 달라고 요청합니다.

설명: 리팩터링의 정의는 관찰 가능한 동작의 보존이고, 버그 수정은 정의상 동작을 바꿉니다. 두 가지가 한 커밋에 섞이면 테스트가 실패했을 때 리팩터링 실수인지 의도한 동작 변경인지 구분할 수 없고, 롤백할 때도 원하는 절반만 되돌릴 수 없습니다. 특성화 테스트가 지금의 잘못된 동작까지 고정하는 이유도 같습니다.

퀴즈 2: 리팩터링 대상 클래스에 단위 테스트가 40개 있습니다. 그런데 그 클래스를 세 개로 쪼갤 계획입니다. 안전망으로 무엇을 준비해야 할까요?

정답: 쪼갤 클래스들의 바깥 경계, 즉 이 세 클래스를 사용하는 모듈 수준에 특성화 테스트를 먼저 만듭니다.

설명: 클래스 내부 구조에 결합된 단위 테스트는 클래스를 쪼개는 순간 함께 다시 써야 하므로, 정작 리팩터링을 하는 동안 안전망이 없는 상태가 됩니다. 바꿀 구조를 감싸는 경계에 테스트를 두면 내부를 어떻게 재배치해도 살아남습니다.

퀴즈 3: 자동 이름 변경 도구로 클래스 이름을 바꾸고 모든 테스트가 통과했습니다. 그래도 확인해야 하는 곳은?

정답: 문자열로 심볼을 참조하는 모든 지점입니다. 리플렉션, 직렬화되어 저장된 타입 이름, 설정 파일과 템플릿, 그리고 다른 저장소의 소비자입니다.

설명: 구문 트리 기반 도구는 정적으로 추적 가능한 참조만 봅니다. 데이터베이스나 메시지 큐에 이미 저장된 이름은 코드가 아니라 데이터이므로 도구의 시야 밖이고, 이 경우 이름 변경은 즉시 비가역 변경이 됩니다. 저장소 전체 문자열 검색은 비용이 거의 들지 않는 보험입니다.

퀴즈 4: 3주짜리 리팩터링 브랜치를 유지하다가 병합 시점에 충돌 300건을 만났습니다. 다음번에 무엇을 바꿔야 할까요?

정답: 브랜치를 길게 유지하는 대신 주 브랜치 위에서 독립 배포 가능한 조각으로 나누어 진행합니다. 필요하면 병렬 변경으로 중간 상태를 합법화합니다.

설명: 긴 브랜치에서는 다른 사람들이 계속 옛 구조 위에 코드를 추가하고, 충돌은 시간에 대해 선형보다 빠르게 늘어납니다. 계층별, 호출처별, 데이터 방향별로 자르고 조각 사이에 실제 배포를 끼우면 각 조각이 되돌릴 수 있는 단위가 됩니다.

퀴즈 5: 리팩터링을 끝냈는데 "좋아졌다"는 근거가 코드 지표뿐입니다. 무엇을 더 봐야 할까요?

정답: 그 영역에서 이루어지는 실제 변경의 비용, 즉 변경 리드 타임과 변경 실패율을 봅니다.

설명: 순환 복잡도나 결합도는 대리 지표일 뿐입니다. 리팩터링의 목적은 다음 변경을 싸게 만드는 것이므로 검증도 다음 변경에서 이루어져야 합니다. 지표가 개선되지 않았다면 되돌리는 것도 정당한 선택입니다.


마치며

리팩터링의 어려움은 어떤 패턴을 아느냐가 아닙니다. 동작을 보존했다고 말할 근거를 확보하는 일, 그리고 어느 시점에 멈춰도 배포 가능한 상태를 유지하는 일입니다. 이 두 가지가 갖춰지면 나머지는 기계적인 반복이고, 갖춰지지 않으면 아무리 좋은 설계 감각도 도박이 됩니다.

절차를 한 줄로 압축하면 이렇습니다. 보존할 것을 적고, 안전망을 만들고, 접합부를 내고, 작은 단계로 바꾸고, 인터페이스는 병렬 변경으로 옮기고, 큰 변경은 되돌릴 수 있는 조각으로 자르고, 종료 조건에 도달하면 멈춥니다. 그리고 멈춘 자리를 기록으로 남깁니다.


참고 자료

  • Definition of Refactoring — Martin Fowler — 명사와 동사로서의 리팩터링 정의, 그리고 관찰 가능한 동작 보존이 리팩터링과 재구조화를 가르는 조건이라는 점을 인용했습니다. 2026-08-15 확인.
  • Parallel Change — Martin Fowler — 확장, 이행, 축소 세 단계의 정의와 Joshua Kerievsky 귀속을 인용했습니다. 2026-08-15 확인.
  • The Practical Test Pyramid — Martin Fowler — 단위의 정의에 합의가 없다는 서술과, 종단 간 테스트가 불안정하며 유지보수 비용이 크고 느리다는 서술을 인용했습니다. 2026-08-15 확인.
  • The Testing Trophy and Testing Classifications — Kent C. Dodds — 테스트가 실제 사용 방식과 닮을수록 더 많은 확신을 준다는 문장과, 단위 테스트 정의가 다수 존재하며 비율 논쟁이 주의를 분산시킨다는 지적을 인용했습니다. 2026-08-15 확인.
  • Code Review Developer Guide, The Standard of Code Review — Google — 전반적인 코드 건강을 확실히 개선한다면 완벽하지 않아도 승인하라는 기준과, 필수가 아닌 제안에 "Nit: "을 붙이는 관행을 인용했습니다. 2026-08-15 확인.
  • Code Review Developer Guide, Speed of Code Reviews — Google — 느린 리뷰가 코드 정리와 리팩터링을 위축시킨다는 서술과, 리뷰 응답 최대 시간이 영업일 하루라는 기준을 인용했습니다. 2026-08-15 확인.
  • DORA metrics: the four keys — DORA — 변경 리드 타임과 변경 실패율의 정의를 인용했습니다. 2026-08-15 확인.
  • 특성화 테스트와 접합부라는 용어는 Michael Feathers의 레거시 코드 저작에서 널리 알려진 것이며, 원문을 인용하지 않고 개념만 사용했습니다. 2절의 안전망 배치 규칙, 4절의 루프와 중단 규칙, 6절의 절단 축, 8절의 코드모드 리뷰 대상, 9절의 중단 신호는 위 자료가 아니라 이 글에서 정리한 절차입니다.

이어서 읽기

완전 가이드 시리즈

The Complete Guide to Refactoring: Changing Structure While Preserving Behaviour

Introduction

This blog already has The Economics of Refactoring: When Does It Pay Off?. That post starts from change frequency and modification cost and computes when refactoring becomes profitable. It is an article about the investment decision.

This post is the other half. Once you have decided to do it, how do you do it safely? How do you acquire the evidence that behaviour was preserved, how do you make the first change to code you cannot touch, how do you stay deployable while changing an interface, and along which axes do you cut a large change so that each piece is a reversible unit? Read in order, the two posts form one decision and one execution.


1. Start from the definition — what refactoring is not

1-1. The noun and the verb

Martin Fowler defines refactoring in two parts of speech. As a noun, a refactoring is "a change made to the internal structure of software to make it easier to understand and cheaper to modify without changing its observable behavior." As a verb, to refactor is "to restructure software by applying a series of refactorings without changing its observable behavior."

Both definitions share exactly one condition: preservation of observable behaviour. That condition is what separates refactoring from restructuring and rewriting.

1-2. The team has to draw the boundary of "observable behaviour"

The definition is clear; the boundary is not. Are the following observable behaviour?

  • Response time: users observe it. A refactoring that made things twice as slow cannot really claim to have preserved behaviour.
  • Log format: barely visible to humans, but a contract to any alerting rule that parses logs.
  • Error message wording and the actual order of an unsorted list: not in the specification, but consumers may be branching on the string or depending on the order.

So in practice the first step is writing down, in one line, what this refactoring will preserve. "Public HTTP response bodies and status codes are preserved; log format is not" saves you an argument with the reviewer.

1-3. Things that are not refactoring

  • Bug fixes: they change behaviour by definition. Mixed into a refactoring commit, the distinction vanishes from the diff.
  • Performance optimisation: it deliberately changes one observable characteristic. Even when it comes with structural improvement, treat it as separate work.
  • Rewrites: building it again from scratch with no guarantee of behaviour preservation.
  • Large dependency upgrades: they absorb the library's own behaviour changes, so they are not pure refactorings.

The distinction matters not as a matter of taste but because the review method and the rollback risk differ. A reviewer reading a refactoring PR is asking "did the structure improve?". A behaviour change hidden inside it gets approved without anyone reviewing that part.

Reduced to one working rule: a commit is either a structural change or a behavioural change, never both. The old advice about not wearing two hats at once is this same idea.


2. Do not start without a safety net — characterization tests

2-1. Behaviour preservation is an observation, not an assertion

"I didn't change behaviour" has to be a verifiable sentence, which requires a way to compare behaviour before and after. The name used for a long time in the legacy-code literature is the characterization test. The term is widely known from Michael Feathers' work on legacy code, and the idea is simple.

A characterization test pins down not what the code should do but what it does right now. That means it pins the bugs down too. This is intent, not accident: if you fix a bug mid-refactoring, a failing test can no longer tell you whether it was a refactoring mistake or the bug fix.

# Example — the procedure for building characterization tests
# 1) Collect inputs: production logs, sample requests, boundary values.
# 2) Run them through the current implementation and record the outputs.
#    Do not hand-write the expected values.
# 3) Freeze the recorded outputs as golden files.
# 4) Freeze results that look wrong too, marking them only in a comment.

def test_pricing_characterization(golden):
    for case in load_cases("fixtures/pricing_inputs.jsonl"):
        actual = calculate_price(**case)
        # This value is not the 'correct' value; it is the 'current' value.
        golden.assert_match(case["id"], actual)

# Known anomaly: a discount rate above 100% produces a negative price.
# Preserve this behaviour during refactoring. Fix it in a separate commit.

2-2. A coverage number is not a safety net

90% overall line coverage does not mean the file you are about to touch is tested. What you need is not a global metric but tests that actually execute the code you are changing. Measure coverage for the target files only before you start, and fill in characterization tests starting from the branches that never execute.

The other trap is that a test passing and a test verifying something are different facts. Break the code deliberately once and confirm the test fails.

2-3. Which layer holds the safety net — this is contested

Whether the refactoring safety net belongs in unit tests or in coarser boundary tests has no agreed answer. The problem is that unit tests coupled to internal structure die together with the refactoring. Split a class and the tests written against that class must be rewritten too — leaving you working without a net.

Kent C. Dodds, explaining the testing trophy, puts it as: the more your tests resemble the way your software is used, the more confidence they can give you. From that angle, placing the refactoring net at the integration layer is natural.

Underneath the argument, though, is a terminology problem. In his practical test pyramid article Fowler writes that if you ask three different people what "unit" means you will probably receive four different, slightly nuanced answers, and Dodds likewise acknowledges that roughly twenty-four definitions of unit test exist, quoting Justin Searls to the effect that debating what percentage of which type of tests to write is itself a distraction.

  • A unit-test net: fast. Keeping steps small requires a feedback loop measured in seconds. But if it is coupled to structure it collapses along with the thing you are refactoring.
  • A boundary/integration net: survives internal restructuring. But it is slow. Fowler writes that end-to-end tests are "notoriously flaky and often fail for unexpected and unforeseeable reasons", and that they "require a lot of maintenance and run pretty slowly".

The practical compromise usually looks like this: put the net at the outer boundary of the structure you are going to change. If you plan to rearrange three classes, put the tests at the module boundary that wraps those three. That rule is not something the sources above tell you; it is what this article proposes.

2-4. Is behaviour preservation achievable without tests?

Also contested. The "yes" camp argues that with a static type system and only verified IDE refactorings the process is mechanically safe. The "no" camp argues that dynamic references and side effects survive in every language, and that believing in "safe transformations" is the most dangerous part. Three axes: the strength of the language's static checking, whether the transformation is purely mechanical or includes judgement, and the density of side effects in the code.


3. Introducing seams — touching code you cannot touch

3-1. Chicken and egg

The most common deadlock in legacy code goes like this. To write a test you must break a dependency; to break the dependency you must change the code; to change the code you need a test. The concept that breaks the cycle is the seam: a place where you can swap behaviour without editing the code at that point. This term, too, is widely known from Michael Feathers' legacy-code work.

There is only one way out: make only the lowest-risk change first. A change that introduces a seam should do almost nothing on its own.

// Example — introducing a seam in three steps, ordered by risk

// Step 0: untouchable. The clock and the network are nailed inside the function.
async function expireSessions() {
  const now = new Date()
  const rows = await db.query('SELECT * FROM sessions')
  return rows.filter((r) => r.expires_at < now)
}

// Step 1: add parameters. With defaults, every call site keeps working unchanged.
async function expireSessions({ now = new Date(), query = db.query } = {}) {
  const rows = await query('SELECT * FROM sessions')
  return rows.filter((r) => r.expires_at < now)
}

// Step 2: now it is testable. The real refactoring starts here.
// expireSessions({ now: new Date('2026-01-01'), query: fakeQuery })

3-2. Three sources of nondeterminism are usually the first seam

When making legacy code testable, the first things you hit are usually the clock, randomness and the network. Make those three injectable and everything afterwards gets dramatically easier. The file system and environment variables come next.

3-3. Kinds of seams and their cost

  • Parameter seam: add an argument with a default. Cheapest and safest.
  • Constructor seam: move the dependency into the constructor. With many call sites you need parallel change.
  • Subclass seam: extract a method and override it in a test subclass. A fast stopgap.
  • Module seam: substitute the implementation at load time. Powerful, but isolation between tests breaks easily.
  • Process seam: intercept outside the process with a stub server. Most realistic, and slowest.

3-4. What you must not call a seam

Putting a branch that asks whether we are in a test into production code is not a seam. A condition like if (isTest) makes the tested path different from the path that actually runs, which destroys the whole point of the net. A seam makes the same code run with different collaborators, not different code run.


4. The discipline of small steps — stay green at all times

4-1. What the discipline actually is

The discipline of small steps is not "commit often". It is staying deployable no matter when you stop. Satisfy that condition and the cost of abandoning a refactoring approaches zero — and when the abandon cost is zero, the threshold for starting drops too.

[The refactoring loop — one lap should not exceed a few minutes]
1. Confirm green   run the tests and check you are green right now
2. One change      one rename, one extraction, one move. Never two at once
3. Test            if it goes red, revert immediately. Do not try to fix it
4. Commit          one line on what changed and why
5. Repeat

[Stop rules]
- If you have been red for more than 10 minutes, revert and restart with smaller steps
- If reverting costs more than redoing, the step was already too big

4-2. How to size a step

There is one practical test of whether a step is right-sized: could you throw it away and redo it? If throwing it away feels wasteful, the step is already too big. The classic beginner failure is spending 30 minutes red while saying "almost there" — which is precisely the point of maximum revert cost.

4-3. Feedback loop speed determines the discipline

If tests take 15 minutes, nobody runs them per step. Changes then clump together, and when something fails you cannot narrow down the cause. In other words, slow tests demolish the entire refactoring procedure. Before starting, secure a way to run only the target scope quickly, and run the full suite after commits.

4-4. The commit history is a safety net too

Never put mechanical changes and judgement changes in the same commit. When 3,000 lines of renaming sit next to 8 lines of logic change, the reviewer will not find the 8 lines. Only by separating commits can you say "just read this one".


5. Changing interfaces with parallel change

5-1. Expand, migrate, contract

The standard way to change an interface while staying green is Martin Fowler's Parallel Change. Fowler describes the expand phase as augmenting the interface to support both the old and the new versions, and the migrate phase as updating all clients using the old version to the new version, which can be done incrementally; once all usages have been migrated you perform the contract phase and remove the old version. The pattern is attributed to Joshua Kerievsky.

// Example — changing a function signature via parallel change

// expand: add the new shape while keeping the old one intact.
export function createOrder(userId, items, options) {
  return createOrderV2({ userId, items, ...options })
}
export function createOrderV2(input) {
  /* new implementation */
}

// migrate: move call sites one at a time. Each move is its own commit.
// Leave a signal on the old function so usage can be detected.
export function createOrder(userId, items, options) {
  logger.warn('createOrder is deprecated', { caller: new Error().stack })
  return createOrderV2({ userId, items, ...options })
}

// contract: remove it once the logs confirm the call count has reached zero.

5-2. The deprecation signal has to live in the code

A deprecation announced only in documentation is read by nobody. The signals that actually work are the ones that detect calls or make the build noisy.

  • Runtime logs: count calls on the old path. The only way to set the contract date from data.
  • Type-level markers and compiler warnings: stop newly written code from using the old path.
  • Lint rules: ban new usage, keep existing sites in an exception list, and track the list shrinking.

5-3. Knowing your consumers is the deciding factor

If you know every call site and can fix them all in one commit, parallel change is overkill. You need it when consumers live in another repository, deploy on a different schedule, or are exposed externally. There is one deciding question: "can I see all the code that would break if I changed this right now?" If you cannot, use parallel change.

5-4. Skipping the contract phase is how debt is created

Do expand and migrate but skip contract and two paths live in the codebase forever. Accumulated, that is a large fraction of what people call technical debt. Creating the contract ticket at the moment migration starts is the only prevention that reliably works. The management side is covered in The Complete Guide to Technical Debt.


6. Cutting a large change into reversible pieces

6-1. Four axes to cut along

Axis A. By layer          repository layer only → service layer only → controllers only
Axis B. By call site      move one call site at a time to the new interface
Axis C. By data direction read path first → write path later (or the reverse)
Axis D. By runtime branch expose the new implementation to part of traffic via a flag

Conditions each piece must satisfy
- It can be deployed independently
- It can be reverted independently
- It is not harmful on its own (it need not add value, but must not subtract any)

6-2. How long branches kill refactorings

Keep a refactoring branch alive for three weeks and two things happen at once: everyone else keeps writing code on top of the old structure, and merge conflicts grow faster than linearly with time. In the end the merge itself becomes a large change with no safety net.

So the bigger the refactoring, the more it must proceed as pieces on the main branch rather than on a branch. If you need system-scale incremental replacement, the structure in The Complete Guide to the Strangler Fig Pattern applies directly.

6-3. Ordering the pieces

  • The most informative piece first. If a design assumption is wrong, you want to know early.
  • The hardest piece to reverse last. Data format changes usually belong here.
  • Pieces that block other people, quickly. Wide renames are the classic example.
  • Put a real deployment between pieces. Pieces stacked without deploying equal one big change.

The criteria for judging the reversibility of a deployment unit are the same checklist as in The Complete Guide to Deployment Strategies. In particular, any piece that touches a data format is conditionally reversible on its own, so expand, migrate and contract must be separate deployments.


7. Should refactoring be mixed with feature work?

7-1. This is contested

Whether to track refactoring as its own ticket or fold it into feature work is an old argument.

  • Separate ticket camp: reviews get easier, rollback units stay separated, and investment is traceable.
  • Fold-in camp: separate tickets always lose the priority contest, and doing it while you already hold the context is far cheaper.
  • Boy scout rule camp: leaving each place you touch a little better makes debt shrink naturally.
  • Planned refactoring camp: incremental tidying never resolves structural problems, and large changes need agreement.

Three axes: the code ownership model, review turnaround speed, and the distribution of change frequency. Where ownership is clear and review is fast, mixing works well. Where review is slow, mixing makes PRs bigger and reviews slower still — a feedback loop in the wrong direction.

7-2. Review speed determines how much refactoring happens

Google's engineering practices documentation states the causation explicitly: slow reviews discourage code cleanups, refactorings, and further improvements to existing CLs. The same document fixes one business day as the maximum time it should take to respond to a code review request.

On the review standard, the same source says reviewers should favour approving a change once it is in a state where it definitely improves the overall code health of the system being worked on, even if the change is not perfect. It also recommends prefixing non-mandatory polish with "Nit: " so the author may ignore it. Since a taste argument stops a refactoring dead, that convention matters here more than anywhere.

7-3. The practical compromise

  • Separate the commits; the PR may still be combined. "This commit is pure refactoring, the next one is the feature" lets the reviewer choose how to read.
  • Refactor first, then add the feature. Ship the feature first and the tidy-up commit usually never arrives.
  • Set a size ceiling. The bigger a refactoring PR gets, the more reviewers approve without really reading. That tendency is not something the sources above measured; it is this article's rule of thumb, so the team must pick its own ceiling.
  • Split mechanical changes into their own PR. Section 8's codemods belong here.

More on the conversation side is in How to Talk in Code Review.


8. Automated refactoring tools and large-scale change

8-1. When IDE refactoring is safe

An IDE's rename or extract-method understands the syntax tree, which makes it far safer than string replacement. But it fails silently in front of references that are not statically traceable.

  • Reflection and dynamic dispatch: names built from strings are invisible to the tool.
  • Serialised identifiers: class names and event type names stored in a database or queue are data, not code.
  • Config files, templates, and consumers in other repositories: all outside the tool's search scope.

That is why a full-repository string search after running an automated refactoring is cheap insurance.

8-2. The codemod procedure

[Running a codemod]
1. Write the transform against the syntax tree (regex replacement also rewrites
   comments and string literals)
2. Apply it to a sample of about 20 files and read the result yourself
3. Fix the rule. Repeat 2 and 3 until the results are boring
4. Apply to everything and run the full test suite
5. When requesting review, state what should actually be reviewed

[What to review — not the entire result diff]
- The transform script itself
- The exception list the tool could not handle
- The transformed output of 10 randomly sampled files
- Test results and coverage change

8-3. Splitting a large change can make it more dangerous

Smaller is usually safer, but mechanical transformations are sometimes an exception. Splitting a rename across several PRs lengthens the period in which the code does not compile or two names coexist. The options are to merge the whole thing at once, or to apply section 5's parallel change so the intermediate state is legal. Forcing a split on a change that cannot be split buys you an unstable intermediate state, not safety.

8-4. Compare behaviour, not the result

The final verification of a large change is not reading the diff. Comparing outputs before and after for the same inputs is far stronger. Golden-file comparison, and running a copy of production traffic through both implementations to compare results, are the usual approaches. The latter has the same structure as the shadow deployment technique, so it has the same precondition: side effects must be isolated.


9. Deciding when to stop

9-1. Write the exit condition first

Refactoring is inherently endless, so before starting write the exit condition in one sentence. A good exit condition is expressed as the cost of the next change, not as a structural metric.

  • Weak goal: "reduce cyclomatic complexity below 15"
  • Strong goal: "adding one more payment method touches at most three files"

A strong goal is verifiable, because actually adding the next feature reveals immediately whether you met it.

9-2. Signals that you should stop

- Files unrelated to the original purpose start appearing in the diff
- Resolving merge conflicts takes longer than the refactoring itself
- You hear twice or more that you are blocking someone else's work
- "Just this last bit" has been said three times
- The net stays red and you cannot tell whether the refactoring is the cause
- You can no longer answer which future change this refactoring was meant to make cheap

9-3. Stopping is also a result

Stopping halfway is not a failure in itself. The failure is walking away without recording the state you stopped in. When you stop, close out the finished part in a deployable state, put the remainder on the debt list, and leave a paragraph on what you learned.

9-4. Measuring the effect

Whether a refactoring actually helped is more honestly checked through change cost than through code metrics. DORA defines change lead time as the amount of time it takes for a change to go from committed to version control to deployed in production, and change fail rate as the ratio of deployments that require immediate intervention following a deployment. If neither improves for work in the refactored area, the structure got prettier but the purpose was not achieved. If the metrics got worse, keep reverting on the table.


Quiz: Check your understanding

Quiz 1: A colleague opens a PR saying "I also fixed a bug I found while refactoring". What do you ask for?

Answer: Ask them to split the bug fix into a separate commit or a separate PR.

Explanation: The definition of refactoring is preservation of observable behaviour, and a bug fix changes behaviour by definition. Mixed into one commit, a failing test can no longer tell you whether it is a refactoring mistake or the intended behaviour change, and a rollback cannot take back only the half you want. It is also why characterization tests pin down even the currently wrong behaviour.

Quiz 2: The class you are refactoring has 40 unit tests, and you plan to split it into three. What safety net do you prepare?

Answer: Build characterization tests first at the outer boundary of those classes — the module level that uses them.

Explanation: Unit tests coupled to the class's internal structure must be rewritten the moment you split the class, which leaves you without a net exactly while you are refactoring. Tests placed at the boundary wrapping the structure you will change survive any internal rearrangement.

Quiz 3: You renamed a class with an automated tool and all tests pass. What still needs checking?

Answer: Every place that references the symbol as a string: reflection, serialised type names in storage, config files and templates, and consumers in other repositories.

Explanation: Syntax-tree tools only see statically traceable references. Names already persisted in a database or message queue are data rather than code and sit outside the tool's field of view — in which case the rename is immediately an irreversible change. A full-repository string search costs almost nothing.

Quiz 4: A three-week refactoring branch met 300 conflicts at merge time. What changes next time?

Answer: Instead of keeping a long branch, proceed as independently deployable pieces on the main branch, using parallel change to legalise intermediate states where needed.

Explanation: On a long branch, everyone else keeps adding code on top of the old structure while conflicts grow faster than linearly with time. Cutting by layer, by call site and by data direction, with a real deployment between pieces, makes each piece a reversible unit.

Quiz 5: The refactoring is finished, but the only evidence it helped is code metrics. What else do you look at?

Answer: The cost of real changes in that area — change lead time and change fail rate.

Explanation: Cyclomatic complexity and coupling are proxy metrics. The purpose of refactoring is to make the next change cheap, so verification has to happen on the next change. If the metrics did not improve, reverting is a legitimate option.


Wrapping up

The hard part of refactoring is not knowing the catalogue of patterns. It is acquiring the evidence to say behaviour was preserved, and staying deployable no matter when you stop. With those two in place the rest is mechanical repetition; without them, even excellent design instincts become gambling.

Compressed into one line: write down what you will preserve, build the safety net, introduce seams, change in small steps, move interfaces via parallel change, cut large changes into reversible pieces, and stop when you reach your exit condition. And leave a record of where you stopped.


References

  • Definition of Refactoring — Martin Fowler — cited for the noun and verb definitions of refactoring, and for behaviour preservation being the condition that separates refactoring from restructuring. Checked 2026-08-15.
  • Parallel Change — Martin Fowler — cited for the definitions of the expand, migrate and contract phases and the attribution to Joshua Kerievsky. Checked 2026-08-15.
  • The Practical Test Pyramid — Martin Fowler — cited for the lack of agreement on what "unit" means, and for end-to-end tests being flaky, maintenance-heavy and slow. Checked 2026-08-15.
  • The Testing Trophy and Testing Classifications — Kent C. Dodds — cited for the claim that tests resembling real usage give more confidence, and for the acknowledgement that many definitions of unit test exist and the ratio debate is a distraction. Checked 2026-08-15.
  • Code Review Developer Guide, The Standard of Code Review — Google — cited for approving a change that definitely improves overall code health even when imperfect, and for the "Nit: " convention. Checked 2026-08-15.
  • Code Review Developer Guide, Speed of Code Reviews — Google — cited for slow reviews discouraging cleanups and refactorings, and for one business day as the maximum response time. Checked 2026-08-15.
  • DORA metrics: the four keys — DORA — cited for the definitions of change lead time and change fail rate. Checked 2026-08-15.
  • The terms characterization test and seam are widely known from Michael Feathers' legacy-code work; this article uses the concepts without quoting the original. The safety-net placement rule in section 2, the loop and stop rules in section 4, the cutting axes in section 6, the codemod review targets in section 8 and the stop signals in section 9 are procedures assembled in this article rather than taken from the sources above.

Further reading

The Complete Guide series