스트래티지 패턴(Strategy Pattern)

Head First - Design Patterns
(에릭 프리먼, 엘리자베스 프리먼, 케이시 시에라, 버트 베이츠 저 | 서환수 역)
을 읽고 정리한 내용입니다.

  • 스트래티지 패턴은 스프링 DI를 구현할 때 이용된 디자인 패턴이다.
  • 스프링 프레임워크의 핵심 기능 중 하나인 DI를 구현할 때 이용된 스트래티지 패턴을 살펴보자.

오리 연못 시뮬레이션 게임을 만든다고 생각해보자.
객체지향 기법을 사용해서 Duck이라는 수퍼클래스를 만든 후,
그 클래스를 확장해서 다른 모든 종류의 오리를 만들었다.

  • 모든 오리들의 모양이 다르기 때문에 Duck 클래스의 display() 메서드추상 메서드이다.
  • MallardDuck과 RedheadDuck 외에도 오리 종류가 추가될 때마다
    Duck 클래스로부터 상속을 받는다.

  • 오리들이 날아다닐 수 있게 만들라는 요구사항이 추가되었다.
  • 상속을 활용하여 Duck 클래스에 fly()라는 메서드를 추가한 후,
    모든 오리들이 상속받게 만들었다.

첫 번째 부작용 발생

  • Duck의 모든 서브 클래스가 날 수 있는 건 아니라는 점을 잊고 넘어갔다.
  • 수퍼클래스인 Duck에 fly() 메서드가 추가되면서
    몇몇 서브 클래스에는 맞지 않는 기능이 추가되었다. (ex) 고무 오리가 날아다니게 되었다.)
  • 코드의 한 부분을 바꾸었는데 그 영향으로 프로그램 전체에 부작용이 발생하게 되었다.
  • RubberDuck 클래스가 정말 fly() 메서드 상속받아야 하는지를 고민해봐야 한다.

상속에 대해 생각해보자.
RubberDuck 클래스에서는 quack() 메서드에서 했던 것처럼
fly()오버라이드 해서 아무것도 하지 않도록 만들 수 있다.

새로운 클래스를 추가해보자.
나무로 만들어진 가짜 오리를 추가하면 어떻게 해야 할까?
날 수도 없고 소리도 낼 수 없다.

  • Duck 수퍼클래스를 상속받아 quack()fly() 메서드에
    오버라이드를 하고 빈 메서드로 만들어서 날지 않고 소리도 내지 않도록 만들었다.
  • quack()fly() 메서드는 필요가 없는데 강제로 상속을 받았기 때문이다.

Duck의 행동을 확장할 때, 상속을 사용하면 갖는 단점

  1. 서브클래스에서 코드가 중복된다.

  2. 모든 오리의 행동을 알기 힘들다.

  3. 실행시(런타임)에 특징을 바꾸기 힘들다.

상속 관계는 컴파일 시점에 정해진다.
따라서, 런타임에 특징을 바꿀 수 없다.
cf) setter를 통해서 런타임시에 의존성을 바꾸는 방법을 이용할 수 있다.

  1. 코드를 변경했을 때, 다른 오리들한테 원치 않는 영향을 끼칠 수 있다.

모든 코드가 Duck을 상속 받고 있기 때문에
Duck에 메서드가 추가되었을 때,
서브클래스들에 원하지 않는 기능이 추가될 수 있다.


상속은 올바른 해결책이 아니다.

상속을 사용한다면, 오리 종류들이 추가될 때마다
매번 Duck 서브 클래스의 fly()와 quack() 메서드를 일일이 살펴봐야 하고,
상황에 따라 오버라이드하는 일을 반복하게 될 것이다.
이를 통해,상속은 올바른 해결책이 아니라는 것을 알게 되었다.


인터페이스를 이용하자.

그렇다면, 인터페이스를 이용하는 건 어떨까?
fly()를 Duck 수퍼클래스에서 빼내서 fly() 메서드가 들어있는 Flyable 인터페이스를 만들어 보자.
이렇게 할 경우 날 수 있는 오리들만 Flyable 인터페이스를 구현하도록 만들면 될 것이다.
quack() 메서드도 마찬가지로 Quackable 인터페이스를 만들어서 소리를 내는 오리 종류들만이
인터페이스를 구현하도록 만들자.


