본문으로 바로가기
검색 (Ctrl+K)

TBD + FeatureFlag 배포 전략을 운영하면서 배웠던 점

·
---
·
회고록개발

메인에 빠르게 머지할래요

들어가며

현재 회사에 합류하고 처음으로 Trunk-Based Development와 Feature Flag를 결합한 개발 방식을 경험했다.

처음에는 이 브랜치 전략이 단순히 브랜치를 짧게 사용하는 전략이라고 생각했는데 운영 환경에서 직접 경험해보니 TBD는 단순히 브랜치 관리 방법에 그치지 않았다. 작업을 나누는 방식, 코드를 배포하는 방식, 기능을 공개하는 방식, QA 범위를 결정하는 방식까지 함께 바꾸는 개발 전략에 가까웠다.

Feature Flag 역시 처음에는 기능을 켜고 끄는 간단한 스위치처럼 보였다. 그러나 플래그가 늘어나면서 코드 경로와 테스트 조합이 증가했고, 언제 플래그를 제거해야 하는지와 진행 중인 사용자의 상태를 어떻게 보장할 것인지 같은 새로운 문제가 나타났다.

이 글에서는 TBD와 Feature Flag를 실제 운영 환경에 적용하며 좋았던 점과 예상하지 못했던 문제, 그리고 이를 개선하기 위해 고민했던 내용을 정리해보려고 한다.


Trunk-Based Development란 무엇인가?

Trunk-Based Development는 개발자가 오랫동안 격리된 브랜치에서 작업하지 않고, 작은 변경을 자주 trunk 또는 main 브랜치에 통합하는 개발 방식이다.

반드시 전부 main 브랜치에 직접 커밋한다는 의미는 아니다. 코드리뷰를 위한 브랜치를 사용하더라도 브랜치의 수명을 짧게 유지하고, 변경 사항이 main과 오랫동안 분리되지 않도록 하는 것이 핵심이다.

DORA는 TBD의 특징을 다음과 같이 설명한다.

  • 활성 브랜치 수가 적다.
  • 브랜치의 수명이 하루 이내로 짧다.
  • 변경 사항을 작은 단위로 main에 자주 통합한다.
  • 병합 충돌이나 안정화 작업을 이유로 코드 프리즈를 반복하지 않는다.

DORA는 이러한 TBD, 테스트 자동화, 배포 자동화를 지속적 배포를 가능하게 하는 주요 기술 역량으로 분류한다. DORA Continuous Delivery (새 탭에서 열림)

하지만 여기에는 현실적인 문제가 하나 있다.

아직 완성되지 않은 기능을 어떻게 main에 병합할 것인가?

완성될 때까지 브랜치에 보관하면 다시 Long-Lived Feature Branch가 된다. 반대로 미완성 코드를 그대로 main에 병합하면 사용자에게 노출되거나 기존 기능을 망가뜨릴 수 있다.

이 지점에서 Feature Flag가 필요해진다.


TBD와 Feature Flag가 잘 맞는 이유

Feature Flag를 사용하면 코드의 배포와 기능의 공개를 분리할 수 있다.

기능이 완성되지 않았더라도 플래그가 꺼져 있다면 사용자에게 노출하지 않고 main에 병합할 수 있다.

return featureFlags.newInboundFlow
  ? <NewInboundFlow />
  : <LegacyInboundFlow />;

TBD 공식 가이드도 작업 중인 변경을 trunk에 점진적으로 통합할 때 미완성 코드가 배포 결과에 영향을 주지 않도록 Feature Flag나 Branch by Abstraction을 활용할 수 있다고 설명한다. Trunk-Based Development (새 탭에서 열림)

코드는 운영 환경에 먼저 배포하되, Feature Flag를 통해 실제 사용자 공개 시점과 대상을 별도로 제어한다.
코드는 운영 환경에 먼저 배포하되, Feature Flag를 통해 실제 사용자 공개 시점과 대상을 별도로 제어한다.

여기서 중요한 것은 다음 두 개념이 분리된다는 점이다.

  • Deployment: 코드가 운영 환경에 설치되는 것
  • Release: 사용자가 해당 기능을 실제로 사용할 수 있게 되는 것

기존에는 코드가 배포되면 기능도 동시에 공개됐고, 따라서 배포 시점에 개발·QA·운영 일정이 모두 맞아야 했다.

