🔧 DevOps

Feature Flag

기능 플래그

코드 배포와 기능 릴리스를 분리하는 기법. A/B 테스트에 활용.

📖 상세 설명

Feature Flag(기능 플래그)는 코드 배포와 기능 릴리스를 분리하는 소프트웨어 개발 기법입니다. 코드를 배포하되, 특정 기능은 플래그(on/off 스위치)로 제어하여 원하는 시점에 활성화할 수 있습니다. Feature Toggle, Feature Switch라고도 불립니다.

핵심 개념:

  • Release Flag: 미완성 기능을 숨기고 점진적으로 릴리스
  • Experiment Flag: A/B 테스트를 위한 사용자 그룹별 기능 노출
  • Ops Flag: 운영 환경에서 성능 조절, 기능 비활성화
  • Permission Flag: 유료 사용자, 베타 테스터 등 권한 기반 기능 제공

주요 활용 사례:

  • Canary Release: 일부 사용자에게만 새 기능 배포 후 확대
  • Kill Switch: 문제 발생 시 즉시 기능 비활성화
  • Trunk-Based Development: 장기 브랜치 없이 메인 브랜치에 계속 병합
  • A/B Testing: 두 가지 버전을 비교하여 데이터 기반 결정

Feature Flag 관리 도구:

  • LaunchDarkly: 엔터프라이즈급 Feature Management 플랫폼
  • Unleash: 오픈소스 Feature Flag 서버
  • Split: A/B 테스트 + Feature Flag 통합
  • Firebase Remote Config: 모바일 앱용 무료 솔루션
  • ConfigCat: 개발자 친화적인 간단한 서비스

Trunk-Based Development과의 관계: Feature Flag는 장기 기능 브랜치를 없애고, 모든 개발자가 메인 브랜치에 자주 병합할 수 있게 합니다. 미완성 기능은 플래그로 숨기면 되므로 병합 충돌이 줄고 CI/CD 파이프라인이 단순해집니다.

💻 코드 예제

간단한 Feature Flag 구현

// TypeScript - 간단한 Feature Flag 서비스
interface FeatureFlags {
  newDashboard: boolean;
  darkMode: boolean;
  betaFeatures: boolean;
  maxUploadSize: number;
}

class FeatureFlagService {
  private flags: FeatureFlags;
  private userOverrides: Map<string, Partial<FeatureFlags>> = new Map();

  constructor() {
    // 기본값 (환경변수나 설정 파일에서 로드)
    this.flags = {
      newDashboard: process.env.FF_NEW_DASHBOARD === 'true',
      darkMode: true,
      betaFeatures: false,
      maxUploadSize: 10 * 1024 * 1024, // 10MB
    };
  }

  // 글로벌 플래그 확인
  isEnabled(flag: keyof FeatureFlags): boolean {
    return Boolean(this.flags[flag]);
  }

  // 사용자별 플래그 확인 (오버라이드 지원)
  isEnabledForUser(flag: keyof FeatureFlags, userId: string): boolean {
    const userFlags = this.userOverrides.get(userId);
    if (userFlags && flag in userFlags) {
      return Boolean(userFlags[flag]);
    }
    return this.isEnabled(flag);
  }

  // 퍼센트 기반 롤아웃 (Canary)
  isEnabledForPercentage(flag: keyof FeatureFlags, userId: string, percentage: number): boolean {
    const hash = this.hashUserId(userId);
    return hash % 100 < percentage && this.isEnabled(flag);
  }

  private hashUserId(userId: string): number {
    let hash = 0;
    for (let i = 0; i < userId.length; i++) {
      hash = ((hash << 5) - hash) + userId.charCodeAt(i);
      hash |= 0;
    }
    return Math.abs(hash);
  }

  // 특정 사용자에게 기능 활성화 (베타 테스터 등)
  enableForUser(userId: string, flags: Partial<FeatureFlags>) {
    const existing = this.userOverrides.get(userId) || {};
    this.userOverrides.set(userId, { ...existing, ...flags });
  }
}

// 사용 예시
const featureFlags = new FeatureFlagService();

// React 컴포넌트에서 사용
function Dashboard({ userId }: { userId: string }) {
  // 10% 사용자에게만 새 대시보드 노출
  if (featureFlags.isEnabledForPercentage('newDashboard', userId, 10)) {
    return <NewDashboard />;
  }
  return <LegacyDashboard />;
}

React Hook with Context

// React Feature Flag Context
import { createContext, useContext, ReactNode } from 'react';

interface FeatureFlagContextType {
  isEnabled: (flag: string) => boolean;
  getVariant: (flag: string) => string | null;
}

const FeatureFlagContext = createContext<FeatureFlagContextType | null>(null);

