본문으로 건너뛰기

refactoring

Martin FowlerISBN 9791162242742

목차129개 항목

Chapter 1: Refactoring: A First Example

The Starting Point

  • 예제 시나리오: 연극 공연 회사의 청구서 출력 프로그램
  • 데이터 구조: plays.json(공연 목록), invoices.json(공연 내역)으로 구성
  • 공연 종류: tragedy(비극), comedy(희극) 두 가지
  • volumeCredits: 향후 공연 할인에 사용되는 고객 충성도 포인트

초기 statement 함수는 단일 함수 안에 청구 계산, 포맷팅, 출력을 모두 포함:

function statement(invoice: Invoice, plays: Plays): string {
  let totalAmount = 0;
  let volumeCredits = 0;
  let result = `Statement for ${invoice.customer}\n`;

  for (const perf of invoice.performances) {
    const play = plays[perf.playID];
    let thisAmount = 0;

    switch (play.type) {
      case "tragedy":
        thisAmount = 40000;
        if (perf.audience > 30) thisAmount += 1000 * (perf.audience - 30);
        break;
      case "comedy":
        thisAmount = 30000;
        if (perf.audience > 20) thisAmount += 10000 + 500 * (perf.audience - 20);
        thisAmount += 300 * perf.audience;
        break;
      default:
        throw new Error(`unknown type: ${play.type}`);
    }

    volumeCredits += Math.max(perf.audience - 30, 0);
    if ("comedy" === play.type) volumeCredits += Math.floor(perf.audience / 5);
    result += `  ${play.name}: ${thisAmount / 100} (${perf.audience} seats)\n`;
    totalAmount += thisAmount;
  }

  result += `Amount owed is ${totalAmount / 100}\n`;
  result += `You earned ${volumeCredits} credits\n`;
  return result;
}

Comments on the Starting Program

  • 현재 코드는 짧아서 이해 가능하지만, 실제로는 수백 줄 규모 시스템을 가정해야 함
  • 컴파일러는 코드가 지저분해도 신경 쓰지 않지만, 사람은 신경 쓴다
  • 잘못 설계된 코드는 변경 시 영향 범위 파악이 어렵고 버그 유입 가능성이 높아짐
  • 요구 변경 사항 두 가지가 리팩터링 필요성 촉발:
  1. HTML 형식 청구서 출력 기능 추가
  2. 비극/희극 외 더 많은 장르 추가 예정 (과금 규칙과 크레딧 계산에 영향)
  • 코드를 복사해 htmlStatement를 만드는 방법은 과금 로직 변경 시 두 곳을 동시에 수정해야 하는 중복 문제를 만들어냄

“기능을 추가해야 하는데 코드 구조가 불편하다면, 먼저 리팩터링해서 추가하기 쉽게 만든 뒤 기능을 추가하라.”

The First Step in Refactoring

  • 리팩터링의 첫 번째 단계는 항상 동일: 탄탄한 테스트 세트 확보
  • 테스트는 반드시 자가 점검(self-checking) 방식이어야 함 (초록/빨강으로 결과 즉시 확인)
  • 테스트는 리팩터링 과정에서 실수를 잡아주는 버그 탐지기 역할

Decomposing the statement Function

Extract Function: amountFor

  • switch 블록을 독립 함수 amountFor로 분리 (Extract Function 기법)
  • 추출 전 체크사항: 범위 밖으로 나갈 변수 식별
    • perf, play: 읽기만 하므로 매개변수로 전달
    • thisAmount: 수정되므로 반환값으로 처리
function amountFor(aPerformance: Performance, play: Play): number {
  let result = 0;
  switch (play.type) {
    case "tragedy":
      result = 40000;
      if (aPerformance.audience > 30) result += 1000 * (aPerformance.audience - 30);
      break;
    case "comedy":
      result = 30000;
      if (aPerformance.audience > 20) result += 10000 + 500 * (aPerformance.audience - 20);
      result += 300 * aPerformance.audience;
      break;
    default:
      throw new Error(`unknown type: ${play.type}`);
  }
  return result;
}
  • 추출 후 즉시 컴파일 + 테스트 + 커밋
  • 변수 이름 개선: thisAmountresult (함수 반환값은 항상 result로 명명하는 컨벤션)
  • 매개변수 이름 개선: perfaPerformance (타입명을 포함한 명확한 이름)

“어리석은 프로그래머는 컴퓨터가 이해하는 코드를 짜고, 좋은 프로그래머는 사람이 이해하는 코드를 짠다.”

Replace Temp with Query: playFor

  • play 변수는 performance에서 계산 가능하므로 임시 변수 제거 대상
  • 임시 변수는 지역 범위 이름을 복잡하게 만들어 추출을 어렵게 함
  • playFor 함수로 추출 후 play 변수를 인라인화:
function playFor(aPerformance: Performance): Play {
  return plays[aPerformance.playID];
}
  • amountForplay 매개변수 제거 가능 (Change Function Declaration)
  • 성능 우려: playFor가 루프마다 3번 호출되지만, 대부분의 경우 무시 가능한 수준
  • 핵심 원칙: 잘 팩터링된 코드가 성능 최적화하기 훨씬 쉬움

Extract Function: volumeCreditsFor

  • 볼륨 크레딧 계산 블록도 독립 함수로 분리:
function volumeCreditsFor(aPerformance: Performance): number {
  let result = 0;
  result += Math.max(aPerformance.audience - 30, 0);
  if ("comedy" === playFor(aPerformance).type)
    result += Math.floor(aPerformance.audience / 5);
  return result;
}

Replace Temp with Query: format → usd

  • format 임시 변수를 선언 함수로 전환 후 더 명확한 이름 usd로 변경 (Change Function Declaration):
function usd(aNumber: number): string {
  return new Intl.NumberFormat("en-US", {
    style: "currency",
    currency: "USD",
    minimumFractionDigits: 2,
  }).format(aNumber / 100);
}
  • 센트 단위 정수를 달러로 변환하는 로직도 함수 내부로 이동

Removing Total Volume Credits

  • volumeCredits 누산 변수 제거를 위한 4단계 패턴:
  1. Split Loop: 루프를 분리해 누산만 담당하도록 격리
  2. Slide Statements: 변수 선언을 루프 바로 위로 이동
  3. Extract Function: 전체 계산 로직을 totalVolumeCredits로 추출
  4. Inline Variable: 변수를 완전히 제거하고 함수 직접 호출
  • 같은 패턴으로 totalAmount도 제거
  • 이름 충돌 시 임시 이름(appleSauce)으로 추출 후 인라인, 이후 적절한 이름으로 변경

“리팩터링은 작은 단계들로 프로그램을 변경한다. 실수해도 어디서 버그가 생겼는지 쉽게 찾을 수 있다.”

Status: Lots of Nested Functions

이 시점의 코드 상태:

function statement(invoice: Invoice, plays: Plays): string {
  let result = `Statement for ${invoice.customer}\n`;
  for (const perf of invoice.performances) {
    result += `  ${playFor(perf).name}: ${usd(amountFor(perf))} (${perf.audience} seats)\n`;
  }
  result += `Amount owed is ${usd(totalAmount())}\n`;
  result += `You earned ${totalVolumeCredits()} credits\n`;
  return result;
}
  • 최상위 statement 함수는 7줄로 줄어들어 출력 레이아웃만 담당
  • 모든 계산 로직은 개별 지원 함수로 이동
  • 각 계산을 독립적으로 이해 가능

Splitting the Phases of Calculation and Formatting

Split Phase 적용

  • 목표: 계산 단계와 포맷팅 단계를 분리 → HTML 버전 지원 시 계산 코드 중복 없이 재사용
  • 계산 결과를 담는 중간 데이터 구조(intermediate data structure) 도입:
// statement.ts
import { createStatementData } from "./createStatementData";

function statement(invoice: Invoice, plays: Plays): string {
  return renderPlainText(createStatementData(invoice, plays));
}

function htmlStatement(invoice: Invoice, plays: Plays): string {
  return renderHtml(createStatementData(invoice, plays));
}
// createStatementData.ts
export function createStatementData(invoice: Invoice, plays: Plays): StatementData {
  const result: StatementData = {
    customer: invoice.customer,
    performances: invoice.performances.map(enrichPerformance),
    totalAmount: 0,
    totalVolumeCredits: 0,
  };
  result.totalAmount = totalAmount(result);
  result.totalVolumeCredits = totalVolumeCredits(result);
  return result;

  function enrichPerformance(aPerformance: Performance) {
    const r = Object.assign({}, aPerformance);
    r.play = playFor(r);
    r.amount = amountFor(r);
    r.volumeCredits = volumeCreditsFor(r);
    return r;
  }
  // ... playFor, amountFor, volumeCreditsFor, totalAmount, totalVolumeCredits
}
  • Object.assign({}, aPerformance): 얕은 복사로 원본 데이터 불변 유지
  • 루프를 파이프라인으로 교체 (Replace Loop with Pipeline):
function totalAmount(data: StatementData): number {
  return data.performances.reduce((total, p) => total + p.amount, 0);
}

Status: Separated into Two Files (and Phases)

  • 두 파일로 분리: statement.ts (렌더링), createStatementData.ts (계산)
  • 코드량은 44줄 → 70줄로 증가했지만, 계산과 레이아웃의 명확한 분리로 가치 있음
  • HTML 버전 추가 시 계산 코드 중복 없이 renderHtml만 추가하면 됨

“캠핑 규칙: 항상 처음 왔을 때보다 코드베이스를 더 건강하게 만들고 떠나라.”

Reorganizing the Calculations by Type

다형성 계산기 도입 동기

  • 새 공연 장르 추가 시마다 amountForswitch 문을 수정해야 하는 구조
  • 타입에 따른 조건 분기는 다형성(Polymorphism)으로 교체하는 것이 자연스러운 OOP 접근

PerformanceCalculator 클래스 생성

class PerformanceCalculator {
  performance: EnrichedPerformance;
  play: Play;

  constructor(aPerformance: EnrichedPerformance, aPlay: Play) {
    this.performance = aPerformance;
    this.play = aPlay;
  }

  get amount(): number {
    throw new Error("subclass responsibility");
  }

  get volumeCredits(): number {
    return Math.max(this.performance.audience - 30, 0);
  }
}

class TragedyCalculator extends PerformanceCalculator {
  get amount(): number {
    let result = 40000;
    if (this.performance.audience > 30)
      result += 1000 * (this.performance.audience - 30);
    return result;
  }
}

class ComedyCalculator extends PerformanceCalculator {
  get amount(): number {
    let result = 30000;
    if (this.performance.audience > 20)
      result += 10000 + 500 * (this.performance.audience - 20);
    result += 300 * this.performance.audience;
    return result;
  }

  get volumeCredits(): number {
    return super.volumeCredits + Math.floor(this.performance.audience / 5);
  }
}

팩토리 함수로 인스턴스 생성

function createPerformanceCalculator(
  aPerformance: EnrichedPerformance,
  aPlay: Play
): PerformanceCalculator {
  switch (aPlay.type) {
    case "tragedy":
      return new TragedyCalculator(aPerformance, aPlay);
    case "comedy":
      return new ComedyCalculator(aPerformance, aPlay);
    default:
      throw new Error(`unknown type: ${aPlay.type}`);
  }
}
  • 새 장르 추가 시 새 서브클래스만 추가하면 됨 → 기존 로직 수정 불필요

Final Thoughts

  • 예제를 통해 배운 핵심: 리팩터링의 리듬
    • 작은 단계들을 반복: 각 단계 후 코드는 항상 컴파일되고 테스트를 통과하는 상태
    • Kent Beck이 보여준 원칙: “작은 단계로 갈수록 더 빠르다 — 코드는 절대 깨지지 않는다”
  • 조그만 변경들이 누적되어 근본적인 설계 개선으로 이어짐
  • 좋은 코드는 명확성이 핵심 (Brevity is the soul of wit, but clarity is the soul of evolvable software)

Chapter 2: Principles in Refactoring

Defining Refactoring

  • 리팩터링의 정의: 소프트웨어의 외부 동작은 바꾸지 않으면서 내부 구조를 개선하는 변경 과정
  • 코드를 작성한 이후에 설계를 개선하는 규율 있는 방법
  • “observable behavior”(관찰 가능한 동작): 의도적으로 느슨한 표현 — 사용자가 신경 쓸 수 있는 것은 변경하지 않음
  • 리팩터링 vs. 리스트럭처링: 리팩터링은 리스트럭처링의 특정 종류로, 작은 단계를 통해 코드가 항상 작동하는 상태를 유지
  • 리팩터링 vs. 성능 최적화:
    • 두 가지 모두 전체 기능을 바꾸지 않고 코드를 조작
    • 차이점: 리팩터링의 목적은 코드를 “이해하기 쉽고 수정하기 저렴하게”, 성능 최적화는 속도만을 추구

“누군가 리팩터링 중에 코드가 며칠 동안 깨져 있었다고 말한다면, 그것은 리팩터링이 아니었다고 확신해도 좋다.”

The Two Hats

  • Kent Beck이 제안한 두 개의 모자(Two Hats) 비유:
모자 활동 규칙
기능 추가 모자 새 기능 구현, 테스트 추가 기존 코드 변경 금지
리팩터링 모자 코드 구조 개선 기능 추가 금지, 테스트는 인터페이스 변경 시에만 수정
  • 개발 중 수시로 모자를 바꿔 쓰되, 현재 어느 모자를 쓰고 있는지 항상 인식해야 함
  • 10분 내에 여러 번 모자를 바꿀 수 있음

Why Should We Refactor?

리팩터링은 소프트웨어 설계를 개선한다

  • 리팩터링 없이는 코드의 내부 설계(아키텍처)가 서서히 붕괴
  • 사람들이 단기 목표를 위해 아키텍처를 제대로 이해하지 않고 코드를 변경하면 구조를 잃어감
  • 구조 손실은 누적 효과: 코드에서 설계가 잘 안 보일수록, 설계를 보존하기 더 어려워지고, 더 빠르게 부식됨
  • 핵심 수단: 중복 제거 — 코드가 모든 것을 한 번씩만 표현할 때 좋은 설계

리팩터링은 소프트웨어를 이해하기 쉽게 만든다

  • 미래의 누군가(또는 몇 달 뒤의 나)가 코드를 읽고 이해해야 함
  • Ward Cunningham의 표현: “이해를 코드 안에 이동시키는 것”
  • 리팩터링하지 않으면 머릿속의 이해가 휘발되어 다시 분석해야 함
  • 코드가 명확해질수록, 이전에는 보이지 않던 설계 문제들이 드러남
  • Ralph Johnson: “이른 리팩터링은 창문의 먼지를 닦아 더 멀리 볼 수 있게 하는 것”

리팩터링은 버그를 찾는 데 도움을 준다

  • 코드를 이해할수록 버그를 발견할 가능성이 높아짐
  • 리팩터링을 통해 코드 구조를 명확히 하면 잠재 버그가 드러남

리팩터링은 더 빠른 프로그래밍을 가능하게 한다

  • 설계 스태미나 가설(Design Stamina Hypothesis): 좋은 내부 설계에 투자하면, 소프트웨어가 더 오래 빠르게 성장할 수 있는 스태미나가 생김
  • 리팩터링을 통해 설계를 개선하면 기능 추가 속도가 오히려 빨라짐
  • 기존 구성요소를 새 조합으로 쉽게 결합할 수 있게 됨

When Should We Refactor?

3의 법칙 (Rule of Three)

Don Roberts의 가이드라인:

  1. 처음 한 번은 그냥 한다
  2. 두 번째에는 중복을 의식하면서도 그냥 한다
  3. 세 번째에는 리팩터링한다 (“세 번 치면 리팩터링”)

준비를 위한 리팩터링 (Preparatory Refactoring)

  • 기능을 추가하기 전에 가장 좋은 시점이 리팩터링 타이밍
  • 기존 코드 구조를 조금만 바꾸면 작업이 훨씬 쉬워지는 경우
  • 예: 매개변수 값만 다른 유사 함수가 있을 때, Parameterize Function을 먼저 적용 후 기능 추가

“100마일 동행을 위해 직진하는 대신 20마일 북쪽 고속도로까지 이동해서 3배 속도로 이동하는 것과 같다. 지름길이 필요할 때 잠깐 멈추고 지도를 확인해야 한다.” — Jessica Kerr

이해를 위한 리팩터링 (Comprehension Refactoring)

  • 코드를 변경하려면 먼저 이해해야 함
  • 코드를 이해하는 과정에서 이해를 코드에 기록하는 행위
    • 변수명 개선, 긴 함수 분할
  • 코드가 명확해질수록 이전에 보이지 않던 설계 문제들을 발견하게 됨

쓰레기 줍기 리팩터링 (Litter-Pickup Refactoring)

  • 코드를 이해한 뒤 코드가 잘못 작성되어 있음을 발견하는 경우
  • 즉시 고칠 수 있다면 바로 수정; 시간이 걸린다면 메모하고 나중에 처리
  • 원칙: 항상 찾았을 때보다 캠프장을 더 깨끗하게 놓고 가라 (캠핑 규칙)

계획적 vs. 기회적 리팩터링

  • 기회적 리팩터링(준비, 이해, 쓰레기 줍기)이 핵심 — 별도 시간 할당 불필요
  • 리팩터링은 기능 추가나 버그 수정 중에 자연스럽게 흐름 안에서 수행됨
  • 리팩터링을 프로그래밍에서 분리된 활동으로 보지 말 것if문을 언제 쓸지 따로 계획하지 않듯이

“For each desired change, make the change easy (warning: this may be hard), then make the easy change.” — Kent Beck

장기 리팩터링 (Long-Term Refactoring)

  • 일부 리팩터링은 팀 전체가 몇 주에 걸쳐 수행
  • 점진적 접근 전략: 별도 기간이 아니라 해당 영역 코드를 건드릴 때마다 조금씩 개선
  • 예: 라이브러리 교체 → 먼저 추상화 계층 도입 후 점진적 마이그레이션

언제 리팩터링하지 않을까?

  • 건드릴 필요가 없는 지저분한 코드 → 리팩터링할 필요 없음
  • API처럼 내부 동작을 이해할 필요 없이 사용 가능한 코드
  • 처음부터 다시 짜는 것이 더 쉬울 때 (판단이 필요한 트레이드오프)

Problems with Refactoring

새 기능 개발 속도를 늦춘다는 오해

  • 리팩터링의 목적은 속도를 높이는 것이지, 코드를 깨끗하게 만드는 것 자체가 아님
  • 리팩터링은 순전히 경제적인 활동: 기능 추가 속도와 버그 수정 속도를 높임
  • 가장 위험한 함정: 리팩터링을 “깨끗한 코드”, “좋은 엔지니어링 관행” 등의 도덕적 이유로 정당화하는 것
  • 경제적 이점이 항상 주된 동인이 되어야 함

코드 소유권 (Code Ownership)

  • 다른 팀이 소유한 코드나 외부에 공개된 API는 자유롭게 변경 불가
  • 함수 이름 변경 시, 구 선언부를 새 선언부로 위임하는 래퍼(pass-through)로 유지해야 함
  • 권장 사항: 팀 단위 코드 소유권(fine-grained 단일 소유 지양)
    • 같은 팀이면 누구나 팀 코드 수정 가능
    • 오픈소스 모델 형태로 다른 팀도 PR로 기여 가능

브랜치 전략 (Branches)

  • 장수명 브랜치는 merge conflict를 심화시켜 리팩터링을 방해
  • 지속적 통합(CI, Continuous Integration) 권장: 모든 팀원이 매일 메인라인에 통합
  • CI + 자가 테스트 코드 + 리팩터링은 강한 시너지를 가진 세 가지 실천법
  • 기능 브랜치는 수명이 짧을수록 문제 감소; 하루 안에 통합하는 것이 이상적

테스팅 (Testing)

  • 리팩터링은 각 단계가 작으므로 실수해도 이전 커밋으로 쉽게 복원 가능
  • 자가 테스트 코드(self-testing code): 리팩터링의 핵심 전제 조건
  • 테스트 없이는 자신감 있는 리팩터링 불가능

레거시 코드 (Legacy Code)

  • 테스트 없는 레거시 코드는 리팩터링이 위험하지만, 발전 자체가 어려움
  • 권장 방법: Working Effectively with Legacy Code (Michael Feathers) 참고
    • 이음새(seam) 찾기: 테스트를 삽입할 수 있는 지점을 찾아 안전망 확보
    • 테스트 없이 리팩터링하는 것은 더 위험하지만, 진전을 위한 필요악
  • 자동화된 안전한 리팩터링 도구가 큰 도움

데이터베이스 (Databases)

  • Pramod Sadalage가 개발한 진화적 데이터베이스 설계 기법으로 해결
  • 핵심: 스키마 변경 + 데이터 마이그레이션 스크립트를 버전 관리에 함께 저장
  • 병렬 변경(Parallel Change / Expand-Contract) 패턴:
  1. 새 필드 추가(구 필드 유지)
  2. 읽기/쓰기가 모두 새 필드 사용하도록 점진적 전환
  3. 구 필드 제거
  • DB 변경은 여러 릴리스에 걸쳐 분산시켜야 롤백이 용이

매니저에게 리팩터링을 어떻게 말할까?

  • 기술을 이해하는 매니저에게는 정당화가 쉬움
  • 그렇지 않다면 저자의 조언: “말하지 마라(Don’t Tell)”
    • 전문가로서 가장 빠른 방법으로 소프트웨어를 만드는 것이 책임
    • 리팩터링은 가장 빠른 방법의 일부이므로 그냥 하면 됨

Refactoring, Architecture, and Yagni

  • 전통적 관점: 아키텍처는 코딩 전에 완성되어야 하며, 코딩 후에는 고정
  • 리팩터링의 혁신: 아키텍처를 지속적으로 진화시킬 수 있음
  • 선제적 유연성 메커니즘(Flexibility Mechanism)의 문제:
    • 예상 변경을 위해 미리 매개변수를 추가하면 현재 사용에 복잡성만 더함
    • 예상이 빗나가거나 메커니즘 설계가 잘못되면 오히려 방해
  • YAGNI (You Aren’t Gonna Need It) 원칙:
    • 현재 이해된 요구사항만 훌륭하게 설계
    • 복잡성을 더하지 않는 메커니즘(작고 잘 명명된 함수)은 포함
    • 복잡성을 더하는 유연성은 그것이 필요할 때 리팩터링으로 추가
  • YAGNI는 아키텍처 사고의 제거가 아닌, 다른 방식으로의 통합
  • 진화적 아키텍처(Evolutionary Architecture): 아키텍처 결정을 반복적으로 개선하는 패턴과 관행 탐구

