Feature Flag로 배포와 출시 분리하기

Feature Flag로 배포와 출시 분리하기

한눈에 보기

Feature Flag는 배포된 code의 실행 경로를 설정으로 선택해 배포와 사용자 출시를 분리한다. 하지만 if 한 줄보다 중요한 것은 누가 어떤 version을 보는지 일관되게 평가하고, flag provider 장애 때 안전한 기본값을 반환하며, 변경자와 이유를 감사하고, 실험이 끝난 flag를 code에서 제거하는 과정이다. Percentage rollout은 요청마다 random을 뽑지 않고 안정적인 targeting key를 hash해야 한다. 인증·권한을 flag에 맡기지 않으며 server와 client가 같은 결정을 요구하면 평가 위치와 snapshot version을 명시한다.

목차

배포와 출시를 같은 사건으로 만들었을 때

새 checkout을 배포하자마자 모든 사용자가 사용한다면 장애 시 이전 artifact를 다시 배포해야 한다.

code deploy = 사용자 노출 = rollback 단위

Feature Flag를 사용하면 새 code를 비활성 상태로 먼저 배포할 수 있다.

flowchart LR
    A[Deploy old and new path]
    B[Internal users]
    C[1 percent]
    D[10 percent]
    E[100 percent]
    F[Remove old path and flag]
    A --> B --> C --> D --> E --> F
const checkoutVariant = flags.getString(
  "checkout-flow",
  "legacy",
  evaluationContext,
);

return checkoutVariant === "v2"
  ? newCheckout.start(command)
  : legacyCheckout.start(command);

문제가 생기면 code를 다시 배포하지 않고 old path로 평가를 돌릴 수 있다. 이 장점은 flag 변경 경로가 배포 경로보다 빠르고 신뢰할 수 있을 때만 성립한다.

분리되는 것

Deploy는 새 code가 production에 존재하게 하고, release는 그 code를 실제 요청에 선택한다.

Feature Flag의 종류를 먼저 구분하기

모든 flag를 같은 정책으로 운영하면 수명이 짧은 release flag가 영구 configuration이 되거나 긴급 kill switch가 실험 도구에 묻힌다.

종류 목적 예상 수명 기본값 예
Release 새 기능 점진 노출 일~주 기존 경로
Experiment 대조군과 변형 비교 주~월 대조군
Operations 부하 기능 차단 장기 가능 안전 경로
Entitlement 계약별 기능 장기 별도 권한 시스템 우선

Release flag는 새 기능이 100% 안정화되면 제거한다. Operations flag는 runbook과 정기 test가 필요하다. Experiment flag는 사용자 cohort를 고정하고 분석이 끝나면 결론을 code에 반영한다.

Premium 사용자 여부 같은 권리는 flag console이 source of truth가 되어서는 안 된다. 결제·권한 domain이 결정하고 flag는 rollout을 추가로 제한하는 용도로 쓴다.

Flag 정의에 수명주기를 포함하기

Flag key와 boolean만 저장하면 누가 왜 만들었고 언제 제거할지 알 수 없다.

key: checkout-flow
type: string
default: legacy
owner: commerce-platform
purpose: release
createdAt: 2026-03-06
expiresAt: 2026-04-17
variants:
  - legacy
  - v2
ticket: RELEASE-204
safeFallback: legacy

날짜와 팀, ticket은 가상 값이다. Owner, 목적, typed variant, 장애 기본값, 생성일, 검토일, 제거 예정일, dashboard, rollback runbook을 flag inventory에 둔다.

Expiration은 production 동작을 자동 반전하는 시간이 아니라 정리 작업을 경고하는 기준으로 사용하는 편이 안전하다. 기한이 지났다고 flag가 갑자기 꺼지면 장애가 날 수 있다.

평가 Context를 작고 명시적으로 만들기

Targeting에는 사용자나 조직, service, region 정보가 필요할 수 있다.

type CheckoutFlagContext = {
  targetingKey: string;
  accountTier: "free" | "premium";
  countryCode: string;
  applicationVersion: string;
};

const context: CheckoutFlagContext = {
  targetingKey: stableAnonymousSubjectId,
  accountTier: entitlement.tier,
  countryCode: request.countryCode,
  applicationVersion: client.version,
};

