“피처 플래그가 OFF면 사용자에게 영향이 없다.”

이 문장은 미완성 코드를 빠르게 main에 합치고, 배포와 릴리스를 분리하려 할 때 자주 전제가 된다. 이 전제를 믿으면 사람 리뷰를 줄이고 자동 게이트가 통과한 변경을 운영까지 먼저 보낼 수도 있다. 기능은 나중에 플래그를 켤 때 검토하면 된다는 논리다.

그런데 그 전제는 실제 실패 조건에서 검증됐을까.

피처 플래그가 준비될 때까지 데이터 요청을 미루는 코드를 가정해보자.

const { value, loading } = useFeatureFlag('new-policy');

const query = useQuery({
  queryKey: ['items', value],
  queryFn: () => fetchItems(value),
  enabled: !loading,
});

정상 상황에서는 문제가 없다. 플래그를 받아 새 정책과 기존 정책 중 하나를 고른 뒤 API를 호출한다. 두 정책의 결과가 다르다면 불필요한 중복 요청을 막는 것도 합리적으로 보인다.

그런데 최초 방문이라 캐시가 없고, 원격 플래그 요청까지 실패하면 어떻게 될까. 클라이언트가 실패를 확정하지 못한 채 loading을 계속 유지한다면 데이터 요청도 영원히 시작하지 않는다. 예외가 던져지지 않을 수도 있다. 화면은 로딩 중이고 라이브러리도 기다리는 중이며, 각 코드 조각은 자기 역할을 정상적으로 수행한 것처럼 보인다.

문제는 플래그 서비스의 장애 자체가 아니다. 플래그를 판정하지 못했을 때 애플리케이션이 무엇을 해야 하는지 정하지 않은 채, OFF면 안전하다고 선언한 것이다.

이런 상황을 마주치면 자연스럽게 신뢰도 이야기가 나온다. 원격 플래그 시스템을 99.9%까지 믿을 수 있어야 할까. 핵심 흐름에 연결한다면 99.99%, 아니면 99.999%가 필요할까.

하지만 숫자부터 정하면 질문의 순서가 뒤집힌다. 먼저 정해야 할 것은 플래그 서버의 신뢰도가 아니라 플래그가 실패했을 때도 제품이 끝까지 동작하는 방식이다. 그 방식도 확인하지 않은 채 운영 문턱부터 낮춘다면, 사용자가 실패 테스트를 대신하게 된다.

이 글의 코드는 특정 서비스의 구현을 옮긴 것이 아니라, 문제를 설명하기 위해 축약한 예시다.

지난 글에서 한 단계 더 가기

예전에 Feature Flag 장애가 흰 화면을 만들 때라는 글을 썼다. 당시의 결론은 로딩, 성공, 값 없음, 실패를 나누고 return null 뒤에 실패를 숨기지 말자는 것이었다.

이번 글에서 같은 실패 처리를 다시 설명하려는 것은 아니다. 질문을 한 단계 위로 올리고 싶다.

  • 원격 플래그를 핵심 흐름의 선행 조건으로 둬도 되는가?
  • 그 판단을 어떤 데이터로 검증할 것인가?
  • 빌드 타임 플래그가 익숙한 환경의 경험을 웹에도 그대로 적용할 수 있는가?
  • 서버가 런타임에 값을 바꾸는 것과 사용자의 경험이 실시간으로 바뀌는 것은 같은가?
  • Trunk-Based Development(TBD)에서 플래그는 정말 안전장치가 되는가?
  • 플래그를 붙인 뒤 언제, 어떤 기준으로 지울 것인가?

개별 컴포넌트의 실패 처리만 고쳐서는 이 질문에 답할 수 없다. 플래그의 종류와 소비처의 중요도, 관측 지표와 제거 시점까지 하나의 운영 계약으로 묶어야 한다.

loading은 실패 상태가 아니다

원격 플래그에는 불리언보다 많은 상태가 있다.

loading            아직 판정 중
resolved-on        정상적으로 ON 판정
resolved-off       정상적으로 OFF 판정
resolution-failed  제한 시간 안에 판정하지 못함

마지막 상태를 모델링하지 않으면 resolution-failed가 끝없는 loading으로 표현된다. 그리고 소비자가 loading을 데이터 요청이나 화면 렌더링의 차단 조건으로 사용하면, 보조적인 제어 시스템이 핵심 기능의 단일 장애점이 된다.

여기서 놓치기 쉬운 사실이 있다. OFF는 플래그 판정에 성공한 뒤 얻는 값이다. 아직 값을 받지 못한 loading과 값을 받지 못하고 끝난 resolution-failed는 OFF가 아니다.

플래그 조회 성공 -> OFF -> 기존 기능 실행
플래그 조회 실패 -> loading 지속 -> 기존 기능도 실행되지 않음

