본문 바로가기

React

React 앱에서 Feature Flag 시스템 설계 및 적용하기

Feature Flag는 배포와 출시를 분리하고, 위험을 줄이며, 점진적 롤아웃과 실험을 가능하게 합니다. React 앱에서 안전하고 성능 친화적으로 플래그를 설계·적용하는 방법을 정리합니다.

1. 목표와 원칙

- 단일 진실 공급원(Single Source of Truth)을 유지합니다. 클라이언트 캐시가 있어도 원천은 서버/SDK입니다.

- 결정적 평가를 보장합니다. 사용자 식별자 기반 해싱으로 퍼센트 롤아웃이 안정적으로 유지되도록 합니다.

- 장애 시 안전합니다. 네트워크 실패 시 캐시/기본값으로 동작하고, Kill-Switch를 제공합니다.

- 환경별 분리와 감사 가능성을 고려합니다. prod/stage/dev 플래그를 분리하고 변경 이력을 남깁니다.

2. 플래그 유형

- Boolean 플래그: 활성/비활성 토글입니다.

- 퍼센트 롤아웃: 사용자 집합의 N%에만 활성화합니다.

- 멀티버리언트: A/B/N 실험에서 변형을 선택합니다.

- 규칙 기반: 국가, 앱 버전, 역할 등 속성에 따라 활성화합니다.

3. React 아키텍처

Context 기반 Provider + 훅 + 가드 컴포넌트로 구성합니다. 원격 구성(JSON/CDN/SDK)을 불러와 캐시하고, 사용자 속성으로 평가합니다.

import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';

const FlagContext = createContext({ flags: {}, user: {}, ready: false });

// 간단한 안정 해시로 0~99 버킷을 생성합니다.
function bucket(userKey, salt = '') {
  const str = String(userKey || 'anonymous') + salt;
  let hash = 0;
  for (let i = 0; i < str.length; i++) {
    hash = ((hash << 5) - hash) + str.charCodeAt(i);
    hash |= 0; // 32-bit
  }
  return Math.abs(hash) % 100;
}

function getDeviceId() {
  let id = localStorage.getItem('deviceId');
  if (!id) {
    id = Math.random().toString(36).slice(2);
    localStorage.setItem('deviceId', id);
  }
  return id;
}

// 플래그 평가: boolean, percentage, rules, variants 지원
function evaluateFlag(def, user, name) {
  if (!def) return { enabled: false, variant: 'control' };
  const ukey = user?.id || getDeviceId();
  // 규칙 평가(예: { attribute: 'country', op: 'eq', value: 'KR' })
  const rules = def.rules || [];
  const rulesPass = rules.every(r => {
    const v = user?.[r.attribute];
    switch (r.op) {
      case 'eq': return v === r.value;
      case 'neq': return v !== r.value;
      case 'in': return Array.isArray(r.value) && r.value.includes(v);
      case 'gte': return Number(v) >= Number(r.value);
      case 'lt': return Number(v) < Number(r.value);
      default: return true;
    }
  });
  if (!rulesPass) return { enabled: false, variant: 'control' };

  // 퍼센트 롤아웃
  const pct = Number(def.percentage || 100);
  const inBucket = bucket(ukey, name) < pct;
  const baseEnabled = Boolean(def.value !== undefined ? def.value : true);
  const enabled = baseEnabled && inBucket;

  // 멀티버리언트
  let variant = 'control';
  const variants = def.variants;
  if (variants && enabled) {
    const b = bucket(ukey, name + ':var');
    let acc = 0;
    for (const [key, weight] of Object.entries(variants)) {
      acc += Number(weight);
      if (b < acc) { variant = key; break; }
    }
  }
  return { enabled, variant };
}

export function FeatureFlagProvider({ user = {}, endpoint = '/flags.json', children, cacheKey = 'flags:v1', onExpose }) {
  const [flags, setFlags] = useState(() => {
    try { return JSON.parse(localStorage.getItem(cacheKey)) || {}; } catch { return {}; }
  });
  const [ready, setReady] = useState(Object.keys(flags).length > 0);
  const exposedRef = useRef(new Set());

  useEffect(() => {
    let abort = false;
    const controller = new AbortController();

    async function load() {
      try {
        const res = await fetch(endpoint, { signal: controller.signal, headers: { 'Cache-Control': 'no-cache' } });
        if (!res.ok) throw new Error('flags fetch failed');
        const json = await res.json();
        if (abort) return;
        setFlags(json);
        localStorage.setItem(cacheKey, JSON.stringify(json));
        setReady(true);
      } catch (e) {
        // 실패 시 캐시 유지, 기본값 사용
        setReady(true);
      }
    }
    // SWR: 캐시로 먼저 그리기, 백그라운드로 최신화
    setReady(true);
    load();
    return () => { abort = true; controller.abort(); };
  }, [endpoint, cacheKey]);

  const value = useMemo(() => ({ flags, user, ready, onExpose, exposedRef }), [flags, user, ready, onExpose]);
  return <FlagContext.Provider value={value}>{children}</FlagContext.Provider>;
}

