문제: if/else 지옥
쇼핑몰의 상품 목록 페이지를 만들고 있습니다. 사용자가 정렬 기준을 가격순, 이름순, 평점순으로 바꿀 수 있어야 합니다.
가장 직관적인 구현은 이렇습니다.
function sortProducts(products, sortType) {
if (sortType === 'price') {
return products.sort((a, b) => a.price - b.price);
} else if (sortType === 'name') {
return products.sort((a, b) => a.name.localeCompare(b.name));
} else if (sortType === 'rating') {
return products.sort((a, b) => b.rating - a.rating);
}
}동작은 합니다. 하지만 이 코드에는 세 가지 문제가 있습니다.
- 분기 폭발, 할인율순, 리뷰 수순, 최신순... 기준이 추가될 때마다
else if를 붙여야 합니다. 10개의 정렬 기준이면 10개의 분기입니다. - 수정의 연쇄, '가격순'의 로직을 바꾸면
sortProducts전체를 건드려야 합니다. 하나의 함수에 모든 변형이 뒤섞여 있으니 버그 가능성이 올라갑니다. - 재사용 불가, '가격순' 비교 로직을 다른 곳 (위시리스트, 검색 결과) 에서도 쓰고 싶은데,
sortProducts안에 갇혀 있어서 꺼낼 수 없습니다.
이 문제를 해결하는 것이 디자인 패턴 중 하나인 Strategy 패턴입니다.
Strategy 패턴
GoF의 정의를 한 줄로 요약하면 이렇습니다.
"알고리즘의 가족을 정의하고, 각각을 캡슐화하며, 교환 가능하게 만든다. Strategy 패턴은 알고리즘을 사용하는 클라이언트와 독립적으로 알고리즘을 변경할 수 있게 한다."
핵심 구조는 세 가지입니다.
- Context, 전략을 사용하는 주체입니다. 어떤 전략이 선택되었는지에 따라 동작이 바뀝니다.
- Strategy, 알고리즘의 공통 인터페이스입니다. "비교 함수는 두 인자를 받아 숫자를 반환한다" 같은 계약입니다.
- ConcreteStrategy, Strategy 인터페이스를 구현한 구체적 알고리즘입니다. 가격순, 이름순, 평점순 각각이 하나의 ConcreteStrategy입니다.
아래 시각화에서 Context가 전략을 교체하는 과정을 확인하세요.
Before/After
아까의 if/else 코드를 Strategy 패턴으로 바꿔봅시다.
Before, if/else 분기:
function sortProducts(products, sortType) {
if (sortType === 'price') {
return products.sort((a, b) => a.price - b.price);
} else if (sortType === 'name') {
return products.sort((a, b) => a.name.localeCompare(b.name));
} else if (sortType === 'rating') {
return products.sort((a, b) => b.rating - a.rating);
}
}After, Strategy 패턴:
// ConcreteStrategy들, 각각 독립적인 함수
const sortStrategies = {
price: (a, b) => a.price - b.price,
name: (a, b) => a.name.localeCompare(b.name),
rating: (a, b) => b.rating - a.rating,
};
// Context, 전략을 받아서 실행
function sortProducts(products, strategyName) {
const strategy = sortStrategies[strategyName];
return [...products].sort(strategy);
}달라진 점을 주목하세요.
- 분기가 사라졌습니다, if/else 대신 전략 객체의 키로 선택합니다.
- 각 전략이 독립적입니다, '가격순' 로직을 수정해도 다른 전략에 영향이 없습니다.
- 확장이 쉽습니다, 새 정렬 기준을 추가할 때 기존 코드를 수정하지 않고 전략만 추가합니다.
// 새 전략 추가, 기존 코드 수정 없음
sortStrategies.discount = (a, b) => b.discount - a.discount;
sortStrategies.newest = (a, b) => new Date(b.date) - new Date(a.date);이것이 Open/Closed Principle (확장에는 열려 있고, 수정에는 닫혀 있다) 입니다.
Array.sort()는 이미 Strategy 패턴
사실 JavaScript의 Array.prototype.sort()는 Strategy 패턴의 교과서적 구현입니다.
- Context =
Array.prototype.sort(), 정렬 메커니즘을 제공하지만, 어떻게 비교할지는 모릅니다. - Strategy =
compareFn, 두 요소를 비교하는 함수 시그니처(a, b) => number가 인터페이스입니다. - ConcreteStrategy = 전달하는 비교 함수,
(a, b) => a - b,(a, b) => a.name.localeCompare(b.name)등
const products = [
{ name: '키보드', price: 89000 },
{ name: '마우스', price: 35000 },
{ name: '모니터', price: 450000 },
];
// 같은 Context(sort), 다른 Strategy(비교 함수)
products.sort((a, b) => a.price - b.price); // 가격 오름차순
products.sort((a, b) => a.name.localeCompare(b.name)); // 이름순sort()의 내부 구현 (V8은 TimSort, 엔진마다 다름. ECMAScript는 ES2019부터 stable sort만 요구) 은 비교 함수가 무엇이든 동일하게 작동합니다. 비교 로직만 갈아끼우는 것, 이것이 Strategy 패턴의 본질입니다.
폼 유효성 검증에서의 Strategy
Strategy 패턴은 정렬뿐 아니라 유효성 검증 에서도 빛을 발합니다.
Before, 모든 규칙이 하나의 함수에:
function validateEmail(value) {
if (!value) return '필수 입력 항목입니다';
if (value.length < 5) return '5자 이상 입력하세요';
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return '유효한 이메일이 아닙니다';
return null;
}After, 검증 전략 분리:
// 각 규칙이 독립적인 전략
const validators = {
required: (v) => v.trim().length > 0 || '필수 입력 항목입니다',
minLength: (min) => (v) => v.length >= min || `${min}자 이상 입력하세요`,
email: (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) || '유효한 이메일이 아닙니다',
};
// Context, 전략들을 조합해서 실행
function validate(value, rules) {
for (const rule of rules) {
const result = rule(value);
if (result !== true) return result; // 첫 번째 실패 메시지 반환
}
return null; // 모두 통과
}
// 사용, 규칙을 조합
validate('', [validators.required]); // '필수 입력 항목입니다'
validate('hi', [validators.required, validators.email]); // '유효한 이메일이 아닙니다'
validate('user@co.kr', [validators.required, validators.email]); // null (통과)아래 시각화에서 검증 전략이 조합되는 과정을 확인하세요.
이 패턴은 Zod, Yup 같은 유효성 검증 라이브러리의 근간입니다. z.string().email().min(5), 이 체이닝도 내부적으로 검증 전략을 파이프라인으로 조합하는 구조입니다.
프론트엔드 실전 사례
1. Passport.js의 인증 전략
Node.js의 인증 라이브러리 Passport.js는 Strategy 패턴을 API 이름 자체에 사용합니다.
import passport from 'passport';
import { Strategy as LocalStrategy } from 'passport-local';
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
// ConcreteStrategy 등록
passport.use(new LocalStrategy((username, password, done) => {
// 로컬 DB 인증
}));
passport.use(new GoogleStrategy({ clientID, clientSecret }, (token, profile, done) => {
// Google OAuth 인증
}));
// Context, 어떤 전략을 사용할지는 라우트에서 결정
app.post('/login', passport.authenticate('local'));
app.get('/auth/google', passport.authenticate('google'));passport.authenticate() (Context) 는 인증 로직을 모릅니다. 등록된 Strategy가 실제 인증을 수행합니다.
2. React의 렌더링 전략
컴포넌트가 데이터를 어떻게 렌더링할지를 외부에서 결정하는 패턴도 Strategy의 변형입니다.
// renderItem이 Strategy
function ProductList({ products, renderItem }) {
return (
<ul>
{products.map((p) => (
<li key={p.id}>{renderItem(p)}</li>
))}
</ul>
);
}
// ConcreteStrategy: 카드형
<ProductList products={data} renderItem={(p) => <ProductCard product={p} />} />
// ConcreteStrategy: 리스트형
<ProductList products={data} renderItem={(p) => <ProductRow product={p} />} />같은 ProductList에 렌더링 전략만 바꿔 끼우는 구조입니다.
3. Winston 로깅 라이브러리
Winston은 로그 출력 대상을 Transport (= Strategy) 로 추상화합니다.
import winston from 'winston';
const logger = winston.createLogger({
transports: [
new winston.transports.Console(), // 콘솔 출력 전략
new winston.transports.File({ filename: 'app.log' }), // 파일 출력 전략
],
});
logger.info('서버 시작'); // 두 전략 모두 실행logger.info() (Context) 는 로그가 어디로 가는지 신경 쓰지 않습니다.
Strategy vs State
Strategy 패턴과 State 패턴은 구조가 거의 동일합니다. 차이는 의도에 있습니다.
| Strategy | State | |
|---|---|---|
| 교체 주체 | 클라이언트가 명시적으로 선택 | 객체가 내부 상태에 따라 자동 전환 |
| 교체 시점 | 보통 한 번 설정 후 유지 | 런타임에 수시로 전환 |
| 예시 | "가격순으로 정렬해줘" | "주문 상태가 배송 중으로 바뀌면 취소 불가" |
| 전략 간 관계 | 서로 독립 (무관한 알고리즘들) | 서로 연결 (상태 전이 그래프) |
핵심: Strategy는 "같은 일을 다른 방법으로", State는 **"다른 상태에서 다른 행동을"**합니다.
언제 Strategy 패턴을 쓸까?
쓰세요:
- 같은 작업을 수행하는 여러 변형이 있고, 런타임에 교체될 수 있을 때
- if/else 또는 switch 분기가 3개 이상 이고 계속 늘어날 가능성이 있을 때
- 알고리즘을 독립적으로 테스트하고 싶을 때
쓰지 마세요:
- 변형이 2개 이하 이고 앞으로도 늘어날 일이 없을 때, 단순 if/else가 더 명확합니다
- 전략이 Context의 내부 상태에 깊이 의존할 때, Strategy의 독립성이 깨집니다
JavaScript에서는 함수가 일급 객체이므로, GoF의 클래스 기반 구현보다 함수를 전달하는 경량 Strategy가 훨씬 자연스럽습니다. Array.sort(compareFn), Array.filter(predicate), Array.map(transform), 이미 매일 쓰고 있는 패턴입니다.
다음 글에서는 Iterator 패턴을 다룹니다. 배열, 트리, API 페이지네이션 등 제각각 다른 자료구조를 하나의 for...of 루프로 순회하는 방법, "순회를 추상화하는 기술"을 살펴보겠습니다.