Ray Book
프론트엔드 디자인 패턴

Strategy 패턴, 행동을 교체하라

끝없이 늘어나는 if/else 분기를 전략 객체로 교체하는 방법, Array.sort() 비교 함수부터 폼 유효성 검증까지 시각화합니다

design-patternstrategysortvalidation

문제: 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);
  }
}

동작은 합니다. 하지만 이 코드에는 세 가지 문제가 있습니다.

  1. 분기 폭발 , 할인율순, 리뷰 수순, 최신순... 기준이 추가될 때마다 else if를 붙여야 합니다. 10개의 정렬 기준이면 10개의 분기입니다.
  2. 수정의 연쇄 , '가격순'의 로직을 바꾸면 sortProducts 전체를 건드려야 합니다. 하나의 함수에 모든 변형이 뒤섞여 있으니 버그 가능성이 올라갑니다.
  3. 재사용 불가 , '가격순' 비교 로직을 다른 곳 (위시리스트, 검색 결과) 에서도 쓰고 싶은데, sortProducts 안에 갇혀 있어서 꺼낼 수 없습니다.

이 문제를 해결하는 것이 디자인 패턴 중 하나인 Strategy 패턴입니다.

Strategy 패턴

GoF의 정의를 한 줄로 요약하면 이렇습니다.

"알고리즘의 가족을 정의하고, 각각을 캡슐화하며, 교환 가능하게 만든다. Strategy 패턴은 알고리즘을 사용하는 클라이언트와 독립적으로 알고리즘을 변경할 수 있게 한다."

핵심 구조는 세 가지입니다.

  • Context , 전략을 사용하는 주체입니다. 어떤 전략이 선택되었는지에 따라 동작이 바뀝니다.
  • Strategy , 알고리즘의 공통 인터페이스입니다. "비교 함수는 두 인자를 받아 숫자를 반환한다" 같은 계약입니다.
  • ConcreteStrategy , Strategy 인터페이스를 구현한 구체적 알고리즘입니다. 가격순, 이름순, 평점순 각각이 하나의 ConcreteStrategy입니다.

아래 시각화에서 Context가 전략을 교체하는 과정을 확인하세요.

구조 파악1 / 5
ProductListsort(strategy)가격순price 비교이름순name 비교평점순rating 비교
ContextActive StrategyStrategy
Context(ProductList)가 세 가지 정렬 전략을 사용할 수 있습니다. 아직 어떤 전략도 선택되지 않았습니다.

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);
}

달라진 점을 주목하세요.

  1. 분기가 사라졌습니다 , if/else 대신 전략 객체의 키로 선택합니다.
  2. 각 전략이 독립적입니다 , '가격순' 로직을 수정해도 다른 전략에 영향이 없습니다.
  3. 확장이 쉽습니다 , 새 정렬 기준을 추가할 때 기존 코드를 수정하지 않고 전략만 추가합니다.
// 새 전략 추가, 기존 코드 수정 없음
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 (통과)

아래 시각화에서 검증 전략이 조합되는 과정을 확인하세요.

검증 전략 구조1 / 4
FormValidatorvalidate(v, rules)requiredtrim().length > 0email이메일 형식 검증minLengthlength >= min
ContextActive StrategyStrategy
FormValidator(Context)가 여러 검증 전략을 조합해서 사용합니다. 각 전략은 하나의 규칙만 담당합니다.

이 패턴은 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 패턴은 구조가 거의 동일합니다. 차이는 의도 에 있습니다.

StrategyState
교체 주체클라이언트가 명시적으로 선택객체가 내부 상태에 따라 자동 전환
교체 시점보통 한 번 설정 후 유지런타임에 수시로 전환
예시"가격순으로 정렬해줘""주문 상태가 배송 중으로 바뀌면 취소 불가"
전략 간 관계서로 독립 (무관한 알고리즘들)서로 연결 (상태 전이 그래프)

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