Strategy 패턴
코드를 작성하다 보면 "같은 목적인데 방식이 다른" 로직이 등장한다. 결제 처리를 예로 들면, 카드 결제, 계좌이체, 간편결제 모두 "돈을 받는다"는 목적은 같지만 실행 방식이 완전히 다르다. 이런 상황에서 가장 본능적인 접근은 조건문이다.
function processPayment(method: string, amount: number) {
if (method === 'card') {
// 카드 결제 로직 50줄
} else if (method === 'bank') {
// 계좌이체 로직 40줄
} else if (method === 'kakao') {
// 카카오페이 로직 60줄
} else if (method === 'naver') {
// 네이버페이 로직 55줄
}
}
처음엔 괜찮다. 그런데 결제 수단이 추가될 때마다 이 함수는 끝없이 길어진다. 새로운 결제 수단을 추가하려면 이 거대한 함수를 열어서 else if를 하나 더 붙여야 하고, 그 과정에서 기존 분기를 건드릴 위험이 항상 있다. 테스트도 어렵다. 카카오페이 로직만 단위 테스트하고 싶어도 함수 전체를 호출해야 한다.
이게 Strategy 패턴이 해결하는 문제다. 동일한 목적을 가진 여러 알고리즘을 각각 독립된 객체로 캡슐화하고, 런타임에 교체 가능하게 만드는 것이다.
핵심 구조
Strategy 패턴은 세 가지 역할로 구성된다.
- Strategy (전략 인터페이스): 모든 전략이 구현해야 할 공통 계약
- Concrete Strategy (구체 전략): 인터페이스를 실제로 구현하는 각각의 알고리즘
- Context (컨텍스트): 전략 객체를 보유하고 실행을 위임하는 주체
// 1. Strategy 인터페이스
interface PaymentStrategy {
pay(amount: number): PaymentResult;
validate(info: PaymentInfo): boolean;
}
// 2. Concrete Strategy들
class CardPayment implements PaymentStrategy {
pay(amount: number): PaymentResult {
// PG사 API 호출, 카드 승인 요청
return { success: true, transactionId: 'card_xxx' };
}
validate(info: PaymentInfo): boolean {
return info.cardNumber != null && info.cvv != null;
}
}
class BankTransfer implements PaymentStrategy {
pay(amount: number): PaymentResult {
// 은행 API 호출, 계좌이체 실행
return { success: true, transactionId: 'bank_xxx' };
}
validate(info: PaymentInfo): boolean {
return info.bankCode != null && info.accountNumber != null;
}
}
// 3. Context
class PaymentProcessor {
private strategy: PaymentStrategy;
constructor(strategy: PaymentStrategy) {
this.strategy = strategy;
}
setStrategy(strategy: PaymentStrategy) {
this.strategy = strategy;
}
checkout(amount: number): PaymentResult {
// 공통 로직: 로깅, 검증 등
console.log(`Processing ${amount}원`);
return this.strategy.pay(amount);
}
}
Context는 구체적인 결제 방식을 모른다. 그저 PaymentStrategy 인터페이스에 정의된 메서드를 호출할 뿐이다. 어떤 전략이 들어오든 동일한 방식으로 동작한다.
if/else와의 근본적인 차이
"인터페이스로 분리하나 if/else로 분기하나 결국 같은 거 아닌가?"라는 의문이 들 수 있다. 핵심적인 차이는 변경의 영향 범위에 있다.
조건문 방식의 문제
// 토스페이 추가하려면?
function processPayment(method: string, amount: number) {
if (method === 'card') { /* ... */ }
else if (method === 'bank') { /* ... */ }
else if (method === 'kakao') { /* ... */ }
else if (method === 'naver') { /* ... */ }
else if (method === 'toss') { // ← 기존 함수를 수정해야 함
// 토스페이 로직
}
}
기존 코드를 수정해야 한다. 이미 동작하고 있는 함수를 열어서 건드리는 것이다. 분기가 많아질수록 한 분기의 수정이 다른 분기에 영향을 줄 가능성이 높아진다.
Strategy 방식
// 토스페이 추가하려면?
class TossPayment implements PaymentStrategy {
pay(amount: number): PaymentResult {
// 토스 API 호출
return { success: true, transactionId: 'toss_xxx' };
}
validate(info: PaymentInfo): boolean {
return info.tossToken != null;
}
}
// 기존 코드 수정 없이 새 전략을 주입하면 끝
const processor = new PaymentProcessor(new TossPayment());
기존 코드는 한 글자도 건드리지 않는다. 새로운 클래스를 추가하는 것만으로 확장이 끝난다. 이것이 객체지향 설계의 개방-폐쇄 원칙(OCP: Open-Closed Principle)이다. 확장에는 열려있고, 수정에는 닫혀있다.
Map 기반 전략 등록
실무에서는 전략을 Map으로 관리하는 패턴이 자주 사용된다. 특히 TypeScript/JavaScript에서 깔끔하게 동작한다.
// 전략 레지스트리
const paymentStrategies = new Map<string, PaymentStrategy>([
['card', new CardPayment()],
['bank', new BankTransfer()],
['kakao', new KakaoPayment()],
]);
// 등록 함수
function registerStrategy(key: string, strategy: PaymentStrategy) {
paymentStrategies.set(key, strategy);
}
// 디스패치
function processPayment(method: string, amount: number): PaymentResult {
const strategy = paymentStrategies.get(method);
if (!strategy) {
throw new Error(`지원하지 않는 결제 수단: ${method}`);
}
return strategy.pay(amount);
}
이 방식의 장점은 전략의 등록과 실행이 완전히 분리된다는 것이다. 새 전략을 추가할 때 registerStrategy()만 호출하면 되고, 디스패치 로직은 전혀 수정할 필요가 없다.
NestJS에서의 Map 기반 전략
NestJS의 DI 컨테이너와 결합하면 더 강력해진다.
// 전략 인터페이스
interface NotificationStrategy {
send(userId: string, message: string): Promise<void>;
}
// 각 전략을 Injectable로 만들기
@Injectable()
class EmailNotification implements NotificationStrategy {
constructor(private readonly mailer: MailService) {}
async send(userId: string, message: string) {
const user = await this.findUser(userId);
await this.mailer.send(user.email, message);
}
}
@Injectable()
class SlackNotification implements NotificationStrategy {
constructor(private readonly slack: SlackClient) {}
async send(userId: string, message: string) {
await this.slack.postMessage(userId, message);
}
}
@Injectable()
class PushNotification implements NotificationStrategy {
constructor(private readonly fcm: FcmService) {}
async send(userId: string, message: string) {
await this.fcm.sendToUser(userId, { body: message });
}
}
// 디스패처 서비스
@Injectable()
class NotificationService {
private strategies: Map<string, NotificationStrategy>;
constructor(
private email: EmailNotification,
private slack: SlackNotification,
private push: PushNotification,
) {
this.strategies = new Map([
['email', this.email],
['slack', this.slack],
['push', this.push],
]);
}
async notify(channel: string, userId: string, message: string) {
const strategy = this.strategies.get(channel);
if (!strategy) throw new Error(`Unknown channel: ${channel}`);
await strategy.send(userId, message);
}
// 여러 채널로 동시 발송
async notifyAll(channels: string[], userId: string, message: string) {
await Promise.all(
channels.map(ch => this.notify(ch, userId, message))
);
}
}
각 전략이 자체적인 의존성(MailService, SlackClient, FcmService)을 가지고 있어도 DI 컨테이너가 알아서 주입해준다. 조건문으로 이걸 처리했다면 하나의 서비스에 모든 의존성이 몰렸을 것이다.
함수형 Strategy
TypeScript에서 클래스까지 만들 필요 없는 간단한 경우라면, 함수 자체를 전략으로 사용할 수 있다. 인터페이스가 메서드 하나뿐이면 함수가 더 깔끔하다.
// 정렬 전략을 함수로 정의
type SortStrategy<T> = (items: T[]) => T[];
const sortByPrice: SortStrategy<Product> = (items) =>
[...items].sort((a, b) => a.price - b.price);
const sortByName: SortStrategy<Product> = (items) =>
[...items].sort((a, b) => a.name.localeCompare(b.name));
const sortByRating: SortStrategy<Product> = (items) =>
[...items].sort((a, b) => b.rating - a.rating);
// Map으로 관리
const sortStrategies = new Map<string, SortStrategy<Product>>([
['price', sortByPrice],
['name', sortByName],
['rating', sortByRating],
]);
// 사용
function getProducts(sortBy: string): Product[] {
const products = fetchProducts();
const sort = sortStrategies.get(sortBy) ?? sortByName;
return sort(products);
}
JavaScript/TypeScript는 함수가 일급 객체이기 때문에 GoF 책에 나오는 클래스 기반 구현보다 이런 함수형 접근이 더 자연스러운 경우가 많다. 핵심은 동일한 시그니처를 가진 함수들을 교체 가능하게 관리한다는 것이다.
React에서의 Strategy 패턴
프론트엔드에서도 Strategy 패턴은 유용하다. 대표적인 예가 폼 검증이다.
// 검증 전략 타입
type ValidationStrategy = (value: string) => string | null;
// 각 전략
const required: ValidationStrategy = (value) =>
value.trim() ? null : '필수 입력 항목입니다';
const minLength = (min: number): ValidationStrategy => (value) =>
value.length >= min ? null : `최소 ${min}자 이상 입력하세요`;
const email: ValidationStrategy = (value) =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? null : '올바른 이메일 형식이 아닙니다';
const phone: ValidationStrategy = (value) =>
/^01[016789]-?\d{3,4}-?\d{4}$/.test(value) ? null : '올바른 전화번호 형식이 아닙니다';
// 전략들을 조합해서 사용하는 훅
function useValidation(strategies: ValidationStrategy[]) {
const validate = useCallback((value: string): string[] => {
return strategies
.map(strategy => strategy(value))
.filter((error): error is string => error !== null);
}, [strategies]);
return { validate };
}
// 사용
function SignupForm() {
const emailValidation = useValidation([required, email]);
const passwordValidation = useValidation([required, minLength(8)]);
const handleSubmit = () => {
const emailErrors = emailValidation.validate(emailValue);
const pwErrors = passwordValidation.validate(passwordValue);
// ...
};
}
검증 규칙이 독립적인 함수이기 때문에, 필요한 규칙만 골라서 조합할 수 있다. 새로운 검증 규칙이 필요하면 함수 하나만 추가하면 된다.
언제 Strategy 패턴을 써야 하는가
Strategy 패턴이 적합한 상황:
- 같은 목적, 다른 방식: 결제, 알림, 정렬, 인증, 직렬화 등 목적은 같지만 구현이 다른 여러 알고리즘이 있을 때
- 런타임 교체가 필요할 때: 사용자 선택이나 설정에 따라 동작을 바꿔야 할 때
- 조건문이 3개 이상으로 늘어날 때: if/else나 switch가 계속 추가되는 패턴이 보이면 Strategy를 고려할 시점
- 각 알고리즘이 독자적인 의존성을 가질 때: 전략마다 다른 외부 서비스를 사용하는 경우
반대로 Strategy가 과한 상황:
- 분기가 2개이고 늘어날 가능성이 없을 때: 단순한 if/else가 더 명확하다
- 전략 간 공유 로직이 대부분일 때: 차이점이 극히 일부라면 Template Method 패턴이 더 적합할 수 있다
- 성능이 극도로 중요한 핫 패스: 인터페이스 호출의 간접 비용이 부담되는 경우 (실제로 이런 경우는 드물다)
Strategy vs 유사 패턴 비교
| 패턴 | 목적 | 차이점 |
|---|---|---|
| Strategy | 알고리즘을 교체 | 동일 인터페이스의 여러 구현을 런타임에 선택 |
| Template Method | 알고리즘 골격 고정, 일부 단계만 변경 | 상속 기반, 전체 구조는 부모가 제어 |
| State | 상태에 따른 행동 변경 | 상태 객체가 스스로 다음 상태로 전이 |
| Command | 행위를 객체화 | 실행 취소, 큐잉, 로깅이 주 목적 |
Strategy와 State는 구조가 거의 동일하다. 차이는 의도에 있다. Strategy는 "이 작업을 어떤 방식으로 할까?"이고, State는 "현재 상태에서 이 작업을 하면 어떻게 동작할까?"이다. Strategy는 클라이언트가 전략을 선택하고, State는 객체 내부에서 상태가 자동으로 전이된다.
정리
Strategy 패턴의 핵심은 알고리즘의 선택과 실행을 분리하는 것이다. 조건문으로 분기하는 대신, 동일한 인터페이스를 구현하는 독립된 객체들을 만들고, 런타임에 필요한 것을 주입한다.
이렇게 하면 새로운 알고리즘을 추가할 때 기존 코드를 수정하지 않아도 되고, 각 알고리즘을 독립적으로 테스트할 수 있으며, 알고리즘 간의 불필요한 결합이 사라진다. TypeScript/JavaScript에서는 함수가 일급 객체이기 때문에 클래스 없이도 Strategy 패턴을 자연스럽게 적용할 수 있다는 점도 기억하자.