Skip to main content

1장 리팩터링: 첫 번째 예시

written by
saengmotmi
saengmotmi 🏆Front End Engineer

예시로 시작하는 이유#

리팩터링이 역사와 원칙을 무작정 나열하는 것보다 보다 구체적이고 실전적인 예시를 들어 설명하는 것이 좋다고 생각했기 때문이다. 먼저 예시를 통해 어떤 것들을 리팩터링이라고 부를 수 있는지에 대해 감을 잡고, 그 다음 세부 항목들에 대해 설명하시는 방식을 택하겠다는 것이다.

아래 코드들이 다양한 연극을 외주로 받아 공연하는 극단의 시스템 일부라고 생각하고 살펴보자. 요구 사항은 다음과 같다.

  • 공연 요청이 들어오면 연극의 장르와 관객 규모를 기초로 비용을 책정한다.
  • 이 극단은 두 가지 장르, 비극과 희극만 공연한다.
  • 공연료와 별개로 포인트(volume credit)를 지급해서 다음번 의뢰시 공연료를 할인받을 수도 있다.

:: Before Refactoring#

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 ("comendy" === 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;  }}

코드는 무리 없이 동작하고 함수는 위에서 아래로 훑으며 충분히 이해할 수 있는 수준의 복잡도를 갖고 있다. 굳이 이 코드를 리팩터링 해야할 이유는 뭘까?

"프로그램이 잘 작동하는 상황에서 그저 코드가 '지저분하다'는 이유로 불평하는 것은 프로그램의 구조를 너무 미적인 기준으로만 판단하는 건 아닐까? (...) 하지만 그 코드를 수정하려면 사람이 개입되고, 사람은 코드의 미적 상태에 민감하다. 설계가 나쁜 시스템은 수정하기 어렵다. (...) 무엇을 수정할지 찾기 어렵다면 실수를 저질러서 버그가 생길 가능성도 높아진다." - 26p

프로그램이 새로운 기능을 추가하기에 편한 구조가 아니라면, 먼저 기능을 추가하기 쉬운 형태로 리팩터링하고 나서 원하는 기능을 추가한다. - 27p

훗날 변경에 기민하게 대응하기 위해서다. 예를 들면 다음과 같은 몇 가지 수정 사항이 있다.

  • 청구 내역을 HTML로 출력하는 기능 추가
  • 희극, 비극 뿐만 아니라 여러 가지 장르의 연극에 대한 확장에도 대응
  • 장르 추가에 따라 공연료와 적립 포인트 계산에 영향을 줄 수 있음

1) 테스트 코드 작성#

먼저 테스트 코드를 작성해야 한다. 몇 가지 입력 값을 준비해두고 현재 statement 함수가 리턴하는 값이 옳은지 테스트하는 코드를 준비한다. 매 번경마다 테스트가 올바르게 동작하는지 확인해야 한다.

2) 함수 쪼개기 (함수 추출하기 - 6.1절)#

statement 함수는 충분히 길게 느껴진다. 여기서 길다는 의미는 하나의 전체 동작이 여러 개의 부분 동작으로 나뉘어질 수 있다는 뜻이다.

함수를 살펴보면 switch 문 부분이 눈에 띈다. 공연 요금을 계산하는 부분이다. 이 사실을 알기 위해서는 switch 문 전체를 분석해야만 한다. 만약 코드에 이러한 의미가 반영되어 있다면 굳이 코드의 세부 사항을 몰라도 의미 파악이 가능할 것이다.

그러면 해당 switch 문을 amountFor(aPerformance)와 같은 형태로 분리하는 것이 좋다.

function statement()
for (let perf of aPerformances) {  const play = plays[perf.playID];  let thisAmount = amountFor(perf, play); // switch 문 이하를 추출한 함수  // ...}

3) play 변수 제거하기 (임시 변수를 질의함수로 바꾸기 - 7.4절)#

amountFor의 매개변수를 살펴보니 amoutFor에 전달하는 playperf를 사용해 얻어낼 수 있는 값이다. 그렇다면 굳이 인자로 받지 않고, 매 루프마다 다시 계산하는 방식으로 사용할 수 있다.