Feature Flag를 사용하면 코드를 먼저 배포한 뒤 준비된 시점에 기능을 공개할 수 있는 장점이 있다. 배포 결과에 문제가 없다면 내부 사용자, 특정 고객 또는 일부 운영 환경부터 점진적으로 기능을 활성화할 수도 있고, 카나리 배포와도 같은 점진적 배포도 가능하다.


가장 좋았던 점. Long-Lived Branch가 사라졌다

TBD와 Feature Flag를 운영하면서 가장 직접적으로 체감한 장점은 Long-Lived Branch가 사라진 것이었다.

기존에는 규모가 큰 기능이나 레거시 화면을 교체하는 작업이 하나의 브랜치에서 몇 주 동안 진행될 가능성이 높았다. 시간이 흐를수록 브랜치는 main과 멀어졌고 다음과 같은 문제가 발생했다.

  • main의 변경 사항을 반복해서 반영해야 했다.
  • 동일한 코드를 수정한 다른 작업과 충돌했다.
  • PR의 변경량이 너무 커져 리뷰하기 어려웠다.
  • 병합 직전에 통합 문제가 한꺼번에 발견됐다.
  • 담당자가 아니면 전체 변경 의도를 파악하기 어려웠다.

Feature Flag를 사용한 뒤에는 완성되지 않은 기능도 비활성 상태로 main에 병합할 수 있었다.

예를 들어 새로운 입고 화면을 개발한다면 전체 화면을 완성한 뒤 한 번에 병합하지 않아도 됐다.

공통 타입과 API 모델
→ 신규 화면의 기본 구조
→ 입력 컴포넌트
→ 검증 로직
→ 다음 단계 이동
→ 모니터링
완성되지 않은 신규 화면은 플래그 뒤에 숨기고 공통 타입, 화면 구조, 검증 로직 등을 독립적인 변경 단위로 나눠 병합한다.
완성되지 않은 신규 화면은 플래그 뒤에 숨기고 공통 타입, 화면 구조, 검증 로직 등을 독립적인 변경 단위로 나눠 병합한다.

각 작업을 작은 PR로 나눠 main에 반영할 수 있었다. 신규 화면은 플래그 뒤에 숨겨져 있기 때문에 기존 사용자에게는 영향을 주지 않았다.

덕분에 코드리뷰 단위가 작아졌고, main과의 차이가 누적되지 않았다. 문제가 발생하더라도 어떤 변경에서 시작됐는지 추적하기 쉬웠다.

무엇보다 작업 중인 코드를 main에 계속 반영할 수 있다는 점이 편했다. 로컬이나 개인 브랜치에만 존재하는 코드가 줄어들면서 팀이 현재 진행 상황을 공유하기도 쉬워졌다.


복잡성이 사라진 것은 아니었다

한동안은 Long-Lived Branch만 사라지면 개발 과정이 계속 단순해질 것이라고 생각했다. 하지만 Feature Flag가 늘어나면서 다른 종류의 복잡성이 생기기 시작했다.

브랜치 격리와 병합 과정의 복잡성은 줄었지만, 기존·신규 코드 경로와 플래그 생명주기를 관리하는 런타임 복잡성이 새롭게 발생
브랜치 격리와 병합 과정의 복잡성은 줄었지만, 기존·신규 코드 경로와 플래그 생명주기를 관리하는 런타임 복잡성이 새롭게 발생
이전에 있던 문제Feature Flag 도입 후 생긴 문제
오래된 브랜치와 병합 충돌기존·신규 코드 경로의 동시 유지
대규모 PR여러 플래그의 상태와 의존관계
병합 직전 통합 위험ON·OFF 조합에 따른 테스트 범위
코드 배포를 통한 롤백실행 중인 사용자 상태와 데이터 호환성
브랜치 삭제플래그와 기존 코드 제거
복잡성은 제거된 것이 아니라 Git의 병합 시점에서 애플리케이션의 실행 시점으로 이동했다.

플래그가 하나라면 두 개의 코드 경로만 고려하면 된다.

OFF → 기존 기능
ON  → 신규 기능

플래그가 여러 개가 되면 상황이 달라진다.

Flag A: ON / OFF
Flag B: ON / OFF
Flag C: ON / OFF

