전략(Strategy) 패턴

한 과일 매장은 상황에 따라 다른 가격 할인 정책을 적용하고 있다. 매장은 첫 번째 손님을 위한 할인과 신선도가 떨어진 상품에 대한 할인 정책을 제공한다고 가정하면, Calculator 객체는 아래와 같이 if-else 블록으로 가격할인 정책을 적용할 것이다.

IF-Else 구조
public class Calculator {
    public int calculate(boolean firstGuest, List<Item> items) {
        int sum = 0;
        for(Item item : items) {
            if(firstGuest) {
                sum += (item.getPrice() * 0.9);     // 첫 손님 10% 할인

            } else if(item.isFresh()) {
                sum += (item.getPrice() * 0.8);     // 덜 신선한 상품은 20% 할인

            } else {
                sum += item.getPrice();

            }

        }

        return sum;

    }

}

위의 코드는 비교적 그럴듯 해보이지만, 다음과 같은 문제가 있다.

  • 서로 다른 계산 정책이 한 코드에 섞여 있어, 정책이 추가될수록 코드 분석을 어렵게 만든다.

  • 가격 정책이 추가될 때마다 calculate 메서드를 수정하는 것이 점점 어려워진다. 예를 들면, 마지막 손님 50% 할인같은 새로운 정책이 추가된다면, calculate 메서드에는 if 블록이 하나 더 추가되어야 한다.

위와 같은 문제를 해결하기 위한 방법 중 하나는 아래 그림처럼 가격 할인 정책을 별도 객체로 분리하는 것이다.

DiscountStrategy 인터페이스는 상품의 할인 정책을 추상화하였고, 각 콘크리드 객체는 상황에 맞는 할인 계산 알고리즘을 제공한다. Caculator 객체는 가격 합산 계산의 책임을 가진다. 결과적으로, 가격 할인 알고리즘을 추상화하고 있는 DiscourseStrategy를 전략(Strategy) 이라 부르고 가격 계산 기능의 책임을 갖고 있는 Calculator를 컨텍스트(Context) 라 부른다. 이는 특정 Context에서 각 알고리즘 전략(Strategy)을 별도로 분리하는 설계 방법이며, 전략 패턴 이라고 부른다.

전략 패턴에서 Context는 사용할 전략을 직접 선택하지 않고, API Client가 사용할 전략을 Context에 전달해준다. 즉, DI(의존 주입)을 이용해서 Context에 전략을 전달하는 것이다.

앞서 보여준 예제에 Strategy Pattern을 적용하면 Calculator는 아래와 같다.

Context
public class Calculator {
    private DiscountStrategy discountStrategy;

    public Calculator(DiscountStrategy discountStrategy) {
        this.discountStrategy = discountStrategy;

    }

    public int calculate(List<Item> items) {
        int sum = 0;
        for(Item item : items) {
            sum += discountStrategy.getDicountPrice(item);

        }

        return sum;

    }

}

Calculator 객체는 생성자를 통해서 전략 객체를 주입받고, calculate() 메서드에는 각 Item의 가격을 책정할 때 전략 객체로 가격 정책을 적용하고 있다.

Strategy
public interface DiscountStrategy {
    int getDicountPrice(Item item);

}

전략 객체는 Context를 사용하는 Client에서 직접 생성하며, 이는 Client가 전략의 상세 구현에 대한 의존이 발생한다는 것을 의미한다.

Client
    @Test
    void shouldApplyFirstGuestStrategy() {
        // given
        DiscountStrategy discountStrategy = new FirstGuestDiscountStrategy();
        Calculator calculator = new Calculator(discountStrategy);
        List<Item> items = Arrays.asList(new Item("사과", 1000, LocalDateTime.now()));

        // when
        int sum = calculator.calculate(items);

        // then
        assertEquals(900, sum, "should apply 10% discounts");

    }

Client가 전략의 인터페이스가 아닌 상세 구현에 의존한다는 것이 문제처럼 보일 수 있으나, 전략의 Concrete 객체와 Client가 쌍을 이루기 때문에 유지 보수 문제가 발생할 가능성이 줄어든다.

예를 들어, 새로운 가격 할인 정책을 추가해야 한다면 새로운 Concrete 객체를 추가하고 Client만 수정하면 된다. 따라서 전략 패턴은 Client-Concrete 객체가 쌍을 이루며 오히려 코드 이해와 응집도를 높여준다.

전략 패턴의 가장 큰 이점은 확장에는 열려 있고 변경에는 닫혀 있는 개방 폐쇄 원칙 따르는 구조를 갖게 된다는 것이다.

만약 마지막 손님 대폭 할인 정책을 추가한다면, Calculator Context에는 변경이 없으며, 오직 새로운 할인 정책에 대한 Concrete 객체 추가하여 Client에서 적용하기만 하면 된다. 따라서, Calculator Context는 확장에는 열려 있고 변경에는 닫혀 있게 된다.

이전의 if-else 블록과 완전히 동일한 기능을 제공하지만, 성능의 장단점에 따라 알고리즘을 선택해야 하는 경우에도 전략 패턴을 사용할 수 있다.

Last updated