본문 바로가기
JavaScript│Node js

마틴 파울러 리팩토링 - Replace conditional with polymorphism

by 자유코딩 2019. 12. 4.

switch 문을 javascript의 클래스를 사용해서 리팩토링 한다.

 

switch(bird.type) {
	case 'EuropeanSwallow':
    	return "average";
    case 'AfricanSwallow':
    	return (bird.numberOfCoconuts > 2) ? "tired" : "average";
	case 'NorwegianBlueParrot':
    	return (bird.voltage > 100) ? "scorched" : "beautiful";
    default:
    	return "unknown";
}

이 코드를 아래 코드 처럼 바꾼다.

 

class EuropeanSwallow {
    get plumage() {
        return "average";
    }
}
class AfricanSwallow {
    get plumage() {
        return (this.numberOfCoconuts > 2) ? "tired" : "average";
    }
}
class EuropeanSwallow {
    get plumage() {
        return (this.voltage > 100) ? "scorched" : "beautiful";
    }
}

Motivation

복잡한 조건문은 개발을 어렵게 만드는 원인 중에 하나다.

그래서 조건문에 구조를 더할 방법을 생각한다.

 

조건문은 큰 조건 아래에 하위 조건이 있는 경우도 있다.

이런 관계는 상위 클래스와 하위 클래스로 표현 될 수도 있다.

 

그렇다고 모든 조건문이 클래스를 사용해서 표현되어야 한다고 주장하는 것은 아니다.

하지만 복잡한 조건문의 경우에는 이렇게 클래스로 표현하는 것이 강력한 도구가 될 수 있다.

 

한 단계씩 살펴본다.

 

function plumages(birds) {
    return new Map(birds.map(b => [b.name, plumages(b)]));
}
function plumages(birds) {
    return new Map(birds.map(b => [b.name, airSpeedVelocity(b)]));
}
function plumages(birds) {
    switch (bird.type) {
        case 'EuropeanSwallow':
            return "average";
        case 'AfricanSwallow':
            return (bird.numberOfCoconuts > 2) ? "tired" : "averages";
        case 'NorwegianBlueParrot':
            return (bird.voltage > 100) ? "scorched" : "beautiful";
        default:
            return "unknown";
    }
}
function airSpeedVelocity(bird) {
    switch (bird.type) {
        case 'EuropeanSwallow':
            return 35;
        case 'AfricanSwallow':
            return 40 -2 * bird.numberOfCoconuts;
        case 'NorwegianBlueParrot':
            return (bird.isNailed) ? 0 : 10 + bird.voltage / 10;
        default:
            return null;
    }
}

먼저 클래스가 전혀 정의되어 있지 않다면 여기에 인스턴스를 리턴하는 함수를 만든다.

인스턴스를 리턴하려면 클래스가 있어야 하니까 Bird 클래스도 만든다.

function plumages(bird) {
    return new Bird(bird).plumages;
}
function airSpeedVelocity(bird) {
    return new Bird(bird).airSpeedVelocity;
}
class Bird {
    constructor(birdObject) {
        Object.assign(this, birdObject);
    }
    get plumages() {
        switch (bird.type) {
            case 'EuropeanSwallow':
                return "average";
            case 'AfricanSwallow':
                return (this.numberOfCoconuts > 2) ? "tired" : "averages";
            case 'NorwegianBlueParrot':
                return (this.voltage > 100) ? "scorched" : "beautiful";
            default:
                return "unknown";
        }
    }
    get airSpeedVelocity() {
        switch (this.type) {
            case 'EuropeanSwallow':
                return 35;
            case 'AfricanSwallow':
                return 40 -2 * bird.numberOfCoconuts;
            case 'NorwegianBlueParrot':
                return (this.isNailed) ? 0 : 10 + bird.voltage / 10;
            default:
                return null;
        }
    }
}

이제 하위 클래스를 생성한다.

 

class EuropeanSwallow extends Bird {
    
}
class AfricanSwallow extends Bird {

}
class NorwegianBlueParrot extends Bird {

}

함수를 고치고 추가한다.