Refactoring and the Wider Software Development Process

  • 리팩터링의 효과는 팀이 사용하는 다른 소프트웨어 실천법과 밀접하게 연결
  • 익스트림 프로그래밍(XP)의 핵심 실천법이 서로 시너지:
    • 자가 테스트 코드 → 리팩터링 안전망
    • 지속적 통합(CI) → 팀 내 리팩터링 효과 즉시 공유, 충돌 조기 감지
    • 리팩터링 → 코드베이스 건강 유지
  • 진정한 애자일 팀 = 열정적인 리팩터러 집단
  • 세 가지 실천법 사이의 강한 시너지가 애자일 개발의 실질적 기반

Refactoring and Performance

  • 리팩터링은 단기적으로 성능을 저하시킬 수 있음(루프 분리, 함수 호출 증가 등)
  • 성능에 대한 전반적 조언: 대부분의 경우 무시하라
    • 소프트웨어 성능은 코드의 일부 구간에만 의존
    • 대부분의 프로그래머는 성능을 직관으로 판단하지만, 그 직관은 자주 틀림
  • 작동 방식:
  1. 리팩터링 먼저 완료
  2. 성능 측정(profiling)으로 실제 병목 파악
  3. 잘 팩터링된 코드를 대상으로 최적화 적용
  • 결과: 더 명확하고 더 빠른 코드

“리팩터링은 소프트웨어를 빠르게 작성하는 데 도움이 된다. 리팩터링하는 동안 단기적으로는 느려지지만, 최적화 시 튜닝이 훨씬 쉬워진다. 결국 훨씬 앞서게 된다.”

Where Did Refactoring Come From?

  • 선구자들:
    • Ward Cunningham & Kent Beck: Smalltalk 환경(1980년대~)에서 리팩터링 개념 발전, XP의 기반
    • Ralph Johnson: 프레임워크 개발에서 리팩터링 활용 연구
    • Bill Opdyke: 리팩터링에 관한 최초의 실질적 학술 연구(박사 논문)
    • John Brant & Don Roberts: 최초의 자동화 리팩터링 도구 Refactoring Browser(Smalltalk 용) 개발
  • Martin Fowler는 Kent Beck과의 프로젝트에서 리팩터링의 중요성을 직접 목격하고 영감을 받아 초판 집필

Automated Refactorings

  • 자동화 리팩터링의 핵심 원칙: 텍스트가 아닌 구문 트리(syntax tree)에서 작동해야 함
    • 텍스트 조작(검색/교체)은 조잡한 근사치에 불과
    • 구문 트리 조작은 코드 동작을 훨씬 안정적으로 보존
  • IDE의 자동화 리팩터링이 강력한 이유: 구문 트리를 사용해 코드 탐색, 린팅, 리팩터링을 모두 수행
  • 정적 타이핑의 장점: 동일 이름의 메서드가 여러 클래스에 있어도 올바른 대상만 안전하게 변경 가능
  • 자동화 도구도 완벽하지 않음 (리플렉션 호출 등 엣지 케이스 존재) → 테스트 스위트를 주기적으로 실행해야 함
  • 도구 발전 역사:
    • Smalltalk Refactoring Browser → IntelliJ IDEA(Java) → Eclipse → C#의 Resharper/Visual Studio

Chapter 3 — Bad Smells in Code (코드의 악취)

“냄새가 나면 바꿔라.” — Grandma Beck

리팩터링을 언제 해야 하는지를 아는 것은 어떻게 하는지를 아는 것만큼 중요하다. 정확한 수치 기준은 없으며, 경험 있는 개발자의 직관이 어떤 지표보다 낫다. 이 챕터는 특정 코드 구조가 리팩터링의 가능성을 암시하는 신호(“냄새”)를 소개한다.

1. Mysterious Name (불가사의한 이름)

  • 코드는 탐정 소설이 아니다. 함수, 변수, 클래스의 이름은 의도를 명확히 전달해야 한다
  • 이름 짓기는 프로그래밍에서 가장 어려운 두 가지 문제 중 하나
  • 좋은 이름 하나가 몇 시간의 혼란을 막을 수 있다
  • 이름을 못 떠올리겠다면, 그것은 종종 더 깊은 설계 문제의 징후다
  • 적용 리팩터링:
    • Change Function Declaration (함수 이름 변경)
    • Rename Variable (변수 이름 변경)
    • Rename Field (필드 이름 변경)
// Before
function d(r: number): number {
  return r * 2 * Math.PI;
}

// After
function calculateCircumference(radius: number): number {
  return radius * 2 * Math.PI;
}

2. Duplicated Code (중복 코드)

  • 같은 코드 구조가 두 군데 이상 있다면, 통합할 방법을 찾아야 한다
  • 중복 코드는 읽을 때마다 두 곳을 비교해야 하고, 수정할 때마다 빠짐없이 찾아야 한다
  • 적용 리팩터링:
    • 같은 클래스 내 두 메서드에 동일 표현식 → Extract Function
    • 비슷하지만 완전히 같지 않은 경우 → Slide Statements로 유사 코드를 모은 뒤 추출
    • 공통 부모 클래스를 가진 서브클래스 간 중복 → Pull Up Method
// Before
class Order {
  getBasePrice() { return this.quantity * this.unitPrice; }
  getFinalPrice() { return this.quantity * this.unitPrice * 0.9; }
}

// After
class Order {
  getBasePrice() { return this.quantity * this.unitPrice; }
  getFinalPrice() { return this.getBasePrice() * 0.9; }
}

3. Long Function (긴 함수)

  • 오래 살아남는 프로그램은 짧은 함수로 구성되어 있다
  • 핵심은 함수의 길이가 아니라, 함수가 무엇을 하는지어떻게 하는지 사이의 의미적 거리(semantic distance)다
  • 주석을 쓰고 싶다면, 그 내용을 함수로 추출하고 의도에 따라 이름을 붙여라
  • 적용 리팩터링:
    • 99%는 Extract Function으로 해결
    • 임시 변수가 많으면 → Replace Temp with Query
    • 매개변수 목록이 길면 → Introduce Parameter Object, Preserve Whole Object
    • 그래도 너무 복잡하면 → Replace Function with Command
    • 조건식 → Decompose Conditional
    • 반복문 → 루프와 내부 코드를 함께 추출; 두 가지 일을 하고 있으면 Split Loop
    • 동일 조건을 여러 switch에서 반복 → Replace Conditional with Polymorphism

4. Long Parameter List (긴 매개변수 목록)

  • 매개변수 목록이 길면 그 자체로 혼란스럽다
  • 전역 데이터를 피하기 위해 모든 것을 매개변수로 넘기던 습관에서 비롯된 나쁜 냄새
  • 적용 리팩터링:
    • 다른 매개변수에서 구할 수 있는 값 → Replace Parameter with Query
    • 기존 자료구조에서 여러 값을 꺼내는 경우 → Preserve Whole Object
    • 항상 같이 다니는 매개변수들 → Introduce Parameter Object
    • 동작 분기용 플래그 매개변수 → Remove Flag Argument
    • 여러 함수가 공통 매개변수를 공유 → Combine Functions into Class
// Before
function createUser(name: string, age: number, email: string, city: string) { ... }

// After
interface UserParams { name: string; age: number; email: string; city: string; }
function createUser(params: UserParams) { ... }

5. Global Data (전역 데이터)

  • 코드베이스 어디서든 수정할 수 있고, 어디서 건드렸는지 추적할 방법이 없다
  • 글로벌 변수뿐 아니라 클래스 변수, 싱글턴도 동일한 문제를 유발한다
  • Paracelsus의 격언: 독과 무해한 것의 차이는 용량이다. 전역 데이터도 적은 양은 허용되지만 기하급수적으로 다루기 어려워진다
  • 적용 리팩터링:
    • 1순위 방어책: Encapsulate Variable로 함수로 감싸 수정 지점 파악 및 접근 제어
    • 이후 클래스나 모듈 내부로 이동해 범위를 최소화
  • 변경 불가능한(immutable) 전역 데이터는 상대적으로 안전하다

6. Mutable Data (변경 가능한 데이터)

  • 데이터 변경은 예기치 않은 결과와 추적하기 어려운 버그를 초래한다
  • 함수형 프로그래밍은 불변성을 핵심 원칙으로 삼아 이 문제를 회피한다
  • 적용 리팩터링:
    • Encapsulate Variable: 모든 갱신이 좁은 함수를 통해서만 일어나게
    • Split Variable: 한 변수가 다른 용도로 계속 재할당될 때
    • Slide Statements + Extract Function: 사이드 이펙트 없는 코드와 갱신 코드를 분리
    • Separate Query from Modifier: API에서 사이드 이펙트가 있는 코드를 호출할 필요가 없는 경우를 보장
    • Remove Setting Method: setter를 없애 변수 범위를 줄임
    • Replace Derived Variable with Query: 다른 곳에서 계산 가능한 변경 가능 데이터를 제거
    • Combine Functions into Class / Combine Functions into Transform: 변수를 갱신해야 하는 코드 범위 제한
    • Change Reference to Value: 내부 구조가 있는 데이터는 in-place 수정 대신 전체 교체

7. Divergent Change (발산적 변화)

  • 하나의 모듈이 서로 다른 이유로 자주 변경되는 상황
  • 예: “DB가 바뀔 때마다 함수 3개를 바꾸고, 금융 상품이 바뀔 때마다 함수 4개를 바꿔야 한다”
  • 이는 단일 책임 원칙(SRP) 위반의 신호
  • 적용 리팩터링:
    • 자연스러운 순서가 있으면 → Split Phase로 명확한 데이터 구조를 사이에 두고 분리
    • 상호 호출이 많으면 → Move Function으로 처리를 나눔
    • 함수 내부에 두 가지 처리가 섞여 있으면 → 먼저 Extract Function으로 분리
    • 클래스 단위면 → Extract Class

8. Shotgun Surgery (산탄총 수술)

  • Divergent Change의 반대. 하나의 변경을 위해 여러 클래스를 조금씩 수정해야 하는 상황
  • 변경이 산재해 있어 빠뜨리기 쉽다
  • 적용 리팩터링:
    • Move Function, Move Field: 모든 변경이 단일 모듈에 모이도록
    • 비슷한 데이터에 작동하는 함수들 → Combine Functions into Class
    • 데이터 구조를 변환/강화하는 함수들 → Combine Functions into Transform
    • Inline Function / Inline Class: 흩어진 로직을 일단 합친 뒤 재구성

커다란 메서드나 클래스를 중간 단계로 만드는 것을 두려워하지 마라. 재구성을 위한 과정일 뿐이다.

9. Feature Envy (기능 편애)

  • 한 모듈의 함수가 다른 모듈의 데이터나 함수와 더 많이 소통하는 상황
  • 예: 다른 객체의 getter를 6개나 호출해 값을 계산하는 함수
// Before: Order 클래스 안에 있지만 Customer의 데이터만 씀
class Order {
  getDiscountedPrice(customer: Customer): number {
    const base = customer.getBaseRate() * customer.getLoyaltyBonus();
    return this.price * base;
  }
}

// After: Customer 또는 별도 서비스로 이동
class Customer {
  getDiscountFactor(): number {
    return this.getBaseRate() * this.getLoyaltyBonus();
  }
}
  • 적용 리팩터링:
    • 함수 전체가 다른 모듈에 있어야 함 → Move Function
    • 함수 일부만 문제 → Extract FunctionMove Function
    • 여러 모듈의 데이터를 쓸 경우 → 가장 많은 데이터가 있는 모듈로 이동
  • 예외: Strategy, Visitor 패턴은 의도적으로 이 규칙을 어긴다 (변경 격리 목적)

10. Data Clumps (데이터 뭉치)

  • 항상 함께 다니는 3~4개의 데이터 항목. 여러 클래스 필드, 여러 메서드 시그니처에 반복 등장
  • 하나를 제거했을 때 나머지가 의미를 잃는다면, 그것들은 하나의 객체로 묶여야 한다
  • 적용 리팩터링:
    • 필드로 나타나는 경우 → Extract Class로 객체화
    • 메서드 시그니처에서 → Introduce Parameter Object 또는 Preserve Whole Object
// Before
function connect(host: string, port: number, protocol: string) { ... }

// After
interface ConnectionConfig { host: string; port: number; protocol: string; }
function connect(config: ConnectionConfig) { ... }

단순 레코드가 아닌 클래스로 만들어라. 그래야 Feature Envy 탐지를 통해 관련 동작을 이동시킬 기회가 생긴다.

11. Primitive Obsession (기본형 집착)

  • 의미 있는 도메인 타입(금액, 좌표, 범위 등)을 만들기를 꺼려 기본형(숫자, 문자열 등)에 의존하는 상황
  • “stringly typed”(문자열로 다 표현): 전화번호, 우편번호 등을 그냥 string으로 처리
  • 적용 리팩터링:
    • 의미 있는 타입 생성 → Replace Primitive with Object
    • 조건 분기를 제어하는 타입 코드 → Replace Type Code with SubclassesReplace Conditional with Polymorphism
    • 함께 다니는 기본형 집합 → Extract Class, Introduce Parameter Object
// Before
function charge(amount: number, currency: string) { ... }

// After
class Money {
  constructor(public amount: number, public currency: string) {}
}
function charge(money: Money) { ... }

12. Repeated Switches (반복되는 switch문)

  • 같은 조건 분기 로직이 여러 곳에 중복 등장하는 경우
  • 새로운 case가 추가될 때마다 모든 switch를 찾아 수정해야 한다
  • 단순히 switch가 있다는 것 자체가 문제가 아니라, 동일한 switch가 여러 곳에 반복될 때가 문제
  • 적용 리팩터링: Replace Conditional with Polymorphism
// Before (여러 곳에 반복)
function getSpeed(type: string): number {
  switch (type) {
    case 'car': return 100;
    case 'bike': return 30;
    default: return 0;
  }
}

// After
abstract class Vehicle { abstract getSpeed(): number; }
class Car extends Vehicle { getSpeed() { return 100; } }
class Bike extends Vehicle { getSpeed() { return 30; } }

13. Loops (반복문)

  • 반복문은 프로그래밍 초기부터 있었지만, 오늘날에는 일급 함수(first-class function) 가 더 나은 대안을 제공한다
  • filter, map 같은 파이프라인 연산이 어떤 요소가 처리되는지, 무엇이 수행되는지를 더 명확히 보여준다
  • 적용 리팩터링: Replace Loop with Pipeline
// Before
const result: string[] = [];
for (const p of people) {
  if (p.age >= 18) result.push(p.name);
}

// After
const result = people.filter(p => p.age >= 18).map(p => p.name);

14. Lazy Element (게으른 요소)

  • 클래스나 함수가 있어야 할 이유가 없어진 경우 (성장하지 못했거나, 리팩터링으로 역할이 축소됨)
  • 함수 이름이 본문 코드와 동일하게 읽히거나, 클래스가 단순 함수 하나에 불과한 경우
  • 적용 리팩터링:
    • Inline Function 또는 Inline Class
    • 상속 구조의 경우 → Collapse Hierarchy

15. Speculative Generality (추측성 일반화)

  • “언젠가 필요할 것 같아서” 미리 만들어 둔 쓰이지 않는 훅, 특수 처리, 추상화
  • 실제로 쓰이지 않으면 이해와 유지보수를 방해하는 짐이 된다
  • 단서: 테스트 케이스만이 유일한 사용처인 함수나 클래스
    • → 테스트 케이스를 삭제하고 Remove Dead Code 적용
  • 적용 리팩터링:
    • 쓸모없는 추상 클래스 → Collapse Hierarchy
    • 불필요한 위임 → Inline Function, Inline Class
    • 사용되지 않는 매개변수 → Change Function Declaration

16. Temporary Field (임시 필드)

  • 특정 상황에서만 값이 설정되는 필드. 객체는 모든 필드가 채워져 있어야 한다고 기대하기 때문에 이해하기 어렵다
  • 적용 리팩터링:
    • Extract Class: 해당 필드들을 위한 새로운 클래스 생성
    • Move Function: 관련 코드를 새 클래스로 이동
    • Introduce Special Case: 필드가 유효하지 않을 때의 대안 클래스 생성으로 조건 코드 제거

17. Message Chains (메시지 체인)

  • 클라이언트가 객체에서 다른 객체를 요청하고, 그 객체에서 또 다른 객체를 요청하는 연쇄
  • 중간 관계가 하나라도 바뀌면 클라이언트도 함께 바꿔야 한다 (강한 결합)
// Before
const city = user.getAddress().getCity().getName();

// After (Hide Delegate)
class User {
  getCityName(): string {
    return this.address.getCity().getName();
  }
}
const city = user.getCityName();
  • 적용 리팩터링:
    • Hide Delegate: 체인의 중간 객체를 위임으로 숨김
    • 결과 객체가 어디에 쓰이는지 파악 후 Extract Function + Move Function으로 체인 아래로 내려 보냄

18. Middle Man (중간 다리)

  • 클래스 인터페이스의 절반 이상이 다른 클래스에 위임만 하고 있는 경우
  • 캡슐화를 위한 위임은 좋지만, 지나치면 진짜 일을 하는 객체를 가린다
  • 적용 리팩터링:
    • Remove Middle Man: 실제로 아는 객체와 직접 소통
    • 일부만 문제라면 → Inline Function으로 호출자에 인라인
    • 추가 동작이 있다면 → Replace Superclass with Delegate 또는 Replace Subclass with Delegate

19. Insider Trading (내부 거래)

  • 모듈 간에 몰래 데이터를 주고받아 결합도가 높아지는 상황
  • 상속 관계에서 서브클래스가 부모에 대해 너무 많이 아는 경우도 포함
  • 적용 리팩터링:
    • Move Function, Move Field: 불필요한 교류를 줄임
    • 공통 관심사는 제3의 모듈로 분리하거나 Hide Delegate 활용
    • 상속이 문제라면 → Replace Subclass with Delegate 또는 Replace Superclass with Delegate

20. Large Class (거대한 클래스)

  • 너무 많은 필드를 가진 클래스에는 중복 코드가 생기기 마련이다
  • 공통 접두사/접미사를 가진 필드 부분 집합이 있다면 → 컴포넌트로 분리 기회
  • 적용 리팩터링:
    • Extract Class: 변수 뭉치를 별도 클래스로
    • Extract Superclass 또는 Replace Type Code with Subclasses: 상속이 적합하다면
    • 클라이언트가 기능의 일부 집합만 사용하는 패턴 관찰 → 각 부분집합이 분리 후보

21. Alternative Classes with Different Interfaces (인터페이스가 다른 대안 클래스들)

  • 서로 교체 가능해야 하는 클래스들이 다른 인터페이스를 가지고 있는 경우
  • 클래스의 큰 장점(치환 가능성)을 활용하지 못하는 상황
  • 적용 리팩터링:
    • Change Function Declaration: 함수 시그니처를 맞춤
    • Move Function: 동작을 이동해 프로토콜을 일치시킴
    • 중복이 발생하면 → Extract Superclass

22. Data Class (데이터 클래스)

  • 필드와 getter/setter만 있고 아무 동작도 없는 클래스
  • 다른 클래스들이 이 클래스를 지나치게 상세하게 조작하고 있다는 신호
  • public 필드가 있다면 → 즉시 Encapsulate Record 적용
  • 변경되면 안 되는 필드 → Remove Setting Method
  • 동작을 데이터 클래스로 이동: Move Function, 불가능하면 Extract Function 후 이동
  • 예외: Split Phase 결과로 생성된 불변(immutable) 레코드는 데이터 클래스가 적절하다

23. Refused Bequest (거부된 유산)

  • 서브클래스가 부모에서 상속받은 메서드와 데이터 중 일부만 사용하는 경우
  • 전통적 해석: 계층 구조 자체가 잘못됐다 → Push Down Method, Push Down Field로 형제 클래스 생성
  • 저자의 입장: 항상 따를 필요는 없다. 냄새가 약하면 그냥 둬도 된다
상황 심각도 권장 대응
구현(implementation)만 거부 약함 무시해도 됨
인터페이스(interface)까지 거부 강함 Replace Subclass with Delegate 또는 Replace Superclass with Delegate

24. Comments (주석)

  • 주석 자체는 나쁜 냄새가 아니다. 오히려 좋은 냄새다
  • 하지만 주석이 나쁜 코드를 가리는 방향제로 쓰일 때 문제다
  • 리팩터링 절차: 먼저 냄새를 제거하면 대부분의 주석은 불필요해진다

“주석을 쓰고 싶다면, 먼저 코드를 리팩터링해 주석이 불필요해지도록 하라.”

  • 적용 리팩터링:
    • 코드 블록이 무엇을 하는지 설명하는 주석 → Extract Function
    • 이미 추출됐는데 설명이 필요 → Change Function Declaration으로 이름 개선
    • 시스템 상태에 대한 규칙 서술 → Introduce Assertion
  • 주석이 적합한 경우: 무엇을 해야 할지 모를 때, 불확실한 영역 표시, 결정의 이유(why) 설명

Chapter 4 — Building Tests (테스트 구축)

리팩터링을 제대로 하려면 견고한 테스트 스위트가 필수다. 자동화된 리팩터링 도구가 있더라도 테스트는 불가결하다.

자가 테스트 코드의 가치

  • 프로그래머의 시간 대부분은 디버깅에 소비된다 (버그 찾기 >> 버그 수정)
  • 자가 테스트 코드의 핵심 원리:
원칙 설명
완전 자동화 테스트는 스스로 결과를 검증해야 한다. 콘솔 출력을 눈으로 확인하는 것은 의미 없다
자주 실행 코드를 수정할 때마다 실행. 빌드할 때마다 실행
버그 탐지기 테스트를 자주 실행하면, 버그가 생긴 직후 수분 내에 포착 가능

