[리팩터링 2판] CHAPTER 01. 리팩터링: 첫 번째 예시 (2)

Mijeong (Rachel)
14 min readDec 23, 2021

--

리팩터링 2판 책을 읽고 공부한 내용을 기록한다. 2판은 자바스크립트를 기반으로 예시를 제공하지만 저자 마틴 파울러는 리팩터링의 핵심 요소는 언어와 상관없이 동일하다고 이야기한다.

해당 기록에서는 책에서 다루는 예제 코드 대신 개인적으로 작성한 예제 코드를 사용한다. 즉, 책의 모든 내용과 순서를 그대로 담지 않을 수 있다.

각 장/절 별로 작성한 코드는 깃허브 개인 저장소에서 관리한다.

불릿 기호는 책의 내용 중 의미있다고 판단한 것을 옮겨 적은 것이다.

1장의 1.6절부터 1.10절까지 학습한 내용을 기록한다. 1장은 예제 코드와 함께 전반적인 리팩터링 진행 절차를 제시한다.

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

책에서는 statement() 함수 결과값의 포맷을 텍스트 버전과 HTML 버전 모두 지원하는 것으로 계획한다. 각 포맷을 위해 statement() 함수 내의 계산 로직이 중복 생성되는 것이 아닌, 동일한 함수를 사용할 수 있도록 개선하고자 한다.

(사실 나는 그럴 계획이 아니었지만) 개인적으로 작성한 예제 코드에서도 createMessage() 함수 결과값을 Object 포맷과 String 포맷을 지원할 계획이라고 가정한다.

이제 해당 절의 목표는 계산 단계와 포맷팅 단계를 분리하는 것이다.

함수 추출하기

  • 결과 데이터 포맷팅 하는 코드를 별도 함수로 추출한다.
import {IMessage} from '../data/types';
import moment from 'moment-timezone';
import 'moment/locale/ko';

export default function createMessage (
type: string,
data: string,
userId: number,
channelId: string,
now: Date,
): IMessage {

return renderObject(type, data, userId, channelId, now); // 결과 Object 생성 전체를 별도 함수로 추출

function renderObject( // 결과 Object 생성 전체를 별도 함수로 추출
type: string,
data: string,
userId: number,
channelId: string,
now: Date,
): IMessage {
const message: IMessage = {
from: userId,
to: channelId,
contentType: type,
...contentFor(type),
displayDate: inKorean(now),
};

return message;
}

function contentFor(type: string) {
let result = {};
switch(type) {
case 'text':
result = {
text: data
};
break;
case 'qna':
result = {
qna: data
};
break;
default:
throw new Error(`지원하지 않는 유형: ${type}`);
}
return result;
}

function inKorean(now: Date) {
return moment(now).tz('Asia/Seoul').format('LT');
}
}

중간 데이터 역할 Object 생성

  • 두 단계 사이의 중간 데이터 구조 역할을 할 Object를 생성하여 추출한 함수의 인자로 전달한다.
  • 중간 데이터 구조로 옮기면, 계산 관련 코드는 전부 statement() 함수(나는 createMessage() 함수) 로 모으고 renderPlainText()(나는 renderObject())는 매개변수로 전달된 데이터만 처리하게 만들 수 있다.

결국 포맷팅 단계에 해당하는 renderXXX() 함수는 전달받은 매개변수를 이용하여 원하는 형태로 값을 반환할 뿐, 데이터를 변경하는 계산 로직과는 온전히 분리된다.

import {IMessage, IMessageParam} from '../data/types';
import moment from 'moment-timezone';
import 'moment/locale/ko';

export default function createMessage (
type: string,
data: string,
userId: number,
channelId: string,
now: Date,
): IMessage {
const param: IMessageParam = { // 중간 데이터 생성
userId,
channelId,
type,
content: {...contentFor(type, data)},
displayDate: inKorean(now),
};

return renderObject(param); // 중간 데이터를 인수로 전달

function renderObject(param: IMessageParam): IMessage {
const message: IMessage = {
from: param.userId,
to: param.channelId,
contentType: param.type,
...param.content,
displayDate: param.displayDate
};

return message;
}

function contentFor(type: string, data: string) {
let result = {};
switch(type) {
case 'text':
result = {
text: data
};
break;
case 'qna':
result = {
qna: data
};
break;
default:
throw new Error(`지원하지 않는 유형: ${type}`);
}
return result;
}

function inKorean(now: Date) {
return moment(now).tz('Asia/Seoul').format('LT');
}
}

중간 데이터 처리에 해당하는 코드, 별도 함수로 분리

중간 데이터 Object를 처리하는 코드를 createMessageParam() 함수로 분리하고, createMessage() 함수에서는 createMessageParam() 함수를 사용한다.

// createMessage.ts 파일import {IMessage, IMessageParam} from '../data/types';
import createMessageParam from './03_separate_function_createMessageParam';
import 'moment/locale/ko';

export default function createMessage (
type: string,
data: string,
userId: number,
channelId: string,
now: Date,
): IMessage {

return renderObject(createMessageParam(type, data, userId, channelId, now)); // 중간 데이터 계산 및 생성 함수 사용

function renderObject(param: IMessageParam): IMessage {
const message: IMessage = {
from: param.userId,
to: param.channelId,
contentType: param.type,
...param.content,
displayDate: param.displayDate
};

return message;
}
}
// createMessageParam.ts 파일import {IMessageParam} from '../data/types';
import moment from 'moment-timezone';
import 'moment/locale/ko';