두 번째 부작용 발생

위와 같이 Flyable과 Quackable 인터페이스를 이용했을 때 문제점이 있다.
날아가는 동작을 조금 바꾸려면,
Duck의 서브 클래스 중에 날아다닐 수 있는 코드들을 전부 고쳐야 하는 문제가 발생한다.

애플리케이션은 아무리 잘 디자인한다고 해도 시간이 지남에 따라 계속해서 성장하고 변화되어야 한다.
나중에 고쳐야 할 때 코드에 미치는 영향을 최소한으로 줄이면서 작업을 할 수 있도록 만드는 방법을 무엇일까?


문제를 명확하게 파악하기

1. 상속을 사용했을 때의 문제점

서브클래스마다 오리의 행동이 바뀔 수 있는데도
모든 서브 클래스에서 한 행동을 사용하도록 하는 건 그리 올바른 방법이 아니다.

2. Flyable과 Quackable 인터페이스를 사용했을 때의 문제점

자바의 인터페이스에는 구현된 코드가 들어가지 않기 때문에
한 행동을 바꿀 때마다 그 행동이 정의되어 있는 서로 다른 서브클래스들을
전부 찾아서 코드를 일일이 수정해야 하고, 그 과정에서 새로운 버그가 발생할 수 있다.


디자인 원칙
: 애플리케이션에서 달라지는 부분을 찾아내고, 달라지지 않는 부분으로부터 분리시키자.

  • 바뀌는 부분을 따로 뽑아서 나머지 코드에 영향을 주지 않도록 캡슐화 한다.
  • 이렇게 하면, 나중에 바뀌지 않는 부분에 영향을 미치지 않고,
    해당 부분만 고치거나 확장할 수 있어서 코드의 복잡성을 낮출 수 있다.

바뀌는 부분을 분리하기

  • 변화하는 부분과 그대로 있는 부분을 분리하기 위해
    Duck과는 완전히 별개인 두 개의 클래스 집합(set)을 만든다.

  • 나는 것과 관련된 집합과 꽥꽥 소리를 내는 것에 관련된 부분이다.

  • 각 클래스 집합에 각각의 행동을 구현한 것을 집어넣는다.
  • ex) 꽥꽥 소리를 내는 것을 구현한 클래스, 삑삑 소리를 내는 것을 구현한 클래스,
    아무 소리도 내지 않는 것을 구현하는 클래스를 각각 만든다.

  • fly()quack()은 Duck 클래스에서 오리마다 달라지는 부분이므로
    이 두 메소드를 Duck 클래스에서 꺼내서 각 행동을 나타내는 클래스 집합을 만들어보자.

오리의 행동 디자인

나는 행동과 꽥꽥 거리는 행동을 구현하는 클래스 집합을 디자인하는 방법

  • Duck 인스턴스에 행동을 할당할 수 있게 만든다.

  • ex1) MallardDuck 인스턴스를 새로 만들고 특정 형식의 나는 행동으로 초기화 한다.
    (생성자를 만들어서)

  • ex2) 오리의 행동을 동적으로 바꿀 수 있도록
    Duck에 행동과 관련된 세터(setter) 메서드를 포함시켜서
    런타임 중에도 MallardDuck의 나는 행동을 바꿀 수 있게 만든다.

디자인 원칙
: 구현이 아닌 인터페이스에 맞춰서 프로그래밍한다.

  • Duck의 행동은 특정한 행동 인터페이스를 구현한 별도의 클래스 안에 들어있게 된다.
  • 상속이나 Flyable와 Quackable 인터페이스를 이용한 방법은
    항상 특정 구현에 의존했기 때문에 행동을 변경할 수 없었다.
  • 이 디자인을 사용하면, 행동을 실제로 구현한 클래스에서 Duck 서브클래스에 국한되지 않는다.

인터페이스에 맞춰서 프로그래밍한다는 것은
상위 형식에 맞춰서 프로그래밍 한다는 것을 의미한다.