“테스트 스위트는 강력한 버그 탐지기로, 버그를 찾는 데 걸리는 시간을 획기적으로 줄인다.”

  • TDD (Test-Driven Development): Kent Beck이 체계화한 방법론
    • 실패하는 테스트 작성 → 통과할 코드 작성 → 리팩터링
    • 이 사이클을 시간당 여러 번 반복
    • 테스트를 먼저 쓰면 구현이 아닌 인터페이스에 집중하게 된다
    • 테스트가 통과되는 순간이 명확한 완료 기준이 된다

테스트 대상 코드 (Sample Code)

Province(지역)와 Producer(생산자) 두 클래스로 구성된 생산 계획 시스템을 예시로 사용한다.

// Province 클래스
class Province {
  private _name: string;
  private _producers: Producer[] = [];
  private _totalProduction: number = 0;
  private _demand: number;
  private _price: number;

  constructor(doc: ProvinceData) {
    this._name = doc.name;
    this._demand = doc.demand;
    this._price = doc.price;
    doc.producers.forEach(d => this.addProducer(new Producer(this, d)));
  }

  addProducer(arg: Producer): void {
    this._producers.push(arg);
    this._totalProduction += arg.production;
  }

  get name(): string { return this._name; }
  get producers(): Producer[] { return this._producers.slice(); }
  get totalProduction(): number { return this._totalProduction; }
  set totalProduction(arg: number) { this._totalProduction = arg; }
  get demand(): number { return this._demand; }
  set demand(arg: number) { this._demand = Number(arg); }
  get price(): number { return this._price; }
  set price(arg: number) { this._price = Number(arg); }

  get shortfall(): number {
    return this._demand - this.totalProduction;
  }

  get profit(): number {
    return this.demandValue - this.demandCost;
  }

  private get demandCost(): number {
    let remainingDemand = this.demand;
    let result = 0;
    this.producers
      .sort((a, b) => a.cost - b.cost)
      .forEach(p => {
        const contribution = Math.min(remainingDemand, p.production);
        remainingDemand -= contribution;
        result += contribution * p.cost;
      });
    return result;
  }

  private get demandValue(): number {
    return this.satisfiedDemand * this.price;
  }

  private get satisfiedDemand(): number {
    return Math.min(this._demand, this.totalProduction);
  }
}

// 테스트용 샘플 데이터
function sampleProvinceData() {
  return {
    name: "Asia",
    producers: [
      { name: "Byzantium", cost: 10, production: 9 },
      { name: "Attalia",   cost: 12, production: 10 },
      { name: "Sinope",    cost: 10, production: 6 },
    ],
    demand: 30,
    price: 20,
  };
}

첫 번째 테스트 (A First Test)

// Mocha + Chai 기준 (TypeScript 적용)
describe('province', () => {
  it('shortfall', () => {
    const asia = new Province(sampleProvinceData());
    expect(asia.shortfall).to.equal(5);
  });
});
  • 테스트는 두 단계로 구성된다:
  1. 픽스처 설정(Setup): 테스트에 필요한 데이터와 객체 준비
  2. 검증(Verify): 특정 특성이 기대한 값인지 확인

“테스트를 작성할 때는 반드시 실패하는 상황을 확인하라. 테스트가 실제로 코드를 검증하는지 확인하기 위해 일시적으로 결함을 주입해봐라.”

// 결함 주입으로 테스트 유효성 검증
get shortfall(): number {
  return this._demand - this.totalProduction * 2; // 의도적 버그
}
// 테스트가 실패해야 정상이다
  • 피드백 신호 체계:
    • 초록 막대(Green bar): 모든 테스트 통과
    • 빨간 막대(Red bar): 테스트 실패
    • “빨간 막대에서 리팩터링하지 마라”

테스트 추가 (Add Another Test)

describe('province', () => {
  let asia: Province;

  beforeEach(() => {
    asia = new Province(sampleProvinceData()); // 매 테스트마다 새 인스턴스
  });

  it('shortfall', () => {
    expect(asia.shortfall).to.equal(5);
  });

  it('profit', () => {
    expect(asia.profit).to.equal(230);
  });
});
  • const asia = new Province(...)describe 블록 바깥으로 빼면 공유 픽스처 문제 발생
    • const는 참조만 고정하고 객체 내용은 변경 가능
    • 테스트 순서에 따라 결과가 달라지는 비결정론적(nondeterministic) 테스트 야기
  • 해결책: beforeEach로 매 테스트마다 새로운 픽스처 생성
    • 테스트 격리 보장
    • 과거에 공유 픽스처로 인한 디버깅 비용이 너무 컸기 때문에 이 패턴을 선호

“불완전한 테스트를 실행하는 것이 완전한 테스트를 실행하지 않는 것보다 낫다.”

픽스처 수정 테스트 (Modifying the Fixture)

  • 읽기뿐 아니라 데이터 갱신 시나리오도 테스트해야 한다
  • 테스트 패턴: Setup → Exercise → Verify (또는 Given → When → Then, Arrange → Act → Assert)
it('change production', () => {
  // Exercise: 픽스처를 변경
  asia.producers.production = 20;
  // Verify: 변경 결과 확인
  expect(asia.shortfall).to.equal(-6);
  expect(asia.profit).to.equal(292);
});
  • 하나의 it 블록에 verify 구문은 하나만 두는 것이 원칙
    • 첫 번째 검증 실패 시 나머지 정보가 숨겨지기 때문
    • 단, 연관성이 높으면 같이 두기도 한다

묵시적 4단계: Setup → Exercise → Verify → Teardown. beforeEach를 사용하면 테스트 프레임워크가 암묵적으로 teardown을 처리한다.

경계 조건 테스트 (Probing the Boundaries)

정상 경로(happy path)뿐 아니라 경계 조건에서 코드가 어떻게 동작하는지 테스트해야 한다.

// 빈 컬렉션
describe('no producers', () => {
  let noProducers: Province;
  beforeEach(() => {
    const data = { name: "No producers", producers: [], demand: 30, price: 20 };
    noProducers = new Province(data);
  });
  it('shortfall', () => expect(noProducers.shortfall).to.equal(30));
  it('profit',   () => expect(noProducers.profit).to.equal(0));
});

// 숫자 경계: 0과 음수
it('zero demand', () => {
  asia.demand = 0;
  expect(asia.shortfall).to.equal(-25);
  expect(asia.profit).to.equal(0);
});

it('negative demand', () => {
  asia.demand = -1;
  expect(asia.shortfall).to.equal(-26);
  expect(asia.profit).to.equal(-10);
});

// 빈 문자열 입력
it('empty string demand', () => {
  asia.demand = "" as any;
  expect(asia.shortfall).to.be.NaN;
  expect(asia.profit).to.be.NaN;
});
  • 경계 테스트를 작성하면서 도메인 규칙 질문이 떠오른다
    • “음수 수요는 유효한가? 최소값이 0이어야 하지 않나?”
    • 이런 질문 자체가 코드 설계를 발전시키는 원동력이 된다
  • 테스트 마인드셋: 자신의 코드를 공격하는 적이 되어라. 능동적으로 코드를 깨려 시도하라
  • 잘못된 입력 타입(예: producers에 배열 대신 문자열)이 들어온 경우:
    • 에러를 더 명확하게 내도록 처리할 수 있다 → Introduce Assertion
    • 리팩터링 전 테스트라면 이런 케이스는 무시해도 된다 (관찰 가능한 동작 범위 밖)

“테스트가 모든 버그를 잡을 수 없다는 두려움 때문에 테스트를 포기하지 마라. 대부분의 버그를 잡는 테스트라도 작성하라.”

테스트에 관한 핵심 원칙 정리

  • 테스트 전략:
    • 모든 public 메서드를 테스트하는 것이 목표가 아니다
    • 위험 중심(risk-driven) 으로 테스트하라: 복잡한 부분, 버그가 숨을 만한 곳에 집중
    • 단순한 getter/setter는 테스트하지 않아도 된다
  • 버그 리포트 대응:
    • 버그를 고치기 전에, 그 버그를 명확히 드러내는 테스트를 먼저 작성하라
    • 그래야 버그가 다시 살아나지 않는다
  • 테스트 커버리지:
    • 커버리지 분석은 테스트되지 않은 코드를 찾는 데만 유용하다
    • 테스트 스위트의 품질을 측정하지는 못한다
  • 좋은 테스트 스위트의 주관적 기준: “누군가 코드에 결함을 넣었을 때, 어떤 테스트가 실패할 것이라고 확신할 수 있는가?”
  • 과도한 테스트의 신호: 테스트 변경에 코드 변경보다 더 많은 시간이 들 때. 그러나 이는 과소 테스트보다 훨씬 드문 경우다
  • 테스트도 리팩터링 대상: 테스트 코드도 지속적으로 개선해야 한다. 충분히 명확한가? 올바른 것을 테스트하고 있는가?

Chapter 5: 카탈로그 소개 (Introducing the Catalog)

카탈로그의 목적

  • 리팩터링 카탈로그는 저자가 리팩터링을 안전하고 효율적으로 수행하기 위해 개인적으로 작성한 노트에서 출발
  • 리팩터링을 한동안 하지 않았을 때 다시 참고하기 위한 실용적 레퍼런스
  • “완전한 카탈로그”가 아니라, 이름 붙여 설명할 가치가 있는 가장 유용한 리팩터링들의 모음

리팩터링 항목의 구성 형식

각 리팩터링 항목은 5가지 구성요소로 이루어짐:

구성요소 설명
이름 (Name) 리팩터링의 어휘를 구축하는 핵심. 별칭(alias)도 함께 표기
개요 (Sketch) 리팩터링을 빠르게 찾기 위한 짧은 코드 변환 예시 + 간단한 다이어그램
동기 (Motivation) 이 리팩터링을 해야 하는 이유, 하지 말아야 할 상황
절차 (Mechanics) 단계별 수행 방법. 간결하고 체크리스트로 활용 가능
예시 (Examples) 리팩터링이 어떻게 작동하는지 보여주는 단순한 예제 코드

절차(Mechanics)는 “왜”에 대한 설명 없이 의도적으로 간결하게 작성됨. “어떻게”는 예시(Examples)에서 상세히 설명됨.

절차 작성 원칙

  • 각 단계를 가능한 한 작게 구성
  • 매 단계마다 테스트하는 안전한 방식 강조
  • 실전에서는 더 큰 스텝으로 진행하되, 버그 발생 시 마지막 스텝을 되돌리고 더 작게 나누어 재시도
  • 절차는 특수 케이스 참조를 포함하므로 체크리스트로도 기능함

카탈로그에 포함된 리팩터링의 선택 기준

  • 자주 사용되면서 이름 붙이고 설명할 가치가 있는 것들
  • 흥미로운 절차(mechanics)로 일반적 리팩터링 기술을 향상시키는 것
  • 코드 설계를 강력하게 개선하는 효과가 있는 것
  • 너무 단순하거나 다른 리팩터링과 유사도가 높은 것들은 제외
  • 모든 리팩터링에는 논리적으로 역방향 리팩터링이 존재하지만, 쓸 일이 거의 없는 역방향은 수록하지 않음

Chapter 6: 기초 리팩터링 (A First Set of Refactorings)

챕터 개요

가장 먼저 배워야 할 핵심 리팩터링 목록:

  • Extract Function / Inline Function (역방향 쌍)
  • Extract Variable / Inline Variable (역방향 쌍)
  • Change Function Declaration (함수명 변경, 파라미터 추가/제거)
  • Rename Variable
  • Encapsulate Variable
  • Introduce Parameter Object
  • Combine Functions into Class
  • Combine Functions into Transform
  • Split Phase

Extract Function (함수 추출)

역방향: Inline Function | 구명칭: Extract Method

동기

  • 코드 조각을 보고 “무엇을 하는지” 파악하는 데 노력이 필요하다면, 그것을 함수로 추출하고 “무엇”을 이름으로 붙여야 한다
  • 의도(intention)와 구현(implementation)의 분리가 핵심 원칙
  • 함수 길이가 아닌 의도가 드러나는 이름을 붙일 수 있느냐가 추출 기준
  • 함수가 단 한 줄이라도 이름이 목적을 더 잘 드러낸다면 추출할 가치가 있음
  • 긴 함수보다 짧은 함수들이 컴파일러 최적화 및 캐싱에도 유리

절차

  1. 새 함수를 만들고 목적(what) 기반으로 이름을 붙임 (how 기반 X)
  2. 중첩 함수를 지원하는 언어라면, 처음엔 소스 함수 안에 중첩으로 생성
  3. 추출 대상 코드를 새 함수로 복사
  4. 소스 함수의 지역 변수 중 추출된 함수 스코프 밖에 있는 것들은 매개변수로 전달
  5. 컴파일 (정적 타입 언어의 경우)
  6. 원본 코드를 새 함수 호출로 교체
  7. 테스트
  8. 중복되는 유사 코드가 있다면 Replace Inline Code with Function Call 적용 검토

주요 케이스별 처리 방법

케이스 1: 스코프 밖 변수 없음

  • 단순 cut & paste, 가장 쉬운 경우

케이스 2: 지역 변수를 읽기만 함 (재할당 없음)

  • 매개변수로 넘기면 됨
function printOwing(invoice: Invoice): void {
  let outstanding = 0;
  printBanner();
  for (const o of invoice.orders) {
    outstanding += o.amount;
  }
  recordDueDate(invoice);
  printDetails(invoice, outstanding); // 읽기 전용 지역변수를 파라미터로 전달
}

function printDetails(invoice: Invoice, outstanding: number): void {
  console.log(`name: ${invoice.customer}`);
  console.log(`amount: ${outstanding}`);
  console.log(`due: ${invoice.dueDate.toLocaleDateString()}`);
}

케이스 3: 지역 변수가 재할당됨

  • 추출된 코드를 쿼리(query) 로 처리하고 결과값을 반환
  • 반환값이 여러 개라면: 코드 분리 재검토, 또는 Split Variable / Replace Temp with Query 선행 적용
function printOwing(invoice: Invoice): void {
  printBanner();
  const outstanding = calculateOutstanding(invoice); // 반환값으로 받음
  recordDueDate(invoice);
  printDetails(invoice, outstanding);
}

function calculateOutstanding(invoice: Invoice): number {
  let result = 0;
  for (const o of invoice.orders) {
    result += o.amount;
  }
  return result;
}

팁: 다른 컨텍스트(최상위 레벨 등)로 이동할 함수를 추출할 때는, 중첩 함수로 먼저 추출하기보다 형제 레벨로 먼저 추출해서 변수 처리 문제를 즉시 확인하는 것이 낫다.

Inline Function (함수 인라인)

역방향: Extract Function | 구명칭: Inline Method

동기

  • 함수 본문이 함수 이름만큼이나 명확할 때, 불필요한 간접 호출을 제거
  • 잘못 쪼개진 함수들을 하나로 합친 뒤 다시 올바르게 추출하기 위한 중간 단계로 활용
  • 과도한 위임(delegation) 때문에 흐름 추적이 어려울 때 사용

절차

  1. 다형성 메서드인지 확인 (서브클래스 오버라이드가 있다면 인라인 불가)
  2. 모든 호출 지점을 찾음
  3. 각 호출을 함수 본문으로 교체, 교체마다 테스트
  4. 함수 정의 제거
// Before
function getRating(driver: Driver): number {
  return moreThanFiveLateDeliveries(driver) ? 2 : 1;
}
function moreThanFiveLateDeliveries(driver: Driver): boolean {
  return driver.numberOfLateDeliveries > 5;
}

// After
function getRating(driver: Driver): number {
  return driver.numberOfLateDeliveries > 5 ? 2 : 1;
}

주의: 재귀, 다중 반환 지점, 접근자 없이 다른 객체 내 메서드로 인라인하는 복잡한 경우에는 이 리팩터링을 시도하지 말 것.

Extract Variable (변수 추출)

역방향: Inline Variable | 구명칭: Introduce Explaining Variable

동기

  • 복잡한 표현식을 읽기 쉽게 분해
  • 표현식의 목적에 이름을 붙여 코드 이해도를 높임
  • 디버깅 시 중간값 캡처 포인트가 됨
  • 이름이 함수 스코프를 넘어 더 넓은 맥락에서 의미 있다면, Extract Function으로 더 넓은 범위에 공유하는 것 고려

절차

  1. 추출 표현식에 부수효과(side effect)가 없는지 확인
  2. 불변 변수로 선언하고, 추출할 표현식으로 초기화
  3. 원본 표현식을 새 변수로 교체
  4. 테스트
  5. 동일 표현식이 여러 곳에 있다면 순서대로 교체하고 매번 테스트
// Before
function price(order: Order): number {
  return order.quantity * order.itemPrice -
    Math.max(0, order.quantity - 500) * order.itemPrice * 0.05 +
    Math.min(order.quantity * order.itemPrice * 0.1, 100);
}

// After
function price(order: Order): number {
  const basePrice = order.quantity * order.itemPrice;
  const quantityDiscount = Math.max(0, order.quantity - 500) * order.itemPrice * 0.05;
  const shipping = Math.min(basePrice * 0.1, 100);
  return basePrice - quantityDiscount + shipping;
}

클래스 맥락에서의 응용: 변수 대신 게터 메서드로 추출하면 클래스 전체에서 재사용 가능

class Order {
  constructor(private _data: OrderData) {}
  get quantity()  { return this._data.quantity; }
  get itemPrice() { return this._data.itemPrice; }

  get price(): number {
    return this.basePrice - this.quantityDiscount + this.shipping;
  }
  get basePrice()        { return this.quantity * this.itemPrice; }
  get quantityDiscount() { return Math.max(0, this.quantity - 500) * this.itemPrice * 0.05; }
  get shipping()         { return Math.min(this.basePrice * 0.1, 100); }
}

Inline Variable (변수 인라인)

역방향: Extract Variable | 구명칭: Inline Temp

동기

  • 변수 이름이 표현식 자체보다 더 많은 정보를 전달하지 못할 때
  • 변수가 주변 코드의 리팩터링을 방해할 때

절차

  1. 우변(right-hand side) 표현식에 부수효과가 없는지 확인
  2. 변수가 불변이 아니라면, 불변으로 선언하고 테스트 (한 번만 할당되는지 검증)
  3. 변수의 첫 번째 참조를 우변 표현식으로 교체
  4. 테스트
  5. 남은 참조를 모두 교체
  6. 변수 선언 및 할당 제거 후 테스트
// Before
const basePrice = anOrder.basePrice;
return basePrice > 1000;

// After
return anOrder.basePrice > 1000;

Change Function Declaration (함수 선언 변경)

별칭: Rename Function, Change Signature | 구명칭: Rename Method, Add/Remove Parameter

동기

  • 함수는 시스템을 나누는 “조인트(joint)” — 이름과 파라미터가 그 품질을 결정
  • 잘못된 이름은 반드시 즉시 변경 (“나중에” 란 없음)
  • 파라미터 선택은 함수의 결합도(coupling)적용 범위(range) 를 결정
    • 예: 전화번호 포맷 함수에 customer 전체를 넘기면 company에는 재사용 불가 → phoneNumber 자체를 넘기는 것이 더 범용적
  • 올바른 파라미터 선택에 정답은 없으며 코드와 이해가 발전함에 따라 계속 변해야 함

절차 A: 단순 방식 (Simple Mechanics)

작은 범위, 호출부가 적을 때 사용

  1. 파라미터를 제거한다면, 함수 본문에서 해당 파라미터가 참조되지 않는지 확인
  2. 함수 선언을 원하는 형태로 변경
  3. 모든 참조를 새 선언에 맞게 업데이트
  4. 테스트

절차 B: 마이그레이션 방식 (Migration Mechanics)

호출부가 많거나, 다형성 메서드이거나, 변경이 복잡할 때 사용

  1. 필요하다면 함수 본문을 리팩터링하여 추출을 쉽게 준비
  2. Extract Function으로 새 함수 생성 (같은 이름을 쓸 경우 임시 이름 사용)
  3. 추가 파라미터가 필요하면 단순 방식으로 추가
  4. 테스트
  5. 기존 함수에 Inline Function 적용
  6. 임시 이름을 사용했다면 원래 이름으로 복원
  7. 테스트
// 마이그레이션 예시: 파라미터를 프로퍼티로 좁히기

// Step 1: 원본
function inNewEngland(aCustomer: Customer): boolean {
  return ["MA", "CT", "ME", "VT", "NH", "RI"].includes(aCustomer.address.state);
}

// Step 2: Extract Variable → Extract Function
function inNewEngland(aCustomer: Customer): boolean {
  const stateCode = aCustomer.address.state;
  return xxNEWinNewEngland(stateCode);
}
function xxNEWinNewEngland(stateCode: string): boolean {
  return ["MA", "CT", "ME", "VT", "NH", "RI"].includes(stateCode);
}

// Step 3: Inline 기존 함수 → 최종
function inNewEngland(stateCode: string): boolean {
  return ["MA", "CT", "ME", "VT", "NH", "RI"].includes(stateCode);
}
// 호출부
const newEnglanders = someCustomers.filter(c => inNewEngland(c.address.state));

Published API인 경우: 새 함수를 만든 뒤 구 함수를 deprecated 표시하고, 클라이언트가 마이그레이션 완료 후 제거.

Encapsulate Variable (변수 캡슐화)

구명칭: Self-Encapsulate Field, Encapsulate Field

동기

  • 함수는 이전 버전을 포워딩 함수로 남겨두며 점진적으로 이동 가능하지만, 데이터는 모든 참조를 한 번에 바꿔야
  • 데이터 접근을 함수로 라우팅하면, 데이터 재구성을 함수 재구성 문제로 전환하여 더 쉽게 처리 가능
  • 변경 감지, 유효성 검사, 부수 로직 추가 지점이 생김
  • 범위(scope)가 넓을수록 캡슐화가 더 중요
  • 불변(immutable) 데이터는 캡슐화의 중요성이 상대적으로 낮음

절차

  1. 변수에 접근/수정하는 함수(getter/setter) 생성
  2. 정적 검사 실행
  3. 변수 참조를 모두 적절한 함수 호출로 교체, 매번 테스트
  4. 변수의 가시성(visibility) 제한
  5. 테스트
  6. 변수의 값이 레코드(객체)라면 Encapsulate Record 추가 적용 검토
// Before (전역 변수)
let defaultOwnerData = { firstName: "Martin", lastName: "Fowler" };

// After (같은 파일 내에서 캡슐화, export로 접근 통제)
let defaultOwnerData = { firstName: "Martin", lastName: "Fowler" };
export function defaultOwner()             { return Object.assign({}, defaultOwnerData); }
export function setDefaultOwner(arg: typeof defaultOwnerData) { defaultOwnerData = arg; }

