디자인패턴

[헤드퍼스트 디자인패턴] 전략 패턴

hongyb 2024. 4. 14. 22:06

전략패턴을 알아보자.

전략 패턴

디자인 원칙

바뀌는 부분은 캡슐화한다.

상속보다는 구성을 활용한다.

구현보다는 인터페이스에 맞춰서 프로그래밍한다.

 

전략 패턴(Strategy Pattern)

객체 지향 디자인 패턴 중 하나로, 행위를 클래스화하여 동적으로 행동을 바꿀 수 있도록 하는 패턴이다. 알고리즘을 캡슐화하여 상호 교환 가능하게 만들고, 클라이언트 코드에서 알고리즘을 독립적으로 변경할 수 있도록 한다.

 

실습 코드 및 문제점

다음 예제 코드를 통해 전략 패턴을 알아보겠다. Vehicle이라는 슈퍼 클래스가 있고 Vehicle을 상속받는 Airplane, Helicopter, Ship 서브 클래스가 존재한다고 하자.

클래스 구조

 

public abstract class Vehicle {
    public abstract void ride();
    public void stop() {
        System.out.println("stop");
    }
}
public class Airplane extends Vehicle {
    @Override
    public void ride() {
        System.out.println("ride airplane");
    }
}
public class Ship extends Vehicle {
    @Override
    public void ride() {
        System.out.println("ride Ship");
    }
}
public class Helicopter extends Vehicle {
    @Override
    public void ride() {
        System.out.println("Helicopter ride");
    }
}

 

 

문제는 swim과 fly라는 새로운 기능이 추가되어야 한다고 했을 때 발생한다. Vehicle에 fly()라는 메서드를 추가했을 때 fly라는 메서드를 사용하면 안 되는 Ship 클래스에 기능이 추가되는 문제가 발생한다. 마찬가지로 swim() 메서드를 Vehicle 클래스에 추가했을 때도 Airplane과 Helicopter 클래스에도 기능이 추가되는 문제가 발생한다. 이를 해결하기 위해 fly() 메서드를 Airplane과 Helicopter 서브 클래스에만 넣으면 코드가 중복되기 때문에 좋은 설계라고 볼 수 없다. 

 

그렇다면 인터페이스 설계 방법을 고민해 보자.

public interface Flyable {
    public void fly();
}

위와 같이 Flyable이라는 인터페이스를 설계한 후 Airplane과 Helicopter가 인터페이스를 설계하도록 만들자. 두 개의 서브 클래스는 fly() 메서드를 각각 구현해야 하기 때문에 코드를 재사용할 수 없다는 문제가 발생한다.

 

문제 해결 아이디어

확장 가능한 클래스 설계를 위해 어떤 아이디어가 필요할까? 이를 위해 3가지 객체지향 원칙이 필요하다.

첫 번째 객체지향 원칙은 '달라지는 부분과 달라지지 않는 부분을 분리한다.'이다. 즉 달라지는 부분을 찾아서 캡슐화한다. 그러면 나중에 바뀌지 않는 부분에는 영향을 미치지 않고 그 부분만 고치거나 확장할 수 없다. 이를 위해 변경되는 부분인 fly()와 swim()을 Vehicle에서 분리한 후 새로운 클래스 집합으로 만들어야 한다.

두 번째 원칙은 '구현보다는 인터페이스에 맞춰서 프로그래밍한다.'이다. 행동을 인터페이스로(Flyable, Swimable)로 표현하고 필요한 클래스가 사용하면 된다. 인터페이스에 맞춰서 프로그래밍하라는 뜻은 상위 형식에 맞춰서 프로그래밍하라는 뜻과 같다. 상위 형식으로 선언하면 어떤 객체를 넣을 수도 있으며(다형성), 실제 객체의 형식과 내용을 몰라도 된다.(캡슐화)

마지막 원칙은 '상속보다는 구성을 활용한다.'이다. 구성을 활용하면 유영성을 크게 향상시킬 수 있다. 인터페이스를 구현하기만 하면 실행 시 행동을 정의할 수 있다. 

 

이 3가지 객체지향 원칙이 어떻게 적용되었는지 알아보도록 하겠다.

 

Flyable과 Swimable 2개의 인터페이스를 사용하고 구현한 예제 코드는 다음과 같다.