따라서 “신규 기능이 OFF라 실행되지 않는다”는 사실만으로 “이 변경은 기존 기능에 영향이 없다”고 결론 내릴 수 없다. 플래그를 읽기 위해 추가한 Provider, 초기화, 캐시, 로딩 분기와 쿼리 차단도 모두 변경의 일부다. 신규 기능의 if 블록은 실행되지 않아도 if에 도달하기 위한 배선이 기존 흐름을 바꿀 수 있다.

이때 원격 플래그는 제어면(control plane)을 넘어 데이터면(data plane)에 들어온다. 제어면은 어떤 기능을 켤지 결정하는 곳이고, 데이터면은 사용자의 실제 요청이 흘러가는 곳이다. 제어면이 잠시 불안정해도 이미 정한 기본 동작으로 서비스는 계속 흘러가야 한다. 그런데 플래그 응답 없이는 API조차 호출하지 못한다면 제어면이 사용자 요청을 직접 붙잡은 셈이다.

OpenFeature 사양도 플래그 평가에는 호출자가 제공하는 기본값이 필요하다고 정의한다. 제공자가 값을 확정하지 못할 때 반환할 결과가 애플리케이션 계약에 포함돼 있어야 한다는 뜻이다. OpenFeature Provider 명세

99.999%는 아직 요구사항이 아니다

99.999%라는 숫자는 정밀해 보이지만, 다음 질문에 답하지 못하면 요구사항이 아니다.

  1. 무엇을 실패로 셀 것인가?
  2. 분모는 플래그 요청, 페이지 진입, 세션, 사용자 중 무엇인가?
  3. 몇 밀리초 이상 기다리면 실패인가?
  4. 하루, 7일, 28일 중 어떤 기간으로 계산하는가?
  5. 판정 실패가 실제 사용자 기능 실패로 이어졌는가?
  6. 실패 시 기존 동작, OFF, ON 중 무엇으로 돌아가는가?

예를 들어 원격 플래그 요청 실패율이 0.1%라고 해도, 모든 실패가 로컬 캐시나 기본값으로 복구돼 핵심 흐름을 막지 않는다면 제품 가용성은 그대로일 수 있다. 반대로 요청 실패율이 0.001%라도 그 실패가 결제나 인증을 무한 대기로 만든다면 작다고 넘길 수 없다.

그래서 플래그 서비스의 성공률과 제품의 성공률을 분리해서 봐야 한다.

플래그 판정 성공률
!=
핵심 사용자 흐름 성공률

관측하고 싶은 것은 이런 지표다.

  • 플래그 초기화 성공률과 지연 시간
  • 캐시 또는 bootstrap으로 복구한 횟수
  • 기본값으로 폴백한 횟수
  • 판정 실패 때문에 핵심 API가 시작되지 않은 횟수
  • 플래그별 판정 실패가 사용자 기능 실패로 이어진 비율
  • 폴백 상태가 정상 상태로 회복되기까지 걸린 시간

그다음에야 서비스 수준 목표(Service Level Objective, SLO)를 논의할 수 있다. 예를 들면 다음과 같다.

핵심 기능 진입의 99.95%는 원격 플래그 제공자의 상태와 무관하게 1초 안에 데이터 요청을 시작한다. 제한 시간 안에 플래그를 판정하지 못한 요청은 미리 정의한 기존 동작으로 폴백한다.

여기서 99.95%1초는 예시다. 실제 숫자는 현재 분포와 사용자 영향, 허용 가능한 오류 예산을 보고 정해야 한다. 중요한 것은 목표의 주어가 플래그 서버가 아니라 사용자 흐름이라는 점이다.

임베디드의 빌드 타임 플래그가 단순해 보이는 이유

임베디드 애플리케이션에서는 피처 플래그를 빌드 타임에 결정할 수 있다. 개발용 또는 카나리 빌드에는 새 기능을 포함하고, 릴리스 후보 빌드에서는 숨기는 식이다.

이 방식에는 분명한 장점이 있었다.

  • 애플리케이션 실행 전에 플래그 값이 정해진다.
  • 런타임 네트워크 실패가 판정 과정에 들어오지 않는다.
  • 미완성 코드를 릴리스 바이너리에서 아예 제외할 수 있다.
  • 하나의 바이너리 안에서 가능한 상태 조합이 줄어든다.

이 방식은 작업을 빠르게 넣고 빼는 느낌을 준다. 빌드 종류가 경계를 만들고, 각 경계 안에서 무엇이 보이는지도 비교적 명확하다.

그러나 빌드 타임이라고 관리가 저절로 쉬워지는 것은 아니었다. 플래그가 다른 플래그와 의존하기 시작했고, 개발자별 로컬 재정의와 릴리스 단계, 원격 전환 요구가 더해지자 상태 조합이 다시 늘어났다. 누가 소유하는지, 어느 버전에서 제거할지, 어떤 플래그가 다른 플래그를 전제로 하는지가 필요해졌다.

