리팩터링

dev_hobin·2022년 1월 2일
0

리팩터링

목록 보기
1/1
post-thumbnail

리팩터링

리팩터링의 정의와 목적

리팩터링 - 코드를 이해하고 수정하기 쉽게 재구성하는 것

리팩터링의 목적 - 개발 속도를 높여 더 적은 노력으로 더 많은 가치를 창출하는 것
리팩터링의 가장 큰 걸림돌은 리팩토링으로 인해 새 기능을 개발하는데 있어 오히려 개발 속도가 늦어진다고 생각하는 것이다. 하지만 리팩터링은 꼭 신경써서 해주는 것이 좋다. 코드는 언제나 바뀌며 요구사항은 많아진다. 리팩터링을 하지 않으면 나중엔 결국 새 기능 개발에 쓰는 시간이 리팩터링에 쓰는 시간보다 오래 걸리는 순간이 반드시 찾아온다.

리팩터링의 필요성

리팩터링의 필요성을 영수증 출력 프로그램의 변화 과정을 보면서 느껴보자

영수증 출력 프로그램

  • 연극의 장르와 관객의 규모에 따라 공연의 비용을 측정한다
  • 연극의 장르와 관객의 규모에 따라 적립 포인트를 측정한다
  • 공연할 연극에 대한 청구내역을 출력한다
// 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
      }
    ]
  }
]
// statement.js (공연료 청구서를 출력하는 함수)
const 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 (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;
};

export default statement;

개발자에게 영수증 출력 프로그램을 만들어달라고 요구 했을 때 위와 같이 코드를 작성했다고 하자. 정상적으로 작동하는 코드이며 추가 요구사항이 없을 경우 더 건드릴 것이 없다. 하지만 요구사항은 보통 계속해서 늘어난다. (서비스가 망하지 않는 이상..)

[+요구사항] 영수증을 HTML로 출력할 수 있게 해주세요

새로운 요구사항이 들어왔을 때 무작정 요구사항을 구현하기 전에 생각해야 하는 것이 있다. 고객의 요구사항을 바탕으로 현재 프로그램에서 어느 부분이 변경 가능성이 높은지 짐작해보는 것이다.

현재 프로그램을 봤을 때, 연극이 추가될 가능성이 있고 연극에 대한 공연료 정책과 적립 포인트 정책이 변경될 가능성이 있다. 이러한 변경은 모두 statement 함수의 수정을 불러온다. 즉 statement 함수는 연극의 추가와 공연료 정책, 포인트 정책에 대한 변화율이 높다.

그럼 현재 요구사항을 다시 생각해보자. 영수증 포맷을 담당하는 부분은 statement 함수가 담당하고 있다.

// statement.js (공연료 청구서를 출력하는 함수)
const statement = (invoice, plays) => {
  ... // (직접적인 관련은 없는 코드)
  
  let result = `청구 내역 (고객명: ${invoice.customer})\n`;
  
  ... // (직접적인 관련은 없는 코드)
  
  result += `  ${play.name}: ${format(thisAmount / 100)} (${perf.audience}석)\n`;
  
  ... // (직접적인 관련은 없는 코드)
  
  result += `총액: ${format(totalAmount / 100)}\n`;
  result += `적립 포인트: ${volumeCredits}점\n`;
  return result;
};

영수증을 출력에 직접적인 관련이 있는 부분만 추려보니 영수증 포맷을 담당하는 부분을 수정하는 것에 영향을 받지 않는 로직들이 statement 함수에 너무 많이 섞여있음을 알 수 있다. 이 상태에서 쉬운 길을 가면 안된다, 예를들어

  1. statement 함수안에 조건을 추가하는 방법 => 이 경우 statement 함수 자체의 복잡도가 크게 증가한다
const statement = (invoice, plays, isHtml) => {
  let result;
  ...
  if (isHtml) {
    result = `<h1>청구 내역 (고객명: ${invoice.customer})</h1>`;
  } else {
    result = `청구 내역 (고객명: ${invoice.customer})\n`;
  }
  ...
};            
  1. html 전용 함수를 만든다 => 이 경우 statement 함수와의 공통로직이 변경될 때마다 잊지않고 함께 수정해야한다