function plumages(bird) {
    return createBird(bird).plumages;
}
function airSpeedVelocity(bird) {
    return createBird(bird).airSpeedVelocity;
}
function createBird(bird) {
    switch (bird.type) {
        case 'EuropeanSwallow':
            return new EuropeanSwallow(bird);
        case 'AfricanSwallow':
            return new AfricanSwallow(bird);
        case 'NorweigianBlueParrot':
            return new NorwegianBlueParrot(bird);
        default:
            return new Bird(bird);
    }
}

plumages 함수는 수정한다.

createBird 함수는 새로 만들었다.

createBird 함수의 역할은 각각의 케이스마다 하위 클래스의 인스턴스를 생성한다.

가장 단순한 구조일때 switch case 문을 사용한다.

이제 하위 클래스도 만들었으니 plumages 함수부터 고쳐보려고 한다.

 

코드를 모두 고치면 아래 코드처럼 된다.

function plumages(birds) {
    return new Map(birds
                    .map(b => createBird(b))
                    .map(bird => [bird.name, bird.plumages]));
}
function speeds(birds) {
    return new Map(birds
                    .map(b => createBird(b))
                    .map(bird => [bird.name, bird.airSpeedVelocity]));
}
function createBird(bird) {
    switch (bird.type) {
        case 'EuropeanSwallow':
            return new EuropeanSwallow(bird);
        case 'AfricanSwallow':
            return new AfricanSwallow(bird);
        case 'NorweigianBlueParrot':
            return new NorwegianBlueParrot(bird);
        default:
            return new Bird(bird);
    }
}
class Bird {
    constructor(birdObject) {
        Object.assign(this, birdObject);
    }
    get plumages() {
        return "unknown";
    }
    get airSpeedVelocity() {
        return null;
    }
}
class EuropeanSwallow extends Bird {
    get plumages() {
        return "average";
    }
    get airSpeedVelocity() {
        return 35;
    }
}
class AfricanSwallow extends Bird {
    get plumages() {
        return (this.numberOfCoconuts > 2) ? "tired" : "average";
    }
    get airSpeedVelocity() {
        return 40 - 2 * this.numberOfCoconuts;
    }
}
class NorwegianBlueParrot extends Bird {
    get plumages() {
        return (this.voltage > 100) ? "scorched" : "beautiful";
    }
    get airSpeedVelocity() {
        return (this.isNailed) ? 0 : 10 + this.voltage / 10;
    }
}

plumages, speeds 함수를 통해서 접근한다.

그리고 createBird 함수에는 switch 문을 작성한다.

아래 Bird 클래스와 각각의 하위 클래스에 본문을 작성한다.

createBird 함수에서 각각의 하위 클래스의 인스턴스를 만드는 형태로 사용한다.

 

리팩토링된 코드를 보면 상위 클래스 Bird가 반드시 필요한 역할을 하지는 않는다.

하지만 코드를 이해하는데 있어서 설명이 수월해지기 때문에 Bird 클래스를 작성한다.

 

Example

Using polymorphism for variation

 

다형성은 조건문을 리팩토링 할때도 쓸 수 있지만 어떤 객체가 다른 객체와 유사한지 검증할 때도 쓸 수 있다.

여행을 평가하는 서비스의 코드중 일부가 아래 코드처럼 있다고 해보자.

function rating(voyage, history) {
    const vpf = voyageProfitFactor(voyage, history);
    const vr = voyageRisk(voyage);
    const chr = captainHistoryRisk(voyage, history);
    if (vpf * 3 > (vr + chr + 2)) return "A";
    else return "B";
}
function voyageRisk(voyage) {
    let result = 1;
    if (voyage.length > 4) result += 2;
    if (voyage.length > 8) result += voyage.length - 8;
    if (["china", "east-indies"].includes(voyage.zone)) result += 4;
    return Math.max(result, 0);
}
function captainHistoryRisk(voyage, history) {
    let result = 1;
    if (history.length < 5) result += 4;
    result += history.filter(v => v.profit < 0).length;
    if (voyage.zone === "china" && hasChina(history)) result -= 2; // 제일 먼저 고치게 될 부분
    return Math.max(result, 0);
}
function hasChina(history) {
    return history.some(v => "china" === v.zone);
}
function voyageProfitFactor(voyage, history) {
    let result = 2;
    if (voyage.zone === "china") result += 1;
    if (voyage.zone === "east-indies") result += 1;
    if (voyage.zone === "china" && hasChina(history)) { // 제일 먼저 고치게 될 부분
        result += 3;
        if (history.length > 10) result += 1;
        if (history.length > 12) result += 1;
        if (history.length > 18) result -= 1;
    }
    else {
        if (history.length > 8) result += 1;
        if (voyage.length > 14) result -= 1;
    }
    return result;
}

