[리팩터링 2판 - ch.1] - 중간점검: 난무하는 중첩함수

늘보·2022년 1월 16일
0

Refactoring

목록 보기
2/4

여기서 잠시 멈춰 서서 지금까지 리팩터링한 결과를 살펴보자.

function statement(invocie, plays) {
  let result = `청구 내역 //...;`;

  for (let perf of invocie.performances) {
    result += `blah blah`;
  }
  result += `총액 ~~`;
  result += `적립 포인트 ~~`;
  return result;
}

function totalAmount() {
  let result = 0;

  for (let perf of invoice.performances) {
    result += amountFor(perf);
  }
  return result;
}

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

function volumeCredits(aPerformance) {
  let result = 0;
  result += Math.max(aPerformance.audience - 30, 0);
  if('comedy' === //..)

  return result;
}

function amountFor(aPerformance) {
  let result = 0;
  // switch(playFor(aPerformance).type) ...

  return result;
}

대략 이런 코드가 나왔을 것이다. (정확한 코드는 책을 통해 확인하자)
코드 구조가 한결 나아졌다. 최상위의 statement() 함수는 이제 단 일곱 줄 뿐이며, 출력할 문장을 생성하는 일만 한다. 계산 로직은 모두 여러 개의 보조 함수로 빼냈다.

결과적으로 로직과 전체 흐름을 좀 더 알아보기 편해졌다!

계산 단계와 포맷팅 단계 분리하기

지금까지는 큰 코드 덩어리를 잘게 쪼개는 작업을 했다. 골격은 충분히 개선됐으니 이제 원하던 기능 변경, 즉 statement() 의 HTML 버전만 작성하면 된다.

저자가 선호하는 개선 방법은 '단계 쪼개기'라고 한다. 목표는 statement()의 로직을 두 단계로 나누는 것이다.

  1. statement() 필요한 데이터를 처리 (=다음 단계에 제공할 데이터 구조 생성)

  2. 처리한 결과를 텍스트나 HTML로 표현하기

단계를 쪼개려면 먼저 두 번째 단계가 도리 코드들을 '함수 추출하기'로 뽑아내야 한다. 이 예에서 두 번째 단계는 청구 내역을 출력하는 코드인데, 현재는 statement() 본문 전체가 여기에 해당한다.

function statement(invoice, plays) {
  return renderPlainText(invoice, plays);
}

function renderPlainText(invocie, plays) {
  let result = `청구 내역 //...;`;

  for (let perf of invocie.performances) {
    result += `blah blah`;
  }
  result += `총액 ~~`;
  result += `적립 포인트 ~~`;
  return result;
}

// ...

이후 두 단계 사이의 중간 데이터 구조 역할을 할 객체를 만들어서 renderPlainText()에 인수로 전달한다.

function statement(invoice, plays) {
 const statementData = {};
 return renderPlainText(statementData, invoice, plays); // 중간 데이터 구조를 인수로 전달
}

function renderPlainText(data, invocie, plays) {
  // ...
}

이번에는 renderPlainText()의 다른 두 인수(invoice, plays)를 살펴보자. 이 인수들을 통해 전달되는 데이터를 모두 방금 만든 중간 데이터 구조로 옮기면

계산 관련 코드는 전부 statement() 함수로 모으고 renderPlainText()data 매개변수로 전달된 데이터만 처리하게 만들 수 있다.

function statement(invoice, plays) {
 const statementData = {};
 statementData.customer = invoice.customer; // 고객 데이터를 중간 데이터로 옮김
 return renderPlainText(statementData, invoice, plays);
}

같은 방식으로 공연 정보까지 중간 데이터 구조로 옮기고 나면 renderPlainText()invoice 매개변수를 삭제해도 된다.

function statement(invoice, plays) {
 const statementData = {};
 statementData.customer = invoice.customer; // 고객 데이터를 중간 데이터로 옮김 1
 statementData.customer = invoice.performances; // 고객 데이터를 중간 데이터로 옮김 2
 return renderPlainText(statementData, plays); // invoice 인수 삭제됨
}

이런 방식으로, 연극 제목도 중간 데이터 구조에서 가져오도록 한다.

function statement(invoice, plays) {
 const statementData = {};
 statementData.customer = invoice.customer; 
 statementData.customer = invoice.performances.map(enrichPerformance); 
 return renderPlainText(statementData, plays); 
}

function enrichPerformance(aPerformance) {
 const result = Object.assign({}, aPerformance); 
 result.play = playFor(result); // 중간 데이터를 연극 정보에 저장
 return result;
  
 function playFor(aPerformance) { // renderPlainText()의 중첩 함수였던 playFor()를 statement()로 옮김
  return plays[aPerformance.playID]; 
 }
}

이 때 저자가 얕은 복사를 진행한 이유는 함수로 건넨 데이터를 수정하기 싫어서라고 한다. 가변 데이터는 금방 상하기 때문에 저자는 데이터를 최대한 불변처럼 취급한다고 한다. 이 부분은 React의 '상태값 불변성 유지'와 일맥상통하는 부분이다.

이렇게 다른 함수들도 statement()로 옮긴다.

function createStatementData(invoice, plays) {
  // 상태값으로 옮기기
  const result = {};
  result.customer = invoice.customer;
  result.performances = invoice.performances.map(enrichPerformance);
  result.totalAmount = totalAmount(result);
  result.totalVolumeCredits = totalVolumeCredits(result);
  return result; 
}

이제 상태 데이터를 작성하는 코드가 모두 끝났다. 결과를 보자. (Calculator - 계산 관련 코드는 잠시 보지 말자)

export default function createStatementData(invoice, plays) {
  const result = {};
  result.customer = invoice.customer;
  result.performances = invoice.performances.map(enrichPerformance);
  result.totalAmount = totalAmount(result);
  result.totalVolumeCredits = totalVolumeCredits(result);
  return result;

  function totalAmount(data) {
    return data.performances.reduce((total, pref) => total + pref.amount, 0);
  }

  function totalVolumeCredits(data) {
    return data.performances.reduce(
      (total, perf) => total + perf.volumeCredits,
      0
    );
  }

  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 playFor(aPerformance) {
    return plays[aPerformance.playID];
  }
}

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}`);
  }
}

class PerformanceCalculator {
  constructor(aPerformance, aPlay) {
    this.performances = aPerformance;
    this.play = aPlay;
  }

  get amount() {
    throw new Error("서브클래스에서 처리하도록 설계되었습니다.");
  }

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

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

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

  get volumeCredits() {
    return super.volumeCredits + Math.floor(this.performances.audience / 5);
  }
}
// 출력용 코드
import plays from "./plays.json";
import invoices from "./invoices.json";
import createStatementData from "./createStatementData";

function statement(invoice, plays) {
  return renderPlainText(createStatementData(invoice, plays)); // 중간 데이터 생성 함수를 공유
}

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;
}

function htmlStatement(invoice, plays) {
  return renderHtml(createStatementData(invoice, plays));
}

function renderHtml(data) {
  let result = `<h1>청구 내역 (고객명: ${data.customer})</h1>`;
  result += "<table>\n";
  result += "<tr><th>연극</th><th>좌석 수</th><th>금액</th></th>";
  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`;
}

