Design Pattern

Strategy pattern, 스트레이티지 패턴

iKay 2020. 4. 1. 00:35
반응형

Strategy pattern 이란

컴퓨터 프로그래밍에서 Strategy pattern(스트레이티지 패턴) 혹은 Policy pattern은 런타임에 알고리즘을 선택할 수 있는 behavioral(행위) 소프트웨어 설계 패턴이다. 코드에 알고리즘을 직접 구현하지 않고, 사용할 알고리즘을 런타임에 수신되도록 한다. Strategy는 알고리즘을 사용하는 클라이언트와 독립되도록 한다.

 

Strategy pattern을 스타크래프트의 테란 유닛을 typescript로 구현 하는 예로 설명한다. 간단히 설명하기 위해 공격 가능한 유닛 'Marine'과 'Ghost'를 구현해볼 예정이다. 'Marine'은 체력이 40, 크기는 소형, 공격형은 일반형이다. 'Ghost'는 체력이 45, 크기는 소형, 공격형은 진동형이다. 참고로 공격형에는 일반형, 폭발형, 진동형이 있는데, 일반형은 모든 크기에 100% 피해를 입히지만 진동형은 소형에는 100% 중형에는 50% 대형에는 25% 피해를 입힌다.

 

abstract class & abstract method

쉽게 생각하면 아마 생각하면 다음과 같이 'TerranUnit'이라는 abstract class에 'attack'과 'getDamaged'라는 abstract method를 두고 'Marine'과 'Ghost'가 상속을 받아 method를 완성하는 식으로 구현할 것이다.

 

type UnitSize = 'small' | 'medium' | 'large';

abstract class Unit {
    abstract name: string;
    abstract hp: number;
    abstract size: UnitSize;
    abstract range: number;
    damegedHp: number = 0;

    abstract attack(unit: Unit): void;

    protected calculateRange() {
        console.log(`current range: ${this.range}`);
        // 사정거리를 계산해서 범위 밖이면 에러를 던진다.
    }

    getDamaged(dmage: number): void {
        this.damegedHp += dmage;
        console.log(`hp: ${this.hp - this.damegedHp}/${this.hp}`);
        if (this.hp < this.damegedHp) {
            console.log('die');
        }
    }
}

class Marine extends Unit {
    name = 'marine';
    hp = 40;
    size: UnitSize = 'small';
    attackDmage = 5;
    range = 5;

    attack(unit: Unit): void {
        this.calculateRange();
        unit.getDamaged(this.attackDmage);
    }
}

class Ghost extends Unit {
    name = 'ghost';
    hp = 45;
    size: UnitSize = 'small';
    attackDmage = 10;
    range = 7;

    attack(unit: Unit): void {
        this.calculateRange();
        switch (unit.size) {
            case 'small':
                unit.getDamaged(this.attackDmage);
                break;
            case 'medium':
                unit.getDamaged(this.attackDmage / 2);
                break;
            case 'large':
                unit.getDamaged(this.attackDmage / 4);
                break;
            default:
                throw new Error('not defined unit size');
        }
    }
}

function Main() {
    const marine1 = new Marine();
    const ghost1 = new Ghost();

    marine1.attack(marine1); // hp: 35/40
    ghost1.attack(ghost1); // hp: 35/45
}

 

'Marine'과 'Ghost'를 잘 구현한 것 같다. 이제 'Vulture'를 추가해보자. 'Vulture'를 추가하기 위해 'Ghost'의 코드를 그대로 copy&paste 했다.

class Vulture extends Unit {
    name = 'vulture';
    hp = 75;
    size: UnitSize = 'medium';
    attackDmage = 20;
    range = 6;

    attack(unit: Unit): void {
        this.calculateRange();
        switch (unit.size) {
            case 'small':
                unit.getDamaged(this.attackDmage);
                break;
            case 'medium':
                unit.getDamaged(this.attackDmage / 2);
                break;
            case 'large':
                unit.getDamaged(this.attackDmage / 4);
                break;
            default:
                throw new Error('not defined unit size');
        }
    }
}

이런 식으로 'TerranUnit' 객체의 attack 메소드를 모두 구현하는 것이 과연 바람직할까? 그렇게 생각 했을 때, 발생할 수 있는 문제점이 두 가지 떠오른다.

 

1. 동일한 코드 중복 때문에 일관성 유지가 어렵다

위에서 공격형이 일반형인 'Marine'과 진동형인 'Ghost' 그리고 'Vulture'를 구현했다. 만약 다음 패치 버전에 "진동형 공격이 중형 유닛을 공격할 때 (dmage/2)가 아니라 (dmage/3)을 해야한다"라는 변경사항을 반영해야 한다면? 'Ghost', 'Vulture' 클래스를 소스코드에서 일일이 찾아서 변경해야 한다. 만약 Unit이 10개 100개이면...? 일관성을 유지하기 힘들 것이다.

 

2. 런타임에 메소드 알고리즘 교체가 어렵다.

만약 런타임에 'Marine'의 range가 변경될 수 있다고 하자. 다시 말해 게임 중에 'Marine'의 사정거리가 업그레이드 되는 것을 어떻게 구현할 수 있을까? 조금 고민해보면 알겠지만 런타임에 메소드의 알고리즘 교치는 이렇게 구현했다면 쉽지 않다.

function Main() {
    const marine1 = new Marine();

    // Research complete! Marine range가 증가한다.

    // 이미 생산된 Marine의 range 증가
    marine1.range += 1;
    
    // 앞으로 생산될 Marine을 위해 Marine class의 range도 증가 시켜야 한다.
    // 어떻게 증가시킬 것인가?
    // Unit.range를 static 변수로 둘 것인가? 또는 Marine.range를 static으로 둘 것인가? 
    // Unit.range를 static 변수로 두는 것은 다른 유닛의 range도 증가되기 때문에 가능한 방법이 아니다.
    // Marine.range를 static 변수로 만들어 버리면 Unit의 공통 함수인 calculateRange 메소드를
    // 활용하지 못하므로 이것도 좋은 방법은 아닌 것 같다.
}