결국 빌드 타임 플래그도 수명과 의존성을 관리하지 않으면 임시 분기가 영구 구조가 된다. 전환용 피처 플래그를 지우기 쉽게 설계하기에서 삭제될 코드와 남을 코드를 섞지 말자고 했던 이유도 여기에 있다.

웹에서도 빌드 타임으로 팍팍 처리할 수 있을까

웹에서도 가능하다. Next.js의 NEXT_PUBLIC_ 환경 변수처럼 빌드 결과에 값을 인라인할 수 있다. 다만 이 값은 빌드 뒤에는 바뀌지 않는다. 같은 이미지를 여러 환경으로 승격하더라도 빌드 시점 값이 그대로 유지된다. 값을 바꾸려면 다시 빌드하고 배포해야 한다. Next.js 환경 변수 가이드

따라서 임베디드에서 느꼈던 단순함을 웹에 가져올 수는 있지만 대가가 있다.

  • 즉시 끄려면 재빌드와 재배포가 필요하다.
  • QA와 운영을 따로 빌드하면 검증한 산출물과 배포한 산출물이 달라질 수 있다.
  • 단순한 조건 분기만으로는 코드가 번들에서 정말 제외됐는지 보장되지 않는다.
  • 사용자군별 점진 노출이나 실험에는 맞지 않는다.

반대로 런타임 플래그는 배포하지 않고도 값을 바꿀 수 있다. 하지만 여기서 런타임이라는 말을 하나의 기능처럼 쓰면 중요한 차이가 사라진다.

서버 런타임과 사용자 런타임은 다르다

서버가 일정 주기로 설정을 다시 읽는다고 가정해보자. 설정을 바꾼 뒤 새로 들어온 요청은 새 값을 평가하고, 신규 API 대신 기존 API를 호출한다. 이것은 분명 런타임 제어다. 서버의 다음 요청 경로를 바꾸는 킬 스위치로도 쓸 수 있다.

그러나 이미 브라우저에 열린 화면은 저절로 바뀌지 않는다. 새 경로로 진입해 페이지네이션 토큰을 받은 세션, 새 정책으로 만들어진 장바구니나 주문 상태, 클라이언트 메모리와 로컬 스토리지에 남은 값도 콘솔의 OFF 버튼만으로 원래 상태로 돌아가지 않는다. 다음 요청부터 서버 경로가 달라진다고 해서 진행 중인 사용자 경험 전체가 되감기는 것은 아니다.

따라서 최소한 네 층을 구분해야 한다.

무엇을 바꾸는가 전환이 반영되는 시점 놓치기 쉬운 문제
빌드 타임 게이트 번들·바이너리에 포함할 코드와 값 재빌드·재배포 검증한 산출물과 배포 산출물의 차이
서버 측 동적 설정 이후 요청의 API·Provider·응답 경로 서버의 다음 갱신과 다음 평가 인스턴스별 수렴 시간, 오래된 폴백, 진행 중 요청
클라이언트 런타임 플래그 열린 앱·웹의 렌더링과 상호작용 SDK 갱신과 상태 재평가 초기 캐시 없음, 화면 깜빡임, 세션 중 변형 변경
실험·노출 시스템 사용자별 배정과 실제 경험의 기록 사용자 컨텍스트 평가와 노출 이벤트 배정과 실제 노출의 불일치, 식별자 변경, 통계 오염

Unleash의 브라우저 SDK는 주기적으로 플래그를 갱신하고 update 이벤트를 제공한다. 애플리케이션은 이 이벤트에 반응해 열린 화면의 상태를 다시 계산할 수 있다. 사용자 ID나 세션 ID 같은 컨텍스트를 이용해 동일한 사용자가 같은 변형을 경험하도록 배정할 수도 있다. 다만 이것 역시 자동으로 안전을 보장하지는 않는다. 새 값이 왔을 때 현재 화면을 바꿀지, 다음 진입부터 적용할지, 진행 중인 작업을 끝까지 같은 경로에 둘지는 제품이 정해야 한다. Unleash Browser SDK, Unleash Activation Strategies

Amplitude Experiment 같은 실험 시스템은 한 걸음 더 나아간다. 사용자 ID·기기 ID와 사용자 속성을 평가 입력으로 사용하고, 어떤 변형을 배정했는지와 사용자가 실제로 그 변형을 경험했는지를 별도 이벤트로 기록한다. 배정(assignment)과 노출(exposure)을 구분해야 실험 결과에서 “대상으로 골랐지만 실제로 보지 않은 사용자”를 분리할 수 있다. Amplitude Experiment Data Model, Amplitude Experiment Event Tracking