값(value) 내부까지 캡슐화하기

단순 참조 캡슐화 외에, 내용 변경까지 막고 싶다면:

  • 복사본 반환: getter에서 Object.assign({}, data) 또는 배열의 경우 [...data] 반환
  • 클래스 래핑: 데이터를 불변 클래스로 감싸 수정 시도 시 에러 발생
class Person {
  constructor(private data: { firstName: string; lastName: string }) {}
  get firstName() { return this.data.firstName; }
  get lastName()  { return this.data.lastName; }
  // setter 없음 → 외부에서 변경 불가
}

export function defaultOwner() { return new Person(defaultOwnerData); }

주의: 복사나 클래스 래핑은 1레벨 깊이에만 적용됨. 더 깊은 중첩 구조는 추가적인 처리 필요.

Rename Variable (변수 이름 변경)

동기

  • 명확한 프로그래밍의 핵심은 좋은 이름 짓기
  • 변수의 중요도는 사용 범위에 비례 — 람다 내 단일 문자는 괜찮지만, 영속 필드(persistent field)는 신중하게 명명
  • 동적 타입 언어(TypeScript의 any 등)에서는 파라미터 이름에 타입을 포함하는 것이 유용 (예: aCustomer)
  • 이해가 깊어지거나 프로그램 목적이 변하면 이름도 바뀌어야 함

절차

  1. 변수가 광범위하게 사용된다면 Encapsulate Variable 먼저 적용
  2. 모든 참조를 찾아 교체
  3. 테스트

점진적 이름 변경 (상수/읽기 전용 변수):

// 기존 이름을 유지하며 새 이름으로 복사 후 점진적 교체
const companyName = "Acme Gooseberries";
const cpyNm = companyName; // 기존 참조를 새 이름으로 전환하는 동안 브릿지 역할
// 모든 참조가 companyName으로 교체되면 cpyNm 제거

Introduce Parameter Object (매개변수 객체 도입)

동기

  • 여러 함수에서 함께 다니는 데이터 뭉치(data clump) 를 단일 데이터 구조로 교체
  • 장점 3가지:
  1. 데이터 항목 간의 관계를 명시적으로 표현
  2. 파라미터 목록 단축
  3. 구조를 사용하는 모든 함수에서 일관된 이름 사용
  • 진짜 힘: 새 구조를 중심으로 동작(behavior)을 이동시키고, 새 추상화를 만들어 도메인 이해를 단순화할 수 있게 됨

절차

  1. 적합한 구조(클래스 권장)가 없다면 생성. Value Object로 만드는 것이 일반적
  2. 테스트
  3. Change Function Declaration으로 새 구조를 파라미터에 추가
  4. 테스트
  5. 각 호출부에서 적절한 구조 인스턴스를 전달하도록 수정, 매번 테스트
  6. 기존 파라미터를 새 구조의 요소로 교체하고 제거, 테스트
// Before
function readingsOutsideRange(station: Station, min: number, max: number): Reading[] {
  return station.readings.filter(r => r.temp < min || r.temp > max);
}

// 새 Value Object 클래스 생성
class NumberRange {
  constructor(private _min: number, private _max: number) {}
  get min() { return this._min; }
  get max() { return this._max; }
  contains(arg: number): boolean { return arg >= this._min && arg <= this._max; }
}

// After: 파라미터 교체 및 행동까지 이동
function readingsOutsideRange(station: Station, range: NumberRange): Reading[] {
  return station.readings.filter(r => !range.contains(r.temp));
}

// 호출부
const range = new NumberRange(operatingPlan.temperatureFloor, operatingPlan.temperatureCeiling);
alerts = readingsOutsideRange(station, range);

핵심 인사이트: 클래스를 만들고 나면 관련 동작(contains 등)을 그 클래스 안으로 이동시켜 진정한 추상화로 발전시킬 수 있다.

Combine Functions into Class (함수들을 클래스로 묶기)

동기

  • 공통 데이터를 함께 조작하는 함수 그룹이 보이면, 클래스 형성 기회
  • 장점:
    • 공통 환경을 명시적으로 표현
    • 함수 호출에서 불필요한 인수 제거
    • 객체 참조를 시스템의 다른 부분에 전달 가능
    • 클라이언트가 핵심 데이터를 변경해도 파생값들이 일관성을 유지
  • 중첩 함수로 묶는 것보다 클래스가 선호됨 (테스트 용이성, 다중 노출 필요 시)

절차

  1. 공통 데이터 레코드에 Encapsulate Record 적용
    • 함수 간 공통 데이터가 구조화되지 않았다면 Introduce Parameter Object로 먼저 묶기
  2. 공통 레코드를 사용하는 각 함수를 Move Function으로 새 클래스로 이동
  3. 해당 멤버 변수를 인수로 받던 파라미터는 제거
  4. 데이터를 조작하는 로직들을 Extract Function으로 추출 후 클래스로 이동
// 원본 데이터
type ReadingData = { customer: string; quantity: number; month: number; year: number };

// 클래스로 캡슐화
class Reading {
  constructor(private _data: ReadingData) {}
  get customer() { return this._data.customer; }
  get quantity()  { return this._data.quantity; }
  get month()     { return this._data.month; }
  get year()      { return this._data.year; }

  // 관련 함수들을 클래스 메서드로 이동
  get baseCharge(): number {
    return baseRate(this.month, this.year) * this.quantity;
  }
  get taxableCharge(): number {
    return Math.max(0, this.baseCharge - taxThreshold(this.year));
  }
}

// 호출부
const rawReading = acquireReading();
const aReading = new Reading(rawReading);
const taxableCharge = aReading.taxableCharge; // 클린한 인터페이스

Uniform Access Principle: baseCharge가 계산된 값인지 저장된 필드인지 클라이언트가 알 필요가 없다. 이것이 좋은 설계다.

Combine Functions into Transform (함수들을 변환 함수로 묶기)

동기

  • 소스 데이터에서 다양한 파생값(derived value)을 계산하는 코드가 여러 곳에 중복될 때
  • 변환 함수(transform function): 소스 데이터를 입력받아 모든 파생값을 추가한 “강화된(enriched)” 레코드를 반환
  • 파생 로직을 한 곳에 모아 중복 제거, 발견과 수정이 쉬워짐

Combine Functions into Class vs. Transform

Class Transform
소스 데이터 변경 가능 적합 (파생값이 항상 최신 상태) 부적합 (저장된 파생값이 불일치 가능)
읽기 전용 데이터 적합 적합
데이터 변이 지원 쉬움 어려움

절차

  1. 변환 함수 생성 (입력 레코드를 받아 깊은 복사(deep copy) 후 반환)
    • 원본 레코드를 변경하지 않는지 테스트 추가 권장
  2. 파생 로직을 변환 함수 안으로 이동하여 새 필드 추가, 클라이언트는 새 필드를 사용하도록 수정
  3. 나머지 관련 함수들도 동일하게 처리
import _ from "lodash";

function enrichReading(original: ReadingData) {
  const result = _.cloneDeep(original);
  result.baseCharge = calculateBaseCharge(result);
  result.taxableCharge = Math.max(0, result.baseCharge - taxThreshold(result.year));
  return result;
}

// 호출부
const rawReading = acquireReading();
const aReading = enrichReading(rawReading);
const taxableCharge = aReading.taxableCharge; // 파생값을 직접 계산하지 않음

함수 이름 컨벤션: 같은 종류의 데이터에 추가 정보만 붙일 때는 enrich, 다른 형태로 바꿀 때는 transform이라는 접두어를 사용.

Split Phase (단계 쪼개기)

동기

  • 서로 다른 두 가지 일을 처리하는 코드가 있다면, 순차적인 두 단계(phase) 로 분리
  • 각 단계를 별도 모듈로 만들면 한 번에 하나의 주제만 고려하면 됨
  • 컴파일러가 대표적 예시: 토크나이징 → 파싱 → 최적화 → 코드 생성 — 각 단계는 다음 단계를 몰라도 됨
  • 서로 다른 데이터와 함수 집합을 사용하는 구간이 보이면 단계 분리의 신호

절차

  1. 2단계 코드를 별도 함수로 Extract Function
  2. 테스트
  3. 두 단계 사이의 중간 데이터 구조(intermediate data structure) 를 추출된 함수의 추가 파라미터로 도입
  4. 테스트
  5. 추출된 2단계 함수의 각 파라미터를 검토:
    • 1단계에서 생성된 것 → 중간 데이터 구조로 이동
    • 1단계에서 생성되지 않은 것 → 그대로 유지
  6. 1단계 코드를 Extract Function으로 추출하여 중간 데이터 구조를 반환
// Before
function priceOrder(
  product: Product,
  quantity: number,
  shippingMethod: ShippingMethod
): number {
  const basePrice = product.basePrice * quantity;
  const discount = Math.max(quantity - product.discountThreshold, 0)
    * product.basePrice * product.discountRate;
  const shippingPerCase = basePrice > shippingMethod.discountThreshold
    ? shippingMethod.discountedFee
    : shippingMethod.feePerCase;
  const shippingCost = quantity * shippingPerCase;
  return basePrice - discount + shippingCost;
}

// After: 두 단계로 분리
interface PricingData {
  basePrice: number;
  quantity: number;
  discount: number;
}

function priceOrder(
  product: Product,
  quantity: number,
  shippingMethod: ShippingMethod
): number {
  const priceData = calculatePricingData(product, quantity); // 1단계
  return applyShipping(priceData, shippingMethod);           // 2단계
}

// 1단계: 상품 관련 가격 계산
function calculatePricingData(product: Product, quantity: number): PricingData {
  const basePrice = product.basePrice * quantity;
  const discount = Math.max(quantity - product.discountThreshold, 0)
    * product.basePrice * product.discountRate;
  return { basePrice, quantity, discount };
}

// 2단계: 배송비 적용
function applyShipping(priceData: PricingData, shippingMethod: ShippingMethod): number {
  const shippingPerCase = priceData.basePrice > shippingMethod.discountThreshold
    ? shippingMethod.discountedFee
    : shippingMethod.feePerCase;
  const shippingCost = priceData.quantity * shippingPerCase;
  return priceData.basePrice - priceData.discount + shippingCost;
}

중간 데이터 구조(PricingData 같은)는 두 단계 사이의 계약(contract) 이 되어, 각 단계가 서로를 이해하지 않아도 협력할 수 있게 만든다.

Chapter 7: 캡슐화 (Encapsulation)

모듈 분해의 핵심 기준은 모듈이 시스템의 나머지 부분으로부터 숨겨야 할 비밀(secrets)을 식별하는 것이다. 데이터 구조가 가장 흔한 비밀이며, 클래스와 함수 모두 캡슐화의 단위가 된다.

7.1 레코드 캡슐화하기 (Encapsulate Record)

가변 데이터에는 레코드(plain object)보다 클래스가 낫다. 클래스는 저장된 값과 계산된 값을 동일한 인터페이스로 감출 수 있고, 필드 이름을 바꿀 때도 구 이름/신 이름을 동시에 제공하며 점진적으로 마이그레이션할 수 있다.

동기

  • 레코드(hashmap, plain object)는 필드가 암묵적이어서 start/end인지 start/length인지 사용처를 뒤져야 알 수 있다
  • 범위가 넓어질수록 암묵적 구조가 복잡성을 키운다
  • 불변 값이라면 레코드를 그대로 써도 무방하지만, 변경 가능한 데이터는 클래스로 감싸는 것이 안전하다

절차

  1. 레코드를 담은 변수에 Encapsulate Variable 적용, 검색하기 쉬운 임시 이름 부여
  2. 레코드를 감싸는 단순 클래스 생성, raw 레코드를 반환하는 접근자 정의
  3. 정적 분석 실행
  4. 레코드를 반환하던 함수를 객체를 반환하는 함수로 교체, 각 단계마다 테스트
  5. raw 데이터 접근자와 임시 함수 제거
  6. 필드 자체가 구조체라면 재귀적으로 Encapsulate Record / Encapsulate Collection 적용

예제: 얕은 레코드

// Before
const organization = { name: "Acme Gooseberries", country: "GB" };
// 직접 접근
result += `<h1>${organization.name}</h1>`;
organization.name = newName;

// After
class Organization {
  private _name: string;
  private _country: string;

  constructor(data: { name: string; country: string }) {
    this._name = data.name;
    this._country = data.country;
  }
  get name(): string    { return this._name; }
  set name(arg: string) { this._name = arg; }
  get country(): string    { return this._country; }
  set country(arg: string) { this._country = arg; }
}

const organization = new Organization({ name: "Acme Gooseberries", country: "GB" });
function getOrganization() { return organization; }

// 사용처
result += `<h1>${getOrganization().name}</h1>`;
getOrganization().name = newName;

핵심 인사이트: 입력 데이터를 개별 필드로 분리(위 예제처럼 this._name = data.name)하면 입력 레코드에 대한 참조가 남아 캡슐화를 깨는 상황을 예방할 수 있다.

예제: 중첩 레코드

  • 쓰기(update)를 먼저 처리하는 것이 핵심이다. 모든 업데이트를 단일 클래스 메서드로 모으면 캡슐화 효과가 극대화된다
  • 읽기(read) 처리 옵션 3가지:
    • 모든 읽기를 클래스 메서드로 추출 (가장 명시적, 코드 양 많음)
    • raw 데이터의 깊은 복사본(deep clone) 반환 (_.cloneDeep) — 단순하지만 대형 구조에서 성능 부담
    • 읽기 전용 프록시 반환 — 수정 시 예외 발생
class CustomerData {
  private _data: Record<string, any>;

  constructor(data: Record<string, any>) {
    this._data = data;
  }

  setUsage(customerID: string, year: string, month: string, amount: number) {
    this._data[customerID].usages[year][month] = amount;
  }

  usage(customerID: string, year: string, month: string): number {
    return this._data[customerID].usages[year][month];
  }

  get rawData() {
    return _.cloneDeep(this._data); // 읽기 전용 복사본 제공
  }
}

7.2 컬렉션 캡슐화하기 (Encapsulate Collection)

컬렉션 변수를 게터로 감싸도, 게터가 컬렉션 자체를 반환하면 외부에서 직접 push/splice 등으로 내용물을 바꿀 수 있다. 컬렉션의 참조가 아닌 컬렉션의 내용물까지 캡슐화해야 한다.

동기

  • aPerson.courses.push(...) 같은 코드는 Person 클래스가 전혀 개입할 수 없어 불변식(invariant)을 깬다
  • add/remove 메서드를 제공하고, 게터에서는 복사본 또는 읽기 전용 뷰를 반환한다
  • 코드베이스 전체에서 하나의 방식만 일관되게 사용해야 한다 (복사 또는 프록시, 혼용 금지)

절차

  1. 컬렉션 변수에 Encapsulate Variable 적용
  2. 클래스에 add / remove 메서드 추가
  3. 세터가 있다면 Remove Setting Method 적용 (불가능하면 복사본을 저장)
  4. 정적 분석
  5. 컬렉션을 직접 수정하는 모든 호출을 새 메서드로 교체, 각 변경 후 테스트
  6. 게터가 보호된 뷰(복사본 또는 읽기 전용 프록시) 를 반환하도록 수정

예제

class Course {
  constructor(
    private _name: string,
    private _isAdvanced: boolean
  ) {}
  get name()       { return this._name; }
  get isAdvanced() { return this._isAdvanced; }
}

class Person {
  private _name: string;
  private _courses: Course[] = [];

  constructor(name: string) { this._name = name; }

  get name() { return this._name; }

  // 복사본 반환 — 외부 수정 차단
  get courses(): Course[] { return this._courses.slice(); }

  // 세터는 복사본을 저장
  set courses(aList: Course[]) { this._courses = aList.slice(); }

  addCourse(aCourse: Course) {
    this._courses.push(aCourse);
  }

  removeCourse(
    aCourse: Course,
    fnIfAbsent: () => void = () => { throw new RangeError(); }
  ) {
    const index = this._courses.indexOf(aCourse);
    if (index === -1) fnIfAbsent();
    else this._courses.splice(index, 1);
  }
}

인사이트: JavaScript의 sort()는 원본 배열을 변경한다. 컬렉션을 불필요하게 복사하더라도 예상치 못한 수정으로 인한 디버깅 비용이 훨씬 크다. 컬렉션 관리를 담당하는 클래스는 항상 복사본을 제공하라.

7.3 기본형을 객체로 바꾸기 (Replace Primitive with Object)

초기에는 단순 문자열이나 숫자로 표현하던 데이터가, 개발이 진행되면서 포맷팅, 비교, 유효성 검사 등의 로직이 필요해진다. 이 시점에 값 클래스(value class) 로 감싸면 해당 로직의 안착점이 생기며, 코드베이스 전반의 중복을 제거할 수 있다. 경험 많은 개발자들이 가장 가치 있는 리팩터링 중 하나로 꼽는다.

절차

  1. 아직 캡슐화되지 않았다면 Encapsulate Variable 적용
  2. 기본형 값을 위한 단순 값 클래스 생성 (생성자 + 게터)
  3. 정적 분석
  4. 세터를 값 클래스 인스턴스를 생성하도록 변경, 필드 타입 변경
  5. 게터를 값 클래스의 게터를 호출하도록 변경
  6. 테스트
  7. Rename Function으로 접근자 이름을 명확히 조정
  8. Change Reference to Value 또는 Change Value to Reference 적용 검토

예제

class Priority {
  private _value: string;
  private static readonly LEGAL_VALUES = ['low', 'normal', 'high', 'rush'];

  constructor(value: string | Priority) {
    if (value instanceof Priority) { this._value = value._value; return; }
    if (!Priority.LEGAL_VALUES.includes(value))
      throw new Error(`<${value}> is invalid for Priority`);
    this._value = value;
  }

  toString(): string { return this._value; }
  private get _index() { return Priority.LEGAL_VALUES.indexOf(this._value); }

  equals(other: Priority): boolean { return this._index === other._index; }
  higherThan(other: Priority): boolean { return this._index > other._index; }
  lowerThan(other: Priority): boolean { return this._index < other._index; }
}

class Order {
  private _priority: Priority;

  constructor(data: { priority: string }) {
    this._priority = new Priority(data.priority);
  }
  get priority(): Priority         { return this._priority; }
  get priorityString(): string     { return this._priority.toString(); }
  set priority(aString: string | Priority) {
    this._priority = new Priority(aString as string);
  }
}

// 사용처 — 의미 있는 표현식으로 개선됨
const highPriorityCount = orders
  .filter(o => o.priority.higherThan(new Priority("normal")))
  .length;

7.4 임시 변수를 질의 함수로 바꾸기 (Replace Temp with Query)

임시 변수를 클래스 메서드(게터)로 추출하면, 긴 함수를 분리할 때 변수를 인자로 전달하지 않아도 되어 추출이 쉬워진다. 또한 유사한 함수들 간에 동일한 계산 로직을 공유할 수 있다. 클래스 내부에서 가장 효과적이다.

적용 조건

  • 변수가 한 번만 대입되고 이후 읽기만 사용되어야 한다
  • 계산 로직이 매번 동일한 결과를 반환해야 한다 (oldAddress 같은 스냅샷 변수에는 부적합)

절차

  1. 변수가 사용 전에 완전히 결정되는지 확인
  2. 가능하면 const로 선언, 테스트
  3. 변수의 대입문을 함수로 추출
  4. 부수효과(side effect)가 없는지 확인, 있다면 Separate Query from Modifier 적용
  5. 테스트
  6. Inline Variable로 임시 변수 제거

예제

class Order {
  constructor(
    private _quantity: number,
    private _item: { price: number }
  ) {}

  get price(): number {
    return this.basePrice * this.discountFactor;
  }

  private get basePrice(): number {
    return this._quantity * this._item.price;
  }

  private get discountFactor(): number {
    let factor = 0.98;
    if (this.basePrice > 1000) factor -= 0.03;
    return factor;
  }
}

7.5 클래스 추출하기 (Extract Class)

클래스는 처음에는 명확한 책임을 가지지만, 기능이 추가되며 점점 비대해진다. 데이터의 일부와 메서드의 일부가 함께 묶이는 패턴이 보이면 그 부분을 별도 클래스로 분리하라. “이 필드나 메서드를 제거하면 다른 것들이 무의미해지는가?” 질문이 유용한 기준이다.

절차

  1. 클래스 책임 분리 방식 결정
  2. 분리 책임을 표현할 새 자식 클래스 생성
  3. 원본 클래스 생성자에서 자식 클래스 인스턴스 생성 후 링크 추가
  4. Move Field로 이동할 필드를 하나씩 이동, 각 이동 후 테스트
  5. Move Function으로 메서드 이동, 하위 레벨 메서드(호출당하는 것)부터 시작
  6. 양쪽 클래스 인터페이스 정리, 불필요한 메서드 제거 및 이름 조정
  7. 새 클래스 공개 여부 결정, 공개 시 Change Reference to Value 적용 고려

예제

// Before: Person 클래스가 전화번호 관련 필드를 직접 소유
class Person {
  private _name: string;
  private _officeAreaCode: string;
  private _officeNumber: string;

  get telephoneNumber() { return `(${this._officeAreaCode}) ${this._officeNumber}`; }
}

// After: TelephoneNumber 클래스로 분리
class TelephoneNumber {
  private _areaCode: string;
  private _number: string;

  get areaCode()    { return this._areaCode; }
  set areaCode(arg: string) { this._areaCode = arg; }
  get number()    { return this._number; }
  set number(arg: string) { this._number = arg; }
  toString() { return `(${this._areaCode}) ${this._number}`; }
}

class Person {
  private _telephoneNumber: TelephoneNumber;

  get officeAreaCode()    { return this._telephoneNumber.areaCode; }
  set officeAreaCode(arg: string) { this._telephoneNumber.areaCode = arg; }
  get officeNumber()    { return this._telephoneNumber.number; }
  set officeNumber(arg: string) { this._telephoneNumber.number = arg; }
  get telephoneNumber() { return this._telephoneNumber.toString(); }
}

7.6 클래스 인라인하기 (Inline Class)

Extract Class의 역연산. 클래스가 더 이상 자신의 역할을 충분히 하지 못할 때(다른 리팩터링으로 책임이 거의 제거됨), 또는 두 클래스를 재배치하기 위해 일단 하나로 합친 후 다시 추출할 때 사용한다.

