[리팩터링 2판] - 리팩터링: 첫 번째 예시

Lee Jeong Min·2022년 7월 31일
1

리팩터링 2판

목록 보기
1/12
post-thumbnail

이번에 사내에서 리팩터링 2판 스터디를 진행한다. 책을 읽으며 지금까지 그래왔듯이, 블로그에 정리하며 책을 읽어보고자 한다.

리팩터링 2판의 Chatper 01을 보고 정리한 글입니다.

예시 프로그램 소개

책에서는 다양한 연극을 외주로 받아서 공연하는 극단의 프로그램을 예시로 사용한다.

다음과 같이 3가지 파일이 있는 상태에서 예시 코드를 설명한다.

plays.json

{
  "hamlet": { "name": "Hamlet", "type": "tragedy" },
  "as-like": { "name": "As You Like It", "type": "comedy" },
  "othello": { "name": "Othello", "type": "tragedy" }
}

invoices.json

[
  {
    "customer": "BigCo",
    "performances": [
      {
        "playID": "hamlet",
        "audience": 55
      },
      {
        "playID": "as-like",
        "audience": 35
      },
      {
        "playID": "othello",
        "audience": 40
      }
    ]
  }
]

공연료 청구서를 출력하는 코드

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

  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(`알 수 없는 장르: ${play.type}`);
    }

    volumeCredits += Math.max(perf.audience - 30, 0);

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

예시 프로그램에 대한 소감

예시 프로그램을 보면, 이 상태로도 잘 작동하므로 쓸만하다는 생각이 들지만, 살짝 지저분한 것을 확인할 수 있다.

그렇다면 지저분하다는 이유로 불평하는 것은 너무 프로그램을 미적인 기준으로만 판단하는 것이 아닐지 의문을 제기한다.

하지만 코드를 수정하려면 사람이 개입되고, 사람은 코드의 미적 상태에 민감하기 때문에 이를 반박한다.

→ 또한 프로그램에 새로운 기능을 추가할때, 기능을 추가하기 쉬운 형태로 리팩터링을 하기위해 수정하기 쉬운 형태로 시스템을 설계해야함을 말한다.

현재의 시스템은 연극 장르와 공연료 정책이 달라질 때마다 statement() 함수를 수정해야하므로 불편하다.

리팩터링의 첫 단계

리팩터링을 하기 전, 리팩터링 코드를 검사해줄 테스트 코드들 부터 작성하자.

이러한 테스트는 반드시 자가진단(개발자가 일일이 눈으로 비교할 필요없이)하도록 만들어야 한다.

statement() 함수 쪼개기

현재 statement() 함수 안에있는 switch 문을 함수로 빼보자.

책에서 이 부분을 리팩토링하는 과정을 함수 추출하기라는 용어로 부른다.

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

  function amountFor(aPerformance, play) {
    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(`알 수 없는 장르: ${play.type}`);
    }
    return result;
  }

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

    volumeCredits += Math.max(perf.audience - 30, 0);

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

책에선 다음과 같이 amountFor이라는 함수로 리팩터링을 진행하였다. 이 부분을 보면서 함수명이 동사형이 아니여서 신경쓰였는데 이 부분은 스터디때 논의해보면 좋을것 같다.

또한 위 코드에서 amountFor로 넘겨주는 매개변수명을 perf에서 aPerformance로 변경하였는데 매개변수의 역할이 뚜렷하지 않을 때 글쓴이는 부정 관사(a/an)를 붙인다고 한다.

이 부분에 대해선 한국인이라 그렇게 와닿지는 않았던것 같다.

play 변수 제거하기

현재는 반복문 안에 다음과 같은 임시변수가 존재한다.

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

이러한 임시변수는 aPerformance 라는 변수에서 얻기 때문에 매개변수로 전달할 필요가 없으므로 이를 추출해보자.

책에서는 이러한 리팩터링을 임시 변수를 질의 함수로 바꾸기라고 부른다.