OpenFeature specification은 subject를 식별하는 targeting key와 custom evaluation context를 정의한다. Provider가 percentage evaluation에 targeting key를 요구할 수 있다.

Context에 database user object 전체를 넘기지 않는다.

// 피해야 할 예
flags.evaluate("checkout-flow", {
  ...databaseUserRecord,
  paymentProfile,
});

PII와 내부 note가 provider나 telemetry로 전송될 수 있다. Email 대신 안정적인 pseudonymous ID를 쓰고 평가에 필요한 최소 field만 typed adapter에서 고른다.

Context schema도 계약이다

country, countryCode, region처럼 runtime마다 다른 key를 보내면 같은 사용자의 평가가 달라진다.

Percentage Rollout을 안정적으로 계산하기

요청마다 Math.random()으로 10%를 뽑으면 같은 사용자가 새 화면과 이전 화면을 번갈아 본다.

// 잘못된 예
const enabled = Math.random() < 0.1;

Flag key와 안정적인 targeting key를 hash해 bucket을 결정한다.

import { createHash } from "node:crypto";

function stableBucket(
  flagKey: string,
  targetingKey: string,
): number {
  const digest = createHash("sha256")
    .update(`${flagKey}:${targetingKey}`)
    .digest();

  return digest.readUInt32BE(0) % 10_000;
}

function isInRollout(
  flagKey: string,
  targetingKey: string,
  percentage: number,
): boolean {
  const threshold = Math.round(percentage * 100);
  return stableBucket(flagKey, targetingKey) < threshold;
}

10,000 bucket에서 1%는 대략 100개 범위다. 개념 예시이므로 provider가 정의한 hash와 allocation algorithm을 별도로 재구현하지 않는다.

Targeting key가 request마다 바뀌면 안정성이 사라진다.

Key 안정성 의미
account immutable ID 높음 계정 단위
device ID 기기별 같은 사용자도 기기마다 다름
session ID 짧음 재로그인 시 cohort 변경
request ID 없음 rollout에 부적합

여러 flag를 같은 cohort로 묶어야 한다면 독립 flag key가 아니라 experiment allocation unit을 별도로 정의한다.

평가 위치를 한 곳으로 정하기

Mobile, Web, Backend가 각각 remote flag를 평가하면 refresh 시점과 context 차이로 결과가 다를 수 있다.

Mobile: 새 checkout 버튼 표시
Backend: legacy command만 허용

보안과 data write에 영향을 주는 결정은 server가 authoritative해야 한다. Client는 UI 힌트를 평가하더라도 server response fallback이 필요하다.

{
  "capabilities": {
    "checkoutFlow": "v2",
    "promotionCode": true
  },
  "configurationVersion": 184
}

Server가 session capability snapshot을 내려줄 수 있다. Long transaction 중 flag가 바뀌면 시작과 완료 단계가 다른 variant를 사용하지 않도록 decision을 command context에 고정한다.

type CheckoutExecutionContext = {
  checkoutVariant: "legacy" | "v2";
  flagConfigurationVersion: number;
};

한 request에서 같은 flag를 여러 번 remote 평가하지 않는다.

Provider 장애의 기본값 정하기

Flag SDK는 값을 얻지 못했을 때 typed default를 받아야 한다.

const variant = await flagClient.getStringValue(
  "checkout-flow",
  "legacy",
  context,
);

항상 false가 안전한 것은 아니다.

Flag 장애 시 안전값
새 UI release 기존 UI
비용 큰 추천 기능 비활성
결제 fraud 검증 우회가 아니라 보수적 거부
새 schema read 현재 배포 단계와 호환되는 경로

Provider가 잠깐 끊길 때 last-known-good를 쓸지 code default로 돌아갈지도 정한다.

flowchart TD
    A[Evaluate flag]
    B[Fresh provider value]
    C[Valid cached snapshot]
    D[Code default]
    A -->|provider ready| B
    A -->|temporary failure| C
    A -->|no valid cache| D

Snapshot에는 version, fetchedAt, expiresAt와 integrity 검증이 필요할 수 있다. 오래된 설정이 영구 유지되지 않도록 최대 stale 시간을 둔다.