단순 계산만으로도 가능한 조합은 여덟 개가 된다. 플래그끼리 의존하기 시작하면 어떤 조합이 정상적인 상태인지 파악하기 어려워진다.

플래그를 코드 곳곳에서 직접 조회하는 것도 문제였다.

if (isEnabled("new-inbound")) {
  // ...
}

이러한 Toggle Point가 여러 컴포넌트와 훅에 흩어지면 플래그를 제거할 때 관련 코드를 모두 찾아야 한다. 동일한 플래그가 서로 다른 의미로 사용될 가능성도 생긴다.

그래서 플래그 값을 결정하는 로직과 실제 코드 경로가 갈리는 지점을 분리할 필요가 있다 . 또한 플래그의 수명이 길수록 조건문을 코드 곳곳에 흩뿌리지 말고 전략이나 의존성 주입 같은 구조로 격리해야 한다.

프론트엔드에서는 이렇게 삼항연산자나 if/else으로 작성하는 것 보다

function InboundPage() {
  const enabled = featureFlags.isEnabled("new-inbound");

  return enabled
    ? <NewInboundPage />
    : <LegacyInboundPage />;
}

플래그 값을 직접 조회해 if/else로 분기하는 대신, 페이지나 기능의 경계에서 사용할 구현을 선언적으로 표현하는 방식이 좋다. 실제 구조를 단순화하면 다음과 같다.

<FeatureFlagGuard
  flag={FeatureFlag.NEW_INBOUND_FLOW}
  fallback={<LegacyInboundPage />}
>
  <NewInboundPage />
</FeatureFlagGuard>

FeatureFlagGuard는 공통 플래그 상태를 읽고 다음 책임을 한곳에서 처리할 수 있다.

  • 플래그가 ON이면 신규 구현을 렌더링한다.
  • 플래그가 OFF이면 기존 구현인 fallback을 렌더링한다.
  • 개별 기능은 플래그 저장 방식이나 조회 API를 알지 못한다.

이 방식의 가장 큰 장점은 JSX 구조만 확인해도 해당 기능이 어떤 플래그의 통제를 받고 있으며, OFF일 때 어떤 화면으로 돌아가는지 알 수 있다는 점이었다. 플래그 조회와 분기 로직이 여러 컴포넌트에 흩어지지 않아 테스트도 단순해졌다.

다만 FeatureFlagGuard가 있다고 해서 아무 위치에나 Guard를 추가해도 되는 것은 아니었다. Guard가 컴포넌트 깊숙한 곳까지 퍼지면 선언적인 문법만 달라졌을 뿐 Toggle Point가 분산되는 문제는 그대로 남는다. 그래서 가능하면 라우트나 페이지처럼 기존 구현과 신규 구현의 경계가 명확한 위치에서 한 번만 분기하도록 했다.

플래그의 책임은 어느 구현을 사용할지 선택하는 데까지다. 신규 구현 내부의 컴포넌트는 Feature Flag의 존재나 저장 방식을 알 필요가 없도록 구성했다. 전체 공개와 안정화가 끝난 뒤에는 Guard와 fallback인 기존 구현을 함께 제거할 수 있었다.


플래그를 카테고리로 분류하자

Feature Flag를 사용하면서 새로운 문제가 생겼다. 플래그는 계속 추가됐지만 각 플래그가 왜 만들어졌고, 언제까지 유지해야 하며, 어떤 조건에서 제거할 수 있는지가 제대로 관리되지 않았다.

백오피스에서는 플래그의 이름과 ON/OFF 상태를 확인할 수 있었지만 그것만으로는 플래그의 성격을 알기 어려웠다. 배포가 끝나면 제거해야 하는 임시 플래그인지, 특정 화주사나 센터를 위해 계속 유지해야 하는 플래그인지 코드와 업무 맥락을 다시 확인해야 했다.

그 결과 출시가 끝난 플래그가 코드에 남거나, 제거해도 되는지 판단하기 어려워 일단 유지하는 상황이 생겼다. 시간이 지날수록 기존·신규 코드 경로가 함께 남았고, 플래그의 ON/OFF 조합도 늘어났다.