이렇게 정의된 코드를 호출하는 부분은 아래 코드처럼 생겼다.

 

const voyage = {zone: "west-indies", length: 10};
const history = [
    {zone: "east-indies", profit: 5},
    {zone: "west-indies", profit: 15},
    {zone: "china", profit: -2},
    {zone: "west-africa", profit: 7},
]
const myRating = rating(voyage, history);

여기서 먼저 고쳐볼 부분은 captainHistoryRisk와 voyageProfitFactor 이다.

voyage.zone이 china이면서 hasChina(history)를 검사하는 부분을 고친다.

 

다른 로직을 고치는 것은 이거보다 이해하기 어렵기때문에 이거 먼저 한다.

rating 함수에 있는 내용을 Rating 을 클래스로 만들어서 고친다.

 

class Rating {
    constructor(voyage, history) {
        this.voyage = voyage;
        this.history = history;
    }
    get value() {
        const vpf = voyageProfitFactor(voyage, history);
        const vr = voyageRisk(voyage);
        const chr = captainHistoryRisk(voyage, history);
        if (vpf * 3 > (vr + chr + 2)) return "A";
        else return "B";
    }
}

고쳐진 코드의 전체 내용은 아래와 같다.

 

class Rating {
    constructor(voyage, history) {
        this.voyage = voyage;
        this.history = history;
    }
    get value() {
        const vpf = this.voyageProfitFactor(voyage, history);
        const vr = this.voyageRisk(voyage);
        const chr = this.captainHistoryRisk(voyage, history);
        if (vpf * 3 > (vr + chr + 2)) return "A";
        else return "B";
    }
    get voyageRisk() {
        let result = 1;
        if (this.voyage.length > 4) result += 2;
        if (this.voyage.length > 8) result += this.voyage.length - 8;
        if (["china", "east-indies"].includes(this.voyage.zone)) result += 4;
        return Math.max(result, 0);
    }
    get captainHistoryRisk(voyage, history) {
        let result = 1;
        if (this.history.length < 5) result += 4;
        result += this.history.filter(v => v.profit < 0).length;
        if (this.voyage.zone === "china" && this.hasChina(history)) result -= 2;
        return Math.max(result, 0);
    }
    get voyageProfitFactor(voyage, history) {
        let result = 2;
        if (this.voyage.zone === "china") result += 1;
        if (this.voyage.zone === "east-indies") result += 1;
        if (this.voyage.zone === "china" && this.hasChina(history)) {
            result += 3;
            if (this.history.length > 10) result += 1;
            if (this.history.length > 12) result += 1;
            if (this.history.length > 18) result -= 1;
        }
        else {
            if (this.history.length > 8) result += 1;
            if (this.voyage.length > 14) result -= 1;
        }
        return result;
    }
    get hasChinaHistory() {
        return this.history.some(v => "china" === v.zone);
    }
}

코드의 내용을 Rating 클래스로 옮겼다.

get 함수를 사용해서 기존 함수를 정의한다.

 

이제 하위 클래스와 이전의 createBird 같은 역할을 하는 factory function 을 만든다.

class ExperiencedChinaRating extends Rating {

}
function createRating(voyage, history) {
    if (voyage.zone === "china" && history.some(v => "china" === v.zone))
        return new ExperiencedChinaRating(voyage, history);
    else return new Rating(voyage, history);
}

createRating 을 호출하는 함수를 하나 만들고 ExperiencedChinaRating 클래스로 일부 로직을 옮긴다.

 