서버형 설정 시스템에 비율 값과 사용자 ID 해싱이 있다고 해도 곧바로 같은 능력을 갖는 것은 아니다. 사용자 속성에 따른 타기팅, 고정 배정, 실제 노출 수집, 열린 클라이언트의 갱신, 실험 진단까지 연결돼야 사용자 단위의 런타임 기능 관리라고 부를 수 있다.

설정값이 런타임에 바뀐다는 말과 사용자 경험을 실시간으로 통제한다는 말은 다르다. 전자를 마련하고 후자까지 확보했다고 가정하면 OFF 버튼은 존재하지만, 어떤 사용자가 언제 OFF가 됐는지는 아무도 모르는 상태가 된다.

빌드 스냅샷과 런타임 갱신을 함께 쓰는 경우

더 까다로운 형태는 빌드 시점의 스냅샷과 서버 런타임 갱신을 함께 사용하는 혼합형이다.

빌드 시점
  원격 설정 -> 키 상수 생성 + 폴백 JSON 포함

실행 시점에 존재할 수 있는 값의 출처
  원격 최신값 / 마지막 정상 캐시 / 빌드 폴백 / 코드 기본값

  실제 우선순위는 시스템의 명시적인 계약이어야 한다.

이 구조는 원격 설정 시스템이 불안정해도 서버가 뜰 가능성을 높인다. 키 오타를 컴파일 타임에 잡고, 원격에 접근하지 못하는 환경에서도 애플리케이션을 실행할 수 있다는 장점도 있다. 대신 값의 출처가 늘어난다.

  • 지금 운영에 게시된 값
  • 각 서버가 마지막으로 읽은 값
  • 배포 이미지가 만들어질 때 포함된 값
  • 조회 코드에 적힌 기본값

여기서 다음 코드만 보고 “장애가 나면 OFF로 간다”고 결론 내리기 쉽다.

const enabled = await attempt(() => provider.isOn(key)).getOrDefault(false);

하지만 제공자가 오래된 캐시나 빌드 스냅샷의 true정상적으로 반환하면 예외 폴백 false는 실행되지 않는다. 코드의 기본값은 최후의 예외 처리일 뿐이고, 실제 장애 때 살아나는 값은 그보다 앞선 캐시 정책이 결정한다.

그래서 폴백이 있다는 사실보다 다음 질문이 중요하다.

  1. 원격 조회 실패 시 어떤 값의 출처를 먼저 사용하나?
  2. 빌드에 포함된 값이 ON이어도 안전한가?
  3. 오래된 값의 최대 허용 시간은 얼마인가?
  4. 서로 다른 시점에 빌드된 인스턴스가 함께 떠도 판정이 일치하는가?
  5. 롤백이나 재시작으로 과거 스냅샷이 다시 살아날 수 있는가?
  6. 폴백을 정상 조회와 구분해 관측할 수 있는가?

운영 중 값을 OFF로 바꿨더라도 모든 인스턴스가 같은 순간에 바뀌지는 않는다. 갱신 주기와 네트워크 상태에 따라 잠시 이전 값과 새 값이 공존할 수 있다. 그때 한 사용자의 연속 요청이 서로 다른 경로로 향해도 괜찮은지 검증해야 한다. 세션의 상태 식별자나 continuation을 서버가 받는다면, 진행 중인 세션은 플래그보다 그 상태를 우선해 같은 경로에 고정하는 방식도 필요하다.

이 혼합형은 빌드 타임과 런타임의 장점을 모두 얻는 자동 해답이 아니다. 빌드 시 폴백을 넣으면 런타임 의존이 사라지는 것이 아니라, 실패했을 때 되살아날 값의 출처가 하나 더 생긴다.

킬 스위치라는 이름이 과장되는 순간

운영 화면에 ON/OFF 버튼이 있다고 모두 같은 수준의 킬 스위치는 아니다. 끌 수 있는 범위를 나누면 적어도 세 단계가 나온다.

단계 OFF가 보장하는 것 별도로 필요한 것
미래 요청 차단 수렴 이후 새 요청이 신규 경로에 들어가지 않음 서버 갱신 완료와 인스턴스별 판정 확인
진행 세션 격리 이미 시작한 사용자가 기존 경로와 신규 경로를 오가지 않음 고정 배정, continuation 우선, 세션 종료 정책
상태 복구 신규 경로가 만든 데이터와 클라이언트 상태까지 정상화 보상 작업, 데이터 호환, 캐시 무효화, 사용자 안내

서버 설정 스위치는 첫 번째 단계에는 유효할 수 있다. 그러나 두 번째와 세 번째는 기능 설계와 데이터 계약이 없으면 얻을 수 없다. 새 API가 이미 쓰기 작업을 끝냈거나 클라이언트가 새 형식의 상태를 저장했다면 OFF는 시간을 되돌리지 않는다.