export function useFlag(name, defaultValue = false) {
  const { flags, user, ready, onExpose, exposedRef } = useContext(FlagContext);
  const def = flags?.[name];
  const { enabled } = evaluateFlag(def, user, name);
  const value = ready ? enabled : defaultValue;
  // 최초 노출 추적(중복 방지)
  useEffect(() => {
    if (!ready) return;
    const key = `${name}:flag:${String(value)}`;
    if (!exposedRef.current.has(key)) {
      exposedRef.current.add(key);
      onExpose?.({ type: 'flag', name, value, user });
    }
  }, [ready, name, value, user, onExpose]);
  return value;
}

export function useVariant(name, fallback = 'control') {
  const { flags, user, ready, onExpose, exposedRef } = useContext(FlagContext);
  const def = flags?.[name];
  const { enabled, variant } = evaluateFlag(def, user, name);
  const value = ready ? (enabled ? variant : fallback) : fallback;
  useEffect(() => {
    if (!ready) return;
    const key = `${name}:variant:${value}`;
    if (!exposedRef.current.has(key)) {
      exposedRef.current.add(key);
      onExpose?.({ type: 'variant', name, value, user });
    }
  }, [ready, name, value, user, onExpose]);
  return value;
}

// 가드 컴포넌트
export function Flag({ name, fallback = null, children }) {
  const enabled = useFlag(name, false);
  return enabled ? children : fallback;
}

4. 사용 예시

// 앱 루트에서 Provider 적용
<FeatureFlagProvider
  user={{ id: currentUser.id, country: currentUser.country, plan: currentUser.plan }}
  endpoint={process.env.REACT_APP_FLAGS_URL}
  onExpose={(e) => analytics.emit('flag_exposure', e)}
>
  <App />
</FeatureFlagProvider>

// UI 가드
function CheckoutEntry() {
  return (
    <Flag name="newCheckout" fallback={<LegacyCheckoutButton />}>
      <NewCheckoutButton />
    </Flag>
  );
}

// 변형 선택(A/B)
function CheckoutPage() {
  const variant = useVariant('checkoutExperiment', 'control');
  if (variant === 'A') return <CheckoutA />;
  if (variant === 'B') return <CheckoutB />;
  return <CheckoutControl />;
}

// 사이드 이펙트(실험에서만 로깅)
function PaymentTracker() {
  const enabled = useFlag('paymentV2', false);
  useEffect(() => {
    if (enabled) analytics.page('payment_v2');
  }, [enabled]);
  return null;
}

// 동적 import로 무게 줄이기
async function loadCheckout(enabled) {
  if (enabled) {
    const mod = await import('./checkout/NewFlow');
    return mod.default;
  } else {
    const mod = await import('./checkout/LegacyFlow');
    return mod.default;
  }
}

5. 구성 예시(JSON)

// /flags.json 예시(서버/SDK가 제공)
{
  "newCheckout": { "value": true, "percentage": 25, "rules": [ { "attribute": "country", "op": "eq", "value": "KR" } ] },
  "paymentV2": { "value": false, "percentage": 0 },
  "checkoutExperiment": { "value": true, "percentage": 50, "variants": { "A": 50, "B": 50 } }
}

6. 롤아웃 전략

- 내부/스테이징에서 먼저 활성화합니다.

- 1% → 5% → 25% → 50% → 100% 순으로 점진적 확대합니다.

- 오류율/성능/전환율 모니터링 후 확대합니다. 이상 시 즉시 Kill-Switch로 비활성화합니다.

7. SSR/SEO 고려사항

- SEO가 중요한 페이지는 서버에서 플래그를 평가해 초기 HTML과 클라이언트 값이 일치하도록 합니다.

- 정적 생성 시 빌드 타임 플래그를 주입하거나, 첫 요청 시 플래그 쿠키/헤더로 렌더링합니다.

- 중요한 마크업은 플래그로 자주 흔들지 않습니다. 레이아웃 이동과 CLS를 줄입니다.

8. 보안과 권한

- 클라이언트 플래그는 표시 제어용입니다. 권한/결제 같은 백엔드 보안을 플래그에 의존하지 않습니다.

- 민감한 실험 이름/설정은 노출을 최소화합니다. 서버 평가/옵스 콘솔로 관리합니다.

9. 테스트/운영

- 테스트에서 Mock Provider로 플래그값을 주입합니다. 특정 케이스(활성/비활성/변형)를 각각 검증합니다.

- 노출 이벤트를 집계해 샘플 수를 확인하고, 변형 간 지표를 비교합니다.

- 캐시 TTL을 관리하고, 실패 시 기본값이 안전하도록 설정합니다.

10. 상용 서비스 연동 팁

- LaunchDarkly/ConfigCat/Split 등 SDK는 사용자 속성·퍼센트 롤아웃·감사를 내장합니다. 위 훅/가드 패턴은 동일하게 적용됩니다.

- SDK를 초기화하는 동안 기본값으로 렌더링하고, 준비되면 리렌더합니다. 노출 이벤트는 해당 SDK의 트래킹을 사용합니다.

위 아키텍처로 배포와 출시를 분리하고, 위험을 줄이며, 실험을 체계적으로 수행할 수 있습니다. 작은 프로바이더부터 시작해 상용 SDK로 확장해도 무리 없습니다.