최종적으로 임시 변수 질의 함수로 바꾸기 → 변수 인라인하기 → 함수 선언 바꾸기를 거쳐 아래와 같은 코드가 나온다.

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

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

  function amountFor(aPerformance) {
    let result = 0;

    switch (playFor(aPerformance).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(`알 수 없는 장르: ${playFor(aPerformance).type}`);
    }
    return result;
  }

  for (const perf of invoice.performances) {
    volumeCredits += Math.max(perf.audience - 30, 0);

    if (playFor(perf).type === 'comedy') volumeCredits += Math.floor(perf.audience / 5);

    result += `${playFor(perf).name}: ${format(amountFor(perf) / 100)} (${perf.audience}석)\n`;
    totalAmount += amountFor(perf);
  }

  result += `총액: ${format(totalAmount / 100)}\n`;
  result += `적립 포인트: ${volumeCredits}점\n;`;
  return result;
}

이렇게 코드를 바꾸고 난뒤 처음든 생각은 변수 인라인 하기 과정에서 임시 변수를 제거한뒤, 함수를 여러번 호출하는 코드로 바뀌었는데 작업이 오래 걸리는 함수라면 성능에 영향을 미치지 않을까 걱정하였다.

그런데 바로 아래에서 지역 변수를 제거해서 얻는 가장 큰 장점으로 추출 작업이 훨씬 쉬워진다는 것을 이야기한다.

적립 포인트 계산 코드 추출하기

함수를 제외한 현재 statement() 코드는 다음과 같다.

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

  for (const perf of invoice.performances) {
    volumeCredits += Math.max(perf.audience - 30, 0);

    if (playFor(perf).type === 'comedy') volumeCredits += Math.floor(perf.audience / 5);

    result += `${playFor(perf).name}: ${format(amountFor(perf) / 100)} (${perf.audience}석)\n`;
    totalAmount += amountFor(perf);
  }

  result += `총액: ${format(totalAmount / 100)}\n`;
  result += `적립 포인트: ${volumeCredits}점\n;`;
  return result;
}

이제 여기서 volumeCreditsformat 변수를 리팩토링 해보자. 각각 함수로 추출하면 다음과 같다.

// 실제로는 인라인 함수로 들어가 있음
  function volumeCreditsFor(aPerformance) {
    let volumeCredits = 0;
    volumeCredits += Math.max(aPerformance.audience - 30, 0);

    if (playFor(aPerformance).type === 'comedy') volumeCredits += Math.floor(aPerformance.audience / 5);

    return volumeCredits;
  }

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

// ------------------------------
// 내부 statement 코드
function statement(invoice, plays) {
  let totalAmount = 0;
  let volumeCredits = 0;
  let result = `청구 내역 (고객명: ${invoice.customer})\n`;

  for (const perf of invoice.performances) {
    volumeCredits += volumeCreditsFor(perf);

    result += `${playFor(perf).name}: ${format(amountFor(perf) / 100)} (${perf.audience}석)\n`;
    totalAmount += amountFor(perf);
  }

  result += `총액: ${format(totalAmount / 100)}\n`;
  result += `적립 포인트: ${volumeCredits}점\n;`;
  return result;
}

volumeCredits 변수 제거하기

이 변수는 현재 위 statement() 함수에서 반복문을 한 바퀴 돌 때마다 값을 누적하기 때문에 리팩터링하기가 까다롭다. 따라서 반복문 쪼개기(변수로 값을 누적시키는 부분을 분리한다.)문장 슬라이드하기(변수 초기화 문장을 변수 값 누적 코드 바로 앞으로 옮긴다.), 임시 변수를 질의 함수로 바꾸기(적립 포인트 계산 부분을 별도 함수로 추출한다.), 변수 인라인하기(volumeCredits 변수를 제거한다.)를 사용하여 누적되는 부분을 따로 빼보자.

변경된 부분만 보면 다음과 같다.

// 실제로는 인라인으로 들어가있는 함수
  function totalVolumeCredits() {
    let volumeCredits = 0;
    for (const perf of invoice.performances) {
      volumeCredits += volumeCreditsFor(perf);
    }
    return volumeCredits;
  }