const htmlStatement = (invoice, plays) => {
  ...
  let result = `<h1>청구 내역 (고객명: ${invoice.customer})</h1>`;
  ...
};           

이런 경우를 새 기능을 추가하기 편한 구조가 아니라고 하며, 기능을 추가하기 쉬운 형태로 리팩터링을 먼저 해야한다.

함수를 리팩토링하기 쉬운 구조로 변경하기

현재 statement 함수는 너무 많은 책임을 가지고 있다. 언젠가는 나뉘어져야 할 코드들이기 때문에 코드의 역할별로 함수를 추출한다

// statement.js (역할별로 중첩함수로 추출)
const statement = (invoice, plays) => {
  const 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;
  };
  const playFor = (aPerformance) => plays[aPerformance.playID];
  const volumeCreditsFor = (aPerformance) => {
    let result = 0;
    result += Math.max(aPerformance.audience - 30, 0);
    if (playFor(perf).type === 'comedy') {
      result += Math.floor(aPerformance.audience / 5);
    }
    return result;
  };
  const usd = (aNumber) => {
    return new Intl.NumberFormat('en-US', {
      style: 'currency',
      currency: 'USD',
      minimumFractionDigits: 2,
    }).format(aNumber / 100);
  };
  const totalVolumeCredits = () => {
    let result = 0;
    for (let perf of invoice.performances) {
      result += volumeCreditsFor(perf);
    }
    return result;
  };
  const totalAmount = () => {
    let result = 0;
    for (let perf of invoice.performances) {
      result += amountFor(perf);
    }
    return result;
  };

  let result = `청구 내역 (고객명: ${invoice.customer})\n`;
  for (let perf of invoice.performances) {
    result += `  ${playFor(perf).name}: ${usd(amountFor(perf))} (${perf.audience}석)\n`;
  }
  result += `총액: ${usd(totalAmount())}\n`;
  result += `적립 포인트: ${totalVolumeCredits()}점\n`;
  return result;
};

export default statement;

구조만 다시 한번 보자

// statement.js (역할별로 중첩함수로 추출)
const statement = (invoice, plays) => {
  // 역할별로 나눠진 중첩함수들
  const amountFor = (aPerformance) => { ... };
  const playFor = (aPerformance) => plays[aPerformance.playID];
  const volumeCreditsFor = (aPerformance) => { ... };
  const usd = (aNumber) => { ... };
  const totalVolumeCredits = () => { ... };
  const totalAmount = () => { ... };
	
  // 영수증 포맷을 담당하는 부분들이 자연스럽게 남겨졌다
  let result = `청구 내역 (고객명: ${invoice.customer})\n`;
  for (let perf of invoice.performances) {
    result += `  ${playFor(perf).name}: ${usd(amountFor(perf))} (${perf.audience}석)\n`;
  }
  result += `총액: ${usd(totalAmount())}\n`;
  result += `적립 포인트: ${totalVolumeCredits()}점\n`;
  return result;
};

export default statement;

구조 변경을 하고 보니 새로운 부분들이 보인다. statement 함수는 영수증 데이터를 계산하는 로직과 영수증 데이터를 포맷팅하는 로직으로 구분될 수 있다. 따라서 영수증 데이터를 계산하여 포맷팅에 필요한 데이터를 만드는 함수와 포맷팅 함수로 statement 함수를 두 단계로 나눌것이다.

단계 나누기

// statement.js (단계 나누기)