function rating(voyage, history) {
    return createRating(voyage, history).value;
}
class ExperiencedChinaRating extends Rating {
    get captainHistoryRisk() {
        const result = super.captainHistoryRisk - 2;
        return Math.max(result, 0);
    }
}

Rating의 captinHistoryRisk에서는 일부 코드를 지운다.

get captainHistoryRisk(voyage, history) {
        let result = 1;
        if (this.history.length < 5) result += 4;
        result += this.history.filter(v => v.profit < 0).length;
        // if (this.voyage.zone === "china" && this.hasChina(history)) result -= 2;
        return Math.max(result, 0);
    }

이제 voyageProfitFactor 함수를 살펴본다.

 

get voyageProfitFactor() {
        let result = 2;
        if (this.voyage.zone === "china") result += 1;
        if (this.voyage.zone === "east-indies") result += 1;
        result += this.voyageAndHistoryLengthFactor;
        return result;
    }

china, hasChina를 검사하는 부분을 없앴다.

voyageAndHistoryLengthFactor 함수를 새로 만든다.

voyageAndHistoryLengthFactor 함수는 Rating 클래스와 ExperiencedChinaRating 클래스에 모두 존재한다.

 

Rating 클래스

 get voyageAndHistoryLengthFactor() {
        let result = 0;
        if (this.history.length > 8) result += 1;
        if (this.voyage.length > 14) result -= 1;
        return result;
    }

 

ExperiencedChinaRating 클래스

get voyageAndHistoryLengthFactor() {
        let result = 0;
        if (this.voyage.zone === "china" && this.hasChinaHistory) {
            result += 3;
            if (this.history.length > 10) result += 1;
            if (this.voyage.length > 12) result += 1;
            if (this.voyage.length > 18) result -= 1;
        }
        else {
            if (this.history.length > 8) result += 1;
            if (this.voyage.length > 14) result -= 1;
        }
        return result;
    }

이 함수는 And로 이어져서 2가지 역할을 하는데 나중에 분리 될 것이다.

하나의 함수는 하나의 일을 하는게 좋고 하나의 클래스는 한가지 책임을 갖는 것이 좋다.

 

상위 클래스에 있는 것부터 분리한다.

get voyageAndHistoryLengthFactor() {
        let result = 0;
        result += this.historyLengthFactor;
        // if (this.history.length > 8) result += 1;
        // if (this.voyage.length > 14) result -= 1;
        return result;
    }
    get historyLengthFactor() {
        return (this.history.length > 8) ? 1 : 0;
    }

if문 두개를 아래 함수로 옮겼다.

 

하위클래스도 똑같다.

get voyageAndHistoryLengthFactor() {
        let result = 0;
        result += 3;
        result += this.historyLengthFactor;
        if (this.voyage.length > 12) result += 1;
        if (this.voyage.length > 18) result -= 1;
        // if (this.voyage.zone === "china" && this.hasChinaHistory) {
        //     if (this.history.length > 10) result += 1;
        // }
        // else {
        //     if (this.history.length > 8) result += 1;
        //     if (this.voyage.length > 14) result -= 1;
        // }
        return result;
    }
    get historyLengthFactor() {
        return (this.history.length > 10) ? 1 : 0;
    }

이제 Rating클래스의 this.historyLengthFactor 부분은 voyageProfitFactor로 옮겨서 지울 수 있다.

get voyageProfitFactor() {
        let result = 2;
        if (this.voyage.zone === "china") result += 1;
        if (this.voyage.zone === "east-indies") result += 1;
        result += this.historyLengthFactor;
        result += this.voyageAndHistoryLengthFactor;
        return result;
    }
    get voyageAndHistoryLengthFactor() {
        let result = 0;
        // result += this.historyLengthFactor;
        // if (this.history.length > 8) result += 1;
        if (this.voyage.length > 14) result -= 1;
        return result;
    }

하위 클래스도 같다.

이제 함수 이름을 바꾼다.

voyageAndHistoryLengthFactor인데 history를 다루지 않으니 voyageLengthFactor 라는 이름이 더 적절하게 되었다.

