책 전체의 축소판이다. 파울러는 원칙을 늘어놓는 대신 곧바로 코드 하나를 던진다 — 연극 공연료 청구서를 문자열로 출력하는 statement() 함수. 그리고 아무 설명 없이 손을 움직이기 시작한다. 원칙은 2장부터 나오지만 1장을 예제로 시작한 이유가 명확하다. 리팩터링은 읽어서 배우는 게 아니라 손의 리듬으로 배우는 것이기 때문이다. 이 노트는 그 여정을 단계별로 따라가며, 각 단계에서 코드가 어떻게 변하는지를 스니펫으로 남긴다.

이 장에서 코드가 거쳐 가는 큰 흐름을 먼저 지도로 펼쳐 두면, 아래 단계별 스니펫이 어디쯤인지 놓치지 않는다.

flowchart LR
  s0["거대한 statement()"] --> s1[함수 추출]
  s1 --> s2["임시 변수를 질의 함수로"]
  s2 --> s3[반복문 쪼개기]
  s3 --> s4["단계 쪼개기: 계산과 출력 분리"]
  s4 --> s5[조건부 로직을 다형성으로]

앞 단계가 뒤 단계의 발판이 되도록 순서가 짜여 있다는 점만 기억하고, 이제 첫 덩어리부터 따라가 보자.

출발점: 하나의 거대한 함수

처음 코드는 이렇게 생겼다. 공연 기록(invoice)과 연극 정보(plays)를 받아 청구 내역 문자열을 만든다.

function statement(invoice, plays) {
  let totalAmount = 0
  let volumeCredits = 0
  let result = `청구 내역 (고객명: ${invoice.customer})\n`
  const format = new Intl.NumberFormat("en-US", {
    style: "currency",
    currency: "USD",
    minimumFractionDigits: 2,
  }).format
 
  for (let 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(`알 수 없는 장르: ${play.type}`)
    }
    // 포인트를 적립한다
    volumeCredits += Math.max(perf.audience - 30, 0)
    // 희극 관객 5명마다 추가 포인트를 제공한다
    if (play.type === "comedy") volumeCredits += Math.floor(perf.audience / 5)
    // 청구 내역을 출력한다
    result += `  ${play.name}: ${format(thisAmount / 100)} (${perf.audience}석)\n`
    totalAmount += thisAmount
  }
  result += `총액: ${format(totalAmount / 100)}\n`
  result += `적립 포인트: ${volumeCredits}점\n`
  return result
}

동작은 한다. 하지만 두 가지 요구가 들어오면 무너진다 — HTML로도 출력해줘, 그리고 장르를 더 추가할 거야. 지금 구조로는 HTML 버전을 만들려면 저 로직 전체를 복붙해야 하고, 장르를 늘리려면 switch를 계속 부풀려야 한다. 파울러의 말대로 “기능을 추가하기 편하게 코드를 먼저 정리하고, 그다음 기능을 추가한다.” 그 정리가 지금부터의 여정이다.

리듬: 작은 변경 → 테스트 → 커밋

시연 내내 반복되는 박자가 있다. 아주 작은 변경 → 테스트 → 커밋. 함수 하나를 추출할 때도, 변수 이름 하나를 바꿀 때도 같은 박자다. 리팩터링을 시작하기 전 파울러가 가장 먼저 하는 일은 자가 테스트를 마련하는 것이다. 리팩터링은 겉보기 동작을 바꾸지 않아야 하는데, 그 보장을 사람 눈으로 할 수는 없다. 여러 청구서 입력에 대해 기대 문자열을 비교하는 테스트를 깔아두면, 리팩터링 도중 실수는 곧바로 빨간불로 드러난다. 변경 폭이 작으면 문제가 생겨도 원인이 방금 그 변경이라는 걸 안다.

1단계: switch 덩어리를 함수로 추출하기

가장 먼저 눈에 띄는 건 금액을 계산하는 switch 문이다. 이 덩어리를 amountFor(perf)라는 별도 함수로 뽑아낸다. 추출할 때는 함수가 사용하는 지역 변수를 확인한다. perf, play, thisAmount가 쓰이는데, 앞의 둘은 값을 읽기만 하니 매개변수로 넘기고, thisAmount는 이 함수 안에서 대입되니 반환값으로 만든다.

function amountFor(perf, play) {
  let result = 0 // thisAmount → result 로 개명, 반환 변수는 항상 result
  switch (play.type) {
    case "tragedy":
      result = 40000
      if (perf.audience > 30) result += 1000 * (perf.audience - 30)
      break
    case "comedy":
      result = 30000
      if (perf.audience > 20) result += 10000 + 500 * (perf.audience - 20)
      result += 300 * perf.audience
      break
    default:
      throw new Error(`알 수 없는 장르: ${play.type}`)
  }
  return result
}