public interface Flyable {
    public void fly();
}
public interface Swimable {
    public void swim();
}
public class FlyWithWings implements Flyable {
    @Override
    public void fly() {
        // business logic 작성
        System.out.println("fly with wings");
    }
}
public class SwimShip implements Swimable {
    @Override
    public void swim() {
        // business logic 작성
        System.out.println("ship swim");
    }
}

이런 식으로 디자인하면 구현 내용이 한 클래스에서만 종속되지 않기 때문에 다른 형식의 객체에서도 코드를 재사용할 수 있다. 또한 기존의 클래스를 건드리지 않고도 새로운 기능을 추가할 수 있는 장점이 있다.

 

코드 리팩토링

인터페이스를 상속받지 않고 인터페이스 형식의 인스턴스 변수를 추가하여 코드를 리팩토링 하겠다.

public abstract class Vehicle {
    protected Flyable flyable;
    protected Swimable swimable;
    public abstract void ride();
    public void stop() {
        System.out.println("stop");
    }
    public void performFly() {
        flyable.fly();
    }
    public void performSwim() {
        swimable.swim();
    }
}
public class Airplane extends Vehicle {
    public Airplane() {
        flyable = new FlyWithWings();
    }
    @Override
    public void ride() {
        System.out.println("ride airplane");
    }
}

위에처럼 Flyable과 Swimable 인터페이스를 만든 후 서브 클래스 생성자에서 인터페이스 구현체를 설정하는 방식으로 만들면 된다.

public class Main {
    public static void main(String[] args) {
        Vehicle vehicle = new Airplane();
        vehicle.performFly();
    }
}

위와 같이 코드를 작성하고 실행하면 "fly with wings"라는 결괏값이 나오는 것을 확인할 수 있다.

 

그렇다면 동적으로 행동을 지정하는 방법은 없을까? 생성자에서 인스턴스를 만드는 방법이 아닌 서브 클래스에서 setter method를 호출하는 방법으로 설정할 수 있다. flyable 인터페이스를 상속받는 새로운 클래스 AirplaneFly라는 클래스를 다음과 같이 만들겠다.

public class AirplaneFly implements Flyable {
    @Override
    public void fly() {
        // business 로직 작성
        System.out.println("airplane fly");
    }
}

그 후 Vehicle class에서 인스턴스를 변경할 수 있는 setter method를 생성한다.

public abstract class Vehicle {
    protected Flyable flyable;
    protected Swimable swimable;
    public abstract void ride();
    public void stop() {
        System.out.println("stop");
    }
    public void performFly() {
        flyable.fly();
    }
    public void performSwim() {
        swimable.swim();
    }
    public void setFlyable(Flyable flyable) {
        this.flyable = flyable;
    }
}

그 후 Main 메서드에서 flyable를 setter로 변경하고 실행해 보겠다.

public class Main {
    public static void main(String[] args) {
        Vehicle vehicle = new Airplane();
        vehicle.performFly();
        vehicle.setFlyable(new AirplaneFly());
        vehicle.performFly();
    }
}

 

실행 결괏값으로 아래와 같이 나온다.

 

 

전략 패턴

위와 같은 디자인 패턴을 전략 패턴이라고 부른다. 전략 패턴을 이해하기 위해 클래스의 관계를 알아야 한다. 클래스의 관계는 총 2가지가 있다.

첫 번째는 "A는 B이다." 관계이다. 이는 상속에서 나타나는 관계이다. 위에 예제에서는 "Airplane은 Vehicle이다."라고 말할 수 있다.

두 번째는 "A에는 B가 있다." 관계이다. Vehicle에는 Flyable과 Swimable이 있으며, 각각 나는 행위와 수영하는 행위를 나타낸다. 이런 식으로 두 클래스를 합치는 것을 구성이라고 부른다. 지금처럼 구성을 이용해서 시스템을 만들면 우연성을 향상할 수 있다. 알고리즘군을 별도의 클래스 집합으로 캡슐화할 수 있고 구성 요소로 사용하는 객체에서 올바른 행동 인터페이스를 구현하기만 하면 실행 시에 행동을 바꿀 수 있다. 다시 정리해 보면 전략 패턴이란 알고리즘군을 정의하고 캡슐화해서 각각의 알고리즘군을 수정해서 쓸 수 있게 해주는 디자인 패턴이다. 전략 패턴을 사용하면 클라이언트로부터 알고리즘을 분리해서 독립적으로 변경할 수 있다.