디자인 패턴 공부 - 전략 패턴

이혁진·2023년 2월 4일

전략 패턴

전략 패턴 혹은 정책 패턴이란 실행 중에 알고리즘을 선택할 수 있게 하는 디자인 패턴이다. 어떤 로직에서 특정 부분의 알고리즘이 동적으로 바뀌어 작동해야 한다거나, 쉽게 변경할 수 있어야 할 때, 바뀌는 알고리즘을 Strategy라는 인터페이스로 캡슐화한다. 해당 로직은 Strategy 인터페이스만 알고 있는 채로 작동하기에, 다른 어떤 Strategy의 구현체가 와도 변경할 필요가 없다. 쉽게 알고리즘을 바꿔낄 수 있고, 필요에 따라 동적으로 바꾸게 할 수도 있다.

마치 상태 패턴과 구조가 유사하다. 거의 같다고 보면 되는데, 목적이 살짝 다를 뿐이다. 상태 패턴은 상태에 따른 조건문 블록들을 예쁘게 디자인하기 위함이고, 전략 패턴은 특정 로직에서 알고리즘을 쉽게 바꿔낄 수 있도록 하기 위함이다. 상태 패턴처럼 비즈니스 로직에서 엄청 쓰고, 프레임워크들에서도 인터페이스만 보이면 거의 전략 패턴이라고 한다.

구현

구조는 간단하다. Context의 로직 상에서 바뀌는 알고리즘을 캡슐화하고, 필요한 정보들을 인자로 넘기면 된다. 상태 패턴이랑 전략 패턴을 짜보니까 중요한 것이 인터페이스의 메소드 매개변수 목록이다. 전략(구현체)마다 다른 정보를 필요로 할 수 있어서, 이것들을 일반화하여 매개변수를 잘 정해야 하는 것 같다. 안그러면 구현체를 만들다가 인터페이스를 바꾸게 되는 일이 생길 수 있다.

무슨 말이냐면, Context에서 위임받을 때, int 타입 데이터 하나만 필요할 거 같아서 이렇게 했다고 해보자.

public interface Strategy {
	public algorithm(int n);
}

첫번째 전략(Strategy의 구현체)에서는 n만 필요했지만, 그 다음 전략은 n 말고도 double 형 데이터 하나가 더 필요하다고 한다. 이러면 인터페이스를 바꿔야 하니까, 처음부터 잘 설계해야 한다는 뜻이다.

아무튼 전략 패턴을 예제에 적용해보겠다.

문제 상황 - Customer 단일 클래스

Customer가 Context이다. 여기에서 add()를 통해 주문한 음료수들의 가격x수량을 리스트로 저장하고, printBill()로 그 총합을 출력한다.

public class Customer {
	private final List<Double> drinks;

	public Customer() {
    	this.drinks = new LinkedList<>();
	}

	public Customer add(double price, int quantity) {
    	drinks.add(price * quantity);
    	// drinks.add(price * quantity * 0.5)
    	// drinks.add(1000.0 * quantity)
    	return this;
	}	

	public void printBill() {
    	double sum = 0;
    	for (double i : drinks) {
        	sum += i;
    	}
    	System.out.println("total due : " + sum);
    	drinks.clear();
	}
}

public class Main {
	public static void main(String[] args) {
    	Customer customer = new Customer();
    	customer.add(1.0, 2)
            	.add(20.0, 3)
            	.add(3.0, 1)
            	.printBill();
	}
}

만약 여기에서 합산 방식이 계속 바뀔 수 있다고 해보자. 가령 곱한 결과에 0.5를 곱한다거나 많이 시킬수록 할인을 해준다거나 하는 식이다. 이 알고리즘을 쉽게 바꿔낄 수 있도록 하는 방법은 세 가지가 있을 것 같다.

  1. 합산 알고리즘 별로 State를 대응시키고, State를 받아서 if절로 다른 알고리즘을 수행한다.
  2. Customer 클래스를 상속한 뒤, add()를 오버라이딩 한다. (상속)
  3. 합산 알고리즘을 새 인터페이스에 캡슐화하고, add()에서 합산 알고리즘을 그 인터페이스 메소드에 위임한다. (구성)