function usd(aNumber) {
  return new Intl.NumberFormat("en-US", {
    style: "currency",
    currency: "USD",
    minimumFractionDigits: 2,
  }).format(aNumber / 100);
}

invoices.map((invoice) => console.log(statement(invoice, plays)));

중간 점검: 두 파일(과 두 단계)로 분리됨

지금 파일은 createStatementData 함수와 renderHTML 함수로 분리되었다. 덕분에 코드의 라인이 기존보다 두 배는 늘어났지만, 로직을 담당하는 부분과 출력을 담당하는 부분이 명확히 구분되었다.

다형성을 활용해 계산 코드 재구성하기

amountFor 함수를 보면 연극 장르에 따라 계산 방식이 달라진다는 사실으 알 수 있는데, 이런 형태의 조건부 로직은 코드 수정 횟수가 늘어날수록 골칫거리로 전락하기 쉽다.

조건부 로직을 명확한 구조로 보완하는 방법은 다양하지만, 여기서는 객체지향의 핵심 특성인 다형성(polymorphism)을 활용하는 것이 자연스럽다.

이 리팩터링을 적용하려면 상속 계층 부터 정의해야 한다. 즉, 공연료와 계산 함수를 담을 클래스가 필요하다.

앞에서 수행한 리팩터링 덕분에 (출력 데이터 구조를 수정하지 않는 한) 출력 포멧 관련 코드에는 신경 쓸 필요가 없다.

공연료 계산기 만들기

여기서 핵심은 각 공연의 정보를 중간 데이터 구조에 채워주는 enrichPerformance() 함수다. 현재 이 함수는 조건부 로직을 포함한 함수인 amountFor(), volumeCreditsFor()를 호출하여 공연료와 적립 포인트를 계산한다.