3. OCP 위반

OCP는 객체 지향 SOLID 법칙 중 두 번째인 Open-Close Principle(개방 폐쇠 법칙)으로, "기존 코드를 변경하지 않으면서 기능을 추가/변경할 수 있도록 설계가 되어야 한다는 법칙"이다. 위 코드에서 진동형 유닛의 공격력을 변화시키거나, 'Marine'의 attck기능 중 range를 변경하기 위해 각 'Unit' 클래스를 직접 수정해야 하므로 OCP를 위반 하는 것이다.

 

Strategy pattern

위 문제점을 해결하기 위해 Strategy pattern을 적용해본다. 클래스 다이어그램, 코드를 보면 알겠지만 각 Unit은 attack하는 method를 상횡에 맞는 strategy를 취하고 있음을 볼 수 있다.

 

아래와 같이 설계를 하면 위에서 언급했던 1,2, 그리고 3의 문제가 모두 해결된다. 자세한 설명은 아래에 클래스 다이어그램, 코드가 있으니 생략한다. 기존과 다른 점은 유닛과 무기가 강하게 결합되어 있는 것을 분리했다는 점이다.

 

type UnitSize = 'small' | 'medium' | 'large';

interface Attack {
    attack(unit: Unit): void;
}

// 일반형
class NormalAttack implements Attack {
    attackDmage: number;
    range: number;

    constructor({
        attackDmage,
        range,
    }: {
        attackDmage: number;
        range: number;
    }) {
        this.attackDmage = attackDmage;
        this.range = range;
    }

    attack(unit: Unit): void {
        this.calculateRange();
        unit.getDamaged(this.attackDmage);
    }

    calculateRange() {
        console.log(`current range: ${this.range}`);
        // 사정거리를 계산해서 범위 밖이면 에러를 던진다.
    }
}

// 진동형
class ConcussiveAttack implements Attack {
    attackDmage: number;
    range: number;

    constructor({
        attackDmage,
        range,
    }: {
        attackDmage: number;
        range: number;
    }) {
        this.attackDmage = attackDmage;
        this.range = range;
    }

    attack(unit: Unit): void {
        this.calculateRange();
        switch (unit.size) {
            case 'small':
                unit.getDamaged(this.attackDmage);
                break;
            case 'medium':
                unit.getDamaged(this.attackDmage / 2);
                break;
            case 'large':
                unit.getDamaged(this.attackDmage / 4);
                break;
            default:
                throw new Error('not defined unit size');
        }
    }

    calculateRange() {
        console.log(`current range: ${this.range}`);
        // 사정거리를 계산해서 범위 밖이면 에러를 던진다.
    }
}

abstract class Unit {
    abstract name: string;
    abstract hp: number;
    abstract size: UnitSize;

    attackStrategy: Attack;
    damegedHp: number = 0;

    constructor({ attackStrategy }: { attackStrategy: Attack }) {
        this.attackStrategy = attackStrategy;
    }

    attack(unit: Unit) {
        this.attackStrategy.attack(unit);
    }

    getDamaged(dmage: number): void {
        this.damegedHp += dmage;
        console.log(`hp: ${this.hp - this.damegedHp}/${this.hp}`);
        if (this.hp < this.damegedHp) {
            console.log('die');
        }
    }
}

class Marine extends Unit {
    name = 'marine';
    hp = 40;
    size: UnitSize = 'small';
}

class Ghost extends Unit {
    name = 'ghost';
    hp = 45;
    size: UnitSize = 'small';
}

class Vulture extends Unit {
    name = 'vulture';
    hp = 75;
    size: UnitSize = 'medium';
}

function Main() {
    const marine1 = new Marine({
        attackStrategy: new NormalAttack({ attackDmage: 5, range: 5 }),
    });
    const ghost1 = new Ghost({
        attackStrategy: new ConcussiveAttack({ attackDmage: 10, range: 7 }),
    });
    const vulture1 = new Vulture({
        attackStrategy: new ConcussiveAttack({ attackDmage: 20, range: 6 }),
    });

    marine1.attack(ghost1); // current range: 5, hp: 40/45
    ghost1.attack(vulture1); // current range: 7, hp: 70/75
    vulture1.attack(marine1); // current range: 6, hp: 20/40

    // Research completed! Marine range가 1 증가!

    // 이미 생산된 Marine의 range 증가
    marine1.attackStrategy = new NormalAttack({ attackDmage: 5, range: 6 });

    // 새로 생산된 Marine의 range 증가
    const marine2 = new Marine({
        attackStrategy: new NormalAttack({ attackDmage: 5, range: 6 }),
    });

    marine1.attack(vulture1); // current range: 6, hp: 65/75
    marine2.attack(marine1); // current range: 6, hp: 15/40
}

 

결론

런타임에 메소드 알고리즘이 교체되는 경우가 발생하거나, 상속받은 객체의 메소드가 객체마다 다양하게 동작하도록 구현해야 하는 경우 Strategy pattern을 고려하자.

반응형

'Design Pattern' 카테고리의 다른 글

Observer pattern, 옵저버 패턴  (0) 2020.05.18
State pattern, 스테이트 패턴  (0) 2020.04.28
Command Pattern, 커맨트 패턴  (0) 2020.04.13
Singleton pattern, 싱글톤 패턴  (0) 2020.04.02