해결 방법1 - 상태 분기

일단 1번이다.

public class Customer {
	private final List<Double> drinks;

	enum Mode { NORMAL, HAPPYHOUR, FREE }

	public Customer() {
    	this.drinks = new LinkedList<>();
	}

	public Customer add(double price, int quantity, Mode mode) {
    	if (mode == Mode.NORMAL) {
        	drinks.add(price * quantity);
    	}
    	else if (mode == Mode.FREE) {
        	drinks.add(0.0);
    	}
    	else if (mode == Mode.HAPPYHOUR) {
        	drinks.add(price * quantity * 0.5);
    	}

    	return this;
	}

	public void printBill() {
    	double sum = 0;
    	for (double i : drinks) {
        	sum += i;
    	}
    	System.out.println("total due : " + sum);
    	drinks.clear();
	}
}

public class Main {
	public static void main(String[] args) {
    	Customer customer = new Customer();
    	customer.add(1.0, 2, Customer.Mode.FREE)
            	.add(20.0, 3, Customer.Mode.HAPPYHOUR)
            	.add(3.0, 1, Customer.Mode.HAPPYHOUR)
            	.printBill();
	}
}

이러면 상태만 넘겨줌으로써 쉽게 합산 알고리즘을 전환할 수 있다. 하지만 확장성 있는 코드는 아니다. 새로운 합산 알고리즘을 반영하려면

  • Customer 클래스의 State에 새로운 상태 추가
  • Customer 클래스의 add() 메소드를 수정
  • Main에서 새로운 상태로 바꿔서 실행

이를 해결하기 위해서 상속을 활용할 수 있다.

해결 방법2 - 상속

Customer 클래스를 상속하는 새로운 클래스를 만든 뒤, 거기에 add()를 오버라이딩하여 새로운 합산 방식을 구현한다. 합산 알고리즘을 전환하기 위해선 Customer 타입의 객체에 그 구현체만 바꾸어주면 된다.

public class Customer {
	protected final List<Double> drinks;

	public Customer() {
    	this.drinks = new LinkedList<>();
	}

	public Customer add(double price, int quantity) {
    	drinks.add(price * quantity);
    	return this;
	}

	public void printBill() {
    	double sum = 0;
    	for (double i : drinks) {
        	sum += i;
    	}
    	System.out.println("total due : " + sum);
    	drinks.clear();
	}
}

public class FreePolicyCustomer extends Customer {
	@Override
	public Customer add(double price, int quantity) {
    	super.drinks.add(0.0);
    	return this;
	}
}

public class HappyHourPolicyCustomer extends Customer {
	@Override
	public Customer add(double price, int quantity) {
    	super.drinks.add(price * quantity * 0.5);
    	return this;
	}
}

public class NormalPolicyCustomer extends Customer {
	@Override
	public Customer add(double price, int quantity) {
    	return super.add(price, quantity);
	}
}

public class Main {
	public static void main(String[] args) {
    	Customer customer = new HappyHourPolicyCustomer();
    	customer.add(1.0, 2)
            	.add(20.0, 3)
            	.add(3.0, 1)
            	.printBill();
	}
}

Customer customer = new Happy~() 여기에서 new 뒤에만 바꾸면 된다. 그리고, 새로운 합산 알고리즘을 반영하려면

  • Customer 클래스를 구현하는 새로운 합산 알고리즘의 클래스 만들기
  • Main에서 객체 바꿔주기

훨씬 간단해졌다. 하지만 이 방법은 상속을 사용했다면 거의 발생하는 고질적인 문제가 남아있다.

첫째, 자식 클래스가 부모 클래스의 필드에 직접 접근할 수 있게 된다. 이는 캡슐화를 깨뜨리게 된다. 접근을 getter 같은 메소드로 캡슐화하면 접근 방식이 바뀌었을 때 거기만 고치면 된다. 반면 직접 접근하였을 때에는 접근하는 모든 구상 클래스의 코드를 다 손봐야 한다. 응집도의 문제이다.