function statement(invoice, plays) {
  let totalAmount = 0;
  let result = `청구 내역 (고객명: ${invoice.customer})\n`;

  for (const perf of invoice.performances) {
    result += `${playFor(perf).name}: ${usd(amountFor(perf))} (${perf.audience}석)\n`;
    totalAmount += amountFor(perf);
  }

  result += `총액: ${usd(totalAmount)}\n`;
  result += `적립 포인트: ${totalVolumeCredits()}점\n;`;
  return result;
}

이처럼 반복문을 쪼개서 성능이 느려지는 것을 걱정하는 경우도 많은데, 이 정도 중복은 성능에 미치는 영향이 미미한 경우가 많다고 한다.

totalAmount 제거하기

위 절차와 똑같이 제거하면 다음과 같은 코드가 된다.

// 실제론 인라인 함수로 들어가 있음
  function totalAmount() {
    let result = 0;
    for (const perf of invoice.performances) {
      result += amountFor(perf);
    }
    return result;
  }


function statement(invoice, plays) {
  let result = `청구 내역 (고객명: ${invoice.customer})\n`;

  for (const perf of invoice.performances) {
    result += `${playFor(perf).name}: ${usd(amountFor(perf))} (${perf.audience}석)\n`;
  }

  result += `총액: ${usd(totalAmount())}\n`;
  result += `적립 포인트: ${totalVolumeCredits()}점\n;`;
  return result;
}

중간 점검: 난무하는 중첩 함수

여기까지 리팩터링 결과는 다음과 같다.

function statement(invoice, plays) {
  let result = `청구 내역 (고객명: ${invoice.customer})\n`;

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

  function amountFor(aPerformance) {
    let result = 0;

    switch (playFor(aPerformance).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(`알 수 없는 장르: ${playFor(aPerformance).type}`);
    }
    return result;
  }

  function volumeCreditsFor(aPerformance) {
    let volumeCredits = 0;
    volumeCredits += Math.max(aPerformance.audience - 30, 0);

    if (playFor(aPerformance).type === 'comedy') volumeCredits += Math.floor(aPerformance.audience / 5);

    return volumeCredits;
  }

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

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

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

  for (const perf of invoice.performances) {
    result += `${playFor(perf).name}: ${usd(amountFor(perf))} (${perf.audience}석)\n`;
  }

  result += `총액: ${usd(totalAmount())}\n`;
  result += `적립 포인트: ${totalVolumeCredits()}점\n;`;
  return result;
}

중첩 함수가 많이 존재하지만, statement() 함수의 코드가 줄어들었으며, 계산 로직을 여러 개의 보조함수로 빼내어 결과적으로 각 계산 과정은 물론 전체 흐름을 이해하기가 훨씬 쉬워졌다.

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

지금까진 프로그램의 논리적인 요소를 파악하기 쉽도록 코드의 구조를 보강하는데 주안점을 두고 리팩터링했다.

이후 statement()의 HTML 버전을 만드는 작업을 하는데 가장 큰 문제점은 statement() 안에 중첩 함수로 들어가있는 함수들이다.

이러한 상황에서 책의 저자는 단계 쪼개기를 제안한다.

우선 단계를 쪼개기 위해 두 번째 단계가 될 코드들을 함수 추출하기로 뽑아내면 다음과 같이 분리할 수 있다.

function renderPlainText(invoice, plays) {
  let result = `청구 내역 (고객명: ${invoice.customer})\n`;

  for (const perf of invoice.performances) {
    result += `${playFor(perf).name}: ${usd(amountFor(perf))} (${perf.audience}석)\n`;
  }

  result += `총액: ${usd(totalAmount())}\n`;
  result += `적립 포인트: ${totalVolumeCredits()}점\n;`;
  return result;

  function playFor(aPerformance) {...}
  function amountFor(aPerformance) {...}
  function volumeCreditsFor(aPerformance) {...}
  function usd(aNumber) {...}
  function totalVolumeCredits() {...}
  function totalAmount() {...}
}

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

이 상태에서 중간 데이터 구조 역할을 할 객체를 만들어 전달하면 다음과 같이 바꿀 수 있다.


