티스토리 뷰
스트래티지 패턴이라는 이름을 처음 들어봤어도 스프링을 사용해보았다면 아마 금방 이해할 수 있을 것이다. 우리가 스프링을 사용해 웹 애플리케이션을 구현할 때, 일반적으로 사용할 빈 타입을 클래스 내에 멤버 변수로 포함시키고 스프링이 해당 빈 객체를 주입함으로써 사용하게 된다.
@Controller
public class HelloController {
private final HelloService service;
public HelloController(HelloService service) {
this.service = service;
}
}
이렇게 함으로써 HelloController의 HelloService 객체는 런타임에서 어떤 객체가 주입되느냐에 따라 실행할 동작을 결정할 수 있게 된다. 사실 이것이 스트래티지 패턴이다.
스트래티지 패턴은 객체의 행동, 즉 알고리즘을 캡슐화해 런타임에 동적으로 변경할 수 있도록 설계하는 패턴이다. 예를들면, 온라인 쇼핑몰 사이트를 구현하는데 카드 결제 뿐만 아니라 토스 페이, 네이버 페이 등 다양한 결제 방식이 존재한다고 해보자.
간단하게 구현한다면 다음과 같이 구현할 수 있을 것이다.
@Service
public class Payment {
public void processPayment(String strategy, int amount) {
switch (strategy) {
case "naver":
naver(amount);
break;
case "toss":
toss(amount);
break;
case "creditCard":
creditCard(amount);
break;
default:
throw new IllegalArgumentException();
}
}
private void naver(int amount) {
// 네이버페이 결제
}
private void toss(int amount) {
// 토스페이 결제
}
private void creditCard(int amount) {
// 신용카드 결제
}
}
물론 위와 같이 구현한다고 해서 기능적으로 문제가 발생하거나 그러진 않는다. 다만, 확장성과 유지보수성 등을 고려했을 때 좋은 코드라고는 볼 수 없다. 이러한 관점에서 보았을 때 가장 큰 문제는 OCP를 위반한다는 것이다. 새로운 결제 방식이 도입될 때, 클래스를 만들어 확장하는 것이 아닌 Payment 클래스를 직접 수정하며 새로운 결제 방식을 도입해야 한다.
스트래티지 패턴은 객체의 행동, 즉 알고리즘을 캡슐화해 런타임에 동적으로 변경할 수 있도록 설계하는 패턴이라고 했는데 그렇다면 위 코드에 어떻게 스트래티지 패턴을 적용하면 OCP를 잘 준수할 수 있을까?
Composition
답은 구성(Composition)을 이용해 구현하는 것이다. 구성은 “A에는 B가 있다.” 라는 관계를 이용한 것이다. 즉, 필요한 기능을 수행하는 객체를 클래스 포함시켜 기능을 재사용하는 방법이다. 이러한 구성을 잘 활용한다면 위 코드를 OCP를 잘 준수하는 구현으로 만들 수 있다.
아래 코드는 위 예제와 동일한 기능을 수행하지만 구성을 활용해 OCP를 준수하도록 만든 코드이다.
public class Payment {
public void pay(PaymentStrategy paymentStrategy, int amount) {
paymentStrategy.processPayment(amount);
}
}
public interface PaymentStrategy {
void processPayment(int amount);
}
public class NaverPayStrategy implements PaymentStrategy {
@Override
public void processPayment(int amount) {
System.out.println("네이버페이 결제");
}
}
public class TossPayStrategy implements PaymentStrategy {
@Override
public void processPayment(int amount) {
System.out.println("토스페이 결제");
}
}
public class CreditCardStrategy implements PaymentStrategy {
@Override
public void processPayment(int amount) {
System.out.println("카드 결제");
}
}
Payment 클래스의 `pay()` 메서드의 인자로 결제 방식이 주어지고 해당 결제 방식에 따른 결제를 진행하게 된다. 여기에 Paypal과 같은 새로운 결제 방식이 도입된다 하더라도 Payment 클래스를 수정할 필요가 전혀 없게 된다. 그저 PaymentStrategy 인터페이스를 구현한 PaypalStrategy 클래스를 추가하기만 하면 된다. 즉, 변경에는 닫혀있고 확장에는 열려있는 OCP를 잘 준수할 수 있게 된다.
위 방식은 클라이언트가 현재 어떤 결제 방식들이 구현되어 있는지 알아낸 다음에 `pay()` 메서드의 인자로 해당 방식을 전달해주어야 한다. 이러한 문제점을 조금 더 개선한다면 Enum을 사용할 수 있을 것 같다. Enum에 각각의 결제 방식에 대한 정보를 저장해두고 Payment 클래스에서는 인자로 paymentStrategy를 입력받는 것이아닌 결제 방식에 관한 Enum을 사용해 결제 전략을 가져와 사용하는 것이다. 다음 코드는 이러한 방식을 사용한 구현 방법이다.
public class Payment {
private final Map<String, PaymentStrategy> paymentStrategyMap;
public Payment(Map<String, PaymentStrategy> paymentStrategyMap) {
this.paymentStrategyMap = paymentStrategyMap;
}
public void pay(PaymentMethod method, int amount) {
PaymentStrategy paymentStrategy = paymentStrategyMap.get(method.getStrategy());
if (paymentStrategy != null) {
paymentStrategy.processPayment(amount);
}
}
}
public enum PaymentMethod {
NAVERPAY("naverPayStrategy"),
TOSSPAY("tossPayStrategy"),
CREDITCARD("creditCardStrategy"),
;
private String strategy;
PaymentMethod(String strategy) {
this.strategy = strategy;
}
public String getStrategy() {
return strategy;
}
}
public class Main {
public static void main(String[] args) {
Map<String, PaymentStrategy> paymentStrategyMap = new HashMap<>();
paymentStrategyMap.put(PaymentMethod.NAVERPAY.getStrategy(), new NaverPayStrategy());
paymentStrategyMap.put(PaymentMethod.TOSSPAY.getStrategy(), new TossPayStrategy());
paymentStrategyMap.put(PaymentMethod.CREDITCARD.getStrategy(), new CreditCardStrategy());
Payment payment = new Payment(paymentStrategyMap);
payment.pay(PaymentMethod.NAVERPAY, 5000);
}
}
이렇게 한다면 각각의 PaymentStrategy 구현을 찾아다니지 않고도 클라이언트에서 메서드 호출시 PaymentMethod의 IDE에서 제공하는 자동 완성만 보고도 어떤 결제 방식들이 존재하는지 알 수 있게 된다.
Map의 Key 타입으로 PaymentMethod를 직접 사용할 수도 있지만 일부러 String을 사용했다. 왜냐하면 위 Payment 클래스는 스프링에서도 동일하게 사용할 수 있기 때문이다. 스프링에서 의존성 주입을 할 때, 타입이 Map<String, Type> 이라면 T에 해당하는 빈들을 Map에 넣어준다. 예를들어, PaymentStrategy 타입에 `NaverPayment`, `TossPayment`, `CreditCardPayment` 3개의 빈 인스턴스가 등록되었다면
Map<String, Type> | |
String (빈 이름) | PaymentStrategy (빈 인스턴스) |
naverPayment | NaverPayment |
tossPayment | TossPayment |
creditCardPayment | CreditCardPayment |
와 같이 주입이 된다. 따라서, main() 메서드가 없더라도 각 전략들이 빈으로 등록되어 있다면 Payment 클래스를 그대로 스프링으로 가져가도 동일하게 사용 가능하다.
Straty Pattern vs Template Method Pattern
전략 패턴과 템플릿 메서드 패턴은 알고리즘을 추상화한다는 점이서 동일하다. 하지만 구현되는 방식에 따라 둘의 특징이 달라지기 때문에 상황과 목적에 맞게 적절하게 선택해 사용해야 한다.
특징 | 스트래티지 패턴 | 템플릿 메서드 패턴 |
목적 | 동작(알고리즘)을 실행 중에 교체 가능하게 설계. | 알고리즘의 구조를 고정하고, 특정 단계의 구현을 서브클래스에 위임. |
구조 | 전략 객체를 통해 동작을 변경. | 상위 클래스에 고정된 템플릿 메서드 + 서브클래스에서 특정 단계 구현. |
알고리즘의 변경 시점 | 런타임(runtime)에 전략 교체 가능. | 컴파일 타임에 알고리즘 구조가 결정됨. |
알고리즘의 제어권 | 클라이언트(사용자)가 전략을 선택하고, 실행 시 직접 교체 가능. | 알고리즘의 구조와 흐름은 상위 클래스가 제어. |
적용 대상 | 런타임에 바꿀 수 있는 "행위(behavior)"에 적합. | 고정된 "알고리즘의 흐름(flow)"과 변화 가능한 "단계(step)"가 있는 경우 적합. |
'CS > Design Pattern' 카테고리의 다른 글
Observer Pattern (0) | 2024.12.10 |
---|---|
Singleton Pattern (0) | 2024.12.10 |