2025-09-23 20:41
-
전략 패턴은 알고리즘을 캡슐화하여 런타임에 교체할 수 있게 만드는 디자인 패턴이다.
-
클라이언트 코드 변경 없이 다양한 행동(전략)을 유연하게 전환할 수 있는 구조를 제공한다.
-
상속 대신 구성을 활용하여 코드의 재사용성과 유지보수성을 높이는 핵심적인 역할을 한다.
개발자의 무기를 업그레이드하는 필살기 전략 패턴 완벽 핸드북
소프트웨어 개발의 세계는 끊임없이 변화하는 요구사항과의 싸움이다. 어제의 최선이 오늘의 최악이 될 수 있고, 간단했던 기능은 어느새 복잡하게 얽힌 괴물이 되어 있기도 하다. 이러한 혼돈 속에서 개발자에게 필요한 것은 바로 ‘유연함’과 ‘확장성’이라는 강력한 무기다. 그리고 이 무기를 날카롭게 갈아주는 숫돌 역할을 하는 것이 바로 **디자인 패턴(Design Pattern)**이다.
수많은 디자인 패턴 중에서도 **전략 패턴(Strategy Pattern)**은 변화에 대응하는 가장 기본적인 동시에 강력한 해법을 제시한다. 마치 게임 캐릭터가 상황에 따라 칼, 활, 마법을 자유자재로 바꾸어 사용하듯, 전략 패턴은 런타임에 알고리즘을 손쉽게 교체할 수 있게 해준다. 이 글에서는 전략 패턴이 왜 필요하게 되었는지, 어떤 구조로 이루어져 있으며, 어떻게 활용하여 우리의 코드를 한 단계 더 발전시킬 수 있는지 심도 있게 탐험해 본다.
1. 전략 패턴은 왜 탄생했을까 변화에 무너진 상속의 꿈
전략 패턴의 필요성을 이해하기 위해서는 먼저 객체 지향 프로그래밍의 핵심 기둥 중 하나인 **상속(Inheritance)**의 한계를 알아야 한다. 상속은 코드 재사용성을 높이는 훌륭한 방법이지만, 변화의 바람 앞에서는 때때로 모래성처럼 허무하게 무너진다.
1.1. 날지 못하는 오리의 슬픈 이야기
상상의 나래를 펼쳐 동물을 표현하는 애플리케이션을 만든다고 가정해 보자. Animal이라는 부모 클래스를 만들고, 모든 동물에게 적용될 sound()라는 메서드를 추가했다.
Java
// 예시 코드 (Java)
public abstract class Animal {
public void sound() {
System.out.println("동물이 소리를 냅니다.");
}
}
public class Dog extends Animal {
@Override
public void sound() {
System.out.println("멍멍!");
}
}
public class Cat extends Animal {
@Override
public void sound() {
System.out.println("야옹!");
}
}
여기까지는 완벽해 보인다. 이제 새로운 요구사항이 추가되었다. “새(Bird) 클래스를 추가하고, 새는 날 수 있어야 합니다.” 우리는 주저 없이 Animal 클래스에 fly() 메서드를 추가하고, Bird 클래스가 이를 상속받게 할 것이다.
Java
public abstract class Animal {
public void sound() {
// ...
}
public void fly() {
System.out.println("하늘을 납니다!");
}
}
public class Bird extends Animal {
// Bird는 날 수 있으니 fly() 메서드를 그대로 사용
}
하지만 기쁨도 잠시, “오리(Duck) 클래스를 추가해주세요. 오리는 새지만 날지 못할 수도 있습니다.” 라는 더 까다로운 요구사항이 등장한다. 이제 문제가 복잡해진다. Duck은 Bird를 상or Animal을 상속해야 하는데, fly() 메서드가 문제다. 날지 못하는 오리를 위해 fly() 메서드를 오버라이드하여 아무 동작도 하지 않도록 만들어야 할까?
Java
public class RubberDuck extends Animal { // 고무 오리는 날면 안 된다!
@Override
public void fly() {
// 아무것도 하지 않음
System.out.println("저는 날 수 없어요.");
}
}
이런 방식은 임시방편일 뿐, 근본적인 해결책이 아니다. 만약 ‘날 수 있는 행동’과 ‘헤엄치는 행동’ 등 수많은 행동이 추가되고, 각 동물마다 가능한 행동의 조합이 달라진다면 어떻게 될까? 상속 구조는 걷잡을 수 없이 복잡해지고, 관련 없는 기능들이 부모 클래스에 쌓여 ‘거대한(Fat)’ 슈퍼클래스를 만들게 된다. 이는 클래스 폭발(Class Explosion) 문제로 이어지며, 코드의 유지보수를 극도로 어렵게 만든다.
1.2. 변하는 것과 변하지 않는 것을 분리하라
전략 패턴은 바로 이 지점에서 해답을 제시한다. “변하는 것은 변하지 않는 것으로부터 분리해야 한다.” 라는 디자인 원칙에 따라, 계속해서 변할 가능성이 있는 ‘행동’(알고리즘)을 별도의 클래스로 캡슐화하는 것이다.
-
변하지 않는 것: 동물의 종류 (Dog, Cat, Duck)
-
변하는 것: 나는 행동, 우는 행동 등
즉, fly()라는 행동을 Animal 클래스에 직접 구현하는 대신, FlyBehavior라는 인터페이스를 만들고, ‘날 수 있는 행동’(FlyWithWings)과 ‘날 수 없는 행동’(FlyNoWay)을 각각의 클래스로 구현한다. 그리고 Animal 클래스는 이 FlyBehavior 인터페이스 타입의 객체를 멤버 변수로 가지게 된다.
이렇게 상속(IS-A) 관계를 구성(HAS-A) 관계로 전환함으로써, 우리는 각 동물이 어떤 행동을 할지 동적으로 결정하고, 필요에 따라 런타임에 행동을 교체할 수도 있게 된다. 이것이 바로 전략 패턴의 핵심 아이디어다.
2. 전략 패턴의 구조 해부도
전략 패턴은 세 가지 핵심 구성 요소로 이루어진다. 이들의 관계를 이해하면 패턴을 완벽하게 파악할 수 있다.
| 구성 요소 (Component) | 역할 (Role) | 비유 (Analogy) |
|---|---|---|
| Context (문맥) | 전략을 사용하는 주체. 구체적인 전략을 알지 못하고, Strategy 인터페이스만 참조한다. | 게임 캐릭터 |
| Strategy (전략) | 모든 구체적인 전략(알고리즘)이 구현해야 하는 공통 인터페이스. | 무기 슬롯 (어떤 무기든 장착 가능) |
| ConcreteStrategy (구체적인 전략) | Strategy 인터페이스를 실제로 구현한 클래스. 실제 알고리즘이 포함된다. | 칼, 활, 마법 지팡이 등 실제 무기 |
이를 시각적으로 표현하면 다음과 같은 관계를 가진다.
+-----------+ +-------------------+ +--------------------+
| Context |<>---->| <<Interface>> | | ConcreteStrategyA |
|-----------| | Strategy |-----> |--------------------|
| setStrategy() | |-------------------| | executeAlgorithm() |
| execute() | | executeAlgorithm()| +--------------------+
+-----------+ +-------------------+ ^
|
+--------------------+
| ConcreteStrategyB |
|--------------------|
| executeAlgorithm() |
+--------------------+
-
Context (예:
Duck클래스):-
Strategy인터페이스 타입의 참조 변수를 가진다. (예:FlyBehavior flyBehavior;) -
클라이언트로부터 구체적인
ConcreteStrategy객체를 주입받아 이 변수에 할당한다. (예:setFlyBehavior(new FlyWithWings());) -
자신의
execute()메서드 (예:performFly())를 호출하면, 실제로는 참조하고 있는Strategy객체의executeAlgorithm()메서드 (예:flyBehavior.fly())를 호출하여 작업을 위임한다.
-
-
Strategy (예:
FlyBehavior인터페이스):-
모든 구체적인 전략들이 따라야 할 메서드의 명세를 정의한다. (예:
void fly();) -
Context는 이 인터페이스에만 의존하므로, 새로운 전략이 추가되어도 Context 코드는 변경되지 않는다. (OCP: 개방-폐쇄 원칙)
-
-
ConcreteStrategy (예:
FlyWithWings,FlyNoWay클래스):-
Strategy인터페이스를 구현하여 실제 알고리즘을 코드로 작성한다. -
FlyWithWings클래스는 실제로 나는 코드를,FlyNoWay클래스는 날지 못하는 코드를 담고 있다.
-
이 구조 덕분에, Duck 객체는 자신이 어떤 FlyBehavior를 가지고 있는지 신경 쓸 필요 없이 그저 performFly()를 호출하기만 하면 된다. 비행 능력을 바꾸고 싶다면, setFlyBehavior()를 통해 다른 FlyBehavior 객체로 갈아 끼우기만 하면 된다. 이것이 바로 런타임에 알고리즘을 교체하는 전략 패턴의 마법이다.
3. 전략 패턴 실전 사용법 단계별 가이드
이제 개념을 넘어 실제 코드로 전략 패턴을 구현해 보자. 결제 시스템을 예로 들어, 다양한 결제 방법(신용카드, 카카오페이, 네이버페이)을 전략 패턴으로 구현하는 과정을 따라가 본다.
3.1. 1단계: 전략 인터페이스 정의
가장 먼저 할 일은 변하는 알고리즘, 즉 ‘결제’라는 행동에 대한 공통 인터페이스를 정의하는 것이다.
Java
// Strategy: PaymentStrategy 인터페이스
public interface PaymentStrategy {
void pay(int amount); // 결제 알고리즘의 표준 메서드
}
3.2. 2단계: 구체적인 전략 클래스 구현
다음으로, PaymentStrategy 인터페이스를 구현하는 실제 결제 방법 클래스들을 만든다.
Java
// ConcreteStrategy 1: CreditCardStrategy
public class CreditCardStrategy implements PaymentStrategy {
private String name;
private String cardNumber;
public CreditCardStrategy(String name, String cardNumber) {
this.name = name;
this.cardNumber = cardNumber;
}
@Override
public void pay(int amount) {
System.out.println(amount + "원을 " + name + " 신용카드로 결제했습니다.");
}
}
// ConcreteStrategy 2: KakaoPayStrategy
public class KakaoPayStrategy implements PaymentStrategy {
private String email;
public KakaoPayStrategy(String email) {
this.email = email;
}
@Override
public void pay(int amount) {
System.out.println(amount + "원을 " + email + " 카카오페이로 결제했습니다.");
}
}
3.3. 3단계: 컨텍스트 클래스 작성
이제 이 전략들을 사용할 주체, 즉 ShoppingCart(장바구니) 클래스를 만든다. ShoppingCart은 구체적인 결제 방법을 몰라야 하며, 오직 PaymentStrategy 인터페이스에만 의존해야 한다.
Java
// Context: ShoppingCart
public class ShoppingCart {
private List<Item> items;
private PaymentStrategy paymentStrategy; // Strategy 인터페이스에 의존
public ShoppingCart() {
this.items = new ArrayList<>();
}
public void addItem(Item item) {
this.items.add(item);
}
public int calculateTotal() {
return items.stream().mapToInt(Item::getPrice).sum();
}
// 런타임에 전략을 설정(교체)할 수 있는 메서드
public void setPaymentStrategy(PaymentStrategy paymentStrategy) {
this.paymentStrategy = paymentStrategy;
}
// 결제 실행
public void checkout() {
int total = calculateTotal();
if (paymentStrategy == null) {
System.out.println("결제 방법을 선택해주세요.");
return;
}
paymentStrategy.pay(total); // 실제 결제는 전략 객체에 위임
}
}
3.4. 4단계: 클라이언트 코드에서 사용하기
마지막으로, 클라이언트 코드에서 ShoppingCart 객체를 생성하고, 원하는 결제 전략을 주입하여 사용하는 예시다.
Java
public class Client {
public static void main(String[] args) {
ShoppingCart cart = new ShoppingCart();
cart.addItem(new Item("MacBook Pro", 3000000));
cart.addItem(new Item("Magic Mouse", 150000));
// 신용카드로 결제
cart.setPaymentStrategy(new CreditCardStrategy("홍길동", "1234-5678-9012-3456"));
cart.checkout(); // 출력: 3150000원을 홍길동 신용카드로 결제했습니다.
System.out.println("--- 결제 방법 변경 ---");
// 카카오페이로 결제 방법 변경
cart.setPaymentStrategy(new KakaoPayStrategy("gildong@example.com"));
cart.checkout(); // 출력: 3150000원을 gildong@example.com 카카오페이로 결제했습니다.
}
}
결과에서 볼 수 있듯이, ShoppingCart 코드의 변경 없이 setPaymentStrategy 메서드를 호출하는 것만으로 결제 방식을 자유롭게 변경할 수 있다. 만약 ‘네이버페이’라는 새로운 결제 수단이 추가되어도, 우리는 NaverPayStrategy 클래스를 하나 더 만드는 것 외에 기존 코드를 수정할 필요가 전혀 없다. 이것이 바로 전략 패턴이 제공하는 **개방-폐쇄 원칙(OCP)**의 힘이다.
4. 전략 패턴 심화 탐구
전략 패턴은 단순하지만 매우 강력하며, 다른 패턴이나 기술과 결합하여 더 큰 시너지를 낼 수 있다.
4.1. 람다(Lambda)와 함수형 인터페이스 활용
Java 8 이상에서는 함수형 인터페이스와 람다 표현식을 사용하여 전략 패턴을 훨씬 간결하게 구현할 수 있다. PaymentStrategy 인터페이스는 pay라는 추상 메서드를 하나만 가지고 있으므로 함수형 인터페이스의 조건을 만족한다.
Java
// 클라이언트 코드
public class Client {
public static void main(String[] args) {
ShoppingCart cart = new ShoppingCart();
cart.addItem(new Item("iPhone 15", 1500000));
// 람다를 사용하여 즉석에서 전략 생성 및 주입
cart.setPaymentStrategy(amount -> System.out.println(amount + "원을 토스(Toss)로 결제합니다."));
cart.checkout();
// 메서드 참조를 사용할 수도 있다.
PaymentProcessor processor = new PaymentProcessor();
cart.setPaymentStrategy(processor::payByPayco);
cart.checkout();
}
}
class PaymentProcessor {
public void payByPayco(int amount) {
System.out.println(amount + "원을 페이코(Payco)로 결제합니다.");
}
}
별도의 ConcreteStrategy 클래스를 만들 필요 없이, 람다식을 통해 알고리즘(행동) 자체를 객체처럼 전달할 수 있게 되어 코드가 훨씬 유연하고 간결해진다.
4.2. 전략 패턴과 상태 패턴(State Pattern)의 비교
전략 패턴은 종종 상태 패턴과 혼동되곤 한다. 두 패턴 모두 객체의 행동을 바꾸는 것을 목표로 하지만, 그 의도와 방식에 미묘한 차이가 있다.
| 구분 | 전략 패턴 (Strategy Pattern) | 상태 패턴 (State Pattern) |
|---|---|---|
| 의도 (Intent) | 알고리즘을 캡슐화하여 클라이언트가 런타임에 교체할 수 있도록 한다. | 객체의 내부 상태에 따라 행동을 변경하도록 한다. |
| 행동 변경 주체 | **클라이언트(Client)**가 명시적으로 전략을 선택하고 교체한다. | 컨텍스트(Context) 또는 상태(State) 객체 스스로가 상태를 전환한다. |
| 상태 인지 여부 | 클라이언트가 다양한 전략의 존재를 알고 선택해야 한다. | 클라이언트는 객체의 내부 상태를 알 필요가 없다. |
| 비유 | 필요에 따라 무기를 교체하는 게임 캐릭터 | 기분에 따라 행동이 바뀌는 사람 (기쁨, 슬픔 상태에 따라 행동이 달라짐) |
간단히 말해, 전략 패턴이 **‘무엇(What)’**을 할 것인가에 대한 다양한 방법을 제공하는 것이라면(How), 상태 패턴은 객체의 **‘상태(State)’**에 따라 행동이 어떻게 달라지는가를 표현하는 데 중점을 둔다.
5. 결론: 변화를 지배하는 개발자의 지혜
전략 패턴은 단순히 코드를 구조화하는 기술을 넘어, 변화를 예측하고 그에 유연하게 대응하는 개발 철학을 담고 있다. 애플리케이션의 요구사항은 언제나 변하기 마련이며, 경직된 상속 구조는 이러한 변화의 파도 앞에서 쉽게 부서진다.
전략 패턴을 통해 우리는 ‘행동’을 독립적인 객체로 분리하고, 이를 조립하는 방식으로 소프트웨어를 설계할 수 있다. 이는 코드의 결합도를 낮추고 응집도를 높이며, 테스트 용이성과 재사용성을 극대화한다. 새로운 기능이 추가될 때마다 기존 코드를 건드리는 대신, 새로운 전략을 추가하는 것만으로 시스템을 확장할 수 있게 되는 것이다.
오늘 당장 당신의 코드에 적용할 부분을 찾아보라. 반복되는 if-else 나 switch 문으로 행동을 분기하고 있다면, 그 부분이 바로 전략 패턴이 활약할 무대다. 전략 패턴이라는 강력한 무기를 당신의 개발 무기고에 추가하여, 끊임없이 변화하는 소프트웨어의 세계를 지혜롭게 지배하는 개발자로 거듭나길 바란다.