문제: 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 루프로 순회하는 방법, "순회를 추상화하는 기술"을 살펴보겠습니다.