Kill Switch는 평소에 시험해야 한다

const recommendationsEnabled =
  flags.getBoolean("recommendations-enabled", false, context);

if (!recommendationsEnabled) {
  return popularItemsFallback();
}

끄는 경로가 수개월 실행되지 않았다면 dependency와 response schema가 깨져 있을 수 있다. 정기적으로 staging과 작은 production cohort에서 fallback을 test한다.

Flag provider 자체가 장애면 remote console의 kill switch를 바꿀 수 없다. 심각한 기능은 local snapshot, 환경 변수 emergency override, gateway 차단, circuit breaker 중 적절한 수단을 함께 둔다.

emergency local override
> validated provider value
> last-known-good snapshot
> code default

Emergency override가 영구적으로 provider 값을 가리지 않도록 expiration과 alert를 둔다.

Flag를 Domain 경계에 모으기

Code 곳곳에 조건을 흩뿌리면 제거가 어렵다.

// controller
if (flags.newCheckout) route();
// service
if (flags.newCheckout) calculate();
// repository
if (flags.newCheckout) persist();

한 composition boundary에서 구현체를 고른다.

interface CheckoutFlow {
  start(command: StartCheckout): Promise<CheckoutResult>;
}

class CheckoutFlowRouter implements CheckoutFlow {
  constructor(
    private readonly flags: CheckoutFlags,
    private readonly legacy: CheckoutFlow,
    private readonly next: CheckoutFlow,
  ) {}

  async start(command: StartCheckout) {
    const decision = await this.flags.resolve(command.actor);

    return decision.variant === "v2"
      ? this.next.start(command)
      : this.legacy.start(command);
  }
}

Flag SDK type을 domain 전체에 전달하지 않는다. Adapter가 provider 문자열과 오류를 typed decision으로 바꾼다.

여러 Flag 조합의 폭발을 막기

Boolean flag가 5개면 이론상 32개 조합이 생긴다. 독립적이지 않은 flag를 각각 평가하면 불가능한 상태가 나온다.

new-checkout=true
new-payment-adapter=false
new-order-schema=true

하나의 typed variant로 묶는다.

type CheckoutVariant =
  | "legacy"
  | "v2-shadow"
  | "v2-enabled";

또는 prerequisite를 명시하고 invalid combination을 검증한다.

function validateCheckoutFlags(
  flags: CheckoutConfiguration,
) {
  if (flags.writeV2 && !flags.schemaV2Available) {
    throw new Error("v2 write requires schema capability");
  }
}

모든 조합을 test하려 하기보다 허용 state를 줄인다. 핵심 결제·data 경로는 각 허용 variant를 명시적으로 검증한다.

Experiment와 Release Flag를 구분하기

Release는 안전하게 100% 전환하는 것이 목표고 experiment는 대조군과 변형군의 차이를 측정하는 것이 목표다.

구분 Release Experiment
Cohort 점진 확대 실험 중 고정
지표 오류, latency, 성공 사전 정의한 business metric
종료 새 경로 100% 분석 뒤 variant 선택
재할당 운영 판단 분석 오염 가능

실험 중 hash algorithm이나 targeting key를 바꾸면 cohort가 이동한다. 사용자가 실제 변형을 볼 때 exposure event를 기록한다.

{
  "event": "feature_exposure",
  "flagKey": "checkout-copy-experiment",
  "variant": "concise",
  "allocationVersion": 3,
  "subjectHash": "anonymous-stable-hash"
}

실제 user ID와 PII를 log에 남기지 않는다.

권한 검사를 Feature Flag로 대신하지 않기

Client flag가 admin 화면을 숨겨도 API 권한 검사는 필요하다.

authorization.require(
  actor,
  "customer-data:delete",
);

if (!flags.getBoolean("admin-delete-enabled", false, context)) {
  throw new FeatureUnavailableError();
}

return deleteCustomerData(command);

Authorization을 먼저 수행하고 flag는 기능 availability를 추가로 제한한다. Flag targeting rule은 권한 source of truth가 아니다.

Client SDK로 전달되는 configuration에는 server secret이나 내부 rule이 노출되지 않게 client/server key를 분리한다.

변경 감사와 승인을 남기기