이 문제를 어떻게 관리할 수 있을지 고민하다가 Martin Fowler의 Feature Toggle 글 (새 탭에서 열림)을 읽게되었다. 이 글에서는 Feature Toggle을 Release, Experiment, Ops, Permissioning 등으로 분류하고, 플래그의 수명과 변경 방식에 따라 서로 다른 관리 전략이 필요하다고 설명한다.

같은 ON/OFF 값을 가진 플래그라도 만들어진 목적과 예상 수명이 다르면 관리 방법도 달라야 한다.

이 관점을 바탕으로 팀에 플래그를 하나의 목록으로만 관리하지 말고, 생성할 때부터 카테고리를 지정하자고 제안했다. 카테고리에 따라 확인해야 할 정보도 다르게 가져가는 방향이었다.

  • 배포를 위해 만든 플래그에는 생성일과 제거 시점을 관리한다.
  • 장기 운영 플래그에는 적용할 화주사와 센터를 관리한다.
  • 백오피스 목록에서 플래그의 유형과 적용 범위를 함께 확인할 수 있도록 한다.
  • 플래그 제거 여부를 판단할 때 ON/OFF 상태가 아니라 생성 목적과 수명을 기준으로 삼는다.

Martin Fowler의 분류를 모두 도입할 필요는 없었다. 우리 서비스에서는 Feature Flag를 이용한 A/B 테스트나 사용자 비율 기반 카나리 배포를 하지 않았기 때문에 Experiment Toggle을 별도로 운영할 이유가 없었다.

실제 사용 사례를 나눠보니 우리에게 필요했던 것은 ReleaseOps, 두 종류였다.

  • Release Flag: 신규 기능을 먼저 배포하고 검증한 뒤 공개하기 위한 임시 플래그
  • Ops Flag: 화주사나 센터마다 서로 다른 기능을 제공하기 위한 장기 운영 플래그

이 글에서 Ops Flag라고 부르는 플래그는 Fowler의 분류로 보면 특정 사용자나 고객에게 기능을 제공하는 Permissioning Toggle에 가깝다. 다만 우리 팀에서는 화주사·센터별 운영 차이를 제어한다는 의미로 Ops Flag라고 구분했다.

종류사용 목적적용 범위예상 수명제거 기준
Release신규 기능의 배포와 전환기능 또는 화면 단위짧음배포 후 안정성이 확인되고 기존 코드로 돌아갈 필요가 없을 때
Ops화주사·센터별 기능 제공특정 화주사 또는 센터장기해당 운영 정책이나 고객 요구가 사라졌을 때

Release Flag

Release Flag는 신규 기능을 운영 환경에 먼저 배포하되, 준비된 시점까지 사용자에게 숨기기 위해 사용했다.

신규 화면을 배포한 뒤 내부에서 확인하고 문제가 없다면 플래그를 ON으로 전환했다. 일정 기간 동안 장애가 없고 기존 화면으로 롤백할 필요가 없다고 판단되면 기존 구현과 Release Flag를 함께 제거할 수 있었다.

따라서 Release Flag는 처음부터 제거를 전제로 만들어지는 임시 코드였다.

Ops Flag

Ops Flag는 화주사나 물류센터마다 서로 다른 기능을 제공하기 위해 사용했다.

예를 들어 동일한 화면이라도 특정 화주사 또는 센터에서만 필요한 업무 기능이 있을 수 있다. 이 차이를 별도의 브랜치나 배포 결과물로 관리하지 않고 Ops Flag의 적용 대상을 기준으로 제어했다.

화주사 A + 센터 1 → 기능 ON
화주사 A + 센터 2 → 기능 OFF
화주사 B          → 기능 OFF

이 글에서 Ops Flag라고 부르는 플래그는 Fowler의 분류로 보면 특정 사용자나 고객에게 기능을 제공하는 Permissioning Toggle에 더 가깝다. 다만 우리 팀에서는 화주사·센터별 운영 차이를 제어하는 플래그를 내부적으로 Ops Flag라고 구분했다.

이 플래그는 배포가 끝났다는 이유만으로 제거할 수 없었다. 해당 화주사나 센터의 운영 정책이 유지되는 동안 계속 필요했기 때문이다.

두 플래그는 같은 ON/OFF 값을 사용하지만 생명주기는 완전히 달랐다.

  • Release Flag는 언제 제거할 것인지가 중요했다.
  • Ops Flag는 어디에 적용할 것인지가 중요했다.