따라서 “OFF 전환 완료”도 한 시각으로 기록해서는 부족하다.

설정 변경이 승인된 시각
모든 서버가 새 값을 읽은 시각
새 요청이 기존 경로로만 들어가기 시작한 시각
진행 중인 신규 경로 세션이 종료된 시각
필요한 데이터·캐시 복구가 끝난 시각

어떤 기능은 첫 번째와 두 번째 줄만으로 충분하다. 결제, 주문, 인증, 데이터 마이그레이션처럼 상태를 남기는 기능은 마지막 줄까지 가야 한다. 스위치가 멈추는 범위를 말하지 않은 채 “즉시 롤백 가능”이라고 쓰면, 제어면의 버튼 하나가 제품 전체를 되돌릴 수 있는 것처럼 보이게 된다.

그러므로 답은 빌드 타임과 런타임 중 하나를 고르는 것이 아니다. 변경의 성질에 따라 제어 수단을 나누고, 각 층의 전환 시점과 폴백 우선순위를 명시하는 것이다.

변경의 성질 적합한 수단 반드시 정할 것
번들에서 제외해야 하는 미완성·구조 변경 빌드 타임 플래그 빌드 종류, 산출물 동일성, 제거 버전
이후 서버 요청의 경로를 긴급 전환 서버 측 동적 설정 수렴 시간, 폴백 출처, 인스턴스 혼재, 진행 중 요청
열린 웹·앱에도 상태 변경을 전달 클라이언트 런타임 플래그 초기값, 갱신 이벤트, 세션 고정, 화면 전환 정책
사용자군별 가설 검증 실험·노출 플랫폼 배정 키, 대조군, 노출 이벤트, 지표, 종료 조건
장기간 진행되는 구조 교체 Branch by Abstraction 기존 경로와 신규 경로의 계약 및 제거 순서
인증·권한 서버의 인가 정책 클라이언트 플래그에 의존하지 않는 강제 조건

같은 기능을 여러 층의 플래그가 동시에 지배한다면 우선순위도 문서화해야 한다. 어느 한쪽이 OFF일 때 최종 상태가 무엇인지 정하지 않으면 안전장치를 두 개 붙이고 상태 공간만 네 배로 늘릴 수 있다.

소비처마다 실패 방향이 다르다

모든 플래그에 하나의 전역 기본값을 적용할 수도 없다. 실패했을 때 안전한 방향이 기능마다 다르기 때문이다.

장식과 부가 기능

  • 기본값: OFF
  • 화면의 핵심 내용은 계속 렌더링
  • 원격 판정이 늦어져도 사용자 흐름을 막지 않음

새 API로 전환하는 기능

  • 기본값: 검증된 기존 API
  • 제한 시간 이후 기존 경로로 폴백
  • 새 경로와 기존 경로의 응답 계약을 함께 테스트

실험 기능

  • 기본값: 대조군
  • 실험 배정에 실패해도 기본 경험 제공
  • 실패한 세션을 실험 결과에서 구분

인증과 권한

  • 기본값: 민감 동작 차단
  • 화면 전체가 아니라 보호 대상 동작만 fail-closed
  • 사용자에게 실패 상태와 재시도 경로 제공

fail-openfail-closed 중 하나를 전사 규칙으로 고르는 것이 아니라, 플래그를 등록할 때 실패 방향까지 함께 등록해야 한다. 기본값은 라이브러리의 편의 옵션이 아니라 제품 정책이다.

TBD는 플래그를 많이 만드는 방법이 아니다

Trunk-Based Development는 개발자가 짧은 브랜치에서 자주 통합하고, 큰 변경은 피처 플래그나 Branch by Abstraction으로 미완성 상태를 숨기면서 main을 계속 배포 가능한 상태로 유지하는 방식이다. Trunk-Based Development, Feature Flags

여기서 짧다는 말은 단순히 기존보다 짧다는 뜻이 아니다. Scaled TBD에서 사용하는 작업 브랜치도 보통 며칠이 아니라 하루나 이틀 안에 trunk로 돌아오는 것을 전제로 한다. 하나의 사용자 스토리를 하나의 큰 PR로 끝내기보다, 각각 독립적으로 배포할 수 있는 작은 변경으로 나누는 쪽에 가깝다. Short-Lived Feature Branches

여기서 자주 생기는 오해가 있다.

플래그 뒤에 있다
-> 사용자에게 보이지 않는다
-> main에 넣어도 안전하다

그러나 OFF인 코드도 부작용을 만들 수 있다. 모듈을 import하면서 전역 상태를 바꾸거나, Provider 초기화가 실패하거나, 백그라운드 요청을 시작하거나, 번들 크기를 늘릴 수 있다. 이번 글의 첫 코드처럼 플래그 판정을 기다리는 행위 자체가 기존 기능을 막을 수도 있다.