function renderPlainText(data) {
  function usd(aNumber) {...}

  let result = `청구 내역 (고객명: ${data.customer})\n`;

  for (const 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 statement(invoice, plays) {
  return renderPlainText(createStatementData(invoice, plays));

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

  // 불변객체를 위한 함수
  function enrichPerformance(aPerformance) {
    const result = { ...aPerformance };
    result.play = playFor(result);
    result.amount = amountFor(result);
    result.volumeCredits = volumeCreditsFor(result);
    return result;
  }

  function playFor(aPerformance) {...}
  function amountFor(aPerformance) {...}
  function volumeCreditsFor(aPerformance) {...}
  function totalVolumeCredits() {...}
  function totalAmount() {...}
}

이제 두 단계가 명확하게 분리되어 있으므로 각 코드를 파일로 나눌 수 있다.

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

두 개의 파일로 나뉜 현재의 코드는 다음과 같다.

자세한 구현사항은 세점으로 표기하였다.

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 enrichPerformance(aPerformance) {...}
  function playFor(aPerformance) {...}
  function amountFor(aPerformance) {...}
  function volumeCreditsFor(aPerformance) {...}
  function totalVolumeCredits() {...}
  function totalAmount() {...}
}

statement.js

import createStatementData from './createStatementData';

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

function htmlStatement(invoice, plays) {
  return renderHTML(createStatementData(invoice, plays));
}
function renderHTML(data) {...}

function usd(aNumber) {...}

결과적으로 보면 처음보다 코드량이 늘었지만 추가된 코드 덕분에 전체 로직을 구성하는 요소 각각이 부각되고 계산과 출력 형식을 다루는 부분을 분리하였다.

이렇게 모듈화하면 각 부분이 하는 일과 그 부분들이 서로 돌아가는 과정을 파악하기 쉬워진다.

캠핑자들에게 도착했을 때보다 깔끔하게 정돈하고 떠난다는 규칙이 있듯이 프로그래밍도 항시 코드베이스를 작업 시작 전보다 건강하게 만들어 놓고 떠나야 한다.

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

현재 짜여진 프로그램에서 연극 장르르 추가하고 장르마다 공연료와 적립 포인트 계산법을 다르게 지정하도록 기능을 수정하려면 계산을 수행하는 함수에서 조건문을 수정해야한다.

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

간단하게 리팩터링 과정을 요약하면 팩터리 패턴을 사용하여 조건부로직에 따른 생성자를 만드는 방법을 사용한다.

전체 코드는 다음과 같다.

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;

  // 내부에서 calculator라는 인스턴스를 사용하여 코드 변화
  function enrichPerformance(aPerformance) {...}
  function playFor(aPerformance) {...}
  function totalVolumeCredits() {...}
  function totalAmount() {...}

  // 팩터리 생성자(조건에 따른 타입 계산기 생성)
  function createPerformanceCalculator(aPerformance, aPlay) {...}

  // 다형성을 위한 슈퍼 클래스
  class PerformanceCaluclator {...}

  // 서브클래스들
  class TragedyCalculator extends PerformanceCaluclator {...}
  class ComedyCalculator extends PerformanceCaluclator {...}
}

상태 점검: 다형성을 활용하여 데이터 생성하기

마찬가지로 함수 추출때와 같이 코드가 늘어났지만 연극 장르별 계산 코드들을 팩터리 패턴과 다형성을 사용하여 함께 묶어두었다.

이렇게 하면 새로운 장르를 추가하려면 서브클래스를 작성하고 팩터리 함수에 추가하기만 하면 된다.

마치며

이번 장에서는 간단한 예시로 다음과 같은 과정을 거치며 리팩터링이 무엇인지 알아보았다.

  • 원본 코드를 중첩함수로 여러개로 나눔
  • 단계 쪼개기(계산 코드와 출력 코드 분리)
  • 계산 로직을 다형성으로 표현

이를 통해 리팩터링이 무엇인지 가늠할 수 있었고, 좋은 코드는 '얼마나 수정하기 쉬운가'로 판단할 수 있음을 알게 되었다.

리팩터링을 효과적으로 하는 핵심은 단계를 잘게 나누는 것임을 명심하자!

profile
It is possible for ordinary people to choose to be extraordinary.

0개의 댓글