
Feature Flag 운영: LaunchDarkly, Unleash로 안전한 배포
배포와 릴리즈를 분리하면 언제든 롤백할 수 있다. LaunchDarkly와 Unleash를 비교하고, React/Next.js에서 SDK를 통합하는 방법, 그리고 플래그 부채를 관리하는 법까지 정리했다.

배포와 릴리즈를 분리하면 언제든 롤백할 수 있다. LaunchDarkly와 Unleash를 비교하고, React/Next.js에서 SDK를 통합하는 방법, 그리고 플래그 부채를 관리하는 법까지 정리했다.
AWS 콘솔 클릭질로 만든 서버가 왜 문제인지, Terraform으로 인프라를 코드로 선언하면 무엇이 달라지는지 실전 예제와 함께 정리했다.

새벽엔 낭비하고 점심엔 터지는 서버 문제 해결기. '택시 배차'와 '피자 배달' 비유로 알아보는 오토 스케일링과 서버리스의 차이, 그리고 Spot Instance를 활용한 비용 절감 꿀팁.

서버를 끄지 않고 배포하는 법. 롤링, 카나리, 블루-그린의 차이점과 실제 구축 전략. DB 마이그레이션의 난제(팽창-수축 패턴)와 AWS CodeDeploy 활용법까지 심층 분석합니다.

내 서버가 해킹당하지 않는 이유. 포트와 IP를 검사하는 '패킷 필터링'부터 AWS Security Group까지, 방화벽의 진화 과정.

처음엔 이 말이 이해가 안 됐다.
"방금 배포했잖아요. 근데 왜 유저들이 아직 못 써요?"
"릴리즈는 아직 안 했거든요."
배포(deploy)는 코드를 서버에 올리는 것. 릴리즈(release)는 유저에게 기능을 공개하는 것. 이 둘을 분리하는 게 Feature Flag의 핵심이다.
예전엔 새 기능을 만들면 feature 브랜치에서 작업하고, 완성되면 main에 머지하고, 배포했다. 근데 이 방식에는 문제가 있다. 기능이 크면 브랜치가 오래 살고, 머지할 때 충돌이 쌓이고, 배포하면 롤백이 어렵다.
Feature Flag를 쓰면 달라진다. 미완성 코드도 main에 머지한다. 그냥 플래그 뒤에 숨겨두면 유저는 못 보니까. 배포는 계속할 수 있고, 준비되면 플래그만 켜면 된다. 문제가 생기면 플래그를 끄면 된다. 코드 롤백 없이.
모든 플래그가 같은 목적으로 쓰이진 않는다. Martin Fowler는 4가지로 분류했다.
가장 흔한 사용 사례. 완성된 기능을 일부 유저에게만 먼저 공개하거나, 전체 공개를 제어한다.
if (featureFlags.isEnabled('new-checkout-flow', user)) {
return <NewCheckoutFlow />;
} else {
return <OldCheckoutFlow />;
}
수명: 짧음 (기능 안정화 후 제거)
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';
수명: 실험 기간 동안만
시스템 동작을 런타임에 제어한다. 서킷 브레이커, 성능 저하 모드, 캐싱 활성화/비활성화 등.
// 외부 결제 API가 불안정할 때 캐시 모드로 전환
if (featureFlags.isEnabled('payment-fallback-mode')) {
return getCachedPaymentMethods();
} else {
return await fetchPaymentMethods();
}
수명: 영구적일 수도 있음
특정 사용자 그룹에게만 기능 접근을 허용한다. 프리미엄 기능, 베타 프로그램 등.
if (featureFlags.isEnabled('advanced-analytics', user)) {
return <AdvancedDashboard />;
}
// 일반 유저는 이 코드 경로에 진입 불가
수명: 비즈니스 모델이 바뀔 때까지
팀에서 항상 나오는 질문: "그냥 feature 브랜치 쓰면 안 돼요?"
| Feature Branch | Feature Flag | |
|---|---|---|
| 배포 가능 시점 | 기능 완성 후 | 언제나 (플래그로 숨김) |
| 통합 리스크 | 머지 시 충돌 위험 | 없음 (항상 main에 통합) |
| 롤백 | 코드 롤백 필요 | 플래그 OFF |
| 점진적 롤아웃 | 어려움 | 쉬움 (1% → 10% → 100%) |
| A/B 테스트 | 매우 어려움 | 기본 기능 |
| 복잡도 | 코드 관리 복잡 | 플래그 관리 복잡 |
Feature Flag의 단점도 있다. 코드에 if (flag) 분기가 쌓인다. 오래된 플래그가 제거되지 않으면 "플래그 부채"가 된다. 나중에 이 내용도 다룬다.
결론: 두 기능 이상이 동시에 개발되고, 점진적 롤아웃이 필요하다면 Feature Flag가 낫다.
두 도구의 특성이 꽤 다르다.
SaaS 기반의 엔터프라이즈 솔루션. 설정 없이 바로 쓸 수 있다.
장점:가격: 시트당 과금. 팀 규모가 커지면 비싸진다.
오픈소스 셀프호스팅 솔루션.
장점:| LaunchDarkly | Unleash OSS | |
|---|---|---|
| 호스팅 | SaaS | 셀프호스팅 |
| 시작 비용 | 중간 (Free tier 있음) | 낮음 (무료) |
| 운영 부담 | 없음 | 있음 |
| 타겟팅 정교함 | 매우 높음 | 높음 |
| 실시간 업데이트 | 스트리밍 | 폴링 (기본 15초) |
| 감사 로그 | 완벽 | 기본 제공 |
| SDK 지원 | 다양함 | 다양함 |
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 />;
}
// 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 />;
}
# 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
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 />;
}
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>
);
}
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);
}
플래그 하나 끄는 것으로 전체 결제 기능을 즉시 비활성화할 수 있다.
// 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)에서 플래그 아카이브
- [ ] 관련 테스트 업데이트
// __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();
});
// 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();
});
Feature Flag를 도입하기 전엔 배포가 두려웠다. "혹시 뭔가 잘못되면 어떡하지?" 금요일 오후엔 절대 배포하지 않았다. 롤백 절차를 미리 준비해두고 배포했다.
Feature Flag 이후엔 달라졌다. 언제든 배포할 수 있다. 문제가 생기면 플래그를 끄면 된다. 점진적으로 롤아웃하면서 지표를 모니터링한다. 확신이 생기면 100%로 올린다.
배포와 릴리즈를 분리하는 것. 그게 전부다. 근데 그 분리 하나가 팀의 배포 자신감을 완전히 바꿔놓는다.