get voyageProfitFactor() {
        let result = 2;
        if (this.voyage.zone === "china") result += 1;
        if (this.voyage.zone === "east-indies") result += 1;
        result += this.historyLengthFactor;
        result += this.voyageLengthFactor;
        return result;
    }
    get voyageLengthFactor() {
        let result = 0;
        // result += this.historyLengthFactor;
        // if (this.history.length > 8) result += 1;
        if (this.voyage.length > 14) result -= 1;
        return result;
    }

하위 클래스도 같다.

get voyageProfitFactor() {
        return super.voyageProfitFactor;
    }
    get voyageAndHistoryLengthFactor() {
        let result = 0;
        result += 3;
        result += this.historyLengthFactor;
        if (this.voyage.length > 12) result += 1;
        if (this.voyage.length > 18) result -= 1;
        return result;
    }
    get historyLengthFactor() {
        return (this.history.length > 10) ? 1 : 0;
    }
    get voyageLengthFactor() {
        let result = 0;
        result += 3;
        if (this.history.length > 12) result += 1;
        if (this.voyage.length > 18) result -= 1;
        return result;
    }

이런 코드를 아래 처럼 바꾼다.

get voyageProfitFactor() {
        return super.voyageProfitFactor + 3;
    }
    // get voyageAndHistoryLengthFactor() {
    //     let result = 0;
    //     result += 3;
    //     result += this.historyLengthFactor;
    //     if (this.voyage.length > 12) result += 1;
    //     if (this.voyage.length > 18) result -= 1;
    //     return result;
    // }
    get historyLengthFactor() {
        return (this.history.length > 10) ? 1 : 0;
    }
    get voyageLengthFactor() {
        let result = 0;
        // result += 3;
        if (this.history.length > 12) result += 1;
        if (this.voyage.length > 18) result -= 1;
        return result;
    }

마지막으로 다 고쳐진 코드를 살펴본다.

class Rating {
    constructor(voyage, history) {
        this.voyage = voyage;
        this.history = history;
    }
    get value() {
        const vpf = this.voyageProfitFactor;
        const vr = this.voyageRisk;
        const chr = this.captainHistoryRisk;
        if (vpf * 3 > (vr + chr + 2)) return "A";
        else return "B";
    }
    get voyageRisk() {
        let result = 1;
        if (this.voyage.length > 4) result += 2;
        if (this.voyage.length > 8) result += this.voyage.length - 8;
        if (["china", "east-indies"].includes(this.voyage.zone)) result += 4;
        return Math.max(result, 0);
    }
    get captainHistoryRisk() {
        let result = 1;
        if (this.history.length < 5) result += 4;
        result += this.history.filter(v => v.profit < 0).length;
        return Math.max(result, 0);
    }
    get voyageProfitFactor() {
        let result = 2;
        if (this.voyage.zone === "china") result += 1;
        if (this.voyage.zone === "east-indies") result += 1;
        result += this.historyLengthFactor;
        result += this.voyageAndHistoryLengthFactor;
        return result;
    }
    get voyageLengthFactor() {
        return (this.voyage.length > 14) ? -1 : 0;
    }
    get historyLengthFactor() {
        return (this.history.length > 8) ? 1 : 0;
    }
}
class ExperiencedChinaRating extends Rating {
    get captainHistoryRisk() {
        const result = super.captainHistoryRisk - 2;
        return Math.max(result, 0);
    }
    get voyageLengthFactor() {
        let result = 0;
        if (this.history.length > 12) result += 1;
        if (this.voyage.length > 18) result -= 1;
        return result;
    }
    get historyLengthFactor() {
        return (this.history.length > 10) ? 1 : 0;
    }
    get voyageProfitFactor() {
        return super.voyageProfitFactor + 3;
    }
}

요약하면

1. 같은 이름의 함수를 상위 클래스, 하위 클래스에 만든다.

2. 상위 클래스에 있는 것과 하위클래스에 있는 것은 같은 함수지만 동작은 다르다.

3. 함수 내부에 if문이 많다면 그것은 하나의 함수로 빼놓는다.

4. 하나의 함수가 한가지의 역할만 할 수 있도록 한다.

5. 최종 호출에 사용하는 switch 문을 담고있는 factory function은 만든다.

댓글