이를 구분하지 않으면 이미 출시가 끝난 Release Flag가 코드에 계속 남거나, 반대로 장기 운영에 필요한 Ops Flag를 불필요한 임시 코드로 오해할 수 있었다.

그래서 백오피스에서도 단순히 플래그를 ON/OFF 하는 것에 그치지 않고, 플래그의 유형과 적용 대상을 구분할 수 있도록 조금씩 고도화했다.

interface FeatureFlagMetadata {
  key: string;
  type: "release" | "ops";
  description: string;
  target?: {
    shipperIds?: string[];
    centerIds?: string[];
  };
  createdAt: string;
  expectedRemovalAt?: string;
}

expectedRemovalAt은 Release Flag에서 의미가 있었지만, 장기 운영을 목적으로 하는 Ops Flag에는 일괄적으로 적용하기 어려웠다. 반대로 target은 특정 화주사와 센터를 대상으로 하는 Ops Flag에서 핵심 정보였다.

플래그 관리 도구를 굳이 외부 SaaS를 쓸 필요는 없었고 기존에 있는 백오피스를 고도화 하는 방법으로 진행했다.


Feature Flag는 언제 제거해야 할까?

가장 어려웠던 문제는 플래그의 제거 시점이었다.

처음에는 생성 후 한 달이 지나면 제거하는 규칙을 생각했다. 명확한 기간을 정하면 방치되는 플래그를 줄일 수 있기 때문이다.

하지만 운영하면서 기간만으로 제거 여부를 결정하기는 어렵다고 느꼈다.

한 달이 지났더라도 아직 일부 사용자에게만 공개된 기능일 수 있다. 반대로 전체 사용자에게 공개한 뒤 충분히 안정화됐다면 일주일 만에도 제거할 수 있다.

따라서 기간은 제거 조건이 아니라 점검을 시작하는 기준으로 사용하는 편이 적절하다.

예를 들어 다음 조건을 모두 충족하면 Release Flag 제거를 검토할 수 있다.

  • 전체 대상에게 신규 기능이 활성화됐다.
  • 일정한 안정화 기간 동안 주요 오류가 발생하지 않았다.
  • 기존 구현으로 롤백할 필요가 없다고 합의했다.
  • 신규·기존 경로의 비즈니스 지표와 장애 지표를 확인했다.
  • 진행 중인 사용자가 기존 경로를 사용하고 있지 않다.
  • QA와 기능 담당자가 신규 경로를 기본 동작으로 승인했다.

expectedRemovalAt은 한 달 뒤로 설정할 수 있다. 다만 해당 날짜가 되면 플래그를 자동 삭제하는 것이 아니라 담당자에게 다음 행동을 요구해야 한다.

  • 제거한다.
  • 제거 기한을 연장하고 이유를 기록한다.
  • 장기 플래그로 전환한다.

Martin Fowler는 Release Toggle이 본질적으로 임시적이며 일반적으로 짧게 유지되어야 한다고 설명한다. 구체적으로는 수 주보다 오래 방치하지 않는 방향을 제시하지만, 제품 출시 목적의 플래그는 더 오래 필요할 수 있다는 예외도 함께 설명한다. 결국 중요한 것은 일률적인 기간보다 플래그의 종류와 제거 조건이다. Feature Toggles (새 탭에서 열림)


QA팀과의 업무 병목

Feature Flag를 활용해서 릴리즈를 진행했을 때 QA 프로세스에서 새로운 병목이 생겼다.

신규 기능이 플래그 뒤에 있고 기본값이 OFF인 상태에서도 코드가 변경되면 기존과 동일한 QA 절차를 거쳐야 했다. 작은 변경을 자주 main에 통합하려고 해도 매번 QA 요청과 확인을 기다려야 했기 때문에 TBD의 장점을 충분히 활용하기 어려웠다.

개발팀에서는 플래그가 OFF라면 사용자에게 신규 기능이 노출되지 않으므로 모든 변경에 수동 QA가 필요하지 않다고 생각했다. 반면 QA팀에서는 코드가 운영 환경에 배포되는 이상 기존 기능에 영향을 줄 가능성이 있다고 보았다.

결국 QA를 생략할 것인지의 문제가 아니라, 변경의 위험에 따라 QA 시점과 범위를 어떻게 나눌 것인지의 문제로 다시 정의했다.

