Skip to content
Published on

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

공유하기
Authors

들어가며

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

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


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절의 중단 신호는 위 자료가 아니라 이 글에서 정리한 절차입니다.

이어서 읽기

완전 가이드 시리즈