export function FeatureFlagProvider({
  children,
  flags
}: {
  children: ReactNode;
  flags: Record<string, boolean | string>;
}) {
  const value: FeatureFlagContextType = {
    isEnabled: (flag) => Boolean(flags[flag]),
    getVariant: (flag) => typeof flags[flag] === 'string' ? flags[flag] : null,
  };

  return (
    <FeatureFlagContext.Provider value={value}>
      {children}
    </FeatureFlagContext.Provider>
  );
}

// Custom Hook
export function useFeatureFlag(flag: string): boolean {
  const context = useContext(FeatureFlagContext);
  if (!context) throw new Error('useFeatureFlag must be used within FeatureFlagProvider');
  return context.isEnabled(flag);
}

// 컴포넌트에서 사용
function CheckoutButton() {
  const newCheckoutEnabled = useFeatureFlag('new-checkout-flow');

  if (newCheckoutEnabled) {
    return <NewCheckoutButton />;
  }
  return <LegacyCheckoutButton />;
}

// A/B 테스트 변형 확인
function PricingPage() {
  const context = useContext(FeatureFlagContext);
  const variant = context?.getVariant('pricing-experiment');

  switch (variant) {
    case 'control': return <PricingA />;
    case 'variant-b': return <PricingB />;
    case 'variant-c': return <PricingC />;
    default: return <PricingA />;
  }
}

LaunchDarkly SDK 사용

// Node.js - LaunchDarkly SDK
import * as ld from 'launchdarkly-node-server-sdk';

const ldClient = ld.init(process.env.LAUNCHDARKLY_SDK_KEY!);

// 서버 시작 시 초기화 대기
await ldClient.waitForInitialization();

// 사용자 컨텍스트 정의
const user = {
  key: 'user-123',
  email: 'user@example.com',
  custom: {
    plan: 'premium',
    country: 'KR',
    signupDate: '2024-01-15',
  },
};

// Boolean 플래그 확인
const showNewFeature = await ldClient.variation('new-feature', user, false);

// 다변형 플래그 (A/B/C 테스트)
const checkoutVariant = await ldClient.variation('checkout-experiment', user, 'control');

// 플래그 변경 실시간 감지
ldClient.on('update:new-feature', async () => {
  const newValue = await ldClient.variation('new-feature', user, false);
  console.log('Feature flag updated:', newValue);
});

// Express 미들웨어
function featureFlagMiddleware(flagKey: string, defaultValue: boolean) {
  return async (req, res, next) => {
    const user = { key: req.user?.id || 'anonymous' };
    req.featureFlags = req.featureFlags || {};
    req.featureFlags[flagKey] = await ldClient.variation(flagKey, user, defaultValue);
    next();
  };
}

// 라우트에서 사용
app.get('/api/checkout', featureFlagMiddleware('new-checkout', false), (req, res) => {
  if (req.featureFlags['new-checkout']) {
    return handleNewCheckout(req, res);
  }
  return handleLegacyCheckout(req, res);
});

🗣️ 실무 대화 예시

릴리스 전략 회의

PM: "새 결제 시스템 언제 출시해요? QA가 2주는 필요하다던데."

개발자: "코드는 이번 주에 배포하고 Feature Flag로 막아둘게요. QA는 스테이징에서 플래그 켜고 테스트하면 되고, 준비되면 프로덕션에서 1% 사용자부터 시작해서 점진적으로 올려요."

PM: "문제 생기면?"

개발자: "대시보드에서 플래그만 끄면 즉시 롤백돼요. 코드 롤백 필요 없이 3초 만에 원복됩니다."

기술 면접

면접관: "Feature Flag 사용 시 주의점은 뭐가 있나요?"

지원자: "첫째, 플래그가 쌓이면 기술 부채가 됩니다. 완전 롤아웃 후엔 플래그 코드를 반드시 제거해야 해요. 둘째, 플래그 조합이 많아지면 테스트 케이스가 기하급수적으로 늘어요. 셋째, 성능에 영향을 줄 수 있어서 플래그 평가는 캐싱하고, 핫패스에서는 플래그 호출을 최소화해야 합니다."

면접관: "플래그 정리는 어떻게 하세요?"

지원자: "플래그 생성 시 만료일을 정하고, CI에서 만료된 플래그 사용을 경고하는 린트 룰을 추가했어요. 분기마다 '플래그 정리의 날'을 정해서 레거시 플래그를 일괄 제거합니다."

장애 대응

온콜: "에러율 급등했어요! 새 추천 알고리즘 배포 후부터인 것 같아요."

개발자: "LaunchDarkly 대시보드에서 'new-recommendation-v2' 플래그 끌게요. 3초 후 반영돼요."

온콜: "에러율 정상으로 돌아왔어요!"

개발자: "일단 급한 불 끄고, 내일 로그 분석해서 원인 파악할게요. 플래그 덕분에 코드 롤백 없이 1분 만에 해결됐네요."

⚠️ 주의사항

🔗 관련 용어

Blue-Green Deployment Canary Release A/B Testing CI/CD Trunk-Based Development Progressive Delivery

📚 더 배우기