// 포맷팅에 필요한 영수증 데이터를 만드는 함수
const createStatementData = (invoice, plays) => {
  const enrichPerformance = (aPerformance) => {
    const result = { ...aPerformance };
    result.play = playFor(result);
    result.amount = amountFor(result);
    result.volumeCredits = volumeCreditsFor(result);
    return result;
  };
  const playFor = (aPerformance) => plays[aPerformance.playID];
  const amountFor = (aPerformance) => {
    let result = 0;

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

    return result;
  };
  const volumeCreditsFor = (aPerformance) => {
    let result = 0;
    result += Math.max(aPerformance.audience - 30, 0);
    if (aPerformance.play.type === 'comedy') {
      result += Math.floor(aPerformance.audience / 5);
    }
    return result;
  };
  const totalAmount = (data) => {
    return data.performances.reduce((total, p) => total + p.amount, 0);
  };
  const totalVolumeCredits = (data) => {
    return data.performances.reduce((total, p) => total + p.volumeCredits, 0);
  };
  const result = {};
  result.customer = invoice.customer;
  result.performances = invoice.performances.map(enrichPerformance);
  result.totalAmount = totalAmount(result);
  result.totalVolumeCredits = totalVolumeCredits(result);

  return result;
};

// 데이터를 받아 포맷팅하는 함수
const renderPlainText = (data) => {
  const usd = (aNumber) => {
    return new Intl.NumberFormat('en-US', {
      style: 'currency',
      currency: 'USD',
      minimumFractionDigits: 2,
    }).format(aNumber / 100);
  };

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

// statement 함수의 단계가 분리된 모습
const statement = (invoice, plays) => {
  // createStatementData -> renderPlainText
  return renderPlainText(createStatementData(invoice, plays));
};

export default statement;

포맷팅에 필요한 데이터를 만드는 함수(createStatementData)를 새로 만듦으로써 기존의 statement 함수가 가지고 있던 계산 로직들이 그쪽으로 옮겨진 것을 확인할 수 있고 포맷팅하는 함수(renderPlainText)는 별도의 데이터 계산로직 없이 데이터를 그대로 사용하는 것을 확인할 수 있다.

다시 말해서 관심사의 분리가 확실히 된 것이며 이제 드디어 요구사항을 구현할 수 있게 되었다. renderHtml 함수를 구현해보자.

Html 출력 기능 추가하기

const renderHtml = (data) => {
  let result = `
<h1>청구내역 (고객명: ${data.customer})</h1>
<table>
  <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><td>${usd(perf.amount)}</td></tr>`;
  }
  result += `</table>
<p>총액: <em>${usd(data.totalAmount)}</em></p>
<p>적립 포인트: <em>${data.totalVolumeCredits}점</em></p>`;
  return result;
};

고객의 요구사항인 html로 출력하기 기능을 추가했다. 여기서 주목할 점이 코드의 중복 없이 변경에 취약한 상태가 아닌 정말 필요한 부분만 새 기능으로써 코드가 추가됐다는 것이다. 코드를 정리하며 마무리하자.

// statement.js (html로 출력하기 기능 추가)

// 포맷팅에 필요한 영수증 데이터를 만드는 함수
const createStatementData = (invoice, plays) => { ... };
// render 함수에 공통적으로 쓰이는 유틸 함수 따로 추출
const usd = (aNumber) => { ... };
// 기본 포맷팅 함수
const renderPlainText = (data) => { ... };
// html 포맷팅 함수
const renderHtml = (data) => { ... };

// 기본 포맷인지 html 포맷인지 확인하는 매개변수 추가
const statement = (invoice, plays, format) => {
  switch (format) {
    case 'html':
      return renderHtml(createStatementData(invoice, plays));
    case 'default':
      return renderPlainText(createStatementData(invoice, plays));
    default:
      return renderPlainText(createStatementData(invoice, plays));
  }
};

export default statement;

[+요구사항] 연극을 추가해주세요

연극을 추가하면 문제가 되는 것이 있다. 그냥 연극만 추가하면 되는 것이 아니라 새로운 장르가 추가될 수 있으며 그에 따른 공연료 정책, 포인트 정책이 달라진다. 자, 요구 사항을 구현하기 전에 현재 구조에서 연극이 추가됨으로 인해 공연료 정책이나 포인트 정책이 바뀌었을 때 어떤 문제가 생기는지 진단해보자.