export default function createMessageParam ( // 중간 데이터 계산 및 생성 단계를 별도 함수로 추출
type: string,
data: string,
userId: number,
channelId: string,
now: Date,
): IMessageParam {
const param: IMessageParam = {
userId,
channelId,
type,
content: {...contentFor(type, data)},
displayDate: inKorean(now),
};

return param;

function contentFor(type: string, data: string) {
let result = {};
switch(type) {
case 'text':
result = {
text: data
};
break;
case 'qna':
result = {
qna: data
};
break;
default:
throw new Error(`지원하지 않는 유형: ${type}`);
}
return result;
}

function inKorean(now: Date) {
return moment(now).tz('Asia/Seoul').format('LT');
}
}

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

이제 계산 단계(createMessageParam())와 포맷팅 단계(renderObject())로 명확히 분리됐다.

  • 간결함이 지혜의 정수일지 몰라도, 프로그래밍에서만큼은 명료함이 진화할 수 있는 소프트웨어의 정수다.
  • 나는 ‘항시 코드베이스를 작업하기 전보다 더 건강하게 고친다’라는 캠핑 규칙의 변형 버전을 적용한다. 완벽하지는 않더라도, 분명 더 나아지게 한다.

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

계산 단계를 다시 살펴보면, type에 따라 로직이 달라진다는 것을 확인할 수 있다. 나의 예제 코드에서는 type이 text 혹은 qna인지에 따라 처리 로직이 달라진다. 그리고 type에는 다른 유형이 추가될 여지가 충분하다.

  • 이런 형태의 조건부 로직은 코드 수정 횟수가 늘어날수록 골칫거리로 전락하기쉽다. 이를 방지하려면 프로그래밍 언어가 제공하는 구조적인 요소로 적절히 보완해야 한다.
  • 조건부 로직을 명확한 구조로 보완하는 방법은 다양하지만, 여기서는 객체지향의 핵심 특성인 다형성(polymorphism)을 활용하는 것이 자연스럽다.

클래스 생성

우선 type에 따라 처리하는 로직을 별도 클래스로 분리한다.

class ContentGenerator { // content 생성 클래스
type: string;
data: string;

constructor(type: string, data: string) {
this.type = type;
this.data = data;
}

get content() {
let result = {};
switch(this.type) {
case 'text':
result = {
text: this.data
};
break;
case 'qna':
result = {
qna: this.data
};
break;
default:
throw new Error(`지원하지 않는 유형: ${this.type}`);
}
return result;
}
}

함수 인라인

생성한 클래스의 함수를 직접 호출하도록 수정한다.

export default function createMessageParam (
type: string,
data: string,
userId: number,
channelId: string,
now: Date,
): IMessageParam {
const generator = new ContentGenerator(type, data);
const param: IMessageParam = {
userId,
channelId,
type,
content: {...generator.content}, // content 생성 클래스의 함수 인라인
displayDate: inKorean(now),
};

return param;

function inKorean(now: Date) {
return moment(now).tz('Asia/Seoul').format('LT');
}
}

다형성 적용

  • 타입(type)에 따른 코드 대신 서브클래스를 사용하도록 변경하는 것
import {IMessageParam} from '../data/types';
import moment from 'moment-timezone';
import 'moment/locale/ko';

class ContentGenerator { // content 생성 클래스
type: string;
data: string;

constructor(type: string, data: string) {
this.type = type;
this.data = data;
}
}

class TextGenerator extends ContentGenerator { // text 유형을 위한 서브클래스
get content() {
return {
text: this.data
};
}
}

class QnaGenerator extends ContentGenerator { // qna 유형을 위한 서브클래스
get content() {
return {
qna: this.data
};
}
}

function createContentGenerator(type: string, data: string) { // 팩터리 함수
switch(type) {
case 'text':
return new TextGenerator(type, data);
case 'qna':
return new QnaGenerator(type, data);
default:
throw new Error(`지원하지 않는 유형: ${type}`);
}
}

export default function createMessageParam (
type: string,
data: string,
userId: number,
channelId: string,
now: Date,
): IMessageParam {
const generator = createContentGenerator(type, data); // 팩터리 함수를 통해 서브클래스 인스턴스 반환
const param: IMessageParam = {
userId,
channelId,
type,
content: {...generator.content},
displayDate: inKorean(now),
};

return param;

function inKorean(now: Date) {
return moment(now).tz('Asia/Seoul').format('LT');
}
}

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

  • 이번 수정으로 나아진 점은 타입별 계산 코드들을 서브클래스에 묶어뒀다는 것이다.
  • 같은 타입의 다형성을 기반으로 실행되는 함수가 많을수록 이렇게(a.k.a. 서브클래스) 구성하는 쪽이 유리하다.

1.10 마치며

  • 1장에서는 리팩터링을 크게 세 단계로 진행했다. 원본 함수를 중첩 함수 여러 개로 나누기, 계산 코드와 포맷팅 코드 분리하기, 계산 로직을 다형성으로 표현하기. 단계가 지날 때 마다 코드가 수행하는 일이 더욱 분명하게 드러났다.
  • 좋은 코드를 가늠하는 확실한 방법은 ‘얼마나 수정하기 쉬운가’다.
  • 리팩터링을 효과적으로 하는 핵심은, 단계를 잘게 나눠야 더 빠르게 처리할 수 있고, 코드는 절대 깨지지 않으며, 이러한 작은 단계들이 모여서 상당히 큰 변화를 이룰 수 있다는 사실을 깨닫는 것이다.

1장의 첫 예제 코드와 비교하여 코드의 양은 훨씬 증가했다. 하지만, 코드가 수행하려는 일은 더욱 명확해졌고 저자의 말 처럼 수정하기 쉬운 코드가 됐다는 점이 중요하다.

--

--

Mijeong (Rachel)

More from Mijeong (Rachel)