추출하면서 두 가지를 손본다. 반환할 변수 이름을 thisAmount에서 무조건 result로 바꾼다 — 이 변수가 이 함수의 결과라는 걸 한눈에 알게 하려는 파울러의 개인 규칙이다. 매개변수 이름도 perf처럼 역할이 드러나게 다듬는다. 이름 짓기는 리팩터링의 핵심이다. “컴퓨터가 이해하는 코드는 누구나 짤 수 있다. 사람이 이해하는 코드를 짜는 프로그래머가 진짜 실력자다.”

2단계: 임시 변수를 질의 함수로 바꾸기

amountFor를 보면 play는 사실 perf만 있으면 plays[perf.playID]로 매번 구할 수 있는 값이다. 이렇게 다른 값에서 유도되는 임시 변수는 나중에 함수를 추출할 때 매개변수 수를 늘려 발목을 잡는다. 그래서 play를 지역 변수로 두지 않고 질의 함수로 바꾼다.

function playFor(perf) {
  return plays[perf.playID]
}

이렇게 하면 amountFor(perf, play)에서 play 매개변수가 사라져 amountFor(perf)가 되고, 필요할 때 함수 안에서 playFor(perf)를 호출한다. 임시 변수를 없앨수록 추출이 쉬워진다는 게 이 단계의 교훈이다. 같은 방식으로 청구 금액 포맷팅(format)도 usd(number) 같은 함수로, 관객 포인트 계산도 volumeCreditsFor(perf)로 뽑아낸다.

3단계: 반복문 쪼개고 누적 변수 없애기

volumeCredits는 반복문을 돌며 값을 누적하는 변수다. 이런 변수는 반복문을 따라다니며 코드를 읽는 사람의 머리를 복잡하게 만든다. 먼저 포인트 계산만 도는 반복문을 따로 쪼갠 뒤(반복문 쪼개기), 그 반복문 전체를 함수로 추출해 질의 함수로 만든다.

function totalVolumeCredits() {
  let result = 0
  for (let perf of invoice.performances) {
    result += volumeCreditsFor(perf)
  }
  return result
}

여기서 누구나 하는 걱정이 나온다 — 반복문을 여러 번 돌면 느려지지 않나? 파울러의 대답은 단호하다. “이 정도 반복문 중복이 성능에 눈에 띄는 영향을 주는 경우는 거의 없다. 설령 준다 해도, 잘 다듬어진 코드는 성능 개선이 훨씬 쉽다.” 일단 구조를 잡고, 정말 느려지면 그때 프로파일러로 측정해 잡는다. 짐작으로 최적화하지 않는다. 같은 방식으로 totalAmount도 질의 함수로 바꾼다. 이 단계가 끝나면 statement()는 결과 문자열을 조립하는 뼈대만 남는다.

4단계: 단계 쪼개기 — 계산과 출력을 분리

이제 HTML 출력이라는 원래 요구로 돌아온다. 지금 statement()는 “금액을 계산하는 일”과 “문자열로 렌더링하는 일”이 뒤섞여 있다. 이 둘을 **단계 쪼개기(Split Phase)**로 분리한다. 1단계에서 렌더링에 필요한 모든 데이터를 중간 데이터 구조로 만들고, 2단계 함수가 그 구조만 받아 문자열을 찍는다.

function statement(invoice, plays) {
  return renderPlainText(createStatementData(invoice, plays))
}
 
function createStatementData(invoice, plays) {
  const data = {}
  data.customer = invoice.customer
  data.performances = invoice.performances.map(enrichPerformance)
  data.totalAmount = totalAmount(data)
  data.totalVolumeCredits = totalVolumeCredits(data)
  return data // 계산 결과만 담긴 순수 데이터
}
 
function renderPlainText(data) {
  let result = `청구 내역 (고객명: ${data.customer})\n`
  for (let perf of data.performances) {
    result += `  ${perf.play.name}: ${usd(perf.amount)} (${perf.audience}석)\n`
  }
  result += `총액: ${usd(data.totalAmount)}\n`
  result += `적립 포인트: ${data.totalVolumeCredits}점\n`
  return result
}

이제 HTML 버전은 계산 로직을 한 줄도 건드리지 않고 renderHtml(data)만 새로 쓰면 끝난다. 두 렌더러가 createStatementData가 만든 같은 데이터를 공유한다. 이것이 단계 쪼개기의 힘이다 — 서로 다른 이유로 바뀌는 두 가지 일을 갈라놓으면, 각자 독립적으로 자란다.

5단계: 조건부 로직을 다형성으로