Duck의 행동을 구현하는 방법

  • 날 수 있는 클래스에서는 무조건 FlyBehavior 인터페이스를 구현한다.
  • 오리가 소리를 내는 것과 관련된 quack() 메서드가 들어있는 QuackBehavior 인터페이스를 구현한다.

  • 이런 식으로 디자인할 경우, 다른 형식의 객체에서도 나는 행동과 꽥꽥 거리는 행동을 재사용할 수 있다.

  • Duck 클래스 안에 이 행동들이 숨겨져 있지 않기 때문이다.
  • 기존의 행동 클래스를 수정하거나 날아다니는 행동을 사용하는
    Duck 클래스를 전혀 건드리지 않고도 새로운 행동을 추가할 수 있다.

Duck 행동 통합하기

  • 포워딩용 메서드인 performQuack()performFly()의 메서드 이름은
    Quack(), Fly()로 써도 된다.
  • 행동 변수는 행동 인터페이스 형식으로 선언한다.

  • performQuack()를 구현해보자.

1
2
3
4
5
6
7
8
9
10
11
public class Duck {
// 인터페이스 변수에 실행시에 특정 행동에 대한 레퍼런스가 저장된다.
QuackBehavior quackBehavior;
// 기타 코드

public void performQuack() {
// 꽥꽥 거리는 행동을 직접 처리하는 대신,
// quackBehavior로 참조되는 객체에 그 행동을 위임한다.
quackBehavior.quack();
}
}
  • 이렇게 하면, 객체의 종류에는 전혀 신경 쓸 필요 없이quack()을 실행시킬 줄 안다는 것만이
    중요해진다.

  • 이제 flyBehaviorquackBehavior 인스턴스 변수를 설정하는 방법에 대해 생각해보자.
1
2
3
4
5
6
7
8
9
public class MallardDuck extends Duck {
public MallardDuck() {
// MallardDuck에서 꽥꽥거리는 소리를 처리할 때는 Quack 클래스를 사용한다.
// 따라서, perfomQuack()이 되면, 꽥꽥거리는 행동은 Quack 객체에 위임된다.
quackBehavior = new Quack();
// flyBehavior의 형식으로 FlyWithWings를 사용한다.
flyBehavior = new FlyWithWings();
}
}
  • 생성자에서 직접 집어넣고 있다.
  • 인터페이스 분리는 했는데, 아직 구현 클래스에 의존하고 있어서 불완전하다.

동적으로 행동을 지정하는 방법

1. Duck 클래스에 메서드 2개를 새로 추가한다.

1
2
3
4
5
6
public void setFlyBehavior (FlyBehavior fb) {
FlyBehavior = fb;
}
public void setQuackBehavior (QuackBehavior qb) {
quackBehavior = qb;
}

오리의 행동을 즉석에서 바꾸고 싶다면, 언제든지 이 두 메서드를 호출하면 된다.

2. Duck의 서브 클래스를 새로 만든다. (ModelDuck.java)

1
2
3
4
5
6
7
8
9
10
public class ModelDuck extends Duck {
public ModelDuck () {
flyBehavior = new FlyNoWay ();
quackBehavior = new Quack();
}

public void display () {
System.out.println("저는 모형 오리입니다.");
}
}

3. FlyBehavior 형식의 클래스를 새로 만든다. (FlyRocketPowered.java)

1
2
3
4
5
public class FlyRocketPowered implements FlyBehavior {
public void fly () {
System.out.println("로켓 추진으로 날아갑니다.");
}
}

4.테스크를 클래스를 수정한다.

ModelDuck을 추가하고 ModelDuck에 로켓 추진 기능을 부여한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class MiniDuckSimulator {
public static void main (String[] args) {
Duck mallard = new MallardDuck();
mallard.performQuack();
mallard.performFly();

Duck model = new ModelDuck();
model.performFly();
// 구현체를 바꾼다.
// 이렇게 하면, 행동 세터 메서드가 호출된다.
model.setFlyBehavior(new FlyRocketPowered());
model.performFly();
}
}
  • 실행 중에 오리의 행동을 바꾸고 싶다면,
    원하는 행동에 해당하는 Duck의 세터 메서드를 호출하기만 하면 된다.

캡슐화된 행동을 큰 그림으로 바라보자.

클라이언트에서는 나는 행동과 꽥꽥거리는 행동 모두에 대해서 캡슐화된 알고리즘군을 사용한다.

A는 B이다”보다 “A에는 B가 있다”가 나을 수 있다.

디자인 원칙
상속보다는 구성을 활용한다.

구성을 이용해서 시스템을 만들면 유연성을 크게 향상시킬 수 있다.