안녕하세요~ 이번 글에는 저번 글에 이어 마저 리팩토링을 해보겠습니다.
지난 글을 안보신 분이라면 여기 를 클릭해서 1편을 먼저 봐주세요!!
1편에서는 프로그램의 논리적인 요소를 파악하기 위해서 쉬운 코드를 작성하는데 초점을 두고 리팩토링을 진행하였습니다!
2편에서는 1편에서 만든 코드를 이용해서 이번에는 단계별로 쪼개는 것을 해보겠습니다
일단 statement 의 로직을 두개의 단계로 나눠보겠습니다.
첫 단계에서는 statement 함수에 필요한 데이터를 처리하고,
다음 단계에서는 처리한 결과를 텍스트로 표현되도록 만들것입니다.
그렇기 때문에 아래와 같이 텍스트를 렌더링하는 함수인 renderPlainText 함수를 따로 만들어 그것을 statement에서 return 해주기 위해 아래와 같이 만들었습니다.
import { Invocie, Play, Plays, Performance } from "./ts";
const statement = (invoice: Invocie, plays: Plays) => {
// 본문을 별도 함수로 생성 후 추출
return renderPlainText(invoice, plays);
};
function renderPlainText(invoice: Invocie, plays: Plays) {
let result = `청구 내역 (고객명 : ${invoice.customer})\n`;
for (let perf of invoice.performances) {
result += `${playFor(perf).name} : ${usdFormat(amountFor(perf))} (${
perf.audience
}석)\n`;
}
result += `총액 : ${usdFormat(totalAmount())}\n`;
result += `적립 포인트 : ${totalVolumeCredit()}점\n`;
return result;
// 총 적립포인트
function totalVolumeCredit() {
let result = 0;
for (let perf of invoice.performances) {
result += volumeCreditsFor(perf);
}
return result;
}
// 총 금액
function totalAmount() {
let result = 0;
for (let perf of invoice.performances) {
result += amountFor(perf);
}
return result;
}
// 연극당 적립포인트 계산
function volumeCreditsFor(perf: Performance) {
let result = 0;
result += Math.max(perf.audience - 30, 0);
if ("comedy" === playFor(perf).type)
result += Math.floor(perf.audience / 5);
return result;
}
// 연극당 요금 계산
function amountFor(perf: Performance) {
let result = 0;
switch (playFor(perf).type) {
case "tragedy":
result = 40000;
if (perf.audience > 30) {
result += 1000 * (perf.audience - 30);
}
break;
case "comedy":
result = 30000;
if (perf.audience > 20) {
result += 10000 + 500 * (perf.audience - 20);
}
result += 300 * perf.audience;
break;
default:
throw new Error(`알수없는 장르 : ${playFor(perf).type}`);
}
return result;
}
// US 달러 형식으로 포맷팅
function usdFormat(currency: number) {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 2,
}).format(currency / 100);
}
// 연극별로 value값 뽑아내기
function playFor(perf: Performance) {
return plays[perf.playId];
}
}
export default statement;
그리고 statement 함수안에서 데이터를 구조화해서 새로운 형태의 데이터로 전달할거기때문에 statementData를 새로 만드는 작업을 하겠습니다. 먼저 그 형식에 맞는 타입 인터페이스를 다시 만들어주겠습니다
export type PlayName = "hamlet" | "as_like" | "othello";
export type PlayType = "comedy" | "tragedy";
export interface Play {
name: string;
type: PlayType;
}
export interface Performance {
playId: PlayName;
audience: number;
}
export interface EnrichPerformance extends Performance {
play: Play;
amount: number;
volumeCredits: number;
}
export type Invocie = {
customer: string;
performances: Performance[];
};
export interface StatementData extends Invocie {
performances: EnrichPerformance[];
totalAmount?: number;
totalVolumeCredits?: number;
}
export type Plays = {
[propName in PlayName]: Play;
};
그리고 그 타입을 이용하여 새롭게 중간 데이터를 구조화해서 인자로 넘겨줘 테스트 코드가 성공할 수 있게 밑에서 만들었던 함수들을 위로 옮겨 형식을 맞춰줍니다.
import {
Invocie,
Play,
Plays,
Performance,
StatementData,
EnrichPerformance,
} from "./ts";
const statement = (invoice: Invocie, plays: Plays) => {
const statementData: StatementData = {
customer: invoice.customer,
performances: invoice.performances.map(enrichPerformance),
};
statementData.totalAmount = totalAmount(statementData);
statementData.totalVolumeCredits = totalVolumeCredit(statementData);
return renderPlainText(statementData, plays);
function enrichPerformance(perf: Performance) {
const result: EnrichPerformance = {
...perf,
play: playFor(perf),
amount: amountFor(perf),
volumeCredits: volumeCreditsFor(perf),
};
return result;
}
// 총 적립포인트
function totalVolumeCredit(data: StatementData): number {
return data.performances.reduce((total, p) => total + p.volumeCredits, 0);
}
// 총 금액
function totalAmount(data: StatementData) {
return data.performances.reduce((total, p) => total + p.amount, 0);
}
// 연극당 적립포인트 계산
function volumeCreditsFor(perf: Performance): number {
let result = 0;
result += Math.max(perf.audience - 30, 0);
if ("comedy" === playFor(perf).type)
result += Math.floor(perf.audience / 5);
return result;
}
function amountFor(perf: Performance): number {
let result = 0;
switch (playFor(perf).type) {
case "tragedy":
result = 40000;
if (perf.audience > 30) {
result += 1000 * (perf.audience - 30);
}
break;
case "comedy":
result = 30000;
if (perf.audience > 20) {
result += 10000 + 500 * (perf.audience - 20);
}
result += 300 * perf.audience;
break;
default:
throw new Error(`알수없는 장르 : ${playFor(perf).type}`);
}
return result;
}
function playFor(perf: Performance): Play {
return plays[perf.playId];
}
};
function renderPlainText(data: StatementData, plays: Plays): string {
const { customer, performances, totalAmount, totalVolumeCredits } = data;
let result = `청구 내역 (고객명 : ${customer})\n`;
for (let perf of performances) {
result += `${perf.play.name} : ${usdFormat(perf.amount)} (${
perf.audience
}석)\n`;
}
result += `총액 : ${usdFormat(totalAmount)}\n`;
result += `적립 포인트 : ${totalVolumeCredits}점\n`;
return result;
// US 달러 형식으로 포맷팅
function usdFormat(currency: number): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 2,
}).format(currency / 100);
}
}
export default statement;
위 처럼 perfomance에 enrichPerformance 함수를 만들고 새로운 키밸류를 넣어 새롭게 다시 구조화를 하여 map 메서드를 사용해서 아래와 같은 구조로 리턴을 하였습니다.
// EnrichPerformance interface
{
playId:PlayName;
audience:number;
play: Play;
amount: number;
volumeCredits: number;
}
그 결과 renderPlanText 함수 내 playFor(perf), amountFor(perf) 그리고 volumeCreditsFor(perf)의 함수들을 이용해 리턴하던 것을 perf.play, perf.amount, perf.volumeCredits 으로 접근하여 조금 더 명료하게 코드를 만들어주었습니다!
또한 총 금액과 총 적립 내역도 마찬가지로 해당 함수들을 사용하여 새로운 객체의 형태로 반환하였습니다.
statementData.totalAmount = totalAmount(statementData);
statementData.totalVolumeCredits = totalVolumeCredit(statementData);
기존에 for문을 사용하여 총 금액을 구했던 함수도 reduce로 변경하여 조금 더 간결한 코드로 만들어줬습니다.
// 총 적립포인트
function totalVolumeCredit(data: StatementData) {
return data.performances.reduce(
(total, p) => total + volumeCreditsFor(p),
0
);
}
// 총 금액
function totalAmount(data: StatementData) {
return data.performances.reduce((total, p) => total + p.amount, 0);
}
createStatementData 라는 팩토리 함수를 만들어 statementData를 만드는 역할이라고 알 수 있게 구분해주고 그 안에 코드를 넣습니다.
const createStatementData = (invoice: Invocie, plays: Plays) => {
const statementData: StatementData = {
customer: invoice.customer,
performances: invoice.performances.map(enrichPerformance),
};
statementData.totalAmount = totalAmount(statementData);
statementData.totalVolumeCredits = totalVolumeCredit(statementData);
return statementData;
....///
};
const statement = (invoice: Invocie, plays: Plays): string => {
return renderPlainText(createStatementData(invoice, plays));
};
그리고 총 금액과 적립 포인트를 구하는 로직은 다형성을 이용하여 재구성을 해줄겁니다!
먼저 다형성을 위해서 PerformanceCalculator의 클래스를 만들어줘야합니다!
class PerformanceCalculator {
protected perf;
public play;
constructor(perf: Performance, play: Play) {
this.perf = perf;
this.play = play;
}
}
그리고 createPerformanceCalculator 라는 팩토리 함수를 만들어줍니다.
const createPerformanceCalculator = (perf: Performance,play: Play) => {
return new PerformanceCalculator(perf: Performance,play: Play)
};
위와 같이 기본 구성을 작성했으면 play.type 별로 TragedyCacluator와 ComedyCaculator를 리턴하는 다형성 구조를 만들어 줄것입니다.
각각의 클래스를 더 만들어줍니다.
class TragedyCacluator extends PerformanceCalculator {
public get amount() {
let result = 40000;
if (this.perf.audience > 30) {
result += 1000 * (this.perf.audience - 30);
}
return result;
}
}
class ComedyCaculator extends PerformanceCalculator {
public get amount() {
let result = 30000;
if (this.perf.audience > 20) {
result += 10000 + 500 * (this.perf.audience - 20);
}
result += 300 * this.perf.audience;
return result;
}
public get volumeCredits() {
return super.volumeCredits + Math.floor(this.perf.audience / 5);
}
}
PerformanceCalculator 을 상속받아 각각의 장르에 맞는 계산 로직을 넣어줬습니다.
const createPerformanceCalculator = (
perf: Performance,
play: Play
): TragedyCacluator | ComedyCaculator => {
switch (play.type) {
case "tragedy": {
return new TragedyCacluator(perf, play);
}
case "comedy": {
return new ComedyCaculator(perf, play);
}
default: {
throw new Error(`알 수 없는 장르 : ${play.type}`);
}
}
};
그러면 위와 같이 타입별로 각 타입에 맞는 객체를 리턴할 수 있습니다.
이로써 모든 리팩토링이 끝이 났습니다. 총 결과물은 아래와 같습니다.
import {
Invocie,
Play,
Plays,
Performance,
StatementData,
EnrichPerformance,
} from "./ts";
// 연극별 요금, 적립 포인트를 계산하는 class
class PerformanceCalculator {
protected perf;
public play;
constructor(perf: Performance, play: Play) {
this.perf = perf;
this.play = play;
}
public get volumeCredits() {
return Math.max(this.perf.audience - 30, 0);
}
}
// 다헝성을 위해 만든 타입별 class
class TragedyCacluator extends PerformanceCalculator {
public get amount() {
let result = 40000;
if (this.perf.audience > 30) {
result += 1000 * (this.perf.audience - 30);
}
return result;
}
}
class ComedyCaculator extends PerformanceCalculator {
public get amount() {
let result = 30000;
if (this.perf.audience > 20) {
result += 10000 + 500 * (this.perf.audience - 20);
}
result += 300 * this.perf.audience;
return result;
}
public get volumeCredits() {
return super.volumeCredits + Math.floor(this.perf.audience / 5);
}
}
// 다형성을 위해만든 팩토리 함수
const createPerformanceCalculator = (
perf: Performance,
play: Play
): TragedyCacluator | ComedyCaculator => {
switch (play.type) {
case "tragedy": {
return new TragedyCacluator(perf, play);
}
case "comedy": {
return new ComedyCaculator(perf, play);
}
default: {
throw new Error(`알 수 없는 장르 : ${play.type}`);
}
}
};
// 새롭게 구조화된 중간 데이터를 만들어주는 팩토리 함수
const createStatementData = (invoice: Invocie, plays: Plays) => {
const statementData: StatementData = {
customer: invoice.customer,
performances: invoice.performances.map(enrichPerformance),
};
statementData.totalAmount = totalAmount(statementData);
statementData.totalVolumeCredits = totalVolumeCredit(statementData);
return statementData;
// 새롭게 구조화된 퍼포먼스 객체의 배열을 리턴해주기 위한 함수
function enrichPerformance(perf: Performance): EnrichPerformance {
const calculator = createPerformanceCalculator(perf, playFor(perf));
const result: EnrichPerformance = {
...perf,
play: calculator.play,
amount: calculator.amount,
volumeCredits: calculator.volumeCredits,
};
return result;
}
// 총 적립포인트
// 총 적립포인트
function totalVolumeCredit(data: StatementData): number {
return data.performances.reduce((total, p) => total + p.volumeCredits, 0);
}
// 총 금액
function totalAmount(data: StatementData): number {
return data.performances.reduce((total, p) => total + p.amount, 0);
}
//최종 결과물
const statement = (invoice: Invocie, plays: Plays): string => {
return renderPlainText(createStatementData(invoice, plays));
};
// 결과물을 만들어 줄 함수
function renderPlainText(data: StatementData): string {
const { customer, performances, totalAmount, totalVolumeCredits } = data;
let result = `청구 내역 (고객명 : ${customer})\n`;
for (let perf of performances) {
result += `${perf.play.name} : ${usdFormat(perf.amount)} (${
perf.audience
}석)\n`;
}
result += `총액 : ${usdFormat(totalAmount!)}\n`;
result += `적립 포인트 : ${totalVolumeCredits}점\n`;
return result;
// US 달러 형식으로 포맷팅
function usdFormat(currency: number): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 2,
}).format(currency / 100);
}
}
export default statement;
간단한 함수에 대해 리팩토링을 하였지만 생각보다 굉장히 세분화되어 쪼개졌다.
1편과 2편을 통해 함수 추출하기, 변수를 인라인하기, 함수 옮기기, 조건부 로직을 다형성으로 변경하기 등 다양한 리팩토링 기법을 사용하면서 새롭게 구조화 작업을 하였다.
저렇게 잘 리팩토링을 함으로써 추후 새롭게 기능을 수정해야할때 변경하기가 쉬워진다.
원하는 기능을 하는 함수에 들어가 코드만 수정하면 되기때문이다. 역시 가장 중요한건 함수를 기능별로 최대한 잘게 쪼개는게 핵심인것 같다. 그리고 정확한 네이밍을 통해 그 함수가 무슨 역할을 하는지 모든 개발자들이 보자마자 알 수 있게 만들어주는것!! 이게 핵심이라고 생각한다.
앞으로 개발을 할때는 이러한 구조를 계속 생각하면서 깔끔하게 코드 짜기 위한 습관을 계속 들여야겠다고 생각한다.
짧은 코드였지만 정말 많은 것을 배울 수 있게해준것 같다.
아직 책의 내용을 다 읽어보진 않았지만 앞으로 시간이 날때마다 계속 읽어가면서 나의 개발 습관을 고쳐나가도록 노력할 것이다!!
감사합니다 🙂