❓ 전략 패턴 (Strategy)
객체들이 할 수 있는 행위 각각에 대해 전략 클래스를 생성하고, 유사한 행위들을 캡슐화 하는 인터페이스를 정의하여, 객체의 행위를 동적으로 바꾸고 싶은 경우 직접 행위를 수정하지 않고 전략을 바꿔주기만 함으로써 행위를 유연하게 확장하는 방법을 말합니다.
전략 패턴도 마찬가지로 단계별로 설명하겠습니다.
1단계 (상속을 이용한 Simple Duck)
오리 시뮬레이션 게임을 예로 들겠습니다.
quck(): 꽥꽥거리는 소리를 내는 메서드swim(): 헤엄치는 메서드display(): 오리의 고유한 모양을 화면에 보여주는 메서드
Duck이라는 추상 클래스가 있고, 여러 유형의 오리가 Duck 클래스로부터 상속을 받습니다.
모든 오리가 꽥꽥 소리를 낼 수 있고, 헤엄을 칠 수 있으므로, quack()과 swim()은 슈퍼 클래스에 작성합니다.
또한, 오리별로 모양이 다르므로, 오리를 화면에 보여주는 display() 메서드는 추상 메서드로 선언하여, 서브 클래스에서 반드시 구현하도록 하였습니다.
이제 오리들이 날 수 있도록 하기 위해서 Duck 클래스에 fly() 메서드를 추가하였습니다.
따라서, 모든 서브 클래스에서 fly()를 상속 받습니다.
하지만, Duck이라는 슈퍼 클래스에 fly() 메서드를 추가하면서, 날아다니면 안 되는 오리(고무오리, RubberDuck)에게도 날아다니는 기능이 추가되었습니다.
즉, 일부 서브 클래스에 적합하지 않은 행동이 추가 되었습니다.
그래서, RubberDuck의 fly() 메서드가 호출되었을 때, 아무것도 하지 않도록 오버라이드 해주었습니다.
하지만, 임원진이 앞으로 6개월마다 제품을 업데이트하기로 결정했습니다.
즉, 앞으로 규격이 계속 바뀔 것입니다.
따라서, 상속을 계속 활용한다면, 규격이 바뀔 때마다 프로그램에 추가했던 Duck 클래스의 메서드들을 일일이 살펴보고 상황에 따라 오버라이드 해야 합니다.
(예를 들면, 나무로 된 가짜 오리는 날 수도, 소리를 낼 수도 없기 때문에 quack()과 fly() 메서드가 아무것도 하지 않도록 오버라이딩 해주어야 합니다.)
2단계 (행동 인터페이스)
상속이 옳은 방법이 아니라는 사실을 깨달았습니다.
그래서 fly()를 Duck 슈퍼 클래스에서 빼고, Flyable 인터페이스를 만들었습니다.
이렇게 하면 날 수 있는 오리만 Flyable 인터페이스를 구현해서 날아다니는 행동, 즉, fly() 메서드를 넣을 수 있습니다.
(quack()도 마찬가지)
하지만, 이러한 설계의 경우, 각 날아다니는 방식의 코드를 재사용할 수 없다는 문제가 있습니다.
ADuck 클래스 | BDuck 클래스 | CDuck 클래스 | |
날아다니는 방식 | (가) | (나) | (나) |
위와 같은 경우, Flyable 인터페이스의 fly() 메서드가 디폴트 메서드이고, (나) 방식으로 작성되어 있다고 하더라도,
(가) 방식으로 날아다니는 오리 클래스 DDuck 클래스가 새로 추가 된다면,
(가) 방식으로 날아다니는 fly() 메서드는 ADuck 클래스와 DDuck 클래스에서 중복됩니다.
하지만, ADuck 클래스와 DDuck 클래스의 fly() 메서드는 재사용할 수 없습니다.
따라서 코드 관리에 커다란 문제가 발생합니다.
따라서, (가) 방식이 수정되는 경우, (가) 방식으로 날아다니는 오리 클래스들의 fly() 메서드를 일일이 모두 수정해주어야 하고, 그 과정에서 새로운 버그가 생길 가능성도 있습니다.
3단계 (행동 클래스 집합)
3단계는 아래와 같은 디자인 원칙을 적용한 방식입니다.
애플리케이션에서 달라지는 부분을 찾아내고, 달라지지 않는 부분과 분리한다.
여러 디자인 원칙 가운데 첫 번째 원칙으로, 모든 디자인 패턴의 기반을 이루는 원칙입니다.
달라지는 부분을 찾아서 나머지 코드에 영향을 주지 않도록 캡슐화한다면, 코드를 변경하는 과정에서 달라지지 않는 부분에는 영향을 미치지 않으면서, 의도치 않게 발생하는 일을 줄이고, 시스템의 유연성을 향상시킬 수 있습니다.
fly()와 quack()는 오리의 종류에 따라 달라지지만, 나머지 부분(display(), swim()) 은 자주 달라지지 않거나 바뀌지 않습니다.
따라서, Duck 클래스는 그대로 두는 것이 좋습니다.
fly()와 quack()을 Duck 클래스로부터 분리하려면 2개의 메서드를 Duck 클래스에서 모두 끄집어내고, 각 행동을 나타낼 클래스 집합을 새로 만들어야 합니다.
나는 행동과 꽥꽥거리는 행동을 구현하는 클래스 집합은 어떻게 디자인해야 할까요?
Duck의 인스턴스에 행동을 할당할 수 있도록 해서, 최대한 유연하게 만드는 것이 좋습니다.
예를 들어, MallardDuck 인스턴스를 특정 형식의 나는 행동으로 초기화 하거나, 오리의 행동을 동적으로 바꿀 수 있도록 디자인 하는 것입니다.
일단 이렇게 목표를 정해놓고, 두 번째 디자인 원칙을 살펴보겠습니다.
구현보다는 인터페이스에 맞춰서 프로그래밍한다.
이 디자인 원칙은 상현님이 발표하신 SOLID 원칙 중 의존관계 역전 원칙을 의미합니다.
즉, “구현클래스가 아닌, 인터페이스에 의존하도록 해야 한다.”라는 걸 의미합니다.
(여기서 인터페이스는 자바의 인터페이스만을 의미하는 것이 아니라, 인터페이스라는 개념을 지칭하는 용도로도 쓰입니다)
즉, 핵심은 실제 실행 시에 쓰이는 객체가 코드에 고정되지 않도록 상위 형식에 맞춰 프로그래밍하여 다형성을 활용해야 한다는 점에 있습니다.
인터페이스 즉, 역할에 의존하게 되면 유연하게 구현체를 바꿀수 있게 됩니다.
⚠️ 구현 의존 VS 인터페이스 의존으로 이동!
이러한 디자인 원칙을 고려하여서 설계를 수정해보겠습니다.
먼저, 각 행동은 인터페이스(ex. FlyBehavior, QuackBehavior)로 표현하도록 수정합니다.
그리고, 이제 나는 행동과 꽥꽤거리는 행동은 Duck 클래스에서 구현하는 것이 아니라, 특정 행동만을 목적으로 하는 클래스 집합에서 구현합니다.
이전 방법은 항상 특정 구현에 의존했기 때문에 행동을 변경할 여지가 없었습니다.
새로운 디자인을 사용하면, 나는 행동과 꽥꽥거리는 행동은 특정 행동 인터페이스를 구현한 별도의 클래스 안에 있습니다.
따라서, Duck 클래스에서는 그 행동을 구체적으로 구현할 필요가 없습니다.
이 설계에서는 FlyBehavior와 QuackBehavior라는 2개의 인터페이스를 사용합니다.
그리고 구체적으로 핼동을 구현하는 클래스들이 있습니다.
또한, FlyBehavior과 QuackBehavior 인터페이스에는 구현 클래스에서 반드시 구현하도록 특정 행동을 나타내는 메서드가 선언되어 있습니다.
각 구현 클래스는 특정 행동에 맞춰서 이 메서드를 구현하면 됩니다.
이런 식으로 디자인하면, 다른 형식의 객체에서도 나는 행동과 꽥꽥거리는 행동을 재사용할 수 있습니다.
또한, 기존의 행동 클래스를 수정하거나 날아다니는 행동을 사용하는 Duck 클래스를 전혀 건드리지 않고도 새로운 행동을 추가할 수 있습니다.
즉, 상속의 부담을 떨쳐 버리고도, 재사용의 장점을 누릴 수 있습니다.
❓ 매번 바뀔 수 있는 부분을 찾아낸 후, 바뀌는 것과 바뀌지 않는 것을 분리해서 캡슐화하는 식으로 작업해야 하나요?
✋ 언제나 그렇게 해야 하는 것은 아닙니다.
애플리케이션을 디자인 하는 과정에서 바뀔 수 있는 부분을 예측하고 대처해서 유연한 코드를 만들 수도 있습니다.
여기에서 설명하는 원칙과 패턴은 개발 라이프사이클 어느 단계에서든지 적용할 수 있습니다.
❓ 행동만 나타내는 클래스를 만든다는 게 이상하게 느껴지네요, 클래스는 원래 어떤 대상을 나타내는 것 아닌가요? 클래스에는 상태와 행동이 모두 들어있어야 하지 않나요?
✋ 객체지향 시스템에서는 질문한 내용이 맞습니다.
클래스는 일반적으로 상태(인스턴스 변수)와 메서드를 모두 가지고 있습니다.
그런데, 이 경우에는 클래스가 '행동'을 가지고 있습니다.
하지만, 행동에도 여전히 상태와 메서드가 들어 있을 수 있습니다.
fly 행동에 속성(ex. 1분당 날개를 펄럭이는 횟수, 최고 높이, 속도)을 나타내는 인스턴스 변수를 넣을 수도 있으니까요.
4단계 (동적으로 행동 지정하기)
가장 중요한 점은 나는 행동과 꽥꽥거리는 행동을 Duck 클래스 또는 서브 클래스에서 정의한 메서드를 써서 구현하지 않고, 다른 클래스에 위임한다는 것입니다.
동적으로 오리의 행동을 지정해주기 위해, Duck 클래스에 flyBehavior과 quackBehavior라는 인터페이스 타입의 멤버 변수를 추가합니다. (특정 서브 클래스의 타입으로 선언하지 않습니다.)
각 오리 객체는 실행 시 이 행동 인터페이스 타입의 레퍼런스 변수에 특정 행동 객체(FlyWithWings 등)를 설정합니다.
또한, 나는 행동과 꽥꽥거리는 행동은 FlyBehavior와 QuackBehavior 인터페이스로 옮겨놨으므로, Duck 클래스와 모든 서브 클래스에서 fly()와 quack() 메서드를 제거하고, Duck 클래스에 performFly()와 performQuack()을 추가합니다.
그럼, 이러한 형태의 Duck 클래스가 만들어집니다.
그럼, flyBehavior 레퍼런스 변수에는 언제 어떤 객체가 들어갈까요?
먼저, Duck 클래스에 행동 객체를 파라미터로 전달하는 setter() 메서드를 정의한 후,
실행 시 동적으로 오리의 행동을 지정하는 방식을 생각해볼 수 있습니다.
interface QuackBehavior {
public void quack();
}
interface FlyBehavior {
public void fly();
}
class Quack implements QuackBehavior {
public void quack() {
System.out.println("Quack");
}
}
class MuteQuack implements QuackBehavior {
public void quack() {
System.out.println("<< Silence >>");
}
}
class FlyRocketPowered implements FlyBehavior {
public void fly() {
System.out.println("I'm flying with a rocket");
}
}
class FlyNoWay implements FlyBehavior {
public void fly() {
System.out.println("I can't fly");
}
}
abstract class Duck {
FlyBehavior flyBehavior;
QuackBehavior quackBehavior;
public Duck() {}
abstract void display();
public void setFlyBehavior(FlyBehavior fb) {
flyBehavior = fb;
}
public void setQuackBehavior(QuackBehavior qb) {
quackBehavior = qb;
}
public void performFly() {
flyBehavior.fly();
}
public void performQuack() {
quackBehavior.quack();
}
public void swim() {
System.out.println("All ducks float, even decoys!");
}
}
class MallardDuck extends Duck {
public MallardDuck() {}
public void display() {
System.out.println("I'm a real Mallard duck");
}
}
class MiniDuckSimulator1 {
public static void main(String[] args) {
Duck mallard = new MallardDuck();
mallard.setFlyBehavior(new FlyRocketPowered());
mallard.setQuackBehavior(new MuteQuack());
mallard.performQuack();
mallard.performFly();
System.out.println();
// setter() 메서드를 이용하여 날아다니는 행동, 소리를 내는 행동을 교체
mallard.setFlyBehavior(new FlyNoWay());
mallard.setQuackBehavior(new Quack());
mallard.performQuack();
mallard.performFly();
}
}
<< Silence >>
I'm flying with a rocket
Quack
I can't fly
performFly()와 performQuack() 메서드에서는 Duck 클래스, 즉 슈퍼 클래스로부터 상속받은 flyBehavior와 quackBehavior에 의해 참조되는 객체의 fly()와 quack() 메서드를 호출하여 행동을 위임해주면 됩니다.
다음으로, 생성자에서 초기화하는 방식을 생각해볼 수 있습니다.
abstract class Duck {
private FlyBehavior flyBehavior;
private QuackBehavior quackBehavior;
public Duck() {}
public Duck(QuackBehavior quackBehavior, FlyBehavior flyBehavior){
this.quackBehavior = quackBehavior;
this.flyBehavior = flyBehavior;
}
abstract void display();
public void performFly() {
flyBehavior.fly();
}
public void performQuack() {
quackBehavior.quack();
}
public void swim() {
System.out.println("All ducks float, even decoys!");
}
}
class MallardDuck extends Duck{
public MallardDuck(QuackBehavior quackBehavior, FlyBehavior flyBehavior){
super(quackBehavior, flyBehavior);
}
@Override
void display() {
}
}
class MiniDuckSimulator1 {
public static void main(String[] args) {
QuackBehavior muteQuack = new MuteQuack();
FlyBehavior flyRocketPowerd = new FlyRocketPowered();
Duck mallard = new MallardDuck(muteQuack, flyRocketPowerd);
mallard.performQuack();
mallard.performFly();
}
}
setter() 메서드 방식의 경우, 실행 시에 언제나 오리의 행동을 바꿀 수 있지만, setter() 메서드가 아니라 생성자에서 오리의 행동을 지정 해준다면, 오리 객체의 행동을 실행 시에 결정하고 난 이후 임의로 오리의 행동을 수정할 수 없도록 할 수 있습니다.
(단, 슈퍼 클래스의 오리 행동 객체를 private로 설정)
마지막 단계까지 마쳤을 때, 프로젝트는 이러한 모습의 구조가 됩니다.
구현 의존 VS 인터페이스 의존
Animal이라는 추상 클래스가 있고, Dog와 Cat이라는 서브 클래스가 있다고 가정해보겠습니다.
구현에 의존하여 프로그래밍한다면 다음과 같이 할 수 있습니다.
Dog dog = new Dog();
d.bark();
하지만, 이러한 방식의 경우, Animal의 구현체를 유연하게 바꿀 수 없다는 단점이 있습니다.
Animal animal = new Dog();
animal.makeSound();
위 코드는 Dog라는 구현체가 아닌 Animal 인터페이스에 의존한 예시입니다.
이 경우, Animal 추상 클래스의 구현체를 갈아낄 수 있다는 장점이 있습니다.
하지만, 이 경우에도 구현체를 갈아끼려면 코드의 수정이 필요합니다.
따라서, 아래와 같은 코드가 더 바람직한 방법입니다.
Animal animal = getAnimal();
animal.makeSound();
구현체를 넣어주는 코드를 직접 작성해주는 대신, 실행 시에 대입하는 방식을 통해서, 기존 코드를 수정하지 않아도 구현체를 바꿔줄 수 있습니다.
전략 패턴의 장단점
장점
1. 전략 사용자(context)의 코드 변경 없이 새로운 전략을 추가 할 수 있다.
따라서 "확장에는 열려있으나 변경에는 닫혀있어야 한다"를 의미하는 Open/Close Prinipal을 준수할 수 있습니다.
또한, if - else 분기를 제거할 수 있습니다.
2. 런타임에 전략을 변경시킬 수 있다.
단점
1. Strategy 객체와 Composition클래스 객체 사이에 의사소통 오버헤드가 발생할 수 있다.
서브클래스에서 구현할 알고리즘의 복잡함과는 상관없이 모든 ConcreteStrategy 클래스는 Strategy 인터페이스를 공유한다.
따라서 어떤 ConcreteStrategy 클래스는 이 인터페이스를 통해 들어온 모든 매개변수를 다 사용하지 않는데도 전달받아야 할 때가 생긴다.
즉, 사용되지도 않을 매개변수를 Composition 객체가 생성하고 초기화하는 경우가 발생할 수 있다.
2. 객체 수가 증가한다.
Strategy들로 생성하는 객체 수가 증가한다.
참조
- Head First Design Pattern Chapter.01 디자인 패턴 소개와 전략 패턴
- 객체지향 설계 5원칙 SOLID (이상현)
- 디자인패턴 - 전략 패턴(Strategy Pattern) in Javascript
- 디자인 패턴 : 전략패턴이란?
'스터디' 카테고리의 다른 글
싱글턴 패턴 (Singleton Pattern)이란? (2) | 2023.04.04 |
---|---|
데코레이터 패턴(Decorator Pattern)이란? (0) | 2023.04.04 |
22.11.8 SSAFY 스터디 CS 발표 - 디자인 패턴 (템플릿 메서드 패턴) (0) | 2022.11.07 |
22.10.11 SSAFY 스터디 CS 발표 - 컴퓨터 구조, 기억장치 (0) | 2022.10.11 |
22.09.22 SSAFY 스터디 CS 발표 - 컴퓨터 구조 (0) | 2022.09.22 |