이번에 할 일은 이 두 함수를 전용 클래스로 옮기는 작업이다. 이 공연료 계산기 클래스는 PerformanceCalculator라 부르기로 하겠다.

class PerformanceCalculator {
  constructor(aPerformance, aPlay) {
    this.performances = aPerformance;
    this.play = aPlay;
  }
}

함수들을 계산기로 옮기기

가장 먼저 할 일은 공연료 계산 코드를 클래스 안으로 복사하는 것이다. 그런 다음 이 코드가 새로운 곳에서 잘 동작하도록 aPerformancethis.performance로 바꾸고 playfor(aPerformance)this.play로 바꿔준다.

playFor(aPerformance) -> this.play // 이렇게 변경해준다.

공연료 계산기를 다형성 버전으로 만들기

클래스에 로직을 담았으니 이제 다형성을 지원하게 만들어보자. 가장 먼저 할 일은 '타입 코드를 서브 클래스로 사용하도록 변경하는 것이다.'

P.70 내용은 이해가 잘 되지 않았습니다. 타입 코드를 서브클래스로 변경하기, 생성자를 팩토리 함수로 바꾸기 등은 아직 명확하게 이해가 되질 않아서 추후에 이해하고 재수정하겠습니다.

리팩터링이 끝난 코드

// createStatementData.js
export default function createStatementData(invoice, plays) {
  const result = {};
  result.customer = invoice.customer;
  result.performances = invoice.performances.map(enrichPerformance);
  result.totalAmount = totalAmount(result);
  result.totalVolumeCredits = totalVolumeCredits(result);
  return result;

  function totalAmount(data) {
    return data.performances.reduce((total, pref) => total + pref.amount, 0);
  }

  function totalVolumeCredits(data) {
    return data.performances.reduce(
      (total, perf) => total + perf.volumeCredits,
      0
    );
  }

  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 playFor(aPerformance) {
    return plays[aPerformance.playID];
  }
}

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}`);
  }
}

class PerformanceCalculator {
  constructor(aPerformance, aPlay) {
    this.performances = aPerformance;
    this.play = aPlay;
  }

  get amount() {
    throw new Error("서브클래스에서 처리하도록 설계되었습니다.");
  }

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

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

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

  get volumeCredits() {
    return super.volumeCredits + Math.floor(this.performances.audience / 5);
  }
}
import plays from "./plays.json";
import invoices from "./invoices.json";
import createStatementData from "./createStatementData";

function statement(invoice, plays) {
  return renderPlainText(createStatementData(invoice, plays));
}

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;
}

function htmlStatement(invoice, plays) {
  return renderHtml(createStatementData(invoice, plays));
}

function renderHtml(data) {
  let result = `<h1>청구 내역 (고객명: ${data.customer})</h1>`;
  result += "<table>\n";
  result += "<tr><th>연극</th><th>좌석 수</th><th>금액</th></th>";
  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`;
}

function usd(aNumber) {
  return new Intl.NumberFormat("en-US", {
    style: "currency",
    currency: "USD",
    minimumFractionDigits: 2,
  }).format(aNumber / 100);
}

invoices.map((invoice) => console.log(statement(invoice, plays)));

1장을 마치며

저자가 생각하는 좋은 코드란, '얼마나 수정하기 쉬운가'에 중점을 두고 있다. 저자는 코드를 수정하기 쉬울수록 고쳐야 할 곳을 쉽게 찾을 수 있고, 생산성이 극대화된다고 한다.

저자가 말한 리팩터링을 효과적으로 하는 핵심은, 단계를 잘게 나눠야 더 빠르게 처리할 수 있다는 것이었다.

1장을 마친 내 생각은, 오히려 어느 부분은 이해하기 어려웠고, 오히려 코드의 흐름을 파악하기 어렵다. 라고 생각했었다. 그러나 이건 명백하게 '내가 이 코드를 이해할 수 있는가?'라는 관점으로, 이러한 코드 스타일을 처음 접했기 때문에 내가 이해하기 어려운 것이 당연하다.

어차피 자신이 작성한 코드가 아니라면 다른 누구의 코드도 이해하기 어려운 것은 똑같다. '이해하기 쉬운 코드'가 리팩터링의 목적이 아니라, 저자는 '수정이 얼마나 쉬운가'에 그 목적을 두고 있다.

그동안 리팩터링의 정확한 목적도 모르고 리팩터링! 리팩터링! 이랬던 나를 반성한다..!

결론

  1. 로직을 담당하는 부분과 출력을 담당하는 부분 분리하기

  2. 조건부 로직을 다형성으로 바꾸기

0개의 댓글