전략 패턴 (Strategy Pattern)
알고리즘군을 정의하고 캡슐화해서 각각의 알고리즘군을 수정해서 사용할 수 있게 해준다.
코드에 적용해보기
이론만 보면 도대체 무슨 소리인지 모르겠는 게 디자인 패턴이다. 나의 경우 예시를 들어 천천히 코드에 적용해보면서 이해를 하는 게 훨씬 빨랐다.
지금부터 배구 게임을 만드는 프로그램을 작성할 것이다. 표준 객체지향 기법을 사용하여 Player라는 슈퍼클래스를 만든 후에 이 클래스를 확장하여 여러 종류의 플레이어를 만들 계획이다.
public abstract class Player {
public void defence() {
System.out.println("수비를 했습니다!");
}
abstract public void attack();
}
public class OutsideHitter extends Player{
@Override
public void attack() {
System.out.println("퀵오픈 공격을 했습니다!");
}
}
public class OppositeSpiker extends Player{
@Override
public void attack() {
System.out.println("라이트 백어택 공격을 했습니다!");
}
}
정보 공유의 예시로는 적절하지 않은 설명이란 걸 알지만, 온전히 내가 이해한 바를 기록하고 싶어서 좋아하는 스포츠를 예로 들었다.
배구는 모든 포지션에 관계없이 수비에 가담하므로 슈퍼클래스로 작성을 하고, 공격의 경우 공격수들의 포지션마다 수행하는 공격 방법이 다르므로 추상 메소드로 구현을 했다. 하지만 배구에는 공격수만 있지 않다. 볼을 셋팅해주는 세터, 수비 전문 선수인 리베로와 같은 포지션도 존재한다.
public class Libero extends Player{
@Override
public void attack() {
System.out.println("리베로는 공격을 할 수 없습니다!");
}
}
수비 전문 선수에게 Player 클래스를 상속했더니 다음과 같은 일이 일어났다. 공격 방법을 기술하라고 작성된 메소드인데 공격을 할 수 없는 포지션이므로 부적절하게 사용하게 된다. 상속을 통해 다형성을 지킬 수 있다고 생각했는데 불필요한 상속들이 늘어나면서 유지보수를 방해하고 있다.
Player 클래스를 상속하게 되면 서브클래스에서 코드가 중복되고, 실행 시에 특징을 바꾸기 어렵다. 또한 코드를 변경했을 때 원치 않는 영향을 줄 수도 있다. 이러한 상황에 사용할 수 있는 해결책이 디자인패턴이다. 이를 구성하는 디자인 원칙을 따라 보자.
애플리케이션에서 달라지는 부분과 달라지지 않는 부분을 분리한다.
달라지는 부분을 찾아서 '캡슐화'해야 한다. 코드를 변경하는 과정에서 다른 코드에 주는 영향을 최소한으로 줄일 수 있다. 달라지는 부분을 찾는 방법은 의외로 간단하다. 요구사항이 바뀔 때마다 반복적으로 수정해야 하는 부분이 있다면 그곳이 달라지는 부분이다. attack() 메소드는 Player의 포지션에 따라 달라지는 부분이므로 캡슐화의 대상이다.
구현보다는 인터페이스에 맞춰서 프로그래밍한다.
이제부터 공격을 하는 행동은 AttackBehavior라는 인터페이스를 사용해서 구현한다. 더이상 공격이라는 행동을 Player에서 구현하지 않을 것이다.
public interface AttackBehavior {
void attack();
}
public class QuickOpen implements AttackBehavior{
@Override
public void attack() {
System.out.println("퀵오픈 공격을 했습니다!");
}
}
public class RightBackAttack implements AttackBehavior{
@Override
public void attack() {
System.out.println("라이트 백어택 공격을 했습니다!");
}
}
Player 클래스를 상속 받은 서브 클래스에서 자체적으로 구현했던 것과는 다르게 공격과 관련된 행동들을 클래스화하여 구현했다. 더이상 포지션에 해당하는 선수 클래스 내에서 구현을 할 필요가 없어지므로 불필요한 상속을 받을 필요도 없어진다. 사실 인터페이스를 사용하지 않아도 추상 슈퍼클래스를 통해 똑같은 일을 할 수 있다.
핵심은 실제 실행 시에 쓰이는 객체가 코드에 고정되지 않도록 상위 형식에 맞춰 프로그래밍하여 다형성을 활용해야 한다는 점이다. 간단한 예시를 들자면 Animal이라는 추상 클래스 혹은 인터페이스를 상속 받는 Dog, Cat 클래스가 있다고 하자.
// Cat 형식으로 선언할 경우 구체적인 구현에 맞추어야 함
Cat cat = new Cat();
cat.meow();
// Animal의 레퍼런스를 사용해도 됨 (다형성의 활용)
Animal animal = new Cat();
animal.makeSound();
Animal을 확장한 구상 클래스인 Cat으로 객체를 생성할 경우 구체적인 meow()라는 구현에 맞추어야 하지만 Animal로 선언하고 Cat으로 객체를 생성하면 makeSound()라는 공통 메소드를 사용할 수 있다.
위의 예시에서는 AttackBehavior라는 행동 하나만을 군으로 묶어 인터페이스를 만들어 구현 클래스들을 만들었다. 공격이라는 행동뿐만 아니라 다른 행동의 군이 형성이 되면 해당 행동들끼리 묶어주면 된다. 가장 처음 말했던 전략 패턴의 정의가 설명되는 순간이다. 알고리즘군을 정의하고 캡슐화해서 각각의 알고리즘군을 수정해서 사용할 수 있게 해준다. 알고리즘군이라고 했지만 비슷한 행동, 기능 그리고 작업군이라고 생각하면 된다.
가장 중요한 점은 공격하는 행동을 Player 클래스에서 정의한 메소드를 사용하지 않고 다른 클래스에 일임한다는 것이다.
public abstract class Player {
AttackBehavior attackBehavior;
public Player() {}
public void defence() {
System.out.println("수비를 했습니다!");
}
public void performAttack() {
attackBehavior.attack();
}
}
AttackBehavior 인터페이스를 만들었으니 Player 클래스를 수정해보자. 인스턴스 변수로 선언해주고 기존의 attack() 추상 메소드를 제거한다. 그 자리에 인터페이스를 활용한 공통 메소드를 사용하는 performAttack()을 만들었다. 참조되는 객체에 행동을 대신 하게 하는 것이다.
public class OutsideHitter extends Player{
public OutsideHitter() {
attackBehavior = new QuickOpen(); // performAttack이 호출되면 QuickOpen 객체에게 해동 위임
}
public void defence() {
System.out.println("수비를 했습니다!");
}
}
public class TestApplication {
public static void main(String[] args) {
Player player = new OutsideHitter();
player.performAttack(); // 퀵오픈 공격을 했습니다!
}
}
OutsideHitter에서 상속 받은 performAttack() 메소드가 호출될 때 AttackBehavior에게 일을 맡긴다. 쉽게 말하면 AttackBehavior 레퍼런스의 attack() 메소드가 호출된다는 말이다. 이제 동적으로 프로그래밍을 했다.
어플리케이션을 실행하는 동안에 동적으로 행동을 바꿀 수 있는 방법이 있다. OutsideHitter가 QuickOpen을 하고 나서 똑같은 공격을 한다는 보장은 없다. QuickOpen을 한 후에 SynchronizedAttack(시간차 공격)을 한다고 해보자. 배구는 한번의 턴이 굉장히 빠르다. 생성자에서 인스턴스를 만드는 방법에서 setter 메소드를 호출하는 방식으로 변경해야 한다.
public abstract class Player {
AttackBehavior attackBehavior;
public Player() {}
public void setAttackBehavior(AttackBehavior attackBehavior) {
attackBehavior = attackBehavior;
}
public void defence() {
System.out.println("수비를 했습니다!");
}
public void performAttack() {
attackBehavior.attack();
}
}
public class SynchronizedAttack implements AttackBehavior{
@Override
public void attack() {
System.out.println("시간차 공격을 했습니다!");
}
}
public class TestApplication {
public static void main(String[] args) {
Player player = new OutsideHitter();
player.performAttack(); // 퀵오픈 공격을 했습니다!
player.setAttackBehavior(new SynchronizedAttack());
player.performAttack(); // 시간차 공격을 했습니다!
}
}
실행중에 행동을 바꾸고 싶다면 해당 클래스의 setter 메소드를 호출하면 된다. 이제 원하는 대로 되었다.
상속보다는 구성을 활용한다.
각각의 선수들에게는 AttackBehavior(공격이라는 행동을 위임 받음)가 있다. A에는 B가 있다. 이런 식으로 두 클래스를 합치는 것을 구성을 사용한다라고 말한다.
지금까지 했던 리팩토링 결과의 다이어그램은 이런 형태이다. 클라이언트로부터 알고리즘(행동)을 분리하여 독립적으로 변경할 수 있기 때문에 전략 패턴을 사용한다.
* 아래의 자료들을 참고하였습니다.
에릭 프리먼 · 엘리자베스 롭슨 · 케이시 시에라 · 버트 베이츠, 『헤드퍼스트 디자인패턴 개정판』, 서환수 옮김, 한빛미디어(2022), p38-60.
'Programming' 카테고리의 다른 글
디자인패턴 시리즈 4. 팩토리 패턴 (Factory Pattern) (0) | 2023.01.16 |
---|---|
디자인패턴 시리즈 3. 데코레이터 패턴 (Decorator Pattern) (0) | 2023.01.13 |
디자인패턴 시리즈 2. 싱글톤 패턴 (Singleton Pattern) (0) | 2023.01.12 |
SOLID 원칙 SRP(단일 책임 원칙), OCP(개방-폐쇄 원칙) (1) | 2023.01.03 |
커밋 내역 관리를 위한 git merge, rebase, squash (0) | 2022.12.22 |
댓글