둘째, 부모 클래스의 변화가 모든 자식 클래스의 변화에 영향을 준다. 자식 클래스는 부모 클래스를 확장한다. 즉, 기존의 모든 코드를 안고 있는 것이다. 꼭 오류가 뜨지 않더라도 부모 클래스에 뭔가 변화가 생기면(필드나 오버라이딩 하지 않는 메소드) 자식 클래스는 전부 영향을 받는다. 다르게 말하면, 모든 자식 클래스가 부모 클래스에 super를 통해서 연관(강한 결합)하므로 부모 클래스가 굉장히 결합도가 큰 클래스가 된다고도 할 수 있다.

바뀌는 알고리즘을 상속된 클래스에 맡기는 것이 아니라, 구성된 클래스(인터페이스)에 맡기면 위 문제가 해결된다. 캡슐화된 메소드로만 필드에 접근 가능하고, 부모의 참조를 반드시 넘길 필요가 없기 때문이다.(일부 필드만 조절하여 넘길 수 있다.) 그래서 상속(inheritance) 대신 구성을 활용하라고 많이들 말을 한다.(composition)

해결 방법3 - 구성(전략 패턴)

이게 전략 패턴이다. 이때 알고리즘을 위임할 클래스들을 BillingStrategy 인터페이스로 묶는다. 당연하지만 이렇게 해야 Customer가 최소한의 정보(인터페이스)만 가지고 작동하므로 확장성이 생기고 쉽게 전환할 수 있다.

public class Customer {
	private final List<Double> drinks;

	public Customer() {
    	this.drinks = new LinkedList<>();
	}

	public Customer add(double price, int quantity, BillingStrategy strategy) {
    	double value = strategy.add(price, quantity);
    	drinks.add(value);
    	return this;
	}

	public void printBill() {
    	double sum = 0;
    	for (double i : drinks) {
        	sum += i;
    	}
    	System.out.println("total due : " + sum);
    	drinks.clear();
	}
}

public interface BillingStrategy {
	public double add(double price, int quantity);
}

public class FreeStrategy implements BillingStrategy {
	@Override
	public double add(double price, int quantity) {
    	return 0.0;
	}
}

public class HappyHourStrategy implements BillingStrategy {
	@Override
	public double add(double price, int quantity) {
    	return price * quantity * 0.5;
	}
}

public class NormalStrategy implements BillingStrategy {
	@Override
	public double add(double price, int quantity) {
    	return price * quantity;
	}
}

합산 알고리즘의 확장과 전환 시 생기는 변경은 상속과 비슷하다. 다만 다른 부분에서 더 유연한 방법이라고 할 수 있다.

장점

아까 다 말했듯이, 전략의 변경/확장이 쉽고 동적으로 바꿀 수도 있다. 다만 클래스가 많아진다는 단점이 있다. 상태 패턴과 마찬가지로 비즈니스 로직에서 요긴하게 잘 써먹을수 있다고 한다.

용어 정리

상속 구성 위임

상속(inheritance) : 상속하는 것, is a 관계
구성(composition) : 클래스의 필드로 다른 클래스의 객체 가지고 있는 것, has a 관계
위임 : 어떤 메소드가 자신이 수행해야할 동작을 다른 메소드에게 시킨다.(동작을 위임한다.)

의존관계의 종류

A가 B에 연관 : A의 필드 목록에 B가 있다. 혹은 B를 필드로 가지고 있다.

public class A {
	private B b;
}

A가 B에 의존 : A의 한 메소드 안에서 B가 리턴타입, 매개변수 타입, 로직 내부 중 한 번이라도 나타난다. 아래 세 메소드는 다 B에 의존한다.

public class A {
	public B fun1() {}
    public int fun2(B b) {}
    public int fun3() { new B().operation(); }
}
profile
한양대학교 정보시스템학과 22학번 이혁진 입니다

0개의 댓글