// createStatementData 함수 안에서...
const amountFor = (aPerformance) => {
  let result = 0;
  switch (aPerformance.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(`알 수 없는 장르: ${aPerformance.play.type}`);
  }

  return result;
};
const volumeCreditsFor = (aPerformance) => {
  let result = 0;
  result += Math.max(aPerformance.audience - 30, 0);
  if (aPerformance.play.type === 'comedy') {
    result += Math.floor(aPerformance.audience / 5);
  }
  return result;
};

위 두 함수를 보았을 때 장르가 추가되면 반드시 가격 정책들을 따져보고 두 함수 모두 알맞게 수정해주어야 한다. 지금 당장은 함수가 2개이기 때문에 별거 아니라고 생각할 지 모르지만, 조건부 로직으로 처리하는 함수가 많을 경우 그 수만큼 모든 조건문을 수정해야한다. 이 문제는 어떻게 해결해야 할까?

코드를 수정하는 요인에 가장 핵심이 되는 것은 장르다. 그런데 현재 코드를 보면 가격 정책(amountFor)과 포인트 정책(volumeCreditsFor)이 각각 연극(aPerformance)을 받아서 장르를 검사하여 따로 처리하고 있다. 이 구조를 고치기 위해 계산기 클래스를 만들것이며 계산기 클래스 하나가 위의 두 함수를 메서드로써 가지게 할 것이다.

계산기 클래스 만들기

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

  get amount() {
    let result = 0;
    switch (this.play.type) {
      case 'tragedy':
        result = 40000;
        if (this.performance.audience > 30) {
          result += 1000 * (this.performance.audience - 30);
        }
        break;
      case 'comedy':
        result = 30000;
        if (this.performance.audience > 20) {
          result += 10000 + 500 * (this.performance.audience - 20);
        }
        result += 300 * this.performance.audience;
        break;
      default:
        throw new Error(`알 수 없는 장르: ${this.play.type}`);
    }
  }

  get volumeCredits() {
    let result = 0;
    result += Math.max(this.performance.audience - 30, 0);
    if (this.play.type === 'comedy') {
      result += Math.floor(this.performance.audience / 5);
    }
    return result;
  }
};

const createStatementData = (invoice, plays) => {
  ...
  
  const enrichPerformance = (aPerformance) => {
    const calculator = new PerformanceCalculator(aPerformance, playFor(aPerformance));
    const result = { ...aPerformance };
    result.play = calculator.play;
    result.amount = calculator.amount;
    result.volumeCredits = calculator.volumeCredits;
    return result;
  };

  ...
};

계산기 클래스를 만들었고 기존에 amountFor, volumeCreditsFor 과 같은 함수로 처리하던 부분을 클래스를 사용하도록 바꾸었다. 지금은 별다른 차이는 없이 코드의 형식만 바뀌었을 뿐이다.

다형성 활용하기

함수를 사용하는 코드에서 클래스를 사용한 코드로 변경한 이유가 다형성을 이용하려고 하기 때문이다. 무슨 말이냐면 일단 클래스를 이용하여 공연료 정책, 포인트 정책을 담당하는 계산 로직을 클래스의 메서드로 묶어두었고 또한 연극 장르에 대한 정보도 가지고 있게 했다. 이제 연극의 장르를 기준으로 서브 클래스들을 만들것이며 장르마다 메서드의 구현을 다르게 하여 장르만 구분되면 알아서 정책에 맞게 원하는 값을 얻도록 만들것이다.

// 서브 클래스들 만들기

// 부모 클래스
const PerformanceCalculator = class {
  constructor(aPerformance, aPlay) {
    this.performance = aPerformance;
    this.play = aPlay;
  }
  // 메서드에 대한 구현을 서브 클래스에게 맡긴다.
  get amount() {
    throw new Error('override!');
  }

  get volumeCredits() {
    throw new Error('override!');
  }
};
// 장르에 따른 서브클래스들
const TragedyCalculator = class extends PerformanceCalculator {
  constructor(aPerformance, aPlay) {
    super(aPerformance, aPlay);
  }
	// 장르에 따른 가격 정책에 맞게 공연료와 포인트를 계산하는 메서드 구현
  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);
  }
};
const ComedyCalculator = class extends PerformanceCalculator {
  constructor(aPerformance, aPlay) {
    super(aPerformance, aPlay);
  }

  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 (
      Math.max(this.performance.audience - 30, 0) +
      Math.floor(this.performance.audience / 5)
    );
  }
};

