저번 리팩토링 글에 이어서 1장 나머지 부분을 정리하려고 한다. 먼저 기존에 작성된 코드를 단개 쪼개기라는 방법을 통해 재구성해야 한다. 근데 이전에 리팩터링 한 결과의 코드는 statement 안에 중첩 함수로 들어가 있다. 이대로 기능을 추가하면 코드를 그대로 복사해서 HTML버전을 만들게 될 거다.
그래서 여기서 목표는 텍스트 버전과 HTML 버전 함수 모두가 똑같은 계산 함수들을 사용하게 만들고 싶다.
따라서 statement 함수의 로직을 두 단계로 나눌 것이다. 첫 단계에서는 statmnet에 필요한 데이터를 처리하고, 다음 단계에서는 앞서 처리한 결과를 텍스트나 HTML로 표현하도록 한다. 일단 기존 코드를 다음과 같이 수정한다
mport plays from "./plays.js"
import invoices from "./invoices.js"
function statement(invoice, plays){
return renderPlainText(invoice, plays)
}
function renderPlainText(invoice, plays){
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;
}
function totalAmount(){
let result =0
for(let perf of invoices[0].performances){
result+=amountFor(perf)
}
return result
}
function totalVolumeCredits(){
let result=0;
for(let perf of invoices[0].performances){
result +=volumeCreditsFor(perf)
}
return result
}
function usd(aNumber){
return new Intl.NumberFormat('en-US', {style: "currency", currency: "USD", minimumFractionDigits: 2}).format(aNumber/100)
}
function volumeCreditsFor(aPerformance){
let result=0
result +=Math.max(aPerformance.audience -30 ,0);
if("comedy" ===playFor(aPerformance).type)
result +=Math.floor(aPerformance.audience / 5);
return result
}
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
}
console.log(statement(invoices[0],plays))
이런 식으로 계산 관련 코드는 전부 statement() 함수로 모으고 renderPlainText()는 data 매개변수로 전달된 데이터만 처리하게 만들 수 있다. 다음으로는 statementData에 고객과 공연정보를 옮겨 준다.
import plays from "./plays.js"
import invoices from "./invoices.js"
function statement(invoice, plays){
const statementData = {};
statementData.customer = invoice.customer;
statementData.performances = invoice.performances
return renderPlainText(statementData, plays)
}
function renderPlainText(data, plays){
let result = `청구 내역 (고객명: ${data.customer})\n`;
for(let perf of data.performances){
result+=`${playFor(perf).name}: ${usd(amountFor(perf))} (${perf.audience} 석) \n`
}
result+=`총액: ${usd(totalAmount())}\n`
result+=`적립 포인트: ${totalVolumeCredits()}점 \n`
return result;
}
그리고 중간 데이터 구조에서 제목도 가져오게 한다. 얕은 복사를 사용하는데 함수로 건넨 데이터를 수정하지 않고 불변처럼 취급한다. 여기서 함수를 따로 만들어서 해도 되지만 많은 자바스크립트 프로그래머들이 이렇게 쓰는 게 더 익숙하다고 한다. 그리고 renderPlainText에서도 데이터 사용하도록 바꾸고 amountFor 함수도 중간 데이터에 저장하게 한다. 마지막으로 적립 포인트 계산 부분과 총합을 구하는 부분도 다 옮겨 준다. 그럼 코드가 다음과 같이 된다.
import plays from "./plays.js"
import invoices from "./invoices.js"
function statement(invoice, plays){
const statementData = {};
statementData.customer = invoice.customer;
statementData.performances = invoice.performances.map(enrichPerformance);
statementData.totalAmount = totalAmount(statementData);
statementData.totalVolumeCredits = totalVolumeCredits(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 playFor(aPerformance){
return plays[aPerformance.playID]
}
function 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
}
function volumeCreditsFor(aPerformance){
let result=0
result +=Math.max(aPerformance.audience -30 ,0);
if("comedy" === aPerformance.play.type)
result +=Math.floor(aPerformance.audience / 5);
return result
}
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)
}
}
function renderPlainText(data, plays){
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 usd(aNumber){
return new Intl.NumberFormat('en-US', {style: "currency", currency: "USD", minimumFractionDigits: 2}).format(aNumber/100)
}
}
console.log(statement(invoices[0],plays))
단계가 확실히 분리되었으니 최종적으로는 별도의 파일로 바꾸고 변수 이름도 알맞게 정해준다.
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){
const result = Object.assign({}, aPerformance); //얕은 복사 수행
result.play = playFor(result);
result.amount = amountFor(result);
result.volumeCredits = volumeCreditsFor(result)
return result
}
function playFor(aPerformance){
return plays[aPerformance.playID]
}
function 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
}
function volumeCreditsFor(aPerformance){
let result=0
result +=Math.max(aPerformance.audience -30 ,0);
if("comedy" === aPerformance.play.type)
result +=Math.floor(aPerformance.audience / 5);
return result
}
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)
}
}
이건 중간 데이터 생성 함수 코드이다.
import plays from "./plays.js"
import invoices from "./invoices.js"
import createStatementData from "./createStatementData.js"
function statement(invoice, plays){
return renderPlainText(createStatementData(invoice, plays))
}
function renderPlainText(data, plays){
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 usd(aNumber){
return new Intl.NumberFormat('en-US', {style: "currency", currency: "USD", minimumFractionDigits: 2}).format(aNumber/100)
}
}
console.log(statement(invoices[0],plays))
기존 코드에서 데이터를 통해 처리하는 부분만 이렇게 남겨 두게 되었다. 이제 여기서 html 출력만 추가해주면 된다.
import plays from "./plays.js"
import invoices from "./invoices.js"
import createStatementData from "./createStatementData.js"
function statement(invoice, plays){
return renderPlainText(createStatementData(invoice, plays))
}
function renderPlainText(data, plays){
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 usd(aNumber){
return new Intl.NumberFormat('en-US', {style: "currency", currency: "USD", minimumFractionDigits: 2}).format(aNumber/100)
}
}
function htmlStatement(invoice, plays){
return renderHtml(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>\n`
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>총액: ${usd(data.totalAmount)}</em></p>\n`
result+=`<p>적립 포인트: ${data.totalVolumeCredits}</em>점</p>\n`
return result
function usd(aNumber){
return new Intl.NumberFormat('en-US', {style: "currency", currency: "USD", minimumFractionDigits: 2}).format(aNumber/100)
}
}
console.log(statement(invoices[0],plays))
코드량은 부쩍 늘었지만, 추가된 코드 덕분에 전체 로직을 구성하는 요소 각각이 더 뚜렷이 부각되고, 계산하는 부분과 출력 형식을 다루는 부분이 분리됐다. 이렇게 모듈화 하면 각 부분이 하는 일과 그 부분들이 맞물려 돌아가는 과정을 파악하기 쉬워진다고 한다.
이제 다형성을 활용해 계산 코드를 재구성해보자.
기존 코드에서 연극 장르를 추가하고 장르마다 공연료와 적립 포인트 계산법을 다르게 지정하도록 기능을 수정하려면, 계산을 수행하는 함수에서 조건문을 수정해야 한다. 또한 amountFor함수에서 연극 장르에 따라 계산 방식이 달라지는 것을 알 수 있다. 이런 형태의 조건부 로직은 코드 수정 횟수가 늘어날수록 골칫거리로 전락하기 쉽다.
여기서 목표는 상속 계층을 구성해서 희극 서브클래스와 비극 서브클래스가 각자의 구체적인 계산 로직을 정의하는 것이다. 여기서 핵심적인 기법은 조건부 로직을 다형성으로 바꾸기이다. 이 리팩터링은 조건부 코드 한 덩어리를 다형성을 활용하는 방식으로 바꿔준다. 그럼 상속 계층부터 정의해 보자. 기존 코드를 보면 조건부 로직을 포함한 함수인 amountFor()과 volumeCreditsFor()를 호출하여 공연료와 적립 포인트를 계산한다. 이번에는 이 두 함수를 전용 클래스로 옮기는 작업을 해보자.
class PerformanceCalculator{
constructor(aPerformance,aPlay){
this.performance = aPerformance;
this.play = aPlay;
}
}
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){
const calculator = new PerformanceCalculator(aPerformance , playFor(aPerformance));
const result = Object.assign({}, aPerformance); //얕은 복사 수행
result.play = calculator.play
result.amount = amountFor(result)
result.volumeCredits = volumeCreditsFor(result)
return result
}
함수들을 계산기로 옮기자.
class PerformanceCalculator{
constructor(aPerformance,aPlay){
this.performance = aPerformance;
this.play = aPlay;
}
get amount(){
let result = 0;
switch(this.play.type){
case "tragedy":
result = 40000;
if(this.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}`)
}
return result
}
get volumeCredits(){
let result=0
result +=Math.max(aPerformance.audience -30 ,0);
if("comedy" === aPerformance.play.type)
result +=Math.floor(aPerformance.audience / 5);
return result
}
}
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){
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
}
다음은 공연료 계산기를 다형성 버전으로 만드는 것이다. 클래스에 로직을 담았으니 다형성을 지원하게 만드는 것이다. 그러기 위해서는 서브클래스를 작성하는데 계산기를 만들 때 단순히 생성자로 만드는 게 아니라 함수를 통해서 연극마다 다르게 생성하게 만들어 줘야 한다. 그리고 로직의 다른 부분은 오버라이딩을 통해서 구현한다. 간략하게 설명했지만 이과정에서 생성자를 팩토리 함수로 바꾸기, 조건부 로직을 다형성으로 바꾸기 가 적용되었다. 그럼 최종 코드를 보자
class PerformanceCalculator {
constructor(aPerformance, aPlay) {
this.performance = aPerformance;
this.play = aPlay;
}
get amount() {
throw new Error("서브 클래스에서 처리하도록 설계되었습니다.")
}
get volumeCredits() {
return Math.max(aPerformance.audience - 30, 0);
}
}
class TragedyCalculator extends PerformanceCalculator {
get amount() {
result = 40000;
if (this.audience > 30) {
result += 1000 * (this.performance.audience - 30);
}
return result
}
}
class ComedyCalculator extends PerformanceCalculator {
get amount() {
result = 30000;
if (this.performance.audience > 20) {
result += 10000 + 500 * (this.performance.audience - 20);
}
result += 300 * this.performance.audience;
return result
}
get volumeCredits() {
return super.volumeCredits+Math.floor(this.performanc.audience /5);
}
}
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}`)
}
}
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) {
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 amountFor(aPerformance) {
return new PerformanceCalculator(aPerformance, aPerformance.play).amount
}
function volumeCreditsFor(aPerformance) {
return new PerformanceCalculator(aPerformance, aPerformance.play).volumeCredits
}
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)
}
}
너무 깔끔하다. 중간에 보면 서브클래스에서 꼭 처리할 수 있게 슈퍼클래스의 함수에서 에러 처리한 부분도 있다.
이번에도 구조를 보강하면서 코드가 늘어났다. 이번 수정으로 나아진 점은 연극 장르별 계산 코드들을 함께 묶어뒀다는 것이다. 또한 새로운 장르를 추가할 때 createPerformanceCalculator 함수에 서브클래스를 추가해주면 된다. 이번 예를 보면 알 수 있듯이 같은 타입의 다형성을 기반으로 실행되는 함수가 많을수록 이처럼 구성하는 게 유리한 것을 알 수 있다. 그리고 자바스크립트 클래스 시스템의 멋진 점 중 하나가 게터 메서드를 호출하는 게 단순히 데이터 접근과 같은 것을 볼 수 있다.
이번장에서는 리팩터링을 크게 세 단계로 진행했다.
- 먼저 원본 함수를 중첩 함수 여러 개로 나눴다.
- 다음으로 단계 쪼개기를 적용해서 계산 코드와 출력 코드를 분리했다.
- 마지막으로 계산 로직을 다형성으로 표현했다.
각 단계에서 코드 구조를 보강했고 그럴 때마다 코드가 수행하는 일이 더욱 분명해졌다.
이렇게 책의 1장이 끝났다. 시작하자마자 예시를 통해 다양한 리팩터링 기법들이 소개가 되었는데, 어차피 뒤에서 자세히 나오는 내용들이어서 2장부터 보면서 나왔던 내용들을 정리하는 시간을 가질 수 있을 거 같다.
책이 진짜 재밌는 거 같다. 일단 4장까지는 쭉 보고 그 뒤부터는 필요할 때마다 보면 좋을 거 같다고 쓰여 있어서 4장까지는 쭉 봐야겠다.
좋은 코드를 가늠하는 확실한 방법은 '얼마나 수정하기 쉬운가'이다.
아 추가적으로 저자가 이번 예시를 통해서 가장 중요하게 생각한건 리팩터링 하는 리듬이라고 한다. 계속 언급했던 단계를 잘게 나누고 매번 컴파일하고 테스트하는 것이다. 리팩터링을 효과적으로 하는 핵심은 단계를 잘게 나눠야 더 빠르게 처리할 수 있고, 코드는 절대 깨지지 않으며, 이러한 작은 단계가 모여서 큰 변화를 이루게 된다는 것이다.
참고 및 출처: <리팩터링 2판> (마틴 파울러 지음, 개앞맵시, 남기혁옮김, 한빛미디어, 2020)
'TIL' 카테고리의 다른 글
Refactoring. 리팩터링이 필요한 경우 (0) | 2022.02.05 |
---|---|
Refactoring. 리팩터링 원칙 (0) | 2022.02.04 |
Refactoring. 예시를 통해 알아보자 (0) | 2022.02.02 |
This Month. 1월 (0) | 2022.01.31 |
This Week. 24 ~ 29 한 주간 정리 (0) | 2022.01.31 |