function statement()
for (let perf of aPerformances) {  let thisAmount = amountFor(perf); // play 매개 변수 제거  // ...}
function amountFor()
function playFor(aPerformance) {  return plays[aPerformance.playID];}
function amountFor()
function amountFor(aPerformance) {  let result = 0  switch (playFor(aPerformance).type) {    case "tragedy": // 비극      thisAmount = 40000;      if (perf.audience > 30) {        thisAmount += 1000 * (perf.audience - 30);      }      break;    // ...

4) thisAmount 변수 제거 (변수 인라인하기 - 6.4절)#

amountFor 함수는 thisAmount의 초기값을 설정하는데만 쓰이고, 추후 변경되지는 않는다. 이 경우 변수를 인라인 하는 방식으로 바꾸어 불필요한 변수를 줄일 수 있다.

  for (let perf of invoice.performances) {    // thisAmount 변수 삭제    volumeCredits += Math.floor(perf.audience - 30, 0)    // ...
// 청구 내역을 출력한다.result += ` ${play.name}: ${format(amountFor(perf) / 100)} (${  perf.audience}석)\n`;totalAmount += amountFor(perf);result += `총액: ${format(totalAmount / 100)}\n`;result += `적립 포인트: ${volumeCredits}점\n`;return result;

5) volumeCreditsFor 함수 추출하기#

포인트를 계산하는 부분도 별도 함수로 분리할 수 있다. 추출한 함수를 루프문 내에서 사용해 volumeCredits 변수에 값을 누적시켜줄 수 있다.

function volumeCreditsFor(perf) {  let volumeCredits = 0;  volumeCredits += Math.max(perf.audience - 30, 0);  if ("comendy" === playFor(perf).type)    volumeCredits += Math.max(perf.audience / 5);  return volumeCredits;}
function statement
  for (let perf of invoice.performances) {    volumeCredits += volumeCreditsFor(perf)    // ...

6) format 변수 제거하기 (함수 선언 바꾸기 - 6.5절)#

화폐 단위를 표현하기 위해 사용한 format은 임시변수에 함수 자체를 저장해둔 형태다. 이 함수는 총액을 구할 때 총액: ${format(totalAmount / 100)}와 같은 형태로 쓰이게 된다.

이 함수의 용도와 맥락을 살려 별도 함수로 분리해보자.

function usd(aNumber) {  return new Intl.NumberFormat("en-US", {    style: "currency",    currency: "USD",    minimumFractionDigits: 2,  }).format(aNumber / 100); // 단위 변환 로직도 한번에 처리하자}
function statement()
result += `총액: ${usd(amountFor(perf))}\n`;

7) volumeCredits 변수 제거하기 (반복문 쪼개기 - 8.7절, 문장 슬라이드하기 - 8.6절)#

volumeCredits 변수는 반복문을 돌 때마다 값을 누적하기 때문에 리팩터링하기 까다롭다. 그래서 먼저 반복문 쪼개기로 별도 반복문으로 분리한 다음, 문장 슬라이드하기를 적용해 volumeCredits 변수 선언하는 문장을 반복문 바로 앞으로 옮긴다.

반복문의 갯수가 하나 더 늘긴 하지만 효과는 미미할 것이다. 경험 많은 프로그래머조차 코드의 실제 성능을 정확히 예측하지 못하고, 최근 컴파일러들은 캐싱 기법 등으로 이러한 부분을 대부분 최적화해 준다.

성능이 크게 떨어졌다면 필요한 시점에 측정 후 개선하면 된다.

function statement()
function statement(invoice, plays) {  let totalAmount = 0;  let result = `청구 내역 (고객명: ${invoice.customer})\n`;  for (let perf of invoice.performances) {    // 청구 내역을 출력한다    result += ` ${playFor(perf).name}: ${usd(      amountFor(perf)    )} ({perf.audience}석)\n`;    totalAmount += amountFor(perf);  }  let volumeCredits = 0;  for (let perf of invoice.performances) {    volumeCredits += volumeCreditsFor(perf);  }  result += `총액: ${usd(totalAmount)}`;  result += `적립 포인트: ${volumeCredits}`;  return result;}

이제 해당 반복문을 함수로 추출한 다음, volumeCredits 변수를 해당 함수로 인라인한다. statement 함수 본문이 한결 가벼워졌다.

function statement()
function statement(invoice, plays) {  let totalAmount = 0;  let result = `청구 내역 (고객명: ${invoice.customer})\n`;  for (let perf of invoice.performances) {    // 청구 내역을 출력한다    result += ` ${playFor(perf).name}: ${usd(      amountFor(perf)    )} ({perf.audience}석)\n`;    totalAmount += amountFor(perf);  }  result += `총액: ${usd(totalAmount)}`;  result += `적립 포인트: ${totalVolumeCredits()}`;  return result;}

8. 계산 단계와 포맷팅 단계 분리하기 (단계 쪼개기 - 6.11절)#

지금까지는 프로그램의 논리적인 요소를 파악하기 쉽도록 코드의 구조를 보강하는데 집중했다. 이제 원하던 기능 변경(statement 함수의 결과물을 HTML로 출력)을 해보자.

이제 HTML 버전 함수만 작성하면 된다. 계산하는 코드 - 렌더링하는 코드 두 단계로 나누어보자. 그리고 고객 데이터는 statement에서 중간데이터로 옮기고, invoice 매개변수는 삭재하자.

// 계산function statement(invoice, plays) {  const statementData = {}  statementData.customer = invoice.customer;  statementData.performances = invoice.performances.map(enrichPerformance);  statementData.totalAmount = totalAmountFor(statementData);  statementData.totalVolumeCredits = totledVolumeCreditsFor(statementData);
  return renderPlainText(statementData, plays);
  function enrichPerformance(aPerformance) {    const result = Object.assign({}, aPerformance)    result.play = playFor(result)    result.amount = amountFor(result)    result.volumeCredits = volumeCreditsFor(result)    return result  }}
// 렌더링function renderPlainText(invoice, plays) { ... }

9. 반복문을 파이프라인으로 바꾸기 (8.8절)#

function renderPlainText()
function totalAmount(data) {  return data.performances.reduce((total, p) => total + p.amount, 0);}
function totalVolumeCredits(data) {  return data.performances.reduce((total, p) => total + p.volumeCredits, 0);}

10. 중간 데이터 생성하는 함수 분리 & HTML 생성에 필요한 함수 준비#

function createStatementData(invoice, plays) {  const statementData = {};  statementData.customer = invoice.customer;  statementData.performances = invoice.performances.map(enrichPerformance);  statementData.totalAmount = totalAmountFor(statementData);  statementData.totalVolumeCredits = totledVolumeCreditsFor(statementData);  return statementData;}
function statement(invoice, plays) {  return renderPlainText(createStatementData(invoice, plays));}
function htmlStatement(invoice, plays) {  return renderPlainText(createStatementData(invoice, plays));}
function renderHtml(data) {  let result = `<h1>청구 내역 (고객명: ${data.customer})</h1>\n`;  result += "<table>\n";  result += "<tr><th>연극</th><th>좌석 수</th><th>금액</th></tr>";  for (let perf of data.performances) {    result += `  <tr><td>${perf.play.name}</td><td>(${perf.audience}석)</td>`;    result += `<td>${usd(perf.amount)}</td></tr>\n`;  }  result += "</table>\n";  result += `<p>총액: <em>${usd(data.totalAmount)}</em></p>\n`;  result += `<p>적립 포인트: <em>${data.totalVolumeCredits}</em>점</p>\n`;  return result;}

11. 다형성을 활용해 계산 코드 재구성하기 (조건부 로직을 다형성으로 바구기 - 10.4절)#

HTML 데이터를 출력하는 기능은 잘 정리되었다. 이제 희극, 비극 뿐만 아니라 여러 가지 장르의 연극에 대한 확장에도 대응할 수 있도록 계산 로직을 확장성 있게 손볼 차례다.

현재 코드 상으로는 연극 장르가 늘어남에 따라 메인 계산 로직의 코드도 함께 수정해줘야 한다. 이를 방지하려면 로직 대신 구조로 보완하는 것이 좋다.

이번 작업은 상속 계층을 구성해서 각 장르를 서브클래스로 만들고, 그 서브클래스에서 각자의 구체적인 계산 로직을 정의해주도록 하자. 그러면 호출하는 쪽에서는 다형성 버전의 공연료 계산 함수를 호출하기만 하면되고, 장르에 따른 계산 함수는 언어에서 처리해줄 것이다. 적립 포인트도 비슷한 구조로 만들어줄 수 있다.

그렇다면 먼저 공연료와 적립 포인트 계산 함수를 담을 클래스를 만들어줘야 한다.

코드를 살펴보자. enrichPerformance() 함수는 각 공연의 정보를 중간 데이터 구조에 채워준다. 내부적으로 amountForvolumeCreditsFor를 호출하여 공연료와 적립 포인트를 계산한다. 따라서 이 두 함수를 전용 클래스로 옮겨야 한다.

공연 관련 데이터를 계산하는 함수들로 구성되므로 PerformanceCalculator라고 이름 붙여주자.

function enrichPerformance(aPerformance) {  const calculator = new PerformanceCalculator( // 공연료 계산기 생성    aPerformance,    playFor(aPerformance) // 공연 정보를 계산기로 전달  );  const result = Object.assign({}, aPerformance);  result.play = playFor(result);  result.amount = amountFor(result);  result.volumeCredits = volumeCreditsFor(result);  return result;}
class PerformanceCalculator {  constructor(aPerformance, aPlay) {    this.performance = aPerformance;    this.play = aPlay;  }}

아직 할 수 있는 일이 없다. 기존 코드에서 하던 동작 몇 가지를 클래스로 옮겨보자.

12. amountFor에서 PerformanceCalculator의 메서드를 호출하도록 하기 (함수 옮기기 - 8.1절)#

공연료 계산 코드를 클래스 안오로 옮기고, 원본 로직에서는 PerformanceCalculator를 호출하도록 수정해주자. 문제 없었다면 적립 포인트 또한 같은 방식으로 옮겨준다.

class PerformanceCalculator
get amount() {  let result = 0;  switch (this.play.type) {    case "tragedy":    // ...}
function amountFor
function amountFor(aPerformance) {  return new PerformanceCalculator(aPerformance, playFor(aPerformance).amount);}
function enrichPerformance()
function enrichPerformance(aPerformance) {  const calculator = new PerformanceCalculator( // 공연료 계산기 생성    aPerformance,    playFor(aPerformance) // 공연 정보를 계산기로 전달  );  const result = Object.assign({}, aPerformance);  result.play = calculator.play;  result.amount = calculator.amount;  result.volumeCredits = calculator.volumeCredits;  return result;}

13. 공연료 계산기를 다형성 버전으로 만들기 (타입 코드를 서브클래스로 바꾸기 - 12.6절, 생성자를 팩터리 함수로 바꾸기 - 11.8절)#

로직을 클래스로 옮겼으니 이제 다형성을 지원해보자. 가장 먼저 할 일은 타입 코드 대신 서브클래스를 사용하도록 하는 것이다. (타입 코드를 서브클래스로 바꾸기 - 12.6절)

PerformanceCalculator의 서브클래스들을 준비하고 createStatementData()에서 그 중 적합한 서브클래스를 사용하게 만들어야 한다.

딱 맞는 서브클래스를 사용하려면 생성자 대신 함수를 호출하도록 바꿔야 한다. (자바스크립트에서는 생성자가 서브클래스의 인스턴스를 반환할 수 없기 때문이다?)

그래서 생성자를 팩터리 함수로 바꾸기를 적용한다. 다음과 같이 팩터리 함수를 이용하면 서브클래스 중에 어떤 것을 생성할지 선택할 수 있다.

amount 메서드는 서브클래스에서 호출하도록 되어있으므로 슈퍼클래스에서는 에러를 리턴하도록 남겨두면 좋다.

그리고 일반적인 경우를 기본으로 삼아 슈퍼클래스에 남겨두고, 장르마다 달라지는 부분은 필요한 경우 오버라이드 하도록 하자.

function createStatementData()
function enrichPerformance(aPerformance) {  // 생성자 대신 팩터리 함수 이용  const calculator = createPerformanceCalculator(    aPerformance,    playFor(aPerformance) // 공연 정보를 계산기로 전달  );  const result = Object.assign({}, aPerformance);  result.play = calculator.play;  result.amount = calculator.amount;  result.volumeCredits = calculator.volumeCredits;  return result;}
function createPerformanceCalculator(aPerformance, aPlay) {  switch (aPlay.type) {    case "tragedy":      return new TragedyCalculator(aPerformance, aPlay);    case "comedy":      return new ComedyCalculator(aPerformance, aPlay);    default:      throw new Error(`알 수 없는 장르: ${aPlay.type}`);  }
  get amount() {    throw new Error(`서브클래스에서 처리하도록 설계되었습니다.`)  }
  get volumeCredits() {    return Math.max(this.performance.audience - 30, 0)  }}
class TragedyCalculator extends PerformanceCalculator {}class ComedyCalculator extends PerformanceCalculator {}
class ComedyCalculator
get volumeCredits() {  return super.volumeCredits + Math.floor(this.performance.audience / 5)}

:: After Refactoring#

// statement.jsexport { statement };export { htmlStatement };
import { createStatementData } from "./createStatementData.js";
function usd(aNumber) {  return new Intl.NumberFormat("en-US", {    style: "currency",    currency: "USD",    minimumFractionDigits: 2,  }).format(aNumber / 100);}
function renderPlainText(statementData) {  let result = `Statement for ${statementData.customer}\n`;  for (let perf of statementData.performances) {    result += `  ${perf.play.name}: ${usd(perf.amount)} (${      perf.audience    } seats)\n`;  }
  result += `Amount owed is ${usd(statementData.totalAmount)}\n`;  result += `You earned ${statementData.totalVolumeCredits} credits\n`;  return result;}
function renderHtml(data) {  let result = `<h1>Statement for ${data.customer}</h1>\n`;  result += "<table>\n";  result += "<tr><th>play</th><th>seats</th><th>cost</th></tr>";  for (let perf of data.performances) {    result += `  <tr><td>${perf.play.name}</td><td>${perf.audience}</td>`;    result += `<td>${usd(perf.amount)}</td></tr>\n`;  }  result += "</table>\n";  result += `<p>Amount owed is <em>${usd(data.totalAmount)}</em></p>\n`;  result += `<p>You earned <em>${data.totalVolumeCredits}</em> credits</p>\n`;  return result;}
function htmlStatement(invoice, plays) {  return renderHtml(createStatementData(invoice, plays));}
function statement(invoice, plays) {  return renderPlainText(createStatementData(invoice, plays));}
// createStatementData.jsexport { createStatementData };
class PerformanceCalculator {  constructor(aPerformance, aPlay) {    this.performance = aPerformance;    this.play = aPlay;  }
  get amount() {    throw new Error("subclass responsibility");  }}
class TragedyCalculator extends PerformanceCalculator {  get amount() {    let result = 40000;    if (this.performance.audience > 30) {      result += 1000 * (this.performance.audience - 30);    }    return result;  }
  get volumeCredits() {    return Math.max(this.performance.audience - 30, 0);  }}
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() {    let volumeCredits = Math.max(this.performance.audience - 30, 0);    // add extra credit for every ten comedy attendees    volumeCredits += Math.floor(this.performance.audience / 5);    return volumeCredits;  }}
function createPerformanceCalculator(aPerformance, aPlay) {  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}`);  }}
function createStatementData(invoice, plays) {  let statementData = {};  statementData.customer = invoice.customer;  statementData.performances = invoice.performances.map(enhancePerformance);  statementData.totalVolumeCredits = totalVolumeCredits(statementData);  statementData.totalAmount = totalAmount(statementData);  return statementData;
  function enhancePerformance(aPerformance) {    const calculator = createPerformanceCalculator(      aPerformance,      playFor(aPerformance)    );    const result = Object.assign({}, aPerformance);    result.play = calculator.play;    result.amount = calculator.amount;    result.volumeCredits = calculator.volumeCredits;    return result;  }
  function totalVolumeCredits(statementData) {    return statementData.performances.reduce(      (total, performance) => total + performance.volumeCredits,      0    );  }
  function totalAmount(statementData) {    return statementData.performances.reduce(      (total, aPerformance) => total + aPerformance.amount,      0    );  }
  function playFor(aPerformance) {    return plays[aPerformance.playID];  }}

마무리#

위 예제에서 우리는 여러가지 자잘한 리팩터링 기법의 단계를 밟으며 코드를 고쳐나갔다. 한번에 코드를 새로 짜는 것이 아니라, 최종적으로는 사라질 함수라도 중간 단계로서 코드를 정리하는데 필요하다면 단계별로 정리하면서 나아갔다.

위에서 진행한 리팩터링은 크게 세 단계로 진행됐다.

  1. 원본 함수를 중첩 함수 여러 개로 나눴다
  2. 단계 쪼개기를 적용해서 계산 코드와 출력 코드를 분리했다
  3. 계산 로직을 다형성으로 표현했다

이번 예시를 통해 앞으로 진행할 리팩터링이 어떤 단계로, 어떤 리듬으로 진행되는지 감을 잡았기를 바란다.