팩토리 함수 만들기

이렇게 정의한 서브클래스들을 활용하려면 장르를 구분하여 알맞은 서브 클래스를 생성해야한다. 그 역할을 할 팩토리 함수를 하나 만든다.

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

const createStatementData = (invoice, plays) => {
  ...
  
  const enrichPerformance = (aPerformance) => {
    // 팩토리 함수 사용
    const calculator = createPerformanceCalculator(aPerformance, playFor(aPerformance));
    ...
  };

  ...
};

정리

// 기존의 가격 정책을 담당하는 함수들을 대신할 클래스들
const PerformanceCalculator = class { ... };
// 장르를 기준으로 나눠진 서브 클래스들 -> 다형성 활용하여 가격정책에 맞는 메서드 구현
const TragedyCalculator = class extends PerformanceCalculator { ... };
const ComedyCalculator = class extends PerformanceCalculator { ... };
// 장르를 구분하여 인스턴스를 만들어주는 팩토리 함수
const 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}`);
  }
};

const createStatementData = (invoice, plays) => {
  ...
	
  const enrichPerformance = (aPerformance) => {
    // 함수를 사용하여 각각 장르에 맞게 공연료, 포인트를 계산하는 로직이 삭제되고 팩토리 함수를 사용하도록 변경
    const calculator = createPerformanceCalculator(aPerformance, playFor(aPerformance));
    const result = { ...aPerformance };
    result.play = calculator.play;
    result.amount = calculator.amount;
    result.volumeCredits = calculator.volumeCredits;
    return result;
  };

  ...
  
};

지금까지 조건부 로직으로 처리하는 함수를 다형성을 이용하는 방식으로 바꾼것이다. 이렇게 바꿈으로써 연극이 추가되어 새로운 장르가 추가되었을 경우에 조건부 로직으로 처리하는 함수를 일일히 전부 수정할 필요없이 새로운 장르를 위한 서브 클래스를 만들어 가격 정책에 맞게 메서드를 구현하고 팩토리 함수에 장르 분기를 추가만 해주면 되도록 코드 구조가 개선되었다. 요구사항이 구현된 것을 예로 들며 끝내겠다.

// 뮤지컬 장르가 추가되었을 경우..

// 뮤지컬 장르에 해당하는 서브클래스 추가
const MusicalCalculator = class extends PerformanceCalculator {
  constructor(aPerformance, aPlay) {
    super(aPerformance, aPlay);
  }
  get amount() {
    // 공연료 정책에 맞는 메서드 구현
  }
  get volumeCredits() {
    // 포인트 정책에 맞는 메서드 구현
  }
};

// 팩토리 함수에 뮤지컬 장르 분기 추가
const createPerformanceCalculator = (aPerformance, aPlay) => {
  switch (aPlay.type) {
    case 'tragedy':
      return new TragedyCalculator(aPerformance, aPlay);
    case 'comedy':
      return new ComedyCalculator(aPerformance, aPlay);
    case 'musical':
      return new MusicalCalculator(aPerformance, aPlay);
    default:
      throw new Error(`알 수 없는 장르: ${aPlay.type}`);
  }
};

즉, 별도의 로직 수정 없이 기능을 확장해나가면 된다.

공부 자료 참조

http://www.yes24.com/Product/Goods/89649360 [리팩터링 2판 / 마틴 파울러]

https://www.youtube.com/playlist?list=PLjQV3hketAJmyZmqXZ1OVEFNctalbf9SX [리팩터링 스터디 / 정재남]

profile
무엇을 기억할지 고민하는 것이 공부다

0개의 댓글