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로 확장해도 무리 없습니다.
'React' 카테고리의 다른 글
| React에서 브라우저 Geolocation API 활용하기 (0) | 2026.07.21 |
|---|---|
| React와 tRPC를 활용한 타입 안전 API 통신 구축하기 (0) | 2026.07.16 |
| React에서 TanStack Table로 고성능 데이터 테이블 구현하기 (0) | 2026.07.15 |
| React 앱에서 주기적인 데이터 동기화 스케줄러 구축하기 (0) | 2026.07.15 |
| React에서 다중 파일 드래그 앤 드롭 업로드 및 진행률 표시하기 (0) | 2026.07.14 |