Prologue: "배포는 했는데 릴리즈는 안 했다"
처음엔 이 말이 이해가 안 됐다.
"방금 배포했잖아요. 근데 왜 유저들이 아직 못 써요?"
"릴리즈는 아직 안 했거든요."
배포(deploy)는 코드를 서버에 올리는 것. 릴리즈(release)는 유저에게 기능을 공개하는 것. 이 둘을 분리하는 게 Feature Flag의 핵심이다.
예전엔 새 기능을 만들면 feature 브랜치에서 작업하고, 완성되면 main에 머지하고, 배포했다. 근데 이 방식에는 문제가 있다. 기능이 크면 브랜치가 오래 살고, 머지할 때 충돌이 쌓이고, 배포하면 롤백이 어렵다.
Feature Flag를 쓰면 달라진다. 미완성 코드도 main에 머지한다. 그냥 플래그 뒤에 숨겨두면 유저는 못 보니까. 배포는 계속할 수 있고, 준비되면 플래그만 켜면 된다. 문제가 생기면 플래그를 끄면 된다. 코드 롤백 없이.
1. Feature Flag의 종류
모든 플래그가 같은 목적으로 쓰이진 않는다. Martin Fowler는 4가지로 분류했다.
Release Flag (릴리즈 플래그)
가장 흔한 사용 사례. 완성된 기능을 일부 유저에게만 먼저 공개하거나, 전체 공개를 제어한다.
if (featureFlags.isEnabled('new-checkout-flow', user)) {
return <NewCheckoutFlow />;
} else {
return <OldCheckoutFlow />;
}
수명: 짧음 (기능 안정화 후 제거)
Experiment Flag (실험 플래그)
A/B 테스트. 사용자를 두 그룹으로 나눠 어떤 버전이 더 효과적인지 측정한다.
const variant = featureFlags.getVariant('homepage-cta-button', user);
// variant: 'control' | 'blue-cta' | 'red-cta'
const buttonColor = variant === 'blue-cta' ? 'blue' :
variant === 'red-cta' ? 'red' : 'gray';
수명: 실험 기간 동안만
Ops Flag (운영 플래그)
시스템 동작을 런타임에 제어한다. 서킷 브레이커, 성능 저하 모드, 캐싱 활성화/비활성화 등.
// 외부 결제 API가 불안정할 때 캐시 모드로 전환
if (featureFlags.isEnabled('payment-fallback-mode')) {
return getCachedPaymentMethods();
} else {
return await fetchPaymentMethods();
}
수명: 영구적일 수도 있음
Permission Flag (권한 플래그)
특정 사용자 그룹에게만 기능 접근을 허용한다. 프리미엄 기능, 베타 프로그램 등.
if (featureFlags.isEnabled('advanced-analytics', user)) {
return <AdvancedDashboard />;
}
// 일반 유저는 이 코드 경로에 진입 불가
수명: 비즈니스 모델이 바뀔 때까지
2. Feature Branch vs Feature Flag
팀에서 항상 나오는 질문: "그냥 feature 브랜치 쓰면 안 돼요?"
| Feature Branch | Feature Flag | |
|---|---|---|
| 배포 가능 시점 | 기능 완성 후 | 언제나 (플래그로 숨김) |
| 통합 리스크 | 머지 시 충돌 위험 | 없음 (항상 main에 통합) |
| 롤백 | 코드 롤백 필요 | 플래그 OFF |
| 점진적 롤아웃 | 어려움 | 쉬움 (1% → 10% → 100%) |
| A/B 테스트 | 매우 어려움 | 기본 기능 |
| 복잡도 | 코드 관리 복잡 | 플래그 관리 복잡 |
Feature Flag의 단점도 있다. 코드에 if (flag) 분기가 쌓인다. 오래된 플래그가 제거되지 않으면 "플래그 부채"가 된다. 나중에 이 내용도 다룬다.
결론: 두 기능 이상이 동시에 개발되고, 점진적 롤아웃이 필요하다면 Feature Flag가 낫다.
3. LaunchDarkly vs Unleash
두 도구의 특성이 꽤 다르다.
LaunchDarkly
SaaS 기반의 엔터프라이즈 솔루션. 설정 없이 바로 쓸 수 있다.
장점:
- 빠른 시작 (5분 내 셋업)
- 강력한 타겟팅 규칙 (사용자 속성, 세그먼트)
- 실시간 업데이트 (SSE/streaming)
- 감사 로그, 권한 관리
- Datadog/Slack 등 다양한 통합
단점:
- 유료 (소규모 팀에 부담)
- 데이터가 외부 서버에 저장됨
- 벤더 종속성
가격: 시트당 과금. 팀 규모가 커지면 비싸진다.
Unleash
오픈소스 셀프호스팅 솔루션.
장점:
- 오픈소스 (무료, 자체 호스팅)
- 데이터가 자사 인프라에
- 활성화된 커뮤니티
- Unleash Cloud 옵션도 있음
단점:
- 직접 운영 필요 (PostgreSQL + Node.js 서버)
- 엔터프라이즈 기능은 유료 플랜에만
비교 테이블:
| LaunchDarkly | Unleash OSS | |
|---|---|---|
| 호스팅 | SaaS | 셀프호스팅 |
| 시작 비용 | 중간 (Free tier 있음) | 낮음 (무료) |
| 운영 부담 | 없음 | 있음 |
| 타겟팅 정교함 | 매우 높음 | 높음 |
| 실시간 업데이트 | 스트리밍 | 폴링 (기본 15초) |
| 감사 로그 | 완벽 | 기본 제공 |
| SDK 지원 | 다양함 | 다양함 |
선택 기준:
- 빠른 시작, 엔터프라이즈 기능 필요 → LaunchDarkly
- 데이터 주권, 비용 민감 → Unleash
- 소규모 사이드프로젝트 → Unleash (직접 호스팅) 또는 LaunchDarkly Free tier
4. LaunchDarkly SDK 통합 (Next.js App Router)
설치
npm install @launchdarkly/node-server-sdk
npm install @launchdarkly/js-client-sdk # 클라이언트 사이드용
서버 컴포넌트에서
// src/lib/launchdarkly.ts
import * as ld from '@launchdarkly/node-server-sdk';
let ldClient: ld.LDClient | null = null;
export async function getLDClient(): Promise<ld.LDClient> {
if (!ldClient) {
ldClient = ld.init(process.env.LAUNCHDARKLY_SDK_KEY!);
await ldClient.waitForInitialization({ timeout: 5 });
}
return ldClient;
}
export async function isFeatureEnabled(
flagKey: string,
user: { id: string; email?: string; plan?: string },
defaultValue = false
): Promise<boolean> {
const client = await getLDClient();
const ldUser: ld.LDContext = {
kind: 'user',
key: user.id,
email: user.email,
custom: { plan: user.plan },
};
return client.variation(flagKey, ldUser, defaultValue);
}
// app/[locale]/dashboard/page.tsx (Server Component)
import { isFeatureEnabled } from '@/lib/launchdarkly';
import { getCurrentUser } from '@/lib/auth';
export default async function DashboardPage() {
const user = await getCurrentUser();
const showNewDashboard = await isFeatureEnabled(
'new-dashboard-ui',
{ id: user.id, email: user.email, plan: user.plan }
);
if (showNewDashboard) {
return <NewDashboard />;
}
return <OldDashboard />;
}
클라이언트 컴포넌트에서 (React Provider 패턴)
// src/components/providers/FeatureFlagProvider.tsx
'use client';
import {
asyncWithLDProvider,
useFlags,
useLDClient,
} from 'launchdarkly-react-client-sdk';
import { useEffect, useState } from 'react';
// Context Provider
export function FeatureFlagProvider({
children,
userId,
}: {
children: React.ReactNode;
userId: string;
}) {
const [LDProvider, setLDProvider] = useState<React.ComponentType<{children: React.ReactNode}> | null>(null);
useEffect(() => {
asyncWithLDProvider({
clientSideID: process.env.NEXT_PUBLIC_LAUNCHDARKLY_CLIENT_ID!,
context: {
kind: 'user',
key: userId,
},
options: {
streaming: true,
},
}).then(setLDProvider);
}, [userId]);
if (!LDProvider) return <>{children}</>;
return <LDProvider>{children}</LDProvider>;
}
// 사용 훅
export function useFeatureFlag(flagKey: string, defaultValue = false): boolean {
const flags = useFlags();
return flags[flagKey] ?? defaultValue;
}
// src/components/blog/NewEditor.tsx
'use client';
import { useFeatureFlag } from '@/components/providers/FeatureFlagProvider';
export function EditorWrapper() {
const showNewEditor = useFeatureFlag('new-markdown-editor');
return showNewEditor ? <NewMarkdownEditor /> : <LegacyEditor />;
}
5. Unleash SDK 통합
Unleash 서버 띄우기 (Docker)
# docker-compose.yml
version: '3.8'
services:
postgres:
image: postgres:16
environment:
POSTGRES_DB: unleash
POSTGRES_USER: unleash_user
POSTGRES_PASSWORD: password
volumes:
- postgres_data:/var/lib/postgresql/data
unleash:
image: unleashorg/unleash-server:latest
ports:
- "4242:4242"
environment:
DATABASE_URL: postgres://unleash_user:password@postgres:5432/unleash
INIT_CLIENT_API_TOKENS: "default:development.unleash-insecure-api-token"
depends_on:
- postgres
volumes:
postgres_data:
docker-compose up -d
# http://localhost:4242 에서 UI 접근
# 기본 계정: admin / unleash4all
Node.js SDK (서버사이드)
npm install unleash-client
// src/lib/unleash.ts
import { initialize, isEnabled, getVariant } from 'unleash-client';
let initialized = false;
export async function initUnleash() {
if (initialized) return;
initialize({
url: process.env.UNLEASH_URL!,
appName: 'codemapo',
customHeaders: {
Authorization: process.env.UNLEASH_API_TOKEN!,
},
// 폴링 간격 (기본 15초)
refreshInterval: 15,
});
initialized = true;
}
export function checkFlag(
flagName: string,
userId: string,
properties?: Record<string, string>
): boolean {
return isEnabled(flagName, {
userId,
properties,
});
}
export function getFlagVariant(flagName: string, userId: string): string {
const variant = getVariant(flagName, { userId });
return variant.enabled ? variant.name : 'disabled';
}
// Server Component에서
import { initUnleash, checkFlag } from '@/lib/unleash';
export default async function Page() {
await initUnleash();
const user = await getCurrentUser();
const showBeta = checkFlag('beta-feature', user.id, {
plan: user.plan,
country: user.country,
});
return showBeta ? <BetaFeature /> : <StableFeature />;
}
클라이언트사이드 Unleash (React SDK)
npm install @unleash/proxy-client-react
// src/components/providers/UnleashProvider.tsx
'use client';
import { FlagProvider } from '@unleash/proxy-client-react';
const unleashConfig = {
url: process.env.NEXT_PUBLIC_UNLEASH_PROXY_URL!,
clientKey: process.env.NEXT_PUBLIC_UNLEASH_CLIENT_KEY!,
appName: 'codemapo',
refreshInterval: 15,
};
export function UnleashProvider({
children,
userId,
}: {
children: React.ReactNode;
userId: string;
}) {
return (
<FlagProvider
config={unleashConfig}
context={{ userId }}
>
{children}
</FlagProvider>
);
}
// 컴포넌트에서 사용
'use client';
import { useFlag, useVariant } from '@unleash/proxy-client-react';
export function FeatureComponent() {
const showNewUI = useFlag('new-checkout-ui');
const buttonVariant = useVariant('checkout-button-test');
return (
<div>
{showNewUI && <NewCheckoutUI />}
<Button variant={buttonVariant.name || 'default'}>
결제하기
</Button>
</div>
);
}
6. 점진적 롤아웃 전략
퍼센티지 롤아웃
Day 1: 1% 유저에게 활성화 → 에러 모니터링
Day 2: 5% → 여전히 안정적?
Day 3: 25%
Day 5: 50%
Day 7: 100% → 플래그 제거 예약
LaunchDarkly에서는 UI로, Unleash에서는 gradualRolloutUserId 전략으로 구현한다.
카나리 릴리즈
특정 세그먼트(베타 유저, 내부 직원, 특정 지역)에게 먼저 공개:
// LaunchDarkly 타겟팅 규칙 (코드가 아닌 UI에서 설정)
// 규칙 1: plan == 'premium' → true
// 규칙 2: email contains '@company.com' → true
// 기본값: false (나머지 유저)
킬 스위치
서비스가 문제가 생겼을 때 즉시 기능을 끄는 메커니즘:
// Ops 플래그 패턴
async function processPayment(order: Order) {
if (!featureFlags.isEnabled('payment-service-enabled')) {
throw new ServiceUnavailableError('결제 서비스가 일시 중단되었습니다');
}
return await paymentProvider.charge(order);
}
플래그 하나 끄는 것으로 전체 결제 기능을 즉시 비활성화할 수 있다.
7. 플래그 부채 관리
문제: 플래그는 쌓인다
// 6개월 후 코드베이스
if (featureFlags.isEnabled('new-ui-2023')) { // 이미 100% 롤아웃됨
if (featureFlags.isEnabled('checkout-v2')) { // 이미 제거됐어야 함
if (featureFlags.isEnabled('beta-checkout-2024')) { // 아직 실험 중
return <NewCheckout />;
}
return <CheckoutV2 />;
}
return <OldCheckout />;
}
return <LegacyUI />;
플래그 생명주기 관리
플래그 생성 시 만료일 설정:
// 플래그 정의 문서화 (코드 내 주석)
/**
* @flag new-checkout-flow
* @type release
* @created 2026-01-15
* @expires 2026-02-15
* @owner team-payments
* @description 새 결제 플로우 A/B 테스트
*/
만료된 플래그 감지:
// scripts/check-stale-flags.ts
import { getAllFlags } from './flagsRegistry';
const STALE_DAYS = 30;
const staleFlags = getAllFlags().filter(flag => {
const daysSinceCreated = daysBetween(flag.createdAt, new Date());
return daysSinceCreated > STALE_DAYS && flag.type === 'release';
});
console.log('만료된 플래그:');
staleFlags.forEach(flag => {
console.log(` - ${flag.key} (${flag.owner}팀, ${flag.createdAt} 생성)`);
});
플래그 제거 체크리스트
플래그를 100% 활성화한 후:
## 플래그 제거 체크리스트
- [ ] 플래그가 100% 활성화 상태로 안정적으로 운영됨 (최소 2주)
- [ ] 에러율, 레이턴시 등 지표 정상
- [ ] 코드에서 `if (featureFlags.isEnabled(...))` 분기 제거
- [ ] 사용하지 않는 코드 경로(old implementation) 삭제
- [ ] 플래그 관리 도구(LaunchDarkly/Unleash)에서 플래그 아카이브
- [ ] 관련 테스트 업데이트
8. 테스트에서 Feature Flag 다루기
단위 테스트: 플래그 모킹
// __tests__/checkout.test.tsx
import { render, screen } from '@testing-library/react';
import { CheckoutPage } from '../CheckoutPage';
// LaunchDarkly 모킹
jest.mock('launchdarkly-react-client-sdk', () => ({
useFlags: () => ({ 'new-checkout-flow': true }),
useLDClient: () => ({ track: jest.fn() }),
}));
describe('CheckoutPage', () => {
it('플래그가 켜지면 새 체크아웃 UI를 보여준다', () => {
render(<CheckoutPage />);
expect(screen.getByTestId('new-checkout')).toBeInTheDocument();
});
});
// 플래그 OFF 케이스
jest.mock('launchdarkly-react-client-sdk', () => ({
useFlags: () => ({ 'new-checkout-flow': false }),
}));
it('플래그가 꺼지면 기존 체크아웃 UI를 보여준다', () => {
render(<CheckoutPage />);
expect(screen.getByTestId('old-checkout')).toBeInTheDocument();
});
E2E 테스트: 플래그 오버라이드
// playwright/tests/checkout.spec.ts
import { test, expect } from '@playwright/test';
test('새 체크아웃 플로우 E2E', async ({ page }) => {
// 쿠키로 플래그 오버라이드 (LaunchDarkly 지원)
await page.context().addCookies([{
name: 'ld_override',
value: JSON.stringify({ 'new-checkout-flow': true }),
domain: 'localhost',
path: '/',
}]);
await page.goto('/checkout');
await expect(page.getByTestId('new-checkout')).toBeVisible();
});
Epilogue: 배포 공포에서 배포 자신감으로
Feature Flag를 도입하기 전엔 배포가 두려웠다. "혹시 뭔가 잘못되면 어떡하지?" 금요일 오후엔 절대 배포하지 않았다. 롤백 절차를 미리 준비해두고 배포했다.
Feature Flag 이후엔 달라졌다. 언제든 배포할 수 있다. 문제가 생기면 플래그를 끄면 된다. 점진적으로 롤아웃하면서 지표를 모니터링한다. 확신이 생기면 100%로 올린다.
배포와 릴리즈를 분리하는 것. 그게 전부다. 근데 그 분리 하나가 팀의 배포 자신감을 완전히 바꿔놓는다.