Flag 변경은 재배포 없이 production 동작을 바꾸므로 code deploy에 준하는 감사가 필요하다.

{
  "flagKey": "checkout-flow",
  "environment": "production",
  "from": { "percentage": 10 },
  "to": { "percentage": 50 },
  "changedBy": "operator-demo",
  "reason": "release ticket RELEASE-204",
  "changedAt": "2026-03-06T04:20:00Z"
}

가상 record다. SSO, 개인 계정, 역할별 권한, production 승인, 전후 값, actor, ticket, emergency change의 사후 review를 둔다.

“누가 켰는지”뿐 아니라 어떤 지표를 보고 얼마까지 올렸는지를 release timeline과 연결한다.

평가 결과를 관측하되 PII를 남기지 않기

feature_flag_evaluation_total{key,variant,reason}
feature_flag_evaluation_error_total{key,error_type}
feature_flag_provider_latency_ms{provider}
feature_flag_fallback_total{key,source}

Flag key와 variant는 제한적이지만 targeting key와 user ID를 metric label로 넣으면 안 된다. Evaluation context 전체 logging도 기본 비활성으로 둔다.

Business 지표에는 variant를 연결한다.

checkout_success_rate{app_version,checkout_variant}
checkout_latency_ms{app_version,checkout_variant}

같은 legacy 결과라도 default, targeting rule, provider error, disabled 상태는 운영 의미가 다르므로 resolution reason도 본다.

Stale Flag를 찾아 제거하기

100% v2가 된 flag를 남기면 old code와 test matrix가 계속 존재한다.

정리 순서:

  1. Variant가 일정 기간 고정됐는지 확인
  2. Supported client 영향을 확인
  3. Code에서 winning path를 기본으로 변경
  4. Old 구현과 test 제거
  5. Flag SDK 조회 제거
  6. 배포 완료 후 control plane flag archive
  7. Dashboard와 runbook 정리

Control plane에서 flag부터 삭제하면 application default가 old path를 다시 켤 수 있다.

// 정리 후
return newCheckout.start(command);

CI가 expiration inventory를 검사할 수 있다.

for (const flag of flagInventory) {
  if (flag.purpose === "release" && flag.expiresAt < today) {
    reportExpiredFlag(flag.key, flag.owner);
  }
}

배포와 Rollback 순서에 Flag 연결하기

새 path가 모든 instance에 배포되기 전에 flag를 켜면 old instance가 값을 이해하지 못할 수 있다.

sequenceDiagram
    participant P as Pipeline
    participant N as New instances
    participant F as Flag provider

    P->>F: Ensure disabled
    P->>N: Deploy compatible code
    P->>P: Verify rollout
    P->>F: Enable internal cohort
    P->>F: Increase percentage
    P->>P: Remove old path later

DB migration과 함께라면 순서는 다음과 같다.

Expand schema
→ deploy code with flag off
→ shadow write/read
→ enable cohort
→ 100% and observe
→ remove old code
→ contract schema

Flag off가 schema rollback을 의미하지 않는다. New path가 data를 썼다면 old path가 읽을 수 있어야 한다.

실패 조건을 포함한 테스트

평가

장애와 수명

구현 체크리스트

정의와 평가

운영과 제거

마무리

Feature Flag는 배포된 code와 실제 사용자 노출을 분리한다. 새 경로를 비활성 상태로 배포하고 내부 사용자부터 점진적으로 늘리며 문제가 생기면 설정으로 되돌릴 수 있다.

하지만 단순 boolean보다 평가 계약이 중요하다. 안정적인 targeting key로 cohort를 고정하고 server와 client 중 누가 authoritative한지 정하며 provider 장애의 default와 last-known-good 수명을 설계해야 한다.

Flag 변경은 production 동작을 바꾸므로 최소 권한, 승인, audit log와 관측이 필요하다. Kill switch는 실제 장애 전에 fallback과 provider 독립 override를 시험한다.

마지막은 제거다. Release가 끝났는데 old code를 남기면 조건 조합과 test 비용이 계속 늘어난다. Code에서 winning path를 고정하고 old path를 제거한 뒤 control plane을 정리해야 배포와 출시의 분리가 기술 부채로 남지 않는다.

관련 노트

참고 자료