개발팀과 QA팀은 다음과 같은 방향으로 업무 방식을 협의했다.

  • 피쳐플래그로 격리된 기능은 작은 단위로 main에 통합하고, 실제로 플래그를 ON해 공개하기 전에 기능 QA를 진행한다.
  • 공통 컴포넌트나 기존 API처럼 OFF 경로에도 영향을 줄 수 있는 변경은 플래그 상태와 관계없이 QA 범위에 포함
  • 리팩토링이나 마이그레이션은 전체 회귀 테스트보다 적용 대상과 핵심 시나리오를 확인하는 방식으로 범위를 조정 (혹은 E2E 테스트로 대체)
  • QA 요청 시 플래그의 종류, 현재 상태, 적용 대상과 기존 기능에 미치는 영향을 함께 전달

이 과정을 통해 개발팀과 QA팀이 배포와 릴리스의 차이, 그리고 플래그가 OFF일 때도 확인해야 하는 변경의 범위에 대해 공통 기준을 맞출 필요가 있다는것을 깨달았다.


TBD와 Feature Flag를 운영하며 배운 것

결론부터 말하면 TBD는 정말 좋은 개발 방식이라고 느꼈다.

작업을 작은 단위로 나눠 main에 자주 병합할 수 있었고, 병합된 코드를 계속 운영 환경에 배포할 수 있었다. 기능 공개 시점은 Feature Flag로 분리했기 때문에 미완성 코드를 오래된 브랜치에 쌓아두지 않아도 됐다.

스프린트 방식과도 잘 맞았다. 하나의 큰 기능을 스프린트 마지막에 한 번에 병합하는 대신, 스프린트 안에서 완료한 작은 작업을 차례대로 main에 반영할 수 있었다. 덕분에 마지막에 큰 PR을 병합하며 충돌을 해결하는 부담이 줄었고, 현재 개발이 어디까지 진행됐는지도 쉽게 확인할 수 있었다.

Feature Flag를 더 적극적으로 활용하면 A/B 테스트나 카나리 배포처럼 기능을 검증하고 공개하는 방법도 확장할 수 있다. 우리 팀에서는 아직 이러한 방식까지 활용하지 않았기 때문에 TBD와 Feature Flag의 장점을 충분히 사용하지 못했다는 아쉬움도 남았다.

운영하면서 가장 크게 배운 점은 개발자만 Feature Flag를 이해한다고 이 방식이 제대로 동작하지는 않는다는 것이었다.

개발팀에서는 Flag가 OFF인 코드를 안전하게 main에 병합할 수 있다고 생각했지만, QA팀에서는 운영 환경에 배포되는 코드인 만큼 기존 기능에 미치는 영향을 확인해야 했다. 서로 다른 관점에서 같은 변경을 바라보고 있었기 때문에 QA 시점과 범위를 정하는 과정에서 병목이 생겼다.

이를 해결하기 위해 개발팀과 QA팀이 배포와 릴리스의 차이, Flag가 OFF일 때도 확인해야 하는 변경, 실제 기능 공개 전에 검증할 범위를 함께 협의했다. 새로운 업무 방식을 도입할 때는 도구보다 먼저 팀이 같은 개념과 기준을 공유해야 한다는 것을 알게 됐다.

협의 과정에서 각 팀이 무엇을 위험하다고 생각하는지 이해할 수 있었던 것도 좋은 경험이었다. TBD와 Feature Flag는 단순한 브랜치 전략이나 ON/OFF 기능이 아니라, 개발·QA·운영이 함께 배포 방식을 바꾸는 일이었다.


참고 자료

https://trunkbaseddevelopment.com/ (새 탭에서 열림)

https://memoryhub.tistory.com/entry/Trunk-Based-Development-%EC%99%84%EB%B2%BD-%EA%B0%80%EC%9D%B4%EB%93%9C-Feature-Flag%EC%99%80-CICD-%ED%8C%8C%EC%9D%B4%ED%94%84%EB%9D%BC%EC%9D%B8-%EA%B5%AC%EC%B6%95%EA%B9%8C%EC%A7%80 (새 탭에서 열림)

https://martinfowler.com/articles/feature-toggles.html (새 탭에서 열림)