절차

  1. 대상 클래스(target)에 원본 클래스(source)의 모든 public 메서드를 위임 메서드로 생성
  2. 원본 클래스 메서드를 참조하는 모든 코드를 대상 클래스의 위임 메서드 호출로 변경, 각 변경 후 테스트
  3. 원본 클래스의 모든 메서드와 데이터를 대상 클래스로 이동, 각 이동 후 테스트
  4. 원본 클래스 삭제

예제

// Before: TrackingInformation이 별도 클래스로 존재
class TrackingInformation {
  get shippingCompany()    { return this._shippingCompany; }
  set shippingCompany(arg: string) { this._shippingCompany = arg; }
  get trackingNumber()    { return this._trackingNumber; }
  set trackingNumber(arg: string) { this._trackingNumber = arg; }
  get display() { return `${this.shippingCompany}: ${this.trackingNumber}`; }
}

// After: Shipment에 인라인
class Shipment {
  private _shippingCompany: string;
  private _trackingNumber: string;

  get trackingInfo() { return `${this._shippingCompany}: ${this._trackingNumber}`; }
  get shippingCompany()    { return this._shippingCompany; }
  set shippingCompany(arg: string) { this._shippingCompany = arg; }
  get trackingNumber()    { return this._trackingNumber; }
  set trackingNumber(arg: string) { this._trackingNumber = arg; }
}

7.7 위임 숨기기 (Hide Delegate)

좋은 모듈 설계의 핵심은 캡슐화다. 클라이언트가 서버 객체의 내부 위임 객체에 직접 접근하면, 위임 객체의 인터페이스가 변경될 때 그 여파가 모든 클라이언트에 전파된다. 서버에 위임 메서드를 두어 이 의존성을 차단한다.

graph LR
    A[Client] -->|직접 체이닝| B[Person]
    B --> C[Department]
    C --> D[Manager]

    E[Client] -->|숨겨진 위임 후| F[Person]
    F -->|내부적으로| G[Department]
    G --> H[Manager]

절차

  1. 위임 객체의 각 메서드에 대해 서버 객체에 단순 위임 메서드 생성
  2. 클라이언트가 서버를 통해 호출하도록 수정, 각 변경 후 테스트
  3. 더 이상 클라이언트가 위임 객체에 직접 접근하지 않으면 서버의 위임 접근자 제거

예제

// Before
const manager = aPerson.department.manager;

// After: Person에 위임 메서드 추가
class Person {
  private _department: Department;
  get manager(): Employee { return this._department.manager; }
  // department 게터를 제거하여 위임 완전히 숨김
}

const manager = aPerson.manager; // 클라이언트는 Department를 알 필요 없음

7.8 중간 단계 제거하기 (Remove Middle Man)

Hide Delegate의 역연산. 위임 메서드를 계속 추가하다 보면 서버 클래스가 단순 전달자(Middle Man)가 된다. 이 시점에는 클라이언트가 위임 객체에 직접 접근하도록 하는 것이 낫다. 얼마나 숨길지에 대한 정답은 없으며, 시스템이 변화함에 따라 적절한 균형도 달라진다.

절차

  1. 위임 객체에 대한 게터 생성
  2. 각 위임 메서드 호출을 게터를 통한 직접 체이닝으로 교체, 각 교체 후 테스트
  3. 위임 메서드를 모두 교체하면 해당 메서드 삭제
// Before
const manager = aPerson.manager; // Person이 단순 중간 전달자

// After
class Person {
  get department(): Department { return this._department; } // 게터만 제공
  // manager 위임 메서드 제거
}
const manager = aPerson.department.manager; // 직접 접근

7.9 알고리즘 교체하기 (Substitute Algorithm)

더 명확하거나, 라이브러리가 지원하거나, 변경이 쉬운 알고리즘으로 교체해야 할 때 사용한다. 알고리즘을 교체하기 전에 함수를 최대한 작게 분해해야 한다. 복잡한 알고리즘을 그대로 교체하는 것은 매우 어렵다.

절차

  1. 교체 대상 코드를 하나의 완전한 함수로 분리
  2. 해당 함수만을 대상으로 테스트 준비 (동작 포착)
  3. 새 알고리즘 준비
  4. 정적 분석
  5. 두 알고리즘의 출력 비교 테스트 — 동일하면 완료, 다르면 구 알고리즘을 기준으로 디버깅
// Before: 명시적 루프
function foundPerson(people: string[]): string {
  for (let i = 0; i < people.length; i++) {
    if (people[i] === "Don")  return "Don";
    if (people[i] === "John") return "John";
    if (people[i] === "Kent") return "Kent";
  }
  return "";
}

// After: 배열 메서드로 교체
function foundPerson(people: string[]): string {
  const candidates = ["Don", "John", "Kent"];
  return people.find(p => candidates.includes(p)) ?? "";
}

Chapter 8: 기능 이동 (Moving Features)

요소 간 이동은 리팩터링의 또 다른 핵심 축이다. 함수, 필드, 문장, 루프 등을 적절한 맥락으로 이동하면 모듈성이 높아지고 의존성이 명확해진다.

8.1 함수 이동하기 (Move Function)

좋은 소프트웨어 설계의 심장은 모듈성이다. 함수가 현재 위치보다 다른 컨텍스트의 요소를 더 많이 참조한다면, 그 컨텍스트로 이동하는 것이 자연스럽다. 함수를 어디에 둘지 결정하기 어려울수록, 어디에 두어도 크게 상관없다는 뜻이기도 하다.

이동 결정 기준

  • 현재 컨텍스트보다 다른 컨텍스트의 요소(함수, 데이터)를 더 많이 참조하는가
  • 호출자들이 어디에 있는가
  • 함수가 어떤 데이터를 사용하는가
  • 함께 이동해야 할 함수가 있는가 (클러스터 단위 이동 시 의존성이 가장 적은 것부터)

절차

  1. 현재 컨텍스트에서 사용하는 프로그램 요소 검토, 함께 이동할 요소 고려
  2. 다형성 메서드인지 확인 (객체지향 언어에서는 상위/하위 클래스 선언 고려)
  3. 함수를 대상 컨텍스트에 복사, 새 환경에 맞게 조정 (원본 컨텍스트 요소는 파라미터로 전달하거나 참조 전달)
  4. 정적 분석
  5. 원본 함수에서 대상 함수를 참조하는 방법 결정
  6. 원본 함수를 위임 함수로 전환
  7. 테스트
  8. 원본 위임 함수를 Inline Function으로 제거할지 검토

예제: 중첩 함수를 최상위로 이동

// Before: calculateDistance가 trackSummary 내부에 중첩
function trackSummary(points: Point[]) {
  const totalTime = calculateTime();
  const totalDistance = calculateDistance();
  const pace = totalTime / 60 / totalDistance;
  return { time: totalTime, distance: totalDistance, pace };

  function calculateDistance(): number {
    let result = 0;
    for (let i = 1; i < points.length; i++) {
      result += distance(points[i - 1], points[i]);
    }
    return result;
  }
  function distance(p1: Point, p2: Point): number { /* haversine */ }
  function radians(degrees: number): number { return degrees * Math.PI / 180; }
  function calculateTime(): number { /* ... */ }
}

// After: 최상위로 추출 (다른 곳에서도 재사용 가능)
function trackSummary(points: Point[]) {
  const totalTime = calculateTime();
  const pace = totalTime / 60 / totalDistance(points);
  return { time: totalTime, distance: totalDistance(points), pace };
}

function totalDistance(points: Point[]): number {
  let result = 0;
  for (let i = 1; i < points.length; i++) {
    result += distance(points[i - 1], points[i]);
  }
  return result;
}
function distance(p1: Point, p2: Point): number { /* haversine */ }
function radians(degrees: number): number { return degrees * Math.PI / 180; }

인사이트: 중첩 함수는 숨겨진 데이터 관계를 만들어 추적하기 어렵게 한다. JavaScript/TypeScript는 모듈 시스템으로 가시성을 제어할 수 있으므로, 중첩보다 최상위 + 모듈 경계를 선호하라.

예제: 클래스 간 이동

// Before: overdraftCharge가 Account에 있어 AccountType별 다형성 처리 어려움
class Account {
  get bankCharge(): number {
    let result = 4.5;
    if (this._daysOverdrawn > 0) result += this.overdraftCharge;
    return result;
  }
  get overdraftCharge(): number {
    if (this.type.isPremium) {
      const baseCharge = 10;
      return this.daysOverdrawn <= 7
        ? baseCharge
        : baseCharge + (this.daysOverdrawn - 7) * 0.85;
    } else {
      return this.daysOverdrawn * 1.75;
    }
  }
}

// After: AccountType으로 이동, 위임 메서드만 남김
class AccountType {
  overdraftCharge(account: Account): number {
    if (this.isPremium) {
      const baseCharge = 10;
      return account.daysOverdrawn <= 7
        ? baseCharge
        : baseCharge + (account.daysOverdrawn - 7) * 0.85;
    } else {
      return account.daysOverdrawn * 1.75;
    }
  }
}

class Account {
  get bankCharge(): number {
    let result = 4.5;
    if (this._daysOverdrawn > 0)
      result += this.type.overdraftCharge(this);
    return result;
  }
}

8.2 필드 이동하기 (Move Field)

프로그램의 진정한 강점은 데이터 구조에서 나온다. 올바른 데이터 구조가 있으면 동작 코드는 단순해진다. 반대로 잘못된 데이터 구조는 데이터를 처리하기 위한 코드만 늘어난다. 데이터 구조의 결함을 발견하는 즉시 수정하라.

이동 결정 기준

  • 함수에 레코드 A를 전달할 때마다 항상 레코드 B도 함께 전달해야 하는가 → 두 필드를 같은 레코드로 이동
  • 한 레코드의 변경이 다른 레코드의 변경을 유발하는가
  • 여러 구조에서 동일 필드를 업데이트해야 하는가

절차

  1. 소스 필드 캡슐화 (Encapsulate Variable)
  2. 테스트
  3. 대상 객체에 필드와 접근자 생성
  4. 정적 분석
  5. 소스 객체에서 대상 객체로의 참조 확인 (없으면 새 필드나 메서드로 추가)
  6. 소스 접근자가 대상 필드를 사용하도록 조정
  7. 테스트
  8. 소스 필드 제거

예제

// Before: discountRate가 Customer에 있음
class Customer {
  private _discountRate: number;
  private _contract: CustomerContract;

  constructor(name: string, discountRate: number) {
    this._name = name;
    this._discountRate = discountRate;
    this._contract = new CustomerContract(dateToday());
  }
  get discountRate() { return this._discountRate; }
}

// After: discountRate를 CustomerContract로 이동
class CustomerContract {
  private _discountRate: number;
  constructor(startDate: Date, discountRate: number) {
    this._startDate = startDate;
    this._discountRate = discountRate;
  }
  get discountRate()    { return this._discountRate; }
  set discountRate(arg: number) { this._discountRate = arg; }
}

class Customer {
  private _contract: CustomerContract;

  constructor(name: string, discountRate: number) {
    this._name = name;
    this._contract = new CustomerContract(dateToday(), discountRate);
  }
  get discountRate() { return this._contract.discountRate; }
  private setDiscountRate(aNumber: number) { this._contract.discountRate = aNumber; }
}

공유 객체로 이동 시 주의: 여러 Account가 동일 AccountType의 interestRate를 공유하게 되면, 기존에 각 Account가 서로 다른 이자율을 가졌을 경우 행동 변화가 발생한다. assert로 일관성을 검증하거나 DB를 직접 확인한 후 이동하라.

8.3 문장을 함수로 이동하기 (Move Statements into Function)

특정 함수를 호출할 때마다 그 전후에 반복되는 코드가 있다면, 그 코드를 함수 내부로 통합하라. 중복 제거는 건강한 코드의 핵심 규칙이다. 나중에 호출자별로 다르게 동작해야 할 필요가 생기면 Move Statements to Callers로 다시 꺼낼 수 있다.

절차

  1. 반복 코드가 대상 함수 호출 옆에 없다면 Slide Statements로 인접시킴
  2. 호출자가 하나뿐이라면 바로 잘라넣고 테스트
  3. 호출자가 여럿이라면, 한 호출처에서 Extract Function으로 대상 함수 호출 + 이동할 문장을 묶어 임시 이름 부여
  4. 나머지 호출처를 모두 새 함수로 교체, 각 변경 후 테스트
  5. 원본 함수를 Inline Function으로 새 함수에 인라인
  6. Rename Function으로 원래 이름 복원

예제

// Before: title 출력이 각 호출처에 중복
function renderPerson(outStream: Stream, person: Person) {
  result.push(`<p>${person.name}</p>`);
  result.push(renderPhoto(person.photo));
  result.push(`<p>title: ${person.photo.title}</p>`); // 중복
  result.push(emitPhotoData(person.photo));
}

function photoDiv(p: Photo) {
  return ["<div>", `<p>title: ${p.title}</p>`, emitPhotoData(p), "</div>"].join("\n");
  //             ^^ 중복
}

// After: title 출력을 emitPhotoData 내부로 이동
function emitPhotoData(aPhoto: Photo): string {
  return [
    `<p>title: ${aPhoto.title}</p>`,
    `<p>location: ${aPhoto.location}</p>`,
    `<p>date: ${aPhoto.date.toDateString()}</p>`,
  ].join("\n");
}

8.4 문장을 호출자로 이동하기 (Move Statements to Callers)

Move Statements into Function의 역연산. 함수의 특정 동작이 일부 호출자에서는 다르게 동작해야 할 때, 변하는 부분을 함수 밖으로 꺼낸다. 경계가 크게 달라져야 한다면, Inline Function 후 재추출하는 것이 더 낫다.

절차

  1. 호출자가 한두 개이고 함수가 단순하면, 해당 줄을 잘라 호출자에 붙여넣고 테스트
  2. 복잡한 경우, 남길 코드에 Extract Function 적용, 임시 이름 부여
  3. 원본 함수에 Inline Function 적용 (한 번에 한 호출처씩)
  4. 추출된 함수에 Rename Function 적용

예제

// Before: emitPhotoData의 location 줄이 listRecentPhotos에서는 다르게 출력 필요
function emitPhotoData(outStream: Stream, photo: Photo) {
  outStream.write(`<p>title: ${photo.title}</p>\n`);
  outStream.write(`<p>date: ${photo.date.toDateString()}</p>\n`);
  outStream.write(`<p>location: ${photo.location}</p>\n`); // ← 이것만 달라짐
}

// After: location 줄을 호출자로 이동
function emitPhotoData(outStream: Stream, photo: Photo) {
  outStream.write(`<p>title: ${photo.title}</p>\n`);
  outStream.write(`<p>date: ${photo.date.toDateString()}</p>\n`);
}

function renderPerson(outStream: Stream, person: Person) {
  outStream.write(`<p>${person.name}</p>\n`);
  renderPhoto(outStream, person.photo);
  emitPhotoData(outStream, person.photo);
  outStream.write(`<p>location: ${person.photo.location}</p>\n`); // ← 이동됨
}

function listRecentPhotos(outStream: Stream, photos: Photo[]) {
  photos
    .filter(p => p.date > recentDateCutoff())
    .forEach(p => {
      outStream.write("<div>\n");
      emitPhotoData(outStream, p);
      outStream.write(`<p>location: ${p.location}</p>\n`); // ← 다르게 처리 가능
      outStream.write("</div>\n");
    });
}

8.5 인라인 코드를 함수 호출로 바꾸기 (Replace Inline Code with Function Call)

이미 존재하는 함수와 동일한 동작을 하는 인라인 코드가 있다면 함수 호출로 교체한다. 중복을 제거하여 라이브러리나 공유 함수의 변경이 자동으로 반영되도록 한다.

// Before
let appliesToMass = false;
for (const s of states) {
  if (s === "MA") appliesToMass = true;
}

// After
const appliesToMass = states.includes("MA");

8.6 문장 슬라이드하기 (Slide Statements)

관련 코드는 모아두어라. 함께 이해해야 하는 코드를 서로 가까이 두면 코드를 이해하기 위해 스크롤을 왔다갔다 할 필요가 없다. 변수 선언은 사용처 바로 위에, 관련 코드 블록은 함께 배치한다.

절차

  1. 코드를 이동할 목표 위치 파악
  2. 이동 경로 사이의 코드를 확인, 이동하는 코드와 간섭이 없는지 검토
    • 이동하는 코드가 참조하는 요소를 사이 코드가 수정하지 않아야 한다
    • 이동하는 코드가 수정하는 요소를 사이 코드가 참조하지 않아야 한다
    • 사이 코드가 수정하는 요소를 이동하는 코드가 수정하지 않아야 한다
  3. 이동 후 테스트

8.7 반복문 쪼개기 (Split Loop)

하나의 루프가 두 가지 이상의 일을 하면, 루프를 수정할 때마다 두 가지 모두를 이해해야 한다. 루프를 쪼개면 각 루프가 단 하나의 일만 하게 된다. 성능이 걱정된다면 먼저 분리하고, 실제 문제가 생길 때 프로파일링으로 판단하라.

절차

  1. 루프를 복제
  2. 중복으로 인한 부수효과를 제거 (결과에 영향을 주는 코드 중 하나를 제거)
  3. 테스트
  4. 각 루프를 Extract Function으로 추출 검토

예제

// Before: 최연소 나이와 총 급여를 하나의 루프에서 계산
function processPeople(people: Person[]) {
  let youngest = people ? people.age : Infinity;
  let totalSalary = 0;
  for (const p of people) {
    if (p.age < youngest) youngest = p.age;
    totalSalary += p.salary;
  }
  return `youngestAge: ${youngest}, totalSalary: ${totalSalary}`;
}

// After: 루프 분리 후 함수 추출, 알고리즘 교체
function processPeople(people: Person[]) {
  return `youngestAge: ${youngestAge(people)}, totalSalary: ${totalSalary(people)}`;
}

function totalSalary(people: Person[]): number {
  return people.reduce((total, p) => total + p.salary, 0);
}

function youngestAge(people: Person[]): number {
  return Math.min(...people.map(p => p.age));
}

8.8 반복문을 파이프라인으로 바꾸기 (Replace Loop with Pipeline)

컬렉션 파이프라인(map, filter, reduce)은 처리 과정을 위에서 아래로 흐르는 일련의 연산으로 표현한다. 데이터가 파이프라인을 흐르는 모습으로 읽혀 이해하기 훨씬 쉽다.

절차

  1. 루프가 반복하는 컬렉션을 새 변수로 분리
  2. 루프의 각 동작을 순서대로 파이프라인 연산으로 교체, 각 교체 후 테스트
  3. 루프가 비면 제거, 축적 변수가 있다면 파이프라인 결과를 대입

예제

// Before: 루프를 이용한 데이터 가공
function acquireData(input: string) {
  const lines = input.split("\n");
  let firstLine = true;
  const result: { city: string; phone: string }[] = [];
  for (const line of lines) {
    if (firstLine) { firstLine = false; continue; }
    if (line.trim() === "") continue;
    const record = line.split(",");
    if (record.trim() === "India") {
      result.push({ city: record.trim(), phone: record.trim() });
    }
  }
  return result;
}

// After: 컬렉션 파이프라인
function acquireData(input: string) {
  const lines = input.split("\n");
  return lines
    .slice(1)
    .filter(line   => line.trim() !== "")
    .map   (line   => line.split(","))
    .filter(fields => fields.trim() === "India")
    .map   (fields => ({ city: fields.trim(), phone: fields.trim() }));
}

인사이트: 파이프라인으로 바꾸면 firstLine 같은 제어 변수가 자연스럽게 사라진다. 제어 변수를 삭제하는 것은 그 자체로 큰 가독성 향상이다.

8.9 죽은 코드 제거하기 (Remove Dead Code)

사용되지 않는 코드는 실행에 영향을 미치지 않지만, 코드를 이해하는 데 드는 비용을 증가시킨다. 읽는 사람은 그 코드가 왜 있는지, 왜 수정해도 결과가 안 바뀌는지 고민하게 된다. 버전 관리 시스템이 있으므로 다시 필요해지면 되살릴 수 있다. 주저 없이 삭제하라.

절차

  1. 죽은 코드가 외부에서 참조될 수 있으면(예: public 함수) 호출처 검색
  2. 죽은 코드 삭제
  3. 테스트
// Before
if (false) {
  doSomethingThatUsedToMatter();
}

// After: 그냥 삭제. 버전 관리 시스템이 역사를 보존한다.

Chapter 9: Organizing Data (데이터 조직화)

데이터 구조는 프로그램의 핵심이다. 잘못 조직된 데이터는 혼란과 버그의 온상이 된다. 이 챕터는 변수 분리, 이름 변경, 파생 변수 제거, 참조/값 변환 등을 다룬다.

Split Variable (변수 쪼개기)

동기

  • 변수는 하나의 책임만 가져야 한다
  • 루프 변수(i)나 수집 변수(collecting variable)는 여러 번 대입이 정상이지만, 그 외의 변수가 두 번 이상 대입된다면 두 가지 역할을 하고 있다는 신호
  • 하나의 변수가 두 가지 역할을 하면 읽는 사람에게 혼란을 준다
  • 역할마다 별도 변수로 분리해야 한다

절차

  1. 변수 선언 및 첫 번째 대입 지점에서 변수 이름을 변경한다
  2. 가능하면 새 변수를 const로 선언해 불변으로 만든다
  3. 두 번째 대입 지점 이전까지 해당 변수의 모든 참조를 새 이름으로 변경한다
  4. 두 번째 대입 지점에서 원래 변수 이름을 다시 선언한다
  5. 테스트한다
  6. 다음 대입 지점이 있을 때까지 반복한다

예시: 두 가지 역할을 하는 변수

Before

function distanceTravelled(scenario: Scenario, time: number): number {
  let result: number;
  let acc = scenario.primaryForce / scenario.mass; // 첫 번째 역할
  const primaryTime = Math.min(time, scenario.delay);
  result = 0.5 * acc * primaryTime * primaryTime;
  const secondaryTime = time - scenario.delay;
  if (secondaryTime > 0) {
    const primaryVelocity = acc * scenario.delay;
    acc = (scenario.primaryForce + scenario.secondaryForce) / scenario.mass; // 두 번째 역할
    result += primaryVelocity * secondaryTime + 0.5 * acc * secondaryTime * secondaryTime;
  }
  return result;
}

