11장 너무 이해하기 어렵다...
이번 장에서 다룰 내용
소프트 웨어 개발에서는 여러 유형의 구조(인색 가능한 패턴)를 다룬다.
이런 구조는 유사한 두개의 메서드가 될 수도 있고 사람들이 매일 하는 행위가 될 수도 있다.
구조를 4가지 범주로 나눈다. 하나의 기준은 구조가 한 팀이나 (팀 내의) 사람에게 영향을 미치는지, 아니면 여러 팀이나(팀 간의) 여러 사람에게 영향을 미치는지 이다. 다른 기준은 구조가 코드에 있는지, 사람에게 있는지 이다.
팀 간 | 팀 내 | |
---|---|---|
코드에 있는 경우 | 외부 API | 데이터와 함수, 대부분의 리팩터링 |
사람에 있는 경우 | 조직도, 프로세스 | 행위 및 도메인 전문가 |
우리는 조직에서 정의한 프로세스와 계층, 즉 팀 간 커뮤티케이션 방식 안에서 작업한다.
여기서 프로세스는 스크럼(Scrum), 칸반(Kanban), 프ㄹ젝트 모델 등을 의미하며 계층은 누구와 이야기 해야하는지를 정의하는 조직도 또는 그와 유사한 것들이다.
행위를 코드에 반영하는 데는 세 가지 방법이 있다.
각 접근 방식에 대해 알아본다. 차이점을 나타내기 위해 유명한 FizzBuzz 프로그램을 서로 다른 방법을 사용해 보여준다.
FizzBuzz 소개
FizzBuzz는 구구단을 가르치는 어린이용 게임이다. 두 개의 숫자를 선택하면 플레이어가 차례대로 숫자를 말한다. 다음 순서의 숫자가 첫 번쨰 숫자로 나누어 떨어지는 경우 'Fizz' 라고 외친다. 두 번째 숫자로 나눌 수 있는 경우 'Buzz' 라고 외친다. 두 숫자로 모두 나눌 수 있으면 'FizzBuzz' 라고 외친다.
코드로 이 게임을 구현할때는. 숫자 N을 입력 받아 0에서 N까지의 모든 숫자를 출력하는 프로그램을 작성한다. 이때 숫자가 3으로 나누어떨어지면 'Fizz'를 출력한다. 5로 나누어 떨어지면 'Buzz'를 출력한다. 둘 다로 나눌 수 있으면 'FizzBuzz'를 출력한다.
제어 흐름은 제어 연산자, 메서드 호출, 또는 단순히 열거된 코드의 줄을 통해 행위를 텍스트로 표현한다.
다음은 세 가지 가장 일반적인 제어 흐름 유형을 사용한 동일한 반복이다.
// 제어 연산자
let i = 0;
while (i < 5){
foo(i);
i++;
}
// 메서드 호출
function loop (i:number) {
if(i < 5){
foo(i);
loop(i + 1)
}
}
// 코드의 줄
foo(0);
foo(1);
foo(2);
foo(3);
foo(4);
코드 중복에 대해 이야기할 때마다 행위를 코드화한 이 세 가지 범주 사이를 오가면서 일반적으로 가장 오른쪽 유형인 열거된 반복적인 코드 줄의 유형을 지양하는 것에 대해 이야기한다.
이 세가지 범주는 묘하게 다르다. 메서드 호출과 열거된 줄은 비지역적 구조를 표현할 수 있지만 루프는 지역적으로만 동작할 수 있다.
//메서드 호출
function insert(data:object) {
let db = new Database();
let normalized = normalize(data);
db.insert(normalized);
}
function a() {
// ...
insert(obj1);
// ...
}
function b() {
// ...
insert(obj2);
// ...
}
// 열거된 코드 줄
function a() {
// ...
let db = new Database();
let normalized = normalize(obj1);
db.insert(normalized);
// ...
}
function b() {
// ...
let db = new Database();
let normalized = normalize(obj2);
db.insert(normalized);
// ...
}
다른 관점에서, 제어 연산자와 메서드 호출은 일련의 반복적인 줄로는 할 수 없는 작업, 바로 무한 루프를 생성할 수 있다.
// 제어 연산자
for (;;) { }
// 메서드 호출
function loop() {
loop();
}
제어 흐름 안에 행위를 기술하면 단순히 여기저기 문장을 이동하는 것만으로도 흐름을 변경할 수 있기 때문에 커다란 변화를 만들기 쉽다.
흔히 우리는 안정성과 작은 변화를 선호하기 때문에 제어 흐름을 사용해 리팩터링하지 않는다.
그러나 어떤 상황에서는 큰 조정이 필요하다. 이런 경우 동작을 제어 흐름으로 리팩터링 한 다음, 이를 다시 리팩터링 하는 것이 유용할 수 있다.
대부분의 사람은 제어 흐름 안에 FizzBuzz를 인코딩해서 구현한다.
// 제어 흐름을 사용한 FizzBuzz
function fizzBuzz(n:number){
for(let i = 0; i < n; i++){
if(i % 3 === 0 && i % 5 === 0){
console.log("FizzBuzz");
}else if(i % 5 === 0){
console.log("Buzz");
}else if(i % 3 === 0){
console.log("Fizz");
}else{
console.log(i);
}
}
}
for, while 같은 제어 연산자 및 재귀 함수를 대신해서 타입을 이용해 무한 루프를 만드는 방법을 살펴보자. 이 예에서는 재귀 데이터 구조를 사용한다.
Rec 에는 타입이 Rec인 필드 f가 있다. 즉, 재귀 데이터 구조이다.
필드 f가 함수이기 때문에 Rec 객체를 취해서 그 안에 있는 함수 f를 가져와 동일한 Rec 객체를 인자로 호출하는 helper 함수를 정의할 수 있다.
이제 Rec 객체를 인스턴스화하고 helper 함수에 전달할 수 있다.
이 예제에서는 어떤 함수도 직접 호출하지 않는다. helper함수는 Rec 데이터 구조를 통해 스스로를 호출한다.
//재귀 데이터 구조
class Rec {
constructor(public readonly f: (_:Rec) => void) { }
}
function loop() {
let helper = (r: Rec) => r.f(r);
helper(new Rec(helper));
}
제어 흐름에서의 행위와 비교해서 이 접근 방식을 사용하면 기존의 변형이 발생하는 지점(변형점)과 일치하지 않는 한 큰 변경을 수행하기가 더 어렵다.
그러나 작은 변경은 더 쉽고 안전하다. 이것은 더 많은 타입 안전성과 지역성을 얻기 때문이다.
마지막 접근 방식은 데이터에 행위를 코드화 하는것이다. 이것은 도구와 컴파일러의 정지 문제 사각지대에 빠르게 맞닥 뜨리기 떄문에 가장 어렵다.
중복된 데이터로 데이터를 구조화 하는 것을 흔히 볼 수 있다. 이것은 특히 데이터가 변경 가능할 경우 일관성 문제로 이어질 수 있다. 성능 향상은 이런 문제를 정당화 할 수 있지만 오류의 원인이 된다.
데이터로 무한 루프를 만들기 위해서는 타입스크립트, 자바 및 C# 배열의 참조를 사용해야 하는데, 객체는 참조로 처리한다. 원리는(자기 자신의) 참조를 찾아 호출하는 함수를 메모리에 넣는것이다. 함수는 직접이 아닌 간접으로 힙을 통해 다시 자신을 호출한다.
// 재귀 데이터
function loop() {
let a = [() => { }];
a[0] = () => a[0]();
a[0]();
}
이 방법은 컴파일러의 지원을 받지 못하기 떄문이 안전하게 사용하기 어렵다.
한 가지 해결책은 데이터를 검색해서 데이터에서 데이터 구조를 생성하는 도구를 사용하는 것이다.
결과적으로 동작을 복제하고 도구를 직접 유지보수하거나 외부 라이브러리를 사용해야한다.
변경 범위를 예측하려는 시도는 코드베이스에 도움이 되기보다 손상을 준다.
코드를 추측하지 말고 경험적인 기술을 사용해야한다.
강력한 도구가 있더라도 항상 사용해서는 안된다. 코드가 어떻게 변경될지는 관찰이 필요하다.
변경 되지 않으면 아무것도 하지말라
예측할 수 없이 변경되는 경우 취약성을 피하기 위해서 리팩터링하라
그렇지 않으면, 과거에 발생한 변경 유형을 적용해 리팩터링하라.
리팩터링은 제어 흐름, 자료구조, 데이터 사이에서 동작을 이동시킨다. 구조가 코드에 있기 때문에 기본 도메인이나 구조에 관계없이 이것은 사실이다. 이미 코드에 있는 구조를 따르고 실수 없이 믿을 만한 리팩터링 패턴을 사용하는 한 작업을 위해 코드를 이해할 필요가 없다.
오류가 발생하더라도 자체 수정하도록 코드를 작성한다.
실패 시 자동 롤백하도록 토글 기능을 추가할 수 있따. 이렇게 하면 리팩터링 하는 동안 실수로 인해 코드가 실패하더라도 기능 전환 시스템이 자동으로 이전 코드로 되돌린다.
코드에서 활용 가능한 구조를 찾는 가장 일반적인 위치와 사용 방법
공백을 넣어 그룹화된 문장을 볼때마다 메서드 추출 패턴을 생각해봐야한다.
다음 예제에서 함수는 배열 내의 최솟값을 찾아내어 배열의 모든 요소에서 해당 값을 빼는 작업을 한다. 빈 줄로 구분된 두 개의 부분이 존재한다.
//변경 전
function subMin(arr:number[]){
let min = Number.POSITIVE_INFINITY;
for(let x = 0; x < arr.length; x++){
min = Math.min(min, arr[x]);
}
for(let x = 0; x < arr.length; x++) {
arr[x] -= min;
}
}
// 변경 후
function subMin(arr:number[]){
let min = findMin(arr)';
subtractFromEach(min, arr);
}
function findMin(arr:number[]){
let min = Number.POSITIVE_INFINITY;
for(let x = 0; x < arr.length; x++){
min = Math.min(min, arr[x]);
}
return min;
}
function subtractFromEach(min:number, arr: number[]){
for(let x = 0; x < arr.length; x++) {
arr[x] -= min;
}
}
공백은 필드를 그룹화 하는데도 사용된다. 이 경우 공백은 어떤 데이터 요소가 더 관련이 있는지(즉, 함께 사용)를 나타낸다. 이 또한 데이터 캡슐화 리팩터링 패턴을 통해 이 구조를 활용하는 연습을 할 수 있다.
다음 예에는 x,y 및 color 필드를 가진 Particle 클래스가 있다. 공백을 통해 x와 y가 color보다 더 밀접하게 연결되어 있음을 추론할 수 있고, 이를 활용한다.
//변경 전
class Paricle {
private x: number;
private y: number;
private color: number;
}
//변경 후
class Vector2D {
private x: number;
private y: number;
}
class Paricle {
private position: Vector2D;
private color:number;
// ...
}
서로 가까이, 또는 서로 다른 클래스의 여러 메서드에 걸쳐 중복된 문장을 가질 수 있다. 두 경우 모두 기본적인 메서드 추출 리팩터링 패턴으로 시작한다.
이 예에는 두개의 포매터(Formater)가 있다. 두 클래스 모두에 나타나는 result += 문의 처리가 다르므로 이를 메서드로 추출한다.
// 변경 전
class XMLFormatter {
format(vals: string[]) {
let result = "";
for (let i =0; i < vals.length; i++){
result += `<Value>${vals[i]}</Value>`
}
return result;
}
}
class JSONFormatter {
format(vals: string[]){
let result = "";
for (let i =0; i < vals.length; i++){
if(i > 0) result += ",";
result += `{value: "${vals[i]}"}`;
}
return result;
}
}
//변경 후
class XMLFormatter {
format(vals: string[]){
let result = "";
for (let i =0; i < vals.length; i++) {
result += this.formatSingle(vals[i);
}
return result;
}
formatSingle(val: string){
return `<Value>${vals[i]}</Value>`
}
}
class JSONFormatter {
format(vals: string[]){
let result = "";
for (let i =0; i < vals.length; i++){
if(i > 0) result += ",";
result += `{value: "${vals[i]}"}`;
}
return result;
}
formatSingle(val: string){
return `{value: "${vals[i]}"}`;
}
}
추출된 메서드가 여러 클래스에 분산되어 있을 경우 이번에는 메서드에 데이터 캡슐화를 사용해서 한 곳으로 모을 수 있다.
// 변경 전
class XMLFormatter {
formatSingle(val: string){
return `<Value>${vals[i]}</Value>`
}
// ...
}
class JSONFormatter {
formatSingle(val: string){
return `{value: "${vals[i]}"}`;
}
// ...
}
// 변경 후
class XMLFormatter {
formatSingle(val: string){
return new XMLFormatSingle()
.format(val);
}
// ...
}
class JSONFormatter {
formatSingle(val: string){
return new JSONFormatSingle()
.format(val);
}
// ...
}
class XMLFormatSingle {
format(val: string) {
return `<Value>${vals[i]}</Value>`
}
}
class JSONFormatSingle {
formatSingle(val: string){
return `{value: "${vals[i]}"}`;
}
}
보유한 메서드가 동일하면 해당 클래스도 동일하기 때문에 하나만 남기고 삭제할 수 있다. 캡슐화한 클래스들이 단순히 유사한 경우나 클래스들이 중복된 것을 볼 때마다 유사 클래스 통합 패턴을 사용해 통합할 수 있다.
// 변경 전
class XMLFormatSingle {
format(val: string) {
return `<Value>${vals[i]}</Value>`
}
}
class JSONFormatSingle {
formatSingle(val: string){
return `{value: "${vals[i]}"}`;
}
}
// 변경 후
class XMLFormatter {
formatSingle(val: string){
return new FormatSingle("<Value>","</Value>")
.format(val);
}
// ...
}
class JSONFormatter {
formatSingle(val: string){
return new FormatSingle("{value: '","'}")
.format(val);
}
// ...
}
class FormatSingle {
constructor(
private before: string,
private after: string
){}
format(val:string){
return `${before}${val}${after}`;
}
}
문장의 제어 흐름은 유사한데, 문장 자체가 다른 경우 전략 패턴의 도입을 사용해서 동일 하게 할 수 있다.
// 변경 전
class XMLFormatter {
format(vals: string[]){
let result = "";
for (let i = 0; i < vals.length; i++) {
result += new FormatSingle("<Value>","</Value>").format(vals[i]);
}
return result;
}
}
class JSONFormatter {
format(vals: string[]){
let result = "";
for (let i = 0; i < vals.length; i++) {
if(i > 0) result += ",";
result += new FormatSingle("{value: '","'}").format(vals[i]);
}
return result;
}
}
//변경 후
class XMLFormatter {
format(vals:string[]){
return new Formatter (
new FormatSingle("<Value>","</Value>"),
new None()
).format(vals);
)
}
}
class JSONFormatter {
format(vals:string[]){
return new Formatter (
new FormatSingle("{value:'","'}"),
new Comma()
).format(vals);
)
}
}
class Formatter {
constructor(
private single: FormatSingle,
private sep: Separator
){}
format(vals: string[]){
let result = "";
for (let i = 0; i < val.length; i++){
result = this.sep.put(i, result);
result += this.single.format(vals[i]);
}
return result;
}
}
interface Separator {
put(i:number, str:string):string;
}
class Comma implements Separator {
put(i:number, result:string){
if(i > 0) result += ',';
return result;
}
}
class None implements Separator {
put(i:number,result:string){
return result;
}
}
이 시점에서 두 원본 포매터는 상수 값만 다르기 때문에 쉽게 통합할 수 있다.
공백이나 중복, 또는 이름에 공통적인 명칭을 통해 그룹화된 것을 발견할 떄 이 구조를 견고하게 하는 방법은 데이터 캡슐화이다.
유사한 이름을 가진 클래스들을 그룹하 하는데도 이 규칙이 사용될 수 있다.
타입스크립트에는 네임스페이스나 모듈을 사용한다.
//변경 전
interface Protocol {...}
class StringProtocol implements Protocol {...}
class JSONProtocol implements Protocol {...}
class ProtobufProtoco implements Protocol {...}
let p = new StringProtocol()
///...
// 모든 클래스에 공통 접사를 사용하지 말 것 규칙을 어기는 공통 접미사 Protocol이 존재한다. 이 경우 String이 내장 클래스 명칭과 충돌하기 때문에 Protocol을 바로 제거할 수 없지만 먼저 네임스페이스에 세 개의 클래스와 인터페이스를 캡슐화 할 수 있는 경우에는 그렇지 않다.
//변경 후
namespace protocol {
export interface Protocol {...}
export class String implements Protocol {...}
export class JSON implements Protocol {...}
export class Protobuf implements Protocol {...}
}
/// ...
let p = new Protocol.String()
///...
//변경 전
function foo(obj: any){
if(obj instanceof A){
obj.methodA()
}else if(obj instanceof B){
obj.methodB()
}
}
class A {
methodA() {...}
}
class B {
methodB() {...}
}
//변경 후
function foo(obj: Foo){
obj.foo(); // 클래스로 이관된 메서드
}
class A implements Foo {
foo() {
this.methodA()
}
methodA() {...}
}
class B implements Foo {
foo() {
this.methodB()
}
methodB() {...}
}
interface Foo {
foo(): void
}