어떤 기능에서 특정한 기준에 따라 과정을 달리해야하는 때가 있다. 예를 들어, 과일 매장에서 과일을 판매한다고 가정하자. 이벤트로 첫 손님에게는 10프로 할인을 하고 신선하지 않은 과일은 20프로 할인한 가격으로 판매한다. 이 경우 계산해야 하는 금액을 정할 때 첫 손님인지
, 과일이 신선한지 아닌지
에 대한 조건에 따라 결과가 다르다. 구매할 과일이 3000원이라 할 때, 첫 손님은 2700원, 과일이 신선하지 않을 경우 2400원을 받아야 한다. 이 경우를 코드로 작성해보면 다음과 같다.
public class Calculator {
public int calculate(final boolean firstGuest, final List<Item> items) {
int sum = 0;
for (Item item : items) {
if (firstGuest) {
sum += (int) (item.getPrice() * 0.9);
} else if (!item.isFresh()) {
sum += (int) (item.getPrice() * 0.8);
} else {
sum += item.getPrice();
}
}
return sum;
}
}
if 문을 활용해 조건에 맞는 처리를 한다. 값은 정확하게 계산할 수 있지만 유지보수가 어렵다는 단점이 있다. 다른 이벤트가 추가되어 조건을 추가해야 할 경우 이미 있던 조건들의 상관관계를 따지기 어렵다. 잘못 수정하면 다른 로직에도 영향이 갈 가능성이 있다. 코드만 보고 바로 어떤 의미로 작성된 코드인지 파악하기도 어렵다.
이처럼 동일한 기능에 대해 내부 로직을 달리해야 하는 경우 전략 패턴을 활용해볼 수 있다.
전략 패턴은 동일한 기능에서 다른 알고리즘을 구현해야할 때 사용
하는 디자인 패턴이다. 위의 예시를 전략 패턴을 사용한 구조로 클래스다이어그램을 그리면 다음과 같다.
기존 Calculator 객체가 수행하던 calculate() 기능을 내부적으로 다른 객체가 수행하도록 한다. 각 조건에 따라 객체를 생성하고 조건에 맞는 처리를 한다. 어떤 전략을 사용하냐에 따라 동일 메서드로 호출하여 다른 값을 가져오기 위해 DiscountStrategy
를 인터페이스로 생성 후 각 세부 전략들은 인터페이스를 구현한다.
기존 if 문을 제거하고 전략패턴을 사용한 코드는 다음과 같다. Calculator 객체 생성 시 사용할 전략을 입력받고 입력받았던 전략에 맞는 결과를 반환한다.
public class Calculator {
private final DiscountStrategy discountStrategy;
Calculator(final DiscountStrategy discountStrategy) {
this.discountStrategy = discountStrategy;
}
public int calculate(final List<Item> items) {
int sum = 0;
for (Item item : items) {
sum += discountStrategy.getDiscountPrice(item);
}
return sum;
}
}
내부 로직의 공통 사항을 메서드로 묶어 가지고 있는 DiscountStrategy 인터페이스의 코드는 다음과 같다. 여러 전략 모두 할인한 가격의 값을 필요로 하므로 메서드 이름을 getDiscountPrice
으로, 입력값은 구매할 과일을 받는다.
public interface DiscountStrategy {
int getDiscountPrice(final Item item);
}
if 문의 첫번째 조건이었던 첫 손님일 경우 10프로 할인
에 대한 전략이다. 조건 자체가 객체로 대체되고 조건에 따른 구현은 메서드로 대체되었다.
public class FirstGuestDiscountStrategy implements DiscountStrategy {
@Override
public int getDiscountPrice(final Item item) {
return (int) (item.getPrice() * 0.9);
}
}
두 번째 조건이었던 신선하지 않은 과일일 경우 20프로 할인
에 대한 전략이다.
public class NonFreshItemDiscountStrategy implements DiscountStrategy {
@Override
public int getDiscountPrice(final Item item) {
return (int) (item.getPrice() * 0.8);
}
}
참고한 서적의 예시 코드에는 포함되어 있지 않았지만, 첫 손님도 아니고 신선한 과일을 구매할 경우 계산하는 로직을 위해 해당 객체도 추가하였다. if 문에서 else 에 대한 처리에 해당된다.
public class OriginalDiscountStrategy implements DiscountStrategy {
public int getDiscountPrice(final Item item) {
return item.getPrice();
}
}
위의 전략패턴을 적용한 코드가 올바르게 작동하는지 확인하기 위해 테스트 코드를 짜봤다. 3개의 테스트 코드 모두 통과하는 것을 확인할 수 있다.
public class CalculatorTest {
@Test
@DisplayName("사과 금액 계산")
void calculateApple() {
Calculator calculator = new Calculator(new OriginalDiscountStrategy());
List<Item> items = new ArrayList<>(List.of(new Apple()));
assertThat(calculator.calculate(items)).isEqualTo(3000); // 3000
}
@Test
@DisplayName("첫번째로 온 손님이 산 사과 금액 계산")
void calculateAppleToFirstGuest() {
Calculator calculator = new Calculator(new FirstGuestDiscountStrategy());
List<Item> items = new ArrayList<>(List.of(new Apple()));
assertThat(calculator.calculate(items)).isEqualTo(2700); // 2700
}
@Test
@DisplayName("썩은 사과 금액 계산")
void calculateRottenApple() {
Calculator calculator = new Calculator(new NonFreshItemDiscountStrategy());
List<Item> items = new ArrayList<>(List.of(new Apple()));
assertThat(calculator.calculate(items)).isEqualTo(2400); // 2400
}
}
전략 패턴을 적용하여 위에서 언급했던 if 문의 단점이 해결되었다. 구현한 클래스의 이름으로 어떤 기능을 하는지 명시적으로 나타내었으며, 다른 전략이 추가되더라도 DiscountStrategy 인터페이스를 구현한 객체를 하나 더 추가하면 된다. 즉, 유지보수가 용이해졌다. 실제로도 전략을 추가하기에 편리해졌는지 마지막 손님일 경우 대폭 할인
전략을 추가해보자. 임의로 90프로 할인된 금액으로 계산하도록 짜보았다.
public class LastGuestDiscountStrategy implements DiscountStrategy {
@Override
public int getDiscountPrice(final Item item) {
return (int) (item.getPrice() * 0.1);
}
}
추가한 전략을 추가한 테스트 케이스를 포함하여 모두 통과하는 것을 확인할 수 있다.
@Test
@DisplayName("마지막으로 온 손님이 산 사과 금액 계산")
void calculateAppleToLastGuest() {
Calculator calculator = new Calculator(new LastGuestDiscountStrategy());
List<Item> items = new ArrayList<>(List.of(new Apple()));
assertThat(calculator.calculate(items)).isEqualTo(300);
}