남은 요구는 장르 추가다. 아직 amountForvolumeCreditsFor에는 장르별 switch가 남아 있다. 새 장르를 추가할 때마다 이 switch들을 찾아 고쳐야 한다 — 전형적인 “반복되는 switch” 악취다. 이를 다형성으로 바꾼다. 장르별 계산기 클래스를 만들고, 팩터리 함수로 알맞은 계산기를 고른다.

class PerformanceCalculator {
  constructor(perf, play) {
    this.performance = perf
    this.play = play
  }
  get amount() {
    throw new Error("서브클래스에서 구현할 것")
  }
  get volumeCredits() {
    return Math.max(this.performance.audience - 30, 0)
  }
}
 
class TragedyCalculator extends PerformanceCalculator {
  get amount() {
    let result = 40000
    if (this.performance.audience > 30) result += 1000 * (this.performance.audience - 30)
    return result
  }
}
 
class ComedyCalculator extends PerformanceCalculator {
  get amount() {
    let result = 30000
    if (this.performance.audience > 20) result += 10000 + 500 * (this.performance.audience - 20)
    result += 300 * this.performance.audience
    return result
  }
  get volumeCredits() {
    return super.volumeCredits + Math.floor(this.performance.audience / 5)
  }
}
 
function createPerformanceCalculator(perf, play) {
  switch (play.type) {
    case "tragedy":
      return new TragedyCalculator(perf, play)
    case "comedy":
      return new ComedyCalculator(perf, play)
    default:
      throw new Error(`알 수 없는 장르: ${play.type}`)
  }
}

switch는 팩터리 함수 하나에만 남는다. 새 장르(뮤지컬 등)를 추가하려면 서브클래스 하나를 만들고 팩터리에 한 줄을 더하면 된다. 조건 분기가 흩어져 있던 것이 상속 구조 한 곳으로 모였다. 다형성이 만능은 아니지만, 이렇게 같은 조건이 여러 곳에 반복될 때는 강력하다.

전체 흐름 한눈에

앞에서는 각 단계마다 새로 생긴 함수·클래스만 떼어 보여 줬다. 이제 같은 여정을 statement() 전체 스냅샷으로 다시 훑어 보자. Next를 누를 때마다 이전 단계 대비 바뀐 줄이 강조되므로, 한 걸음이 코드 전체를 어떻게 움직이는지가 눈에 들어온다.

Refactoring Step 원본 — 한 덩어리 statement()
1/5
function statement(invoice, plays) {  let totalAmount = 0;  let volumeCredits = 0;  let result = `청구 내역 (고객명: ${invoice.customer})\n`;  const format = new Intl.NumberFormat("en-US", {    style: "currency",    currency: "USD",    minimumFractionDigits: 2  }).format;  for (let 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(`알 수 없는 장르: ${play.type}`);    }    volumeCredits += Math.max(perf.audience - 30, 0);    if ("comedy" === play.type) volumeCredits += Math.floor(perf.audience / 5);    result += `  ${play.name}: ${format(thisAmount / 100)} (${perf.audience}석)\n`;    totalAmount += thisAmount;  }  result += `총액: ${format(totalAmount / 100)}\n`;  result += `적립 포인트: ${volumeCredits}점\n`;  return result;}

한 화면에 다 담기지 않던 마지막 다형성 단계까지, 결국 statement() 본체는 renderPlainText(createStatementData(...)) 단 한 줄로 줄었다. 원본의 거대한 for 문이 계산기 클래스 계층으로 흩어지는 과정을 스냅샷으로 되짚으면, 왜 앞 단계의 정리가 뒤 단계의 발판이 되는지가 분명해진다.

정리

  • 좋은 코드의 기준은 “수정하기 쉬운가”다. 컴파일러는 지저분한 코드도 이해하지만 사람은 못 한다. 리팩터링은 미래의 나와 동료를 위한 투자다.
  • 리팩터링의 단위는 생각보다 훨씬 작다. 함수 하나, 이름 하나. 커밋이 부끄러울 만큼 작아도 된다 — 잘못돼도 방금 그 한 걸음만 되돌리면 되니까.
  • 성능 걱정으로 구조 개선을 미루지 않는다. 먼저 명료한 구조를 만들고, 느려지면 그때 측정해서 잡는다. 잘 다듬어진 코드가 튜닝도 쉽다.
  • 함수 추출 → 임시 변수를 질의 함수로 → 단계 쪼개기 → 다형성. 이 순서는 우연이 아니다. 앞 단계가 뒤 단계를 가능하게 한다. 임시 변수를 없애야 추출이 쉽고, 계산을 분리해야 다형성을 넣을 자리가 생긴다.
  • 나도 Tauri 터미널 앱에서 렌더링과 계산이 뒤섞인 함수를 자주 만나는데, 단계 쪼개기 하나만 익혀도 “새 출력 포맷 추가”가 복붙 지옥에서 벗어난다.