After

function distanceTravelled(scenario: Scenario, time: number): number {
  let result: number;
  const primaryAcceleration = scenario.primaryForce / scenario.mass;
  const primaryTime = Math.min(time, scenario.delay);
  result = 0.5 * primaryAcceleration * primaryTime * primaryTime;
  const secondaryTime = time - scenario.delay;
  if (secondaryTime > 0) {
    const primaryVelocity = primaryAcceleration * scenario.delay;
    const secondaryAcceleration = (scenario.primaryForce + scenario.secondaryForce) / scenario.mass;
    result += primaryVelocity * secondaryTime + 0.5 * secondaryAcceleration * secondaryTime * secondaryTime;
  }
  return result;
}
  • acc 하나가 primaryAcceleration, secondaryAcceleration 두 개의 명확한 변수로 분리됨

예시: 입력 매개변수에 대입

Before

function discount(inputValue: number, quantity: number): number {
  if (inputValue > 50) inputValue = inputValue - 2;
  if (quantity > 100) inputValue = inputValue - 1;
  return inputValue;
}
  • inputValue가 입력값 역할과 결과값 역할을 동시에 수행하고 있음

After

function discount(inputValue: number, quantity: number): number {
  let result = inputValue;
  if (inputValue > 50) result = result - 2;
  if (quantity > 100) result = result - 1;
  return result;
}
  • 원본 inputValue는 불변 입력으로 유지, result가 수정 역할을 전담

Rename Field (필드 이름 바꾸기)

동기

  • 데이터 구조의 필드 이름은 프로그램 전체에서 매우 중요한 의미를 가진다
  • Fred Brooks의 말: “플로우차트가 아닌 테이블(자료구조)을 보여주면 알고리즘은 명백해진다”
  • 소프트웨어를 더 이해할수록 더 나은 이름을 발견하게 되며, 그 개선된 이해를 코드에 반영해야 한다
  • 클래스의 getter/setter도 레코드 필드와 마찬가지로 중요하게 이름을 관리해야 한다

절차

  1. 레코드 범위가 제한적이라면 모든 참조를 직접 바꾸고 테스트한다 (이하 불필요)
  2. 아직 캡슐화되지 않았다면 Encapsulate Record(162)를 먼저 적용한다
  3. 클래스 내부의 private 필드 이름을 변경하고, 내부 메서드를 맞춰 수정한다
  4. 생성자가 해당 이름을 사용하면 Change Function Declaration(124) 적용
  5. getter/setter에 Rename Function(124) 적용

예시

Before

const organization = { name: "Acme Gooseberries", country: "GB" };

과도기 단계 (점진적으로 이름 변경)

class Organization {
  private _title: string;
  private _country: string;

  constructor(data: { title?: string; name?: string; country: string }) {
    this._title = data.title !== undefined ? data.title : data.name!;
    this._country = data.country;
  }

  get name(): string { return this._title; }
  set name(value: string) { this._title = value; }
}

After (리팩토링 완료)

class Organization {
  private _title: string;
  private _country: string;

  constructor(data: { title: string; country: string }) {
    this._title = data.title;
    this._country = data.country;
  }

  get title(): string { return this._title; }
  set title(value: string) { this._title = value; }
  get country(): string { return this._country; }
  set country(value: string) { this._country = value; }
}

핵심 인사이트: 불변(immutable) 데이터 구조는 이름 변경이 더 쉽다. 새 이름으로 복사하고, 사용자를 점진적으로 이전한 뒤 기존 이름을 제거하면 된다. 가변 데이터에서 중복은 재앙의 씨앗이다.

Replace Derived Variable with Query (파생 변수를 질의 함수로 바꾸기)

동기

  • 가변 데이터(mutable data)는 소프트웨어 문제의 가장 큰 원인 중 하나다
  • 어떤 변수가 다른 데이터로부터 계산 가능하다면, 그 변수를 유지하는 것은 불필요한 위험이다
  • 계산식(query)으로 대체하면:
    • 데이터의 의미가 더 명확해진다
    • 원본 데이터가 변경될 때 파생 변수가 갱신되지 않아 발생하는 버그를 방지할 수 있다
  • 예외: 원본 데이터가 불변(immutable)이고 파생 데이터도 불변으로 강제할 수 있다면 유지해도 좋다

절차

  1. 변수에 대입하는 모든 지점을 찾는다. 필요하면 Split Variable(240) 적용
  2. 해당 변수의 값을 계산하는 함수를 만든다
  3. Introduce Assertion(302)으로 변수 값과 계산 결과가 동일한지 검증한다
  4. 테스트한다
  5. 변수를 읽는 코드를 새 함수 호출로 교체한다
  6. 테스트한다
  7. Remove Dead Code(237)로 변수 선언 및 갱신 코드를 제거한다

예시: 단일 소스

Before

class ProductionPlan {
  private _production: number = 0;
  private _adjustments: Adjustment[] = [];

  get production(): number { return this._production; }

  applyAdjustment(anAdjustment: Adjustment): void {
    this._adjustments.push(anAdjustment);
    this._production += anAdjustment.amount; // 파생 변수를 수동으로 갱신
  }
}

After

class ProductionPlan {
  private _adjustments: Adjustment[] = [];

  get production(): number {
    return this._adjustments.reduce((sum, a) => sum + a.amount, 0);
  }

  applyAdjustment(anAdjustment: Adjustment): void {
    this._adjustments.push(anAdjustment);
  }
}

예시: 소스가 여러 개인 경우

  • 초기값과 누적값이 분리되어 있다면, Split Variable로 먼저 분리 후 동일하게 적용
class ProductionPlan {
  private _initialProduction: number;
  private _adjustments: Adjustment[] = [];

  constructor(production: number) {
    this._initialProduction = production;
  }

  get production(): number {
    return this._initialProduction + this._adjustments.reduce((sum, a) => sum + a.amount, 0);
  }
}

Change Reference to Value (참조를 값으로 바꾸기)

역방향: Change Value to Reference (256)

동기

  • 내부 객체를 참조(Reference)로 다루면: 내부 객체는 그대로 유지하고 프로퍼티만 업데이트
  • 내부 객체를 값(Value)으로 다루면: 원하는 값을 가진 새 객체로 통째로 교체
  • 값 객체(Value Object)는 불변이어서 추론하기 쉽다
  • 분산 시스템, 동시성 환경에서 특히 유용하다
  • 단, 공유 객체가 여러 곳에서 참조되고 변경사항이 모두에게 반영되어야 한다면 참조를 유지해야 한다

절차

  1. 후보 클래스가 불변인지, 혹은 불변으로 만들 수 있는지 확인한다
  2. 각 setter에 Remove Setting Method(331) 적용
  3. 필드 값을 기반으로 하는 동치 비교 메서드를 제공한다 (값 기반 동등성)

예시

Before (참조로 다루는 방식)

class TelephoneNumber {
  private _areaCode: string = "";
  private _number: string = "";

  get areaCode(): string { return this._areaCode; }
  set areaCode(arg: string) { this._areaCode = arg; }
  get number(): string { return this._number; }
  set number(arg: string) { this._number = arg; }
}

class Person {
  private _telephoneNumber: TelephoneNumber = new TelephoneNumber();

  get officeAreaCode(): string { return this._telephoneNumber.areaCode; }
  set officeAreaCode(arg: string) { this._telephoneNumber.areaCode = arg; } // 내부 객체 직접 수정
}

After (값으로 다루는 방식)

class TelephoneNumber {
  constructor(
    private readonly _areaCode: string,
    private readonly _number: string
  ) {}

  get areaCode(): string { return this._areaCode; }
  get number(): string { return this._number; }

  equals(other: TelephoneNumber): boolean {
    if (!(other instanceof TelephoneNumber)) return false;
    return this.areaCode === other.areaCode && this.number === other.number;
  }
}

class Person {
  private _telephoneNumber: TelephoneNumber = new TelephoneNumber("", "");

  get officeAreaCode(): string { return this._telephoneNumber.areaCode; }
  set officeAreaCode(arg: string) {
    // 새 객체로 교체
    this._telephoneNumber = new TelephoneNumber(arg, this.officeNumber);
  }
  get officeNumber(): string { return this._telephoneNumber.number; }
  set officeNumber(arg: string) {
    this._telephoneNumber = new TelephoneNumber(this.officeAreaCode, arg);
  }
}

Change Value to Reference (값을 참조로 바꾸기)

역방향: Change Reference to Value (252)

동기

  • 같은 논리적 데이터가 여러 레코드에 복사되어 있을 때 문제가 생긴다
  • 공유 데이터를 업데이트해야 할 때 모든 복사본을 찾아야 하고, 하나라도 놓치면 데이터 불일치가 발생한다
  • 하나의 엔티티 객체를 Repository에서 관리하고 공유 참조로 사용하면 이 문제를 해결할 수 있다

절차

  1. 관련 객체를 위한 Repository를 생성한다 (이미 있다면 활용)
  2. 생성자에서 올바른 인스턴스를 조회하는 방법을 확보한다
  3. 호스트 객체의 생성자를 Repository에서 객체를 가져오도록 변경한다
  4. 변경할 때마다 테스트한다

예시

Before (값으로 다루는 방식 - 복사본 다수 생성)

class Order {
  private _customer: Customer;

  constructor(data: OrderData) {
    this._number = data.number;
    this._customer = new Customer(data.customer); // 매번 새 객체 생성 -> 5개 주문 = 5개의 Customer
  }
}

After (참조로 다루는 방식 - Repository 패턴 사용)

// customerRepository.ts
const repositoryData: { customers: Map<string, Customer> } = {
  customers: new Map(),
};

export function registerCustomer(id: string): Customer {
  if (!repositoryData.customers.has(id)) {
    repositoryData.customers.set(id, new Customer(id));
  }
  return findCustomer(id)!;
}

export function findCustomer(id: string): Customer | undefined {
  return repositoryData.customers.get(id);
}

// Order.ts
class Order {
  private _customer: Customer;

  constructor(data: OrderData) {
    this._number = data.number;
    this._customer = registerCustomer(data.customer); // Repository에서 가져옴 -> 동일 ID = 동일 객체
  }
}

핵심 인사이트: 이 방식으로 특정 고객의 데이터를 변경하면, 해당 고객을 참조하는 모든 주문에 즉시 반영된다. 전역 Repository는 강력하지만 오남용에 주의해야 하며, 필요하다면 생성자 매개변수로 주입하는 것이 더 낫다.

Chapter 10: Simplifying Conditional Logic (조건부 로직 간소화)

프로그램의 힘은 조건 로직에서 나오지만, 복잡성도 그곳에서 나온다. 이 챕터는 조건을 분해하고, 통합하고, 다형성으로 대체하는 다양한 기법을 다룬다.

Decompose Conditional (조건문 분해하기)

동기

  • 복잡한 조건문은 코드가 길어지고 “무슨 일이 일어나는지”는 보이지만 “왜 일어나는지”는 숨겨진다
  • 조건 검사 코드와 각 분기 코드를 의도를 담은 이름의 함수로 추출하면 의도가 명확하게 드러난다
  • 이는 본질적으로 Extract Function(106)의 특수한 적용 사례다

절차

  1. 조건 부분에 Extract Function(106) 적용
  2. then 분기 코드에 Extract Function 적용
  3. else 분기 코드에 Extract Function 적용

예시

Before

if (!aDate.isBefore(plan.summerStart) && !aDate.isAfter(plan.summerEnd)) {
  charge = quantity * plan.summerRate;
} else {
  charge = quantity * plan.regularRate + plan.regularServiceCharge;
}

After

charge = summer() ? summerCharge() : regularCharge();

function summer(): boolean {
  return !aDate.isBefore(plan.summerStart) && !aDate.isAfter(plan.summerEnd);
}

function summerCharge(): number {
  return quantity * plan.summerRate;
}

function regularCharge(): number {
  return quantity * plan.regularRate + plan.regularServiceCharge;
}

Consolidate Conditional Expression (조건식 통합하기)

동기

  • 서로 다른 조건 검사지만 결과가 같은 여러 조건문이 나란히 있다면, 하나의 조건으로 통합해야 한다
  • 통합이 중요한 이유:
    • 실제로 하나의 검사임을 명확히 한다 (우연히 붙어 있는 게 아님을 독자에게 알림)
    • 통합 후 Extract Function을 적용하면 “무엇을 하는가”가 아닌 “왜 하는가”를 표현하는 이름을 붙일 수 있다
  • 단, 독립적인 검사들이 맞다면 통합하지 말 것

절차

  1. 조건문에 부수효과(side effect)가 없는지 확인한다. 있다면 Separate Query from Modifier(306) 먼저 적용
  2. 두 조건문을 논리 연산자로 결합한다 (순차 조건 → ||, 중첩 if → &&)
  3. 테스트한다
  4. 모든 조건이 통합될 때까지 반복한다
  5. Extract Function(106)으로 통합된 조건에 의미 있는 이름을 붙인다

예시: || 통합

Before

function disabilityAmount(anEmployee: Employee): number {
  if (anEmployee.seniority < 2) return 0;
  if (anEmployee.monthsDisabled > 12) return 0;
  if (anEmployee.isPartTime) return 0;
  // 장애 금액 계산
}

After

function disabilityAmount(anEmployee: Employee): number {
  if (isNotEligibleForDisability(anEmployee)) return 0;
  // 장애 금액 계산
}

function isNotEligibleForDisability(anEmployee: Employee): boolean {
  return (
    anEmployee.seniority < 2 ||
    anEmployee.monthsDisabled > 12 ||
    anEmployee.isPartTime
  );
}

예시: && 통합

Before

if (anEmployee.onVacation)
  if (anEmployee.seniority > 10)
    return 1;
return 0.5;

After

if (anEmployee.onVacation && anEmployee.seniority > 10) return 1;
return 0.5;

Replace Nested Conditional with Guard Clauses (중첩 조건문을 보호 구문으로 바꾸기)

동기

  • 조건문에는 두 가지 스타일이 있다:
    • if-else: 두 경로 모두 정상적인 동작일 때. 양쪽에 동등한 비중을 줌
    • 보호 구문(Guard Clause): 한쪽이 비정상 케이스일 때. “이건 핵심이 아니니 처리하고 빠져나가라”는 의미
  • 보호 구문의 핵심은 강조다. 이 경우가 함수의 핵심 흐름이 아님을 명확히 한다
  • “함수는 하나의 반환 지점만 가져야 한다”는 규칙은 유용하지 않다. 명확성이 기준이다

절차

  1. 가장 바깥 조건을 보호 구문으로 교체한다
  2. 테스트한다
  3. 필요한 만큼 반복한다
  4. 모든 보호 구문이 같은 결과를 반환한다면 Consolidate Conditional Expression(263) 적용

예시: 정방향

Before

function payAmount(employee: Employee): PayResult {
  let result: PayResult;
  if (employee.isSeparated) {
    result = { amount: 0, reasonCode: "SEP" };
  } else {
    if (employee.isRetired) {
      result = { amount: 0, reasonCode: "RET" };
    } else {
      // 핵심 로직
      result = someFinalComputation();
    }
  }
  return result;
}

After

function payAmount(employee: Employee): PayResult {
  if (employee.isSeparated) return { amount: 0, reasonCode: "SEP" };
  if (employee.isRetired) return { amount: 0, reasonCode: "RET" };
  return someFinalComputation();
}

예시: 조건 반전 (Reversing the Conditions)

Before

function adjustedCapital(anInstrument: Instrument): number {
  let result = 0;
  if (anInstrument.capital > 0) {
    if (anInstrument.interestRate > 0 && anInstrument.duration > 0) {
      result = (anInstrument.income / anInstrument.duration) * anInstrument.adjustmentFactor;
    }
  }
  return result;
}

After (조건을 반전시켜 보호 구문으로 + Consolidate Conditional Expression 적용)

function adjustedCapital(anInstrument: Instrument): number {
  if (
    anInstrument.capital <= 0 ||
    anInstrument.interestRate <= 0 ||
    anInstrument.duration <= 0
  ) return 0;

  return (anInstrument.income / anInstrument.duration) * anInstrument.adjustmentFactor;
}

Replace Conditional with Polymorphism (조건부 로직을 다형성으로 바꾸기)

동기

  • switch/caseif/else가 타입 코드에 따라 분기될 때, 여러 함수에 동일한 switch 패턴이 반복된다면 다형성을 사용할 때다
  • 다형성을 쓰는 두 가지 상황:
    • 타입 계층 구조: 각 타입이 독립적인 동작을 가질 때. 타입별로 서브클래스를 만들고 메서드를 오버라이드
    • 기본 동작 + 변형: 대부분이 공통이고 일부만 다를 때. 기본 동작을 슈퍼클래스에, 변형을 서브클래스에
  • 모든 조건 로직을 다형성으로 바꿔야 한다는 주장에는 동의하지 않는다. 대부분의 단순한 조건문은 그대로 두는 게 낫다

절차

  1. 다형적 동작을 위한 클래스가 없다면 팩토리 함수와 함께 생성한다
  2. 호출 코드에서 팩토리 함수를 사용한다
  3. 조건 함수를 슈퍼클래스로 이동한다 (독립 함수가 아니면 Extract Function 먼저 적용)
  4. 서브클래스 하나를 선택해 해당 case 내용을 오버라이드 메서드로 복사한다
  5. 모든 case에 대해 반복한다
  6. 슈퍼클래스 메서드는 기본 케이스로 남기거나, 추상 메서드 또는 에러 throw로 만든다

예시 1: 타입 계층 구조

Before

function plumage(bird: Bird): string {
  switch (bird.type) {
    case "EuropeanSwallow": return "average";
    case "AfricanSwallow": return bird.numberOfCoconuts > 2 ? "tired" : "average";
    case "NorwegianBlueParrot": return bird.voltage > 100 ? "scorched" : "beautiful";
    default: return "unknown";
  }
}

function airSpeedVelocity(bird: Bird): number | null {
  switch (bird.type) {
    case "EuropeanSwallow": return 35;
    case "AfricanSwallow": return 40 - 2 * bird.numberOfCoconuts;
    case "NorwegianBlueParrot": return bird.isNailed ? 0 : 10 + bird.voltage / 10;
    default: return null;
  }
}

After

function createBird(birdData: BirdData): Bird {
  switch (birdData.type) {
    case "EuropeanSwallow": return new EuropeanSwallow(birdData);
    case "AfricanSwallow": return new AfricanSwallow(birdData);
    case "NorwegianBlueParrot": return new NorwegianBlueParrot(birdData);
    default: return new Bird(birdData);
  }
}

class Bird {
  constructor(protected data: BirdData) {}
  get plumage(): string { return "unknown"; }
  get airSpeedVelocity(): number | null { return null; }
}

class EuropeanSwallow extends Bird {
  get plumage(): string { return "average"; }
  get airSpeedVelocity(): number { return 35; }
}

class AfricanSwallow extends Bird {
  get plumage(): string { return this.data.numberOfCoconuts > 2 ? "tired" : "average"; }
  get airSpeedVelocity(): number { return 40 - 2 * this.data.numberOfCoconuts; }
}

class NorwegianBlueParrot extends Bird {
  get plumage(): string { return this.data.voltage > 100 ? "scorched" : "beautiful"; }
  get airSpeedVelocity(): number { return this.data.isNailed ? 0 : 10 + this.data.voltage / 10; }
}

예시 2: 기본 동작 + 변형 (Variation)

  • 항해 등급 평가 시스템에서 “중국 항해 경험이 있는 선장의 중국 항해” 케이스만 다르게 처리해야 하는 상황
  • 기본 Rating 클래스를 만들고, 변형 케이스를 ExperiencedChinaRating 서브클래스로 분리
function createRating(voyage: Voyage, history: History[]): Rating {
  if (voyage.zone === "china" && history.some(v => v.zone === "china")) {
    return new ExperiencedChinaRating(voyage, history);
  }
  return new Rating(voyage, history);
}

class Rating {
  constructor(protected voyage: Voyage, protected history: History[]) {}

  get value(): string {
    const vpf = this.voyageProfitFactor;
    const vr = this.voyageRisk;
    const chr = this.captainHistoryRisk;
    return vpf * 3 > vr + chr * 2 ? "A" : "B";
  }

  get voyageLengthFactor(): number { return this.voyage.length > 14 ? -1 : 0; }
  get historyLengthFactor(): number { return this.history.length > 8 ? 1 : 0; }
  // ... 기타 기본 로직
}

class ExperiencedChinaRating extends Rating {
  get captainHistoryRisk(): number {
    return Math.max(super.captainHistoryRisk - 2, 0); // 차이점만 오버라이드
  }
  get voyageProfitFactor(): number {
    return super.voyageProfitFactor + 3; // 중국 경험 보너스
  }
  get voyageLengthFactor(): number {
    let result = 0;
    if (this.voyage.length > 12) result += 1;
    if (this.voyage.length > 18) result -= 1;
    return result;
  }
  get historyLengthFactor(): number { return this.history.length > 10 ? 1 : 0; }
}

핵심 인사이트: 슈퍼클래스의 로직은 기본 케이스에 집중할 수 있고, 서브클래스 코드는 기본 케이스와의 차이점만 표현한다. 이로써 두 케이스를 독립적으로 이해하고 수정할 수 있다.

Introduce Special Case (특이 케이스 추가하기)

구 명칭: Introduce Null Object

동기

  • 특정 값(주로 null, "unknown" 등)에 대한 검사가 코드 곳곳에 반복되고, 그 반응도 대부분 동일하다면 그 반응을 한 곳으로 모아야 한다
  • 이를 위한 메커니즘이 Special Case Pattern이다: 모든 공통 동작을 캡슐화한 특이 케이스 객체를 만든다
  • 대부분의 특이 케이스 검사를 단순한 메서드 호출로 대체할 수 있게 된다
  • Null Object Pattern은 Special Case Pattern의 특수한 형태다