따라서 FF-wrapped는 구현 상태이지 안전성의 증거가 아니다. 플래그를 TBD의 병합 조건으로 사용하려면 적어도 다음을 증명해야 한다.

  1. OFF 경로: 기존 동작과 결과가 같은가?
  2. 부작용 없음: OFF여도 초기화·요청·전역 상태 변경이 발생하지 않는가?
  3. 실패 폴백: 네트워크 실패와 캐시 없음이 겹쳐도 핵심 흐름이 끝나는가?
  4. 왕복 가능성: ON에서 문제가 생겨 OFF로 돌렸을 때 실제로 복구되는가?
  5. 관측 가능성: 판정 지연, 폴백, 핵심 흐름 차단을 구분해 볼 수 있는가?
  6. 수명: 소유자, 만료일, 제거 작업이 있는가?

TBD의 속도는 검토 단계를 생략해서 나오지 않는다. 자주 통합해도 안전하다는 것을 반복 가능한 검사로 바꿨을 때 나온다.

문서 안에서는 안전했다

TBD를 조직에 도입할 때는 측정하기 쉬운 것부터 표준화하게 된다.

  • main 직접 push 금지
  • 필수 리뷰 수
  • 자동 코드 리뷰와 정적 분석
  • main 머지 후 개발 환경 자동 배포
  • 플래그가 있는지 확인하는 병합 게이트

필요한 기반이다. 문서와 설정 화면만 보면 안전해 보이기도 한다. 위험한 변경은 플래그로 감싸고, 자동 리뷰가 코드를 확인하고, 문제가 생기면 OFF로 되돌린다. 각 문장은 그럴듯하고 서로도 잘 이어진다.

다만 이것들은 TBD를 가능하게 하는 저장소 설정이지 TBD의 결과 자체는 아니다. 승인 수를 줄여 PR이 빨리 머지됐더라도 브랜치가 일주일 동안 살아 있었거나, 한 PR에 며칠치 변경을 모았거나, main이 즉시 배포 불가능하다면 핵심 문제는 남아 있다.

더 중요한 문제는 문서의 화살표가 실제 시스템에서도 이어지는지다.

자동 리뷰 통과
-> 플래그 격리 확인
-> OFF 상태로 운영 배포
-> 사용자 영향 없음

이 연결 중 하나라도 아직 측정하지 않았다면 전체는 안전 규칙이 아니라 가설이다. 정적 분석이 if 문을 찾았다고 초기화 실패까지 검증한 것은 아니다. 플래그가 OFF라고 번들, Provider, 캐시, 전역 상태와 기존 요청이 영향을 받지 않는 것도 아니다. 롤백 버튼이 있다고 데이터와 클라이언트 상태까지 원래대로 돌아오는 것도 아니다.

그럴 것이라고 생각한 것과 실제 실패를 주입해 확인한 것은 다르다. 운영과 직접 연결되는 규칙이라면 이 차이를 문장 사이에 숨기면 안 된다.

둘을 섞으면 대리 지표가 목표를 대신한다.

측정하기 쉬운 대리 지표 실제로 확인할 결과
PR 승인 수 변경이 작고 독립적으로 배포 가능한가
PR 생성부터 머지까지 걸린 시간 브랜치 생성부터 통합까지의 전체 수명이 짧은가
자동 리뷰 통과 trunk가 실제 테스트와 빌드를 통과해 배포 가능한가
Feature Flag 존재 OFF에서 신규 코드의 부작용이 정말 없는가
main→dev 자동배포 운영 후보와 같은 산출물을 반복해서 검증할 수 있는가

자동 리뷰와 병합 게이트가 잘못됐다는 뜻은 아니다. 이것들은 통합 비용을 낮추는 중요한 장치다. 다만 게이트 통과율을 TBD 도입률로 읽으면 안 된다. 일반적인 TBD의 중심은 도구의 종류나 승인 수가 아니라 짧은 통합 주기와 항상 배포 가능한 trunk다.

승인을 줄이는 실험을 하려면 “얼마나 빨라졌는가”만큼 “무엇을 놓쳤는가”를 먼저 셀 수 있어야 한다. 발견된 장애만 비교하면 조용히 잘못된 상태, 늦게 발견된 잠복 결함, 소유 팀이 대신 수습한 재작업은 통계에서 빠진다. 속도는 즉시 숫자가 되지만 안전성은 관측 체계가 없으면 아예 숫자가 되지 않는다.

피처 플래그에도 같은 구분이 필요하다. 일반적인 TBD에서 플래그는 긴 변경을 작게 나눠 통합하기 위한 여러 기법 중 하나다. 모든 사용자 노출 변경을 무조건 원격 플래그로 감싸는 규칙은 운영 통제를 단순하게 만들 수 있지만, 플래그 재고와 초기화 의존성, 상태 조합을 함께 늘린다. 작은 변경은 그대로 작게 배포하고, 장기 구조 변경은 Branch by Abstraction을 사용해야 한다. 즉시 제어가 필요한 변경에는 서버 요청 경로, 열린 클라이언트, 사용자별 노출 중 무엇을 바꿔야 하는지 먼저 정한 뒤 그 범위에 맞는 런타임 제어를 선택해야 한다.

