마틴 파울러의 리팩터링 2판 책을 읽으며 정리한 내용입니다.
다양한 연극을 외주로 받아서 공연하는 극단이 있다고 생각해보자.
극단은 공연할 연극 정보를 다음과 같이 간단한 JSON 파일에 저장한다.
//plays.json
{
"hamlet" : {"name" : "Hamlet", "type":"tragedy"},
"as-like" : {"name" : "As You Like Itt", "type": "comedy"},
"othello" : {"name": "Othello", "type" : "tragedy"}
}
공연료 청구서에 들어갈 데이터도 다음과 같이 JSON 파일로 표현한다.
// invoices.json
[
{
"customer" : "BigCo",
"performances":[
{
"playID" : "hamlet",
"audiences" : 55
},
{
"playID" : "as-like",
"audiences" : 35
},
{
"playID" : "othello",
"audiences" : 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,
}).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;
}
}
프로그램이 잘 작동하는 상황에서 그저 코드가 ‘지저분하다’는 이유로 불평하는 것은 프로그램의 구조를 너무 미적인 기준으로만 판단하는 건 아닐까? (…) 하지만 그 코드를 수정하려면 사람이 개입되고, 사람은 코드의 미적 상태에 민감하다. 설계가 나쁜 시스템은 수정하기 어렵다. 원하는 동작을 수행하도록 하기 위해 수정해야 할 부분을 찾고, 기존 코드와 잘 맞물려 작동하게 할 방법을 강구하기가 어렵기 때문이다. 무엇을 수정할지 찾기 어렵다면 실수를 저질러서 버그가 생길 가능성도 높아진다.
따라서 수백 줄짜리 코드를 수정할 때, 먼저 프로그램의 작동 방식을 더 쉽게 파악할 수 있도록 코드를 여러 함수와 프로그램 요소로 재구성
한다. 프로그램의 구조가 빈약하다면, 구조부터 바로잡은 뒤에 기능을 수정하는 편이 작업하기가 훨씬 수월하다. 다시 말해, 프로그램이 새로운 기능을 추가하기에 편한 구조가 아니라면, 먼저 기능을 추가하기 쉬운 형태로 리팩터링하고 나서 원하는 기능을 추가
한다.
예를 들어, 위 코드에 다음과 같은 기능들을 추가한다고 해보자.
리팩터링이 필요한 이유는 바로 이러한 변경 때문이다. 잘 작동하고 나중에 변경할 일이 없다면 코드를 현재 상태로 놔둬도 아무런 문제가 없지만, 대부분 그렇지 않기 때문이다. 😀
statement() 함수의 테스트는 어떻게 구성하면 될까? 이 함수가 문자열을 반환하므로, 다양한 장르의 공연들로 구성된 공연료 청구서 몇 개를 미리 작성하여 문자열 형태로 준비해둔다. 그런 다음 statement()가 반홚나 문자열과 준비해둔 정답 문자열을 비교한다. 그리고 테스트 프레임워크를 이용하여 모든 테스트를 단축키 하나로 실행할 수 있도록 설정해둔다.
statement()처럼 긴 함수를 리팩터링할 때는 먼저 전체 동작을 각각의 부분으로 나눌 수 있는 지점을 찾는다. 그러면 switch문이 가장 먼저 눈에 띈다. 이 switch문을 살펴보면 한 번의 공연에 대한 요금을 계산하고 있다. 여기서는 코드 조각을 별도 함수로 추출하는 방식으로 앞서 파악한 정보를 코드에 반영할 것이다. 추출한 함수에는 그 코드가 하는 일을 설명하는 이름을 지어준다.
// switch문을 따로 떼어서 함수로 만든다.
function amountFor(perf, play){
let thisAmount = 0;
...
// statement()에서는 thisAmount 값을 채울 때 방금 추출한 amountFor() 함수를 호출한다.
function statement(invoice, plays){
let totalAmount = 0;
let volumneCredits = 0;
....
for (let perf of invoice.performances){
const play = plays[perf.playID];
let thisAmount = amountFor(perf, play); // 추출한 함수를 이용
...
함수를 추출하고 나면 추출된 함수 코드를 보면서 지금보다 명확하게 표현할 수 있는 간단한 방법은 없는지 검토한다. 가장 먼저 변수의 이름을 더 명확하게 바꿔보자
. 가령 thisAmount를 result로 변경할 수 있다.
function amountFor(perf, play) {
let result = 0; // 명확한 이름으로 변경
switch (play.type) {
case "tragedy" : // 비극
result = 40000;
...
}
이 함수의 첫 번째 인수인 perf를 aPerformance로 이름을 변경할 수도 있을 것이다.
// amountFor의 play는 결국 perf에서 추출되기 때문에 따로 빼줄 수 있다.
// 대입문의 우변을 함수로 추출한다.
// const play = plays[perf.playId]
function playFor(aPerformance){
rerturn plays[aPerformance.playId];
}
statement() 안에서 위 함수를 다시 작성해보자.
function statement(invoice, plays){
...
for (let perf of invoice.performances){
let thisAmount = amountFor(perf, playfor(perf));
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": // 희극
...
지역 변수를 제거해서 얻는 가장 큰 장점은 추출 작업이 훨씬 쉬워진다는 것이다. 유효범위를 신경 써야 할 대상이 줄어들기 때문이다. 실제로 필자는 추출 작업 전에는 거의 항상 지역 변수부터 제거한다.
amounFor
함수는 thisAmount의 초기값을 설정하는 데만 쓰이고, 추후 변경되지 않는다. 따라서 이 변수를 인라인 하는 방식으로 바꾸어 불필요한 변수를 줄일 수 있다.
for (let perf of invoice.performances){
// thisAmount 변수 삭제
volumeCredits += ...
result += `${playFor(perf).name} : ${format(amountFor(perf)/100)} ...`
포인트를 계산하는 부분도 별도 함수로 분리할 수 있는데, 추출한 함수를 루프문 내에서 사용해 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)
// ...
화폐 단위를 표현하기 위해 사용한 format
은 임시변수에 함수 자체를 저장해둔 형태다. 이 함수는 총액을 구할 때 총액: ${format(totalAmount / 100)}
와 같은 형태로 쓰이게 된다.
임시 변수는 나중에 문제를 일으킬 수 있다. 자신이 속한 루틴에서만 의미가 있어서 루틴이 길고 복잡해지기 쉽기 때문이다.
이 함수의 용도와 맥락을 살려 별도 함수로 분리해보자.
function usd(aNumber) {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 2,
}).format(aNumber / 100); // 단위 변환 로직도 한번에 처리하자
}
result += `총액: ${usd(amountFor(perf))}\n`;
volumeCredits
변수는 반복문을 돌 때마다 값을 누적하기 때문에 리팩터링 하기 까다롭다. 따라서 먼저 반복문 쪼개기
로 누적되는 부분을 따로 빼낸 후, 문장 슬라이드
하기를 적용하여 volumeCredits 변수를 선언하는 문장을 반복문 바로 앞으로 옮긴다.
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 값 계산 코드를 함수로 추출하는 작업부터 한다.
function totalVolumeCredits() {
let volumeCredits = 0;
for (let perf of invoice.performances){
volumeCredits += volumeCreditsFor(perf);
}
return volumeCredits;
}
자 이제 이 값을 인라인해서 사용할 수 있다.
// statement()
result += `적립 포인트 : ${totalVolumeCredits()}점`;
반복문을 쪼개서 성능이 느려지지 않을까 걱정할 수 있다. 그렇지만, 특별한 경우가 아니라면 일단 무시하고, 리팩터링 때문에 성능이 떨어진다면, 하던 리팩터링을 마무리하고 나서 성능을 개선하면 된다.
앞에서 했던 내용은 프로그램의 논리적인 요소를 파악하기 쉽도록 코드의 구조를 보강하는 데 주안점을 두고 리팩터링 했다면, 이제 골격은 충분히 개선됐으니 기능 변경을 살펴보도록 하자.
statement()의 HTML 버전을 만드는 작업을 살펴보자. 로직을 두 단계로 단계 쪼개기
를 할 수 있는데 , 첫 단계에서는 statement()에 필요한 데이터를 처리하고, 다음 단계에서는 앞서 처리한 결과를 텍스트나 HTML로 표현하는 것이다. 다시 말해, 첫 번째 단계에서는 두 번째 단계로 전달할 중간 데이터 구조를 생성하는 것이다.
단계를 쪼개려면 먼저 두 번째 단계가 될 코드들을 함수 추출하기
로 뽑아내야 한다.
// 본문 전체를 별도 함수로 추출
function statement(invoice, plays){
return renderPlainText(invoice, plays);
}
function renderPlainText(invoice, plays){
let result = ...
function totalAmount(){...}
function totalVolumeCredits(){...}
...
}
자 이제 renderPlainText()의 다른 두 인수 invoice와 plays를 살펴보자. 이 인수들을 통해 전달되는 데이터를 모두 방금 만든 중간 데이터 구조로 옮기면, 계산 관련 코드는 전부 statement() 함수로 모으고 renderPlainText()는 data매개변수로 전달된 데이터만 처리하게 만들 수 있다.
function statement(invoice, plays){
const statementData = {};
statementData.customer = invoice.customer;
statementData.performances = invoice.performances.map(enrichPerformance);
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 totalAmount(data){
return daata.performances.reduce((total, p) => total + p.amount, 0);
}
function totalVolumeCredits(data){
return data.performances.reduce((total, p) => total + p.volumeCredits,0);
}
이번에는 연극 장르를 추가하고 장르마다 공연료와 적립 포인트 계산법을 다르게 지정하도록 기능을 수정해보자.
조건부 로직을 명확한 구조로 보완하는 방법은 다양하지만, 여기서는 객체지향의 핵심 특성인 다형성을 활용하는 것이 자연스럽다.
여기서 핵심은 각 공연의 정보를 중간 데이터 구조에 채워주는 enrichPerforamance() 함수다. 현재 이 함수는 조건부 로직을 포함한 함수인 amountFor()와 volumeCreditsFor()를 호출하여 공연료와 적립 포인트를 계산한다.
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;
}
공연료 계산 코드를 클래스 안오로 옮기고, 원본 로직에서는 PerformanceCalculator를 호출하도록 수정해주자. 문제 없었다면 적립 포인트 또한 같은 방식으로 옮겨준다.
// class PerformanceCalculator
get amount() {
let result = 0;
switch (this.play.type) {
case "tragedy":
// ...
}
// function amountFor
function amountFor(aPerformance){
return new PerformanceCalculator(aPerformancce, playFor(aPerformance).amount);
}
// enrichPerformance()
function enrichPerformance(aPerformance){
const calculator = new PerformanceCalculator(
aPerformance,
playFor(aPerformance));
const result = Object.assign({}, aPerformance);
로직을 클래스로 옮겼으니 이제 다형성을 지원해보자. 가장 먼저 할 일은 타입 코드 대신 서브클래스를 사용하도록 하는 것이다. (타입 코드를 서브클래스로 바꾸기 - 12.6절
)
PerformanceCalculator의 서브클래스들을 준비하고 createStatementData()에서 그 중 적합한 서브클래스를 사용하게 만들어야 한다. 딱 맞는 서브클래스를 사용하려면 생성자 대신 함수를 호출하도록 바꿔야 한다.
그래서 생성자를 팩터리 함수로 바꾸기
를 적용한다. 다음과 같이 팩터리 함수를 이용하면 서브클래스 중에 어떤 것을 생성할지 선택할 수 있다.
amount 메서드는 서브클래스에서 호출하도록 되어있으므로 슈퍼클래스에서는 에러를 리턴하도록 남겨두면 좋다.
그리고 일반적인 경우를 기본으로 삼아 슈퍼클래스에 남겨두고, 장르마다 달라지는 부분은 필요한 경우 오버라이드 하도록 하자.
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 {}
get volumeCredits() {
return super.volumeCredits + Math.floor(this.performance.audience / 5)
}
// statement.js
export { 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.js
export { 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];
}
}