절차

  1. 대상 프로퍼티에 isUnknown 같은 특이 케이스 검사 프로퍼티를 추가하고 false를 반환하게 한다
  2. 특이 케이스 객체를 생성하고, isUnknowntrue를 반환하게 한다
  3. 특이 케이스 비교 코드에 Extract Function(106) 적용. 모든 클라이언트가 새 함수를 사용하게 한다
  4. 코드에 특이 케이스 객체를 반환하도록 도입한다
  5. 특이 케이스 비교 함수가 검사 프로퍼티를 사용하도록 변경한다
  6. 테스트한다
  7. Combine Functions into Class(144) 또는 Combine Functions into Transform(149)으로 공통 동작들을 특이 케이스 객체로 이동한다
  8. 특이 케이스 비교 함수가 더 이상 필요 없는 곳에 Inline Function(115) 적용

예시: 클래스 기반

Before (코드 곳곳에 "unknown" 검사 반복)

// client 1
const customerName = aCustomer === "unknown" ? "occupant" : aCustomer.name;

// client 2
const plan = aCustomer === "unknown" ? registry.billingPlans.basic : aCustomer.billingPlan;

// client 3
if (aCustomer !== "unknown") aCustomer.billingPlan = newPlan;

// client 4
const weeksDelinquent = aCustomer === "unknown"
  ? 0
  : aCustomer.paymentHistory.weeksDelinquentInLastYear;

After (특이 케이스 클래스 도입)

class Customer {
  get name(): string { /* ... */ }
  get billingPlan(): string { /* ... */ }
  set billingPlan(arg: string) { /* ... */ }
  get paymentHistory(): PaymentHistory { /* ... */ }
  get isUnknown(): boolean { return false; }
}

class UnknownCustomer {
  get isUnknown(): boolean { return true; }
  get name(): string { return "occupant"; }
  get billingPlan(): string { return registry.billingPlans.basic; }
  set billingPlan(_arg: string) { /* 무시 */ }
  get paymentHistory(): NullPaymentHistory { return new NullPaymentHistory(); }
}

class NullPaymentHistory {
  get weeksDelinquentInLastYear(): number { return 0; }
}

class Site {
  get customer(): Customer | UnknownCustomer {
    return this._customer === "unknown" ? new UnknownCustomer() : this._customer;
  }
}

// 클라이언트 코드가 단순해짐
const customerName = aCustomer.name;
const plan = aCustomer.billingPlan;
aCustomer.billingPlan = newPlan;
const weeksDelinquent = aCustomer.paymentHistory.weeksDelinquentInLastYear;

핵심 인사이트: 특이 케이스 객체는 값 객체(value object)이므로 항상 불변이어야 한다. 특이 케이스 객체가 관련 객체를 반환해야 한다면, 그것도 특이 케이스 객체여야 한다(NullPaymentHistory). 다른 반응이 필요한 클라이언트는 aCustomer.isUnknown으로 직접 검사한다.

예시: 객체 리터럴 기반

  • 데이터를 읽기만 하고 수정하지 않는다면 클래스 대신 리터럴 객체를 사용할 수 있다
function createUnknownCustomer() {
  return Object.freeze({
    isUnknown: true,
    name: "occupant",
    billingPlan: registry.billingPlans.basic,
    paymentHistory: {
      weeksDelinquentInLastYear: 0,
    },
  });
}

예시: Transform 기반

  • 클래스가 없고 순수 레코드 구조를 사용한다면 변환(enrichment) 함수를 통해 특이 케이스를 주입할 수 있다
function enrichSite(aSite: SiteData): EnrichedSiteData {
  const result = _.cloneDeep(aSite);
  const unknownCustomer = {
    isUnknown: true,
    name: "occupant",
    billingPlan: registry.billingPlans.basic,
    paymentHistory: { weeksDelinquentInLastYear: 0 },
  };
  if (isUnknown(result.customer)) result.customer = unknownCustomer;
  else result.customer.isUnknown = false;
  return result;
}

Introduce Assertion (어서션 추가하기)

동기

  • 코드의 특정 구간은 특정 조건이 참일 때만 올바르게 동작한다. 이 가정이 주석으로만 남아있거나 알고리즘을 통해서만 추론 가능한 경우가 많다
  • 어서션(Assertion)은 그 가정을 명시적으로 만든다
  • 어서션 특성:
    • 항상 참이어야 하는 조건문이다
    • 어서션 실패 = 프로그래머 에러 (비즈니스 로직 에러가 아님)
    • 다른 코드에서 어서션 실패를 체크하지 않아야 한다
    • 어서션을 모두 제거해도 프로그램은 동일하게 작동해야 한다 (일부 언어에서는 컴파일 타임에 비활성화 가능)
  • 어서션은 단순히 버그 찾기 도구가 아니라 커뮤니케이션 도구다 - 해당 지점에서 프로그램 상태에 대한 가정을 독자에게 알린다

절차

  • 조건이 참이라고 가정되는 곳을 발견하면 어서션을 추가한다
  • 어서션은 동작을 바꾸지 않으므로 항상 행동 보존적(behavior-preserving)이다

예시

Before

class Customer {
  applyDiscount(aNumber: number): number {
    return this.discountRate
      ? aNumber - this.discountRate * aNumber
      : aNumber;
  }
}

After (할인율이 항상 양수라는 가정을 어서션으로 명시)

class Customer {
  set discountRate(aNumber: number | null) {
    // setter에 어서션 추가 - 잘못된 값이 어디서 유입되는지 더 빨리 발견
    console.assert(aNumber === null || aNumber >= 0, "Discount rate must be non-negative");
    this._discountRate = aNumber;
  }

  applyDiscount(aNumber: number): number {
    if (!this.discountRate) return aNumber;
    return aNumber - this.discountRate * aNumber;
  }
}

어서션 사용 원칙

  • 프로그래머 에러에만 사용한다 - 외부 입력값 검증은 어서션이 아닌 정식 에러 처리로 해야 한다
  • 남용하지 않는다 - 참이어야 하는 것 전부가 아니라, 반드시 참이어야 의미가 있는 것에만 사용
  • 조건 중복을 방지한다 - 중복 어서션은 Extract Function(106)으로 제거
  • 어서션 실패 없이 버그를 찾으리라 기대하지 말 것. 어서션은 “이게 실패할 리 없다고 생각할 때” 추가하는 것이다

챕터 9-10 핵심 원칙 요약

원칙 적용 기법
변수 하나, 역할 하나 Split Variable
데이터 구조 이름이 곧 문서다 Rename Field
가변 데이터 범위를 최소화하라 Replace Derived Variable with Query
불변 객체는 추론하기 쉽다 Change Reference to Value
공유 엔티티는 단일 참조로 관리하라 Change Value to Reference
조건의 “왜”를 이름으로 표현하라 Decompose Conditional, Consolidate Conditional Expression
비정상 경로는 일찍 처리하고 빠져나가라 Replace Nested Conditional with Guard Clauses
타입별 분기가 반복되면 다형성으로 Replace Conditional with Polymorphism
반복되는 특이 케이스 반응을 한 곳으로 Introduce Special Case
숨겨진 가정을 코드로 드러내라 Introduce Assertion

Chapter 11: Refactoring APIs

API는 모듈과 함수를 연결하는 조인트다. 좋은 API는 데이터를 변경하는 함수와 읽기만 하는 함수를 명확히 분리하고, 파라미터 목록을 간결하게 유지하며, 호출자가 불필요한 부담을 지지 않도록 설계된다.

Separate Query from Modifier (쿼리와 변경 분리)

동기

  • 값을 반환하면서 동시에 부수 효과(Side Effect)를 일으키는 함수는 테스트하기 어렵고 재사용하기 힘들다
  • “값을 반환하는 함수는 관측 가능한 부수 효과가 없어야 한다”는 커맨드-쿼리 분리 원칙(CQS) 을 따른다
  • 캐싱처럼 상태를 바꾸더라도 외부에서 관측 불가능하면 부수 효과로 보지 않는다

절차

  1. 함수를 복사하고 쿼리 역할을 담당하는 이름으로 명명한다
  2. 새 쿼리 함수에서 모든 부수 효과를 제거한다
  3. 정적 검사를 실행한다
  4. 원래 함수의 각 호출부에서 반환값을 사용하는 경우, 해당 호출 앞에 쿼리 함수 호출을 삽입하고 원래 함수 호출은 유지한다
  5. 원래 함수에서 반환값을 제거한다
  6. 테스트

예시

// Before: 쿼리와 변경이 혼합된 함수
function alertForMiscreant(people: string[]): string {
  for (const p of people) {
    if (p === "Don") { setOffAlarms(); return "Don"; }
    if (p === "John") { setOffAlarms(); return "John"; }
  }
  return "";
}

// After: 역할 분리
function findMiscreant(people: string[]): string {
  for (const p of people) {
    if (p === "Don") return "Don";
    if (p === "John") return "John";
  }
  return "";
}

function alertForMiscreant(people: string[]): void {
  if (findMiscreant(people) !== "") setOffAlarms();
}

// 호출부
const found = findMiscreant(people);
alertForMiscreant(people);

Parameterize Function (함수 매개변수화)

동기

  • 리터럴 값만 다르고 로직이 동일한 함수들이 있다면 매개변수 하나로 통합할 수 있다
  • 함수의 유용성이 높아지고 코드 중복이 제거된다

절차

  1. 비슷한 함수 중 하나를 선택한다
  2. Change Function Declaration으로 리터럴을 파라미터로 추가한다
  3. 각 호출부에 리터럴 값을 전달하도록 수정한다
  4. 함수 본문이 새 파라미터를 사용하도록 수정한다
  5. 나머지 유사 함수들의 호출을 매개변수화된 함수 호출로 대체한다

예시

// Before: 리터럴 값만 다른 중복 함수들
function tenPercentRaise(person: { salary: number }) {
  person.salary *= 1.1;
}
function fivePercentRaise(person: { salary: number }) {
  person.salary *= 1.05;
}

// After: 매개변수화된 단일 함수
function raise(person: { salary: number }, factor: number) {
  person.salary *= 1 + factor;
}

// 범위 기반 예시: 리터럴 100, 200, Infinity로 통합
function withinBand(usage: number, bottom: number, top: number): number {
  return usage > bottom ? Math.min(usage, top) - bottom : 0;
}

function baseCharge(usage: number): number {
  if (usage < 0) return 0;
  return withinBand(usage, 0, 100) * 0.03
       + withinBand(usage, 100, 200) * 0.05
       + withinBand(usage, 200, Infinity) * 0.07;
}

Remove Flag Argument (플래그 인수 제거)

동기

  • 플래그 인수(flag argument) 란 호출자가 함수가 실행할 로직을 결정하기 위해 전달하는 리터럴 boolean/enum/string 값이다
  • bookConcert(customer, true) 처럼 true의 의미를 코드에서 즉시 파악하기 어렵다
  • 코드 분석 도구가 어떤 로직이 호출되는지 구분할 수 없어 디버깅이 어렵다
  • 플래그 인수의 조건: 호출자가 리터럴로 값을 설정하고, 함수 내부에서 제어 흐름에 영향을 주는 경우에만 해당한다

플래그 인수가 여러 개라면 조합의 수만큼 명시적 함수가 필요하다. 이는 함수가 너무 많은 일을 하고 있다는 신호다.

절차

  1. 파라미터의 각 값에 대해 명시적 함수를 생성한다
  2. 주 함수에 명확한 분기 조건이 있으면 Decompose Conditional로 명시적 함수를 생성하고, 그렇지 않으면 래핑 함수를 만든다
  3. 리터럴 값을 사용하는 각 호출부를 명시적 함수 호출로 교체한다

예시

// Before
function deliveryDate(order: Order, isRush: boolean): Date {
  if (isRush) { /* 급행 로직 */ }
  else { /* 일반 로직 */ }
}
aShipment.deliveryDate = deliveryDate(anOrder, true); // true가 무슨 의미?

// After: 명시적 함수로 분리
function rushDeliveryDate(order: Order): Date {
  const deliveryTime = ["MA","CT"].includes(order.deliveryState) ? 1
                     : ["NY","NH"].includes(order.deliveryState) ? 2 : 3;
  return order.placedOn.plusDays(1 + deliveryTime);
}
function regularDeliveryDate(order: Order): Date {
  const deliveryTime = ["MA","CT","NY"].includes(order.deliveryState) ? 2
                     : ["ME","NH"].includes(order.deliveryState) ? 3 : 4;
  return order.placedOn.plusDays(2 + deliveryTime);
}

aShipment.deliveryDate = rushDeliveryDate(anOrder); // 의도가 명확

로직이 복잡하게 얽혀 분리가 어려운 경우, 래핑 함수 패턴을 사용한다: typescript > function rushDeliveryDate(order: Order) { return deliveryDate(order, true); } > function regularDeliveryDate(order: Order) { return deliveryDate(order, false); } >

Preserve Whole Object (전체 객체 유지)

동기

  • 레코드에서 여러 값을 추출해 함수에 전달하는 대신, 레코드 전체를 전달하면 변화에 강하다
  • 나중에 함수가 더 많은 데이터를 필요로 하더라도 파라미터 목록을 바꿀 필요가 없다
  • 여러 함수에 흩어진 부분 처리 로직을 객체 자체로 이동시킬 수 있다
  • 단, 호출되는 함수가 전체 객체에 대한 의존성을 갖지 않아야 하는 경우(예: 다른 모듈에 있는 경우)에는 적용하지 않는다
  • Feature Envy 냄새가 나는 경우(객체에서 일부 값만 꺼내 로직 처리) 이 리팩터링이 유용하다

절차

  1. 원하는 파라미터를 가진 빈 함수를 생성한다 (검색하기 쉬운 이름 부여)
  2. 새 함수 본문에 기존 함수 호출을 작성하고 파라미터를 매핑한다
  3. 각 호출부가 새 함수를 사용하도록 변경한다
  4. 기존 함수를 Inline Function으로 인라인한다
  5. 새 함수의 이름을 정리한다

예시

// Before: 객체에서 값을 분해하여 전달
const low = aRoom.daysTempRange.low;
const high = aRoom.daysTempRange.high;
if (!aPlan.withinRange(low, high)) alerts.push("온도 범위 초과");

// After: 전체 객체 전달
if (!aPlan.withinRange(aRoom.daysTempRange)) alerts.push("온도 범위 초과");

class HeatingPlan {
  withinRange(range: { low: number; high: number }): boolean {
    return range.low >= this._temperatureRange.low
        && range.high <= this._temperatureRange.high;
  }
}

Replace Parameter with Query (매개변수를 질의 함수로 교체)

동기

  • 함수가 스스로 결정할 수 있는 값을 파라미터로 받는 경우, 그 파라미터는 불필요한 중복이다
  • 파라미터를 제거하면 호출자의 부담이 줄어든다
  • 단, 제거 시 함수 본문에 원치 않는 의존성이 추가된다면 적용하지 않는다
  • 참조 투명성(같은 입력에 항상 같은 출력)을 깨는 가변 전역 변수에 접근하게 된다면 적용하지 않는다

절차

  1. 필요하면 파라미터 계산 로직을 Extract Function으로 추출한다
  2. 함수 본문에서 파라미터 참조를 해당 표현식으로 대체한다
  3. Change Function Declaration으로 파라미터를 제거한다

예시

// Before
class Order {
  get finalPrice() {
    const basePrice = this.quantity * this.itemPrice;
    return this.discountedPrice(basePrice, this.discountLevel);
  }
  get discountLevel() { return this.quantity > 100 ? 2 : 1; }
  discountedPrice(basePrice: number, discountLevel: number) {
    switch (discountLevel) {
      case 1: return basePrice * 0.95;
      case 2: return basePrice * 0.90;
    }
  }
}

// After: discountLevel은 함수 내에서 직접 조회 가능
class Order {
  get finalPrice() {
    return this.discountedPrice(this.quantity * this.itemPrice);
  }
  get discountLevel() { return this.quantity > 100 ? 2 : 1; }
  discountedPrice(basePrice: number) {
    switch (this.discountLevel) {
      case 1: return basePrice * 0.95;
      case 2: return basePrice * 0.90;
    }
  }
}

Replace Query with Parameter (질의 함수를 매개변수로 교체)

동기

  • 함수 본문에서 전역 변수나 이동하고 싶은 요소를 직접 참조하는 경우, 그 참조를 파라미터로 올려 의존성을 줄인다
  • 파라미터로 올리면 참조 투명성(Referential Transparency) 을 확보하여 테스트와 추론이 쉬워진다
  • 반면, 호출자가 값을 직접 제공해야 하므로 호출부가 복잡해지는 트레이드오프가 있다

절차

  1. Extract Variable로 쿼리 코드를 함수 본문에서 분리한다
  2. 쿼리를 제외한 나머지 본문을 Extract Function으로 추출한다 (검색 가능한 임시 이름 부여)
  3. 방금 만든 변수를 Inline Variable로 제거한다
  4. 원래 함수를 Inline Function으로 인라인한다
  5. 새 함수의 이름을 원래 이름으로 변경한다

예시

// Before: 내부에서 전역 thermostat 참조
class HeatingPlan {
  get targetTemperature(): number {
    if (thermostat.selectedTemperature > this._max) return this._max;
    if (thermostat.selectedTemperature < this._min) return this._min;
    return thermostat.selectedTemperature;
  }
}

// After: 의존성을 파라미터로 분리 → 참조 투명성 확보
class HeatingPlan {
  targetTemperature(selectedTemperature: number): number {
    if (selectedTemperature > this._max) return this._max;
    if (selectedTemperature < this._min) return this._min;
    return selectedTemperature;
  }
}

// 호출부: 더 verbose하지만 결합도가 낮아짐
if (thePlan.targetTemperature(thermostat.selectedTemperature) > thermostat.currentTemperature)
  setToHeat();

Remove Setting Method (세터 제거)

동기

  • setter를 제공하면 그 필드가 변경될 수 있음을 암시한다
  • 객체 생성 후 변경되지 않아야 할 필드에는 setter를 제거하여 불변성을 명시한다
  • 주로 발생하는 두 가지 케이스:
    • 생성자에서만 setter를 호출하는 경우
    • 객체를 생성 스크립트(여러 setter 호출 시퀀스)로 생성하지만 이후 수정을 기대하지 않는 경우

절차

  1. 설정할 값이 생성자에 없으면 Change Function Declaration으로 추가한다
  2. 생성자 외부에서 setter를 호출하는 각 지점을 제거하고 생성자 값으로 대체한다
  3. (공유 객체를 업데이트하는 경우라면 리팩터링을 중단한다)
  4. Inline Function으로 setter를 제거하고 필드를 불변으로 만든다

예시

// Before: id에 setter가 존재
class Person {
  get name() { return this._name; }
  set name(v: string) { this._name = v; }
  get id() { return this._id; }
  set id(v: string) { this._id = v; }
}
const martin = new Person();
martin.name = "martin";
martin.id = "1234";

// After: id는 생성자에서만 설정, setter 제거
class Person {
  constructor(id: string) { this._id = id; }
  get name() { return this._name; }
  set name(v: string) { this._name = v; }
  get id() { return this._id; }
  // id setter 제거
}
const martin = new Person("1234");
martin.name = "martin";

Replace Constructor with Factory Function (생성자를 팩터리 함수로 교체)

동기

  • 생성자의 한계:
    • 반환 타입이 고정됨 (자신의 클래스만 반환 가능)
    • 이름이 클래스명으로 고정됨 (의도를 표현하는 이름 불가)
    • new 키워드 필요 (일급 함수로 전달 불가)
  • 팩터리 함수는 이 모든 한계를 해소한다

절차

  1. 생성자를 호출하는 팩터리 함수를 생성한다
  2. 생성자의 각 호출부를 팩터리 함수 호출로 교체한다
  3. 생성자의 가시성을 최대한 제한한다

예시

class Employee {
  constructor(private _name: string, private _typeCode: string) {}
  get name() { return this._name; }
  get type() { return Employee.legalTypeCodes[this._typeCode]; }
  static legalTypeCodes: Record<string, string> = {
    "E": "Engineer", "M": "Manager", "S": "Salesman"
  };
}

// 일반 팩터리
function createEmployee(name: string, typeCode: string): Employee {
  return new Employee(name, typeCode);
}

// 의도가 담긴 명시적 팩터리 (타입 코드를 이름에 녹임)
function createEngineer(name: string): Employee {
  return new Employee(name, "E");
}

const leadEngineer = createEngineer(document.leadEngineer);

Replace Function with Command (함수를 커맨드로 교체)

동기

  • 커맨드 객체(Command Object): 단일 메서드를 중심으로 구성되고, 그 메서드의 요청과 실행이 객체의 목적인 객체
  • 커맨드가 유용한 경우:
    • undo 같은 보완 연산이 필요할 때
    • 복잡한 파라미터 생성 주기가 필요할 때
    • 상속과 훅으로 커스터마이징할 때
    • 복잡한 함수를 메서드와 필드로 분해하여 테스트/디버깅하기 쉽게 할 때
  • 95% 상황에서 일반 함수가 더 낫다. 커맨드는 단순한 방법으로 해결되지 않을 때만 사용한다

커맨드 vs. 커맨드-쿼리 분리의 “커맨드” 는 다른 개념이다. 여기서 커맨드는 GoF의 Command Pattern을 의미한다.

절차

  1. 함수 이름을 기반으로 빈 클래스를 생성한다
  2. Move Function으로 함수를 클래스로 이동시킨다 (원본을 포워딩 함수로 유지)
  3. execute 또는 call 같은 실행 메서드 이름을 정한다
  4. 각 인수에 대한 필드를 만들고 인수를 생성자로 이동시킨다

예시

// Before: 복잡한 일반 함수
function score(candidate: Candidate, medicalExam: MedicalExam, scoringGuide: ScoringGuide): number {
  let result = 0;
  let healthLevel = 0;
  // ... 복잡한 로직
  return result;
}

// After: 커맨드 객체로 전환 → 복잡한 로직을 작은 메서드로 분해 가능
class Scorer {
  private _result = 0;
  private _healthLevel = 0;
  private _highMedicalRiskFlag = false;
  private _certificationGrade = "regular";

  constructor(
    private _candidate: Candidate,
    private _medicalExam: MedicalExam,
    private _scoringGuide: ScoringGuide
  ) {}

  execute(): number {
    this._result = 0;
    this._healthLevel = 0;
    this._highMedicalRiskFlag = false;
    this.scoreSmoking(); // 추출된 서브 메서드
    // ... 나머지 로직
    this._result -= Math.max(this._healthLevel - 5, 0);
    return this._result;
  }

  private scoreSmoking(): void {
    if (this._medicalExam.isSmoker) {
      this._healthLevel += 10;
      this._highMedicalRiskFlag = true;
    }
  }
}