특히 웹에서는 “플래그가 OFF면 사용자 영향이 없다”는 문장을 자동으로 참으로 볼 수 없다. OFF 구현도 번들에 포함될 수 있고, 플래그 SDK와 Provider는 초기화되며, import 시점의 코드와 공통 상태는 실행될 수 있다. 판정을 기다리는 동안 기존 요청을 막을 수도 있다. OFF는 신규 화면이 보이지 않는다는 뜻일 뿐, 플래그를 붙인 변경이 시스템에 존재하지 않는다는 뜻은 아니다.

코드를 합치는 사람과 운영하는 사람이 다를 때

TBD 위에 조직 간 기여 모델이 올라가면 검증할 것이 하나 더 늘어난다. 코드를 변경하는 사람과 그 서비스를 장기 운영하는 사람이 다를 수 있기 때문이다.

외부 기여자가 변경을 만들고 자동 게이트를 통과해 직접 머지·배포한 뒤, 일정 기간이 지나면 운영 책임을 소유 팀에 넘기는 방식을 생각해보자. 이 구조는 소유 팀의 리뷰 대기열을 줄이고 조직 전체의 실행 속도를 높일 수 있다. 그러나 책임의 경계가 자동으로 정리되지는 않는다.

  • 요구사항과 도메인 제약을 누가 확정했는가?
  • 테스트 데이터와 핵심 회귀 시나리오는 누가 준비했는가?
  • 자동 리뷰가 알 수 없는 운영 불변 조건은 어디에 기록돼 있는가?
  • 플래그 ON과 롤백은 누가 결정하는가?
  • 장애가 일어나면 변경한 사람과 운영하는 사람 중 누가 먼저 대응하는가?
  • 지원 기간이 끝난 뒤 발견된 잠복 결함은 누가 설명할 수 있는가?

이 질문에 답하지 않은 채 “게이트를 통과했으니 운영 가능”이라고 하면 속도는 앞단에서 얻고 불확실성은 운영자에게 넘기게 된다. 변경을 만든 사람은 다음 일로 이동하고, 운영자는 뒤늦게 드러난 도메인 제약과 잠복 결함을 인수한다. 반대로 모든 변경을 소유 팀 사람이 다시 처음부터 검토하면 협업 모델을 만든 의미가 사라진다.

해결책은 사람 승인을 무조건 남기거나 없애는 것이 아니다. 자동화가 대신할 수 있는 판단의 경계를 증명하는 것이다.

문서가 있다
-> 외부 기여자가 그 문서만으로 같은 결론을 내리는가

테스트가 있다
-> 핵심 실패 조건을 실제로 재현하는가

롤백 버튼이 있다
-> 운영 상태와 데이터까지 되돌아오는가

대시보드가 있다
-> 잘못된 변경을 사용자가 신고하기 전에 잡는가

특히 결제, 인증, 권한, 공용 계약, 마이그레이션처럼 되돌리기 어려운 영역은 별도의 위험 구역으로 두고 소유자의 명시적 판단을 남겨야 한다. 낮은 위험 영역에서 충분한 표본과 오탐·미탐 데이터를 쌓은 뒤 자동화 범위를 넓히는 순서가 맞다.

TBD는 이 책임 경계를 없애는 방식이 아니다. 변경을 작게 만들어 책임과 원인을 더 빨리 확인할 수 있게 하는 방식이다. 통합 속도를 높인 만큼 인수인계, 관측, 롤백의 정확도도 함께 높아져야 한다.

플래그를 만들 때 함께 작성할 계약

플래그 레지스트리에 불리언 키만 적는 것으로는 부족하다. 최소한 다음 정보가 함께 있어야 한다.

type FeatureFlagContract = {
  key: string;
  layer: 'build' | 'server-runtime' | 'client-runtime' | 'experiment';
  owner: string;
  defaultValue: boolean;
  fallbackOrder: Array<'remote' | 'last-known' | 'build-snapshot' | 'code-default'>;
  failureMode: 'use-default' | 'use-last-known' | 'keep-session-path' | 'block-sensitive-action';
  evaluationTrigger: 'build' | 'server-refresh' | 'request' | 'client-update';
  sessionPolicy: 'sticky' | 'next-entry' | 'immediate';
  maxWaitMs: number;
  convergenceSloMs: number;
  exposureEvent?: string;
  expiresAt: string;
  removalIssue: string;
};

그리고 테스트는 정상적인 ON/OFF만 확인해서는 안 된다.