// 포워딩 함수 유지
function score(candidate: Candidate, medicalExam: MedicalExam, scoringGuide: ScoringGuide): number {
  return new Scorer(candidate, medicalExam, scoringGuide).execute();
}

Replace Command with Function (커맨드를 함수로 교체)

동기

  • 커맨드 객체는 강력하지만 복잡도라는 비용을 수반한다
  • 함수가 단순하고 복잡한 생명주기가 필요 없다면 커맨드를 다시 일반 함수로 되돌린다

절차

  1. Extract Function으로 커맨드 생성과 실행 메서드 호출을 하나의 함수로 감싼다
  2. 실행 메서드가 호출하는 각 지원 메서드에 Inline Function을 적용한다
  3. Change Function Declaration으로 생성자 파라미터를 실행 메서드로 이동시킨다
  4. 각 필드 참조를 파라미터 참조로 변경한다
  5. 생성자 호출과 실행 메서드 호출을 호출자(새 함수)에 인라인한다
  6. Remove Dead Code로 커맨드 클래스를 삭제한다

예시

// Before: 불필요하게 복잡한 커맨드 클래스
class ChargeCalculator {
  constructor(
    private _customer: Customer,
    private _usage: number,
    private _provider: Provider
  ) {}
  get baseCharge() { return this._customer.baseRate * this._usage; }
  get charge() { return this.baseCharge + this._provider.connectionCharge; }
}
// 사용: new ChargeCalculator(customer, usage, provider).charge

// After: 단순 함수로 교체
function charge(customer: Customer, usage: number, provider: Provider): number {
  const baseCharge = customer.baseRate * usage;
  return baseCharge + provider.connectionCharge;
}

Chapter 12: Dealing with Inheritance

상속은 OOP의 핵심 기능이지만, 강력한 만큼 오용하기 쉽다. 이 챕터는 상속 계층에서 기능을 올리고 내리고, 계층을 추가/제거하고, 상속을 위임으로 전환하는 리팩터링을 다룬다.

Pull Up Method (메서드 올리기)

동기

  • 서브클래스에 동일한 메서드가 중복되어 있다면 슈퍼클래스로 올린다
  • 중복 코드는 한 쪽만 수정되는 버그의 온상이다
  • 서로 완전히 동일한 본문이 아닐 경우, 차이점을 먼저 살펴보면 테스트에서 미처 다루지 않은 동작을 발견할 수 있다
  • 메서드 본문이 서브클래스에만 있는 요소를 참조한다면 Pull Up Field나 Pull Up Method를 먼저 적용한다

절차

  1. 두 메서드가 동일한지 확인하고, 다르다면 본문이 동일해질 때까지 리팩터링한다
  2. 메서드 본문의 모든 참조가 슈퍼클래스에서 호출 가능한지 확인한다
  3. 시그니처가 다르다면 Change Function Declaration으로 통일한다
  4. 슈퍼클래스에 새 메서드를 생성하고 본문을 복사한다
  5. 서브클래스의 메서드를 하나씩 삭제하며 테스트한다

예시

// Before: 두 서브클래스에 동일한 메서드 중복
class Employee extends Party {
  get annualCost() { return this.monthlyCost * 12; }
}
class Department extends Party {
  get totalAnnualCost() { return this.monthlyCost * 12; } // 이름만 다름
}

// After: 슈퍼클래스로 통합
class Party {
  get annualCost() { return this.monthlyCost * 12; }
  // 서브클래스 책임을 알리는 트랩 메서드 (TypeScript에서는 abstract로 표현)
  abstract get monthlyCost(): number;
}
class Employee extends Party { /* annualCost 삭제 */ }
class Department extends Party { /* totalAnnualCost 삭제, annualCost 상속 */ }

Pull Up Field (필드 올리기)

동기

  • 서브클래스들이 같은 방식으로 사용하는 중복 필드가 있다면 슈퍼클래스로 올린다
  • 데이터 선언 중복을 제거하고, 해당 필드를 사용하는 동작도 슈퍼클래스로 이동할 수 있게 된다
  • 동적 언어에서는 필드 선언이 없으므로 Pull Up Constructor Body의 부수 결과로 처리된다

절차

  1. 후보 필드의 모든 사용처가 동일한 방식으로 쓰이는지 확인한다
  2. 필드 이름이 다르면 Rename Field로 통일한다
  3. 슈퍼클래스에 새 필드를 생성한다 (서브클래스에서 접근 가능하도록 protected)
  4. 서브클래스의 필드를 삭제한다

Pull Up Constructor Body (생성자 본문 올리기)

동기

  • 생성자는 일반 메서드와 다른 규칙이 있어 Pull Up Method를 그대로 적용하기 어렵다
  • 서브클래스 생성자들에 공통 코드가 있을 때 슈퍼클래스 생성자로 올린다
  • 복잡해지면 Replace Constructor with Factory Function을 고려한다

절차

  1. 슈퍼클래스 생성자가 없으면 정의하고, 서브클래스에서 호출되도록 한다
  2. Slide Statements로 공통 코드를 super() 호출 바로 다음으로 이동시킨다
  3. 공통 코드를 슈퍼클래스로 이동시키고 관련 생성자 파라미터를 super()에 전달한다
  4. 생성자 중간 이후에 있는 공통 코드는 Extract Function + Pull Up Method로 처리한다

예시

// Before: 모든 서브클래스에서 _name 초기화 중복
class Party {}
class Employee extends Party {
  constructor(name: string, id: string, monthlyCost: number) {
    super();
    this._id = id;
    this._name = name; // 공통
    this._monthlyCost = monthlyCost;
  }
}
class Department extends Party {
  constructor(name: string, staff: Employee[]) {
    super();
    this._name = name; // 공통
    this._staff = staff;
  }
}

// After: 공통 초기화를 슈퍼클래스로 올림
class Party {
  constructor(protected _name: string) {}
}
class Employee extends Party {
  constructor(name: string, private _id: string, private _monthlyCost: number) {
    super(name);
  }
}
class Department extends Party {
  constructor(name: string, private _staff: Employee[]) {
    super(name);
  }
}

Push Down Method (메서드 내리기)

동기

  • 슈퍼클래스에 있는 메서드가 일부 서브클래스에만 관련이 있다면 해당 서브클래스로 내린다
  • 호출자가 특정 서브클래스를 알고 있을 때만 적용 가능하다. 그렇지 않으면 Replace Conditional with Polymorphism을 사용한다

절차

  1. 필요한 모든 서브클래스에 메서드를 복사한다
  2. 슈퍼클래스에서 메서드를 제거한다
  3. 해당 메서드가 필요 없는 서브클래스에서도 제거한다

Push Down Field (필드 내리기)

동기

  • 슈퍼클래스의 필드가 일부 서브클래스에서만 사용된다면 해당 서브클래스로 내린다

절차

  1. 필요한 서브클래스에 필드를 선언한다
  2. 슈퍼클래스에서 필드를 제거한다
  3. 해당 필드가 필요 없는 서브클래스에서도 제거한다

Replace Type Code with Subclasses (타입 코드를 서브클래스로 교체)

동기

  • 타입 코드(enum/string/number)를 사용하면 분기 로직이 여러 곳에 흩어진다
  • 서브클래스 전환이 유리한 두 가지 상황:
    • 타입 코드 값에 따라 다른 동작을 하는 함수가 여러 개 있을 때 → Replace Conditional with Polymorphism 적용 가능
    • 특정 타입 코드에서만 유효한 필드/메서드가 있을 때 → Push Down Field 로 명확한 관계 표현 가능
  • 직접 상속 vs. 간접 상속 선택 기준:
    • 타입이 변경 가능하거나 다른 상속 계층이 이미 있으면 간접 상속(타입 코드용 별도 클래스) 사용

절차

  1. 타입 코드 필드를 self-encapsulate한다 (getter 생성)
  2. 타입 코드 값 하나를 선택하고 그 서브클래스를 생성한다. 타입 코드 getter를 리터럴 값을 반환하도록 오버라이드한다
  3. 팩터리 함수에 선택 로직을 추가한다 (직접 상속의 경우 Replace Constructor with Factory Function 선행)
  4. 나머지 타입 코드 값에 대해 반복한다
  5. 타입 코드 필드를 제거하고, 타입 코드 accessor를 사용하는 메서드에 Push Down Method + Replace Conditional with Polymorphism을 적용한다

예시 (직접 상속)

// Before
class Employee {
  constructor(name: string, private _type: string) {
    this._name = name;
  }
}
createEmployee("kim", "engineer");

// After: 직접 상속
class Employee {
  constructor(protected _name: string) {}
  get type(): string { throw new Error("subclass responsibility"); }
}
class Engineer extends Employee {
  get type() { return "engineer"; }
}
class Salesman extends Employee {
  get type() { return "salesman"; }
}
class Manager extends Employee {
  get type() { return "manager"; }
}

function createEmployee(name: string, type: string): Employee {
  switch (type) {
    case "engineer": return new Engineer(name);
    case "salesman": return new Salesman(name);
    case "manager":  return new Manager(name);
    default: throw new Error(`Unknown type: ${type}`);
  }
}

예시 (간접 상속 - 타입 변경 가능 or 이미 다른 상속 계층 존재)

class EmployeeType {
  toString(): string { throw new Error("subclass responsibility"); }
  get capitalizedName(): string {
    const s = this.toString();
    return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase();
  }
}
class EngineerType extends EmployeeType { toString() { return "engineer"; } }
class ManagerType extends EmployeeType { toString() { return "manager"; } }
class SalesmanType extends EmployeeType { toString() { return "salesman"; } }

class Employee {
  private _type: EmployeeType;
  constructor(private _name: string, type: string) {
    this._type = Employee.createEmployeeType(type);
  }
  static createEmployeeType(type: string): EmployeeType {
    switch (type) {
      case "engineer": return new EngineerType();
      case "manager":  return new ManagerType();
      case "salesman": return new SalesmanType();
      default: throw new Error(`Unknown type: ${type}`);
    }
  }
  // 타입 변경 가능
  set type(arg: string) { this._type = Employee.createEmployeeType(arg); }
  toString() { return `${this._name} (${this._type.capitalizedName})`; }
}

Remove Subclass (서브클래스 제거)

동기

  • 소프트웨어가 발전하면서 서브클래스가 지원하던 변형이 제거되거나 다른 곳으로 이동할 수 있다
  • 너무 적은 일을 하는 서브클래스는 이해 비용만 유발한다
  • 서브클래스를 슈퍼클래스의 필드로 대체하는 것이 낫다

절차

  1. 서브클래스 생성자에 Replace Constructor with Factory Function을 적용한다
  2. 서브클래스 타입을 검사하는 코드가 있으면 Extract Function + Move Function으로 슈퍼클래스로 이동시킨다
  3. 서브클래스 타입을 나타낼 필드를 슈퍼클래스에 생성한다
  4. 서브클래스를 참조하는 메서드를 새 타입 필드를 사용하도록 변경한다
  5. 서브클래스를 삭제한다

예시

// Before
class Person { get genderCode() { return "X"; } }
class Male extends Person { get genderCode() { return "M"; } }
class Female extends Person { get genderCode() { return "F"; } }

// After: instanceof 제거, 필드로 대체
class Person {
  constructor(private _name: string, private _genderCode: string = "X") {}
  get name() { return this._name; }
  get genderCode() { return this._genderCode; }
  get isMale() { return this._genderCode === "M"; }
}

function createPerson(record: { name: string; gender: string }): Person {
  switch (record.gender) {
    case "M": return new Person(record.name, "M");
    case "F": return new Person(record.name, "F");
    default:  return new Person(record.name);
  }
}

Extract Superclass (슈퍼클래스 추출)

동기

  • 두 클래스가 비슷한 데이터 구조와 동작을 가지고 있다면 공통점을 슈퍼클래스로 올린다
  • 이는 중복 제거이자 변경 지점 단일화다

절차

  1. 빈 슈퍼클래스를 생성하고 원래 클래스들이 이를 상속하도록 한다
  2. Pull Up Constructor Body, Pull Up Method, Pull Up Field로 공통 요소를 올린다
  3. 서브클래스에 남은 코드를 검토하고 더 올릴 것이 없는지 확인한다
  4. 원래 클래스들을 사용하는 클라이언트를 검토하여 슈퍼클래스 인터페이스를 활용할 수 있는지 확인한다

Collapse Hierarchy (계층 합치기)

동기

  • 슈퍼클래스와 서브클래스가 더 이상 충분히 다르지 않다면 하나로 합친다
  • 리팩터링을 거치면서 슈퍼클래스와 서브클래스 간 차이가 사라질 수 있다

절차

  1. 두 클래스 중 어느 쪽으로 합칠지 결정한다 (대개 상위 클래스로 합치는 것이 자연스럽다)
  2. Pull Up Method/Field 또는 Push Down Method/Field로 모든 요소를 한 쪽으로 이동시킨다
  3. 빈 클래스를 참조하는 모든 호출부를 수정한다
  4. 빈 클래스를 제거한다

Replace Subclass with Delegate (서브클래스를 위임으로 교체)

동기

  • 상속의 단점 두 가지:
  1. 상속은 한 번만 쓸 수 있다. 두 가지 축(예: 나이 + 소득 수준)으로 변형이 필요하면 상속으로 표현할 수 없다
  2. 상속은 클래스 간 강한 결합을 만든다. 슈퍼클래스 변경이 서브클래스를 쉽게 깨뜨린다
  • 위임(Delegation) 은 이 두 문제를 모두 해결한다. 여러 클래스에 각기 다른 이유로 위임할 수 있고, 클래스 간 결합이 약하다
  • “상속보다 합성을 선호하라(Favor Composition over Inheritance)” 원칙은 상속 금지가 아니라 과용에 대한 반응이다. 상속을 먼저 사용하고, 문제가 생기면 위임으로 전환하면 된다
  • GoF의 State/Strategy 패턴으로 생각하면 이해가 쉽다
graph LR
  A[Booking] -- "_premiumDelegate?" --> B[PremiumBookingDelegate]
  A -- "없으면 자체 처리" --> A

절차

  1. 생성자 호출이 많으면 Replace Constructor with Factory Function으로 감싼다
  2. 위임 클래스를 생성한다. 생성자에 서브클래스 전용 데이터와 슈퍼클래스에 대한 역참조를 포함한다
  3. 슈퍼클래스에 위임 객체를 저장할 필드를 추가한다
  4. 서브클래스 생성 시 위임 필드를 초기화한다
  5. 서브클래스의 메서드를 Move Function으로 위임 클래스로 이동시킨다 (소스의 위임 코드는 유지)
  6. 서브클래스 외부에 호출자가 있으면 슈퍼클래스에 위임 조건 분기를 추가한다
  7. 모든 메서드를 이동한 뒤 서브클래스 생성자 호출을 슈퍼클래스 생성자로 교체한다
  8. Remove Dead Code로 서브클래스를 삭제한다

예시 (단일 서브클래스)

// Before
class Booking {
  constructor(protected _show: Show, protected _date: Date) {}
  get hasTalkback() {
    return "talkback" in this._show && !this.isPeakDay;
  }
  get basePrice(): number {
    let result = this._show.price;
    if (this.isPeakDay) result += Math.round(result * 0.15);
    return result;
  }
  get isPeakDay(): boolean { /* ... */ return false; }
}
class PremiumBooking extends Booking {
  constructor(show: Show, date: Date, private _extras: Extras) {
    super(show, date);
  }
  get hasTalkback() { return "talkback" in this._show; } // 오버라이드
  get basePrice() { return Math.round(super.basePrice + this._extras.premiumFee); }
  get hasDinner() { return "dinner" in this._extras && !this.isPeakDay; } // 서브클래스 전용
}

// After: 위임으로 전환
class PremiumBookingDelegate {
  constructor(private _host: Booking, private _extras: Extras) {}
  get hasTalkback() { return "talkback" in this._host._show; }
  extendBasePrice(base: number) { return Math.round(base + this._extras.premiumFee); }
  get hasDinner() { return "dinner" in this._extras && !this._host.isPeakDay; }
}

class Booking {
  private _premiumDelegate?: PremiumBookingDelegate;
  constructor(protected _show: Show, protected _date: Date) {}

  _bePremium(extras: Extras) {
    this._premiumDelegate = new PremiumBookingDelegate(this, extras);
  }
  get hasTalkback() {
    return this._premiumDelegate
      ? this._premiumDelegate.hasTalkback
      : "talkback" in this._show && !this.isPeakDay;
  }
  get basePrice(): number {
    let result = this._show.price;
    if (this.isPeakDay) result += Math.round(result * 0.15);
    return this._premiumDelegate
      ? this._premiumDelegate.extendBasePrice(result)
      : result;
  }
  get hasDinner() {
    return this._premiumDelegate ? this._premiumDelegate.hasDinner : undefined;
  }
}

function createPremiumBooking(show: Show, date: Date, extras: Extras): Booking {
  const result = new Booking(show, date);
  result._bePremium(extras);
  return result;
}

이 리팩터링은 상속을 제거하는 것 자체가 코드를 개선하지는 않는다. 위임 로직, 양방향 참조 등 복잡성이 추가된다. 프리미엄 상태를 런타임에 전환해야 하거나 다른 목적으로 상속을 사용해야 할 때 비로소 가치가 생긴다.

예시 (전체 계층 교체)

전체 서브클래스 계층을 위임으로 전환할 때는 공통 기본 동작을 담은 SpeciesDelegate 슈퍼클래스를 추출하여 위임 계층 자체를 상속으로 구성한다.

class SpeciesDelegate {
  constructor(protected _data: BirdData, protected _bird: Bird) {}
  get plumage() { return this._bird._plumage ?? "average"; }
  get airSpeedVelocity(): number | null { return null; }
}
class EuropeanSwallowDelegate extends SpeciesDelegate {
  get airSpeedVelocity() { return 35; }
}
class AfricanSwallowDelegate extends SpeciesDelegate {
  constructor(data: BirdData, bird: Bird) {
    super(data, bird);
    this._numberOfCoconuts = data.numberOfCoconuts;
  }
  get airSpeedVelocity() { return 40 - 2 * this._numberOfCoconuts; }
}

class Bird {
  private _speciesDelegate: SpeciesDelegate;
  constructor(private _data: BirdData) {
    this._speciesDelegate = this.selectSpeciesDelegate(_data);
  }
  private selectSpeciesDelegate(data: BirdData): SpeciesDelegate {
    switch (data.type) {
      case "EuropeanSwallow": return new EuropeanSwallowDelegate(data, this);
      case "AfricanSwallow":  return new AfricanSwallowDelegate(data, this);
      default:                return new SpeciesDelegate(data, this);
    }
  }
  get plumage() { return this._speciesDelegate.plumage; }
  get airSpeedVelocity() { return this._speciesDelegate.airSpeedVelocity; }
}

function createBird(data: BirdData): Bird { return new Bird(data); }

Replace Superclass with Delegate (슈퍼클래스를 위임으로 교체)

동기

  • 상속을 잘못 모델링하는 대표적인 케이스: 서브클래스가 슈퍼클래스의 모든 기능을 활용하지 않거나, 서브클래스의 인스턴스가 실제로 슈퍼클래스의 인스턴스가 아닌 경우
  • 올바른 상속 관계: 서브클래스 is-a 슈퍼클래스여야 한다. Scroll is-a CatalogItem은 물리적 두루마리가 카탈로그 항목이라는 의미인데, 이는 모델링 오류다
  • 위임을 사용하면 슈퍼클래스 인터페이스 중 실제로 필요한 것만 노출할 수 있다

Replace Superclass with Delegate vs. Replace Subclass with Delegate:

  • Replace Subclass with Delegate: 서브클래스를 위임으로 전환 (서브클래스가 슈퍼클래스의 변형)
  • Replace Superclass with Delegate: 슈퍼클래스를 위임으로 전환 (상속 관계 자체가 잘못됨)

절차

  1. 슈퍼클래스 객체를 저장할 필드를 서브클래스에 생성하고, 슈퍼클래스의 새 인스턴스로 초기화한다
  2. 슈퍼클래스의 각 메서드에 대해 서브클래스에 포워딩 메서드를 생성한다
  3. 상속 링크를 제거한다

예시

// Before: 잘못된 상속 - 물리적 스크롤이 카탈로그 아이템을 상속
class CatalogItem {
  constructor(
    private _id: number,
    private _title: string,
    private _tags: string[]
  ) {}
  get id() { return this._id; }
  get title() { return this._title; }
  hasTag(arg: string) { return this._tags.includes(arg); }
}

class Scroll extends CatalogItem {
  constructor(id: number, title: string, tags: string[], dateLastCleaned: Date) {
    super(id, title, tags);
    this._lastCleaned = dateLastCleaned;
  }
  needsCleaning(targetDate: Date): boolean {
    const threshold = this.hasTag("revered") ? 700 : 1500;
    return this.daysSinceLastCleaning(targetDate) > threshold;
  }
  daysSinceLastCleaning(targetDate: Date): number { /* ... */ return 0; }
}

// After: 위임으로 전환 (+ Change Value to Reference 적용)
class Scroll {
  private _id: number;
  private _catalogItem: CatalogItem;

  constructor(
    id: number,
    dateLastCleaned: Date,
    catalogID: number,
    catalog: Map<number, CatalogItem>
  ) {
    this._id = id;
    this._catalogItem = catalog.get(catalogID)!; // 공유 참조
    this._lastCleaned = dateLastCleaned;
  }

  // 포워딩 메서드
  get id() { return this._id; }
  get title() { return this._catalogItem.title; }
  hasTag(arg: string) { return this._catalogItem.hasTag(arg); }

  needsCleaning(targetDate: Date): boolean {
    const threshold = this.hasTag("revered") ? 700 : 1500;
    return this.daysSinceLastCleaning(targetDate) > threshold;
  }
  daysSinceLastCleaning(targetDate: Date): number { /* ... */ return 0; }
}

이 예시의 핵심 인사이트: 같은 카탈로그 항목에 대한 여러 물리적 스크롤이 존재할 수 있다. 상속은 각 스크롤이 독립적인 CatalogItem 인스턴스를 갖게 만들지만, 위임(+ Change Value to Reference)은 여러 Scroll이 하나의 CatalogItem을 공유하도록 만들어 데이터 정합성 문제도 해결한다.