[ ] ON에서 새 기능이 동작한다
[ ] OFF에서 기존 기능이 그대로 동작한다
[ ] 최초 방문 + 캐시 없음 + timeout에서도 핵심 흐름이 끝난다
[ ] 원격 실패 + 빌드 스냅샷 ON에서도 의도한 경로로 간다
[ ] 서로 다른 스냅샷을 가진 인스턴스가 함께 떠도 세션이 깨지지 않는다
[ ] 잘못된 payload에서도 기본 동작으로 돌아간다
[ ] ON -> OFF 전환 후 이전 상태가 남지 않는다
[ ] 이미 열린 화면과 진행 중 세션의 전환 정책이 지켜진다
[ ] 배정된 사용자와 실제 노출된 사용자를 구분할 수 있다
[ ] 만료일이 지나면 CI 또는 대시보드가 알린다

여기서 가장 중요한 테스트는 정상적인 ON/OFF 사이가 아니라 값의 출처가 바뀌는 순간이다. 캐시가 있는 개발자 환경에서는 원격 요청을 끊어도 마지막 정상값으로 동작한다. 서버 한 대만 보면 인스턴스 혼재도 재현되지 않는다. 이미 열린 화면을 새로고침하면 진행 중인 세션의 문제도 사라진다. 그래서 평소 테스트로는 가장 위험한 조합이 잘 재현되지 않는다. 깨끗한 저장소, 서로 다른 빌드 스냅샷, 원격 실패 주입, 새로고침하지 않은 클라이언트를 함께 사용해야 한다.

완료 조건은 코드 병합 뒤에 있다

플래그가 들어간 기능의 완료를 “ON으로 배포했다”로 정의하면 플래그는 계속 쌓인다. 완료 조건에는 최소한 다음 단계가 필요하다.

  1. 노출 전 성공 지표와 보호 지표를 정한다.
  2. 일부 사용자에게 열고 두 지표를 함께 관측한다.
  3. 전체 노출 뒤 정한 기간 동안 오류 예산을 넘지 않는지 확인한다.
  4. 기존 경로로 되돌아갈 필요가 없다고 합의한다.
  5. 플래그와 기존 코드를 제거한다.
  6. 제거 후에도 같은 지표가 유지되는지 확인한다.

플래그가 다른 플래그에 의존한다면 제거 순서도 함께 기록해야 한다. A를 지우려는데 BA의 OFF 상태를 전제로 한다면, 이미 플래그가 아니라 숨어 있는 상태 머신이다. 의존을 허용하더라도 그래프로 볼 수 있어야 하고 순환 의존은 등록 단계에서 막는 편이 낫다.

마치며

빌드 타임 플래그는 런타임 네트워크 의존을 없애지만 즉시 전환과 점진 배포를 포기한다. 서버 측 동적 설정은 이후 요청의 경로를 빠르게 바꾸지만 이미 열린 클라이언트와 진행 중인 세션까지 되돌리지는 못한다. 클라이언트 런타임 플래그와 실험 플랫폼은 사용자 단위 제어와 관측을 더하지만 초기화, 캐시, 식별자와 노출 정합성이라는 새로운 실패 조건을 가져온다. 어느 쪽도 그 자체로 안전하지 않다.

TBD를 도입하면서 필요한 것은 플래그의 개수가 아니다. 변경의 성질에 맞는 제어 수단을 고르고, 실패했을 때의 기본 동작과 최대 대기 시간, 관측 지표와 제거 시점을 함께 정하는 능력이다.

처음 질문으로 돌아가자. 피처 플래그는 99.999% 신뢰할 수 있어야 할까.

내 답은 이렇다.

원격 플래그를 99.999%까지 믿게 만드는 것보다, 나머지 순간에도 핵심 흐름이 끝나고 사용자가 어느 경로를 경험했는지 설명할 수 있게 만드는 것이 먼저다.

플래그로 감쌌다는 것은 구현 방식일 뿐, 안전하다는 증거는 아니다. 자동 리뷰를 붙이고 승인 수를 줄였다는 것도 운영 모델이 검증됐다는 증거는 아니다. 폴백과 관측 가능성, 만료일과 제거 조건까지 정해졌을 때 비로소 피처 플래그는 빠르게 통합하기 위한 안전장치가 된다.

새로운 운영 모델은 과감하게 실험할 수 있다. 다만 실험에는 대상, 기간, 성공 기준, 중단 기준과 비교군이 있어야 한다. 위험이 낮은 경로에 실패 조건을 주입하고, 자동화가 무엇을 놓치는지 확인하고, 사람이 빠져도 복구되는 범위를 증명한 뒤 다음 단계로 가야 한다.

운영은 검증하지 않은 가정이 처음으로 현실을 만나는 장소가 되어서는 안 된다.