기능 브랜치를 만들고 며칠, 길게는 몇 주씩 작업하다 메인 브랜치에 합치려는 순간, 지옥이 시작된다. 그 사이 다른 사람들이 올린 수십 개의 커밋과 충돌하고, 충돌을 해결하고 나면 테스트가 깨지고, 다시 리베이스하면 또 다른 충돌이 튀어나온다. “머지 지옥(merge hell)”이라 불리는 이 상황은 브랜치가 오래 살아남을수록 필연적으로 악화된다. 브랜치의 수명과 통합 고통은 정비례하기 때문이다.

트렁크 기반 개발(Trunk-Based Development, TBD)은 이 문제를 근본에서 다르게 접근한다. 긴 브랜치를 없애고, 모든 개발자가 하루에도 여러 번 메인(트렁크)에 작은 변경을 자주 통합하는 방식이다. 통합을 미루지 않으면 충돌이 쌓일 틈이 없다. 하지만 “완성 안 된 기능을 메인에 어떻게 넣지?”라는 당연한 의문이 따라온다. 이 글에서는 TBD의 원리, 짧은 브랜치 운영, 미완성 기능을 감추는 피처 플래그, 그리고 이 모든 것을 떠받치는 CI 규율까지 실전 관점에서 다룬다.

왜 오래 사는 브랜치가 문제인가

흔한 Git Flow 방식에서는 develop, feature/*, release/* 등 여러 장수 브랜치가 병렬로 존재한다. 각 브랜치가 독립적으로 발전할수록 서로의 코드가 멀어지고, 최종 통합 시점에 폭발적인 충돌이 발생한다. 이것을 통합 지연 비용이라 부른다.

  • 브랜치가 오래 살수록 메인과의 차이(divergence)가 커진다
  • 차이가 클수록 머지 충돌 확률과 규모가 커진다
  • 충돌 해결은 원저자가 직접 해야 하는데, 시간이 지나면 맥락을 잊는다
  • 큰 머지는 리뷰가 어렵고, 리뷰가 어려우면 버그가 새어 나간다

TBD의 대전제는 단순하다. 통합을 미루지 마라. 변경을 작게 쪼개 자주 메인에 합치면, 각 통합의 충돌 규모가 작아 감당할 수 있는 수준에 머문다.

짧은 수명의 브랜치 운영

순수한 TBD는 개발자가 트렁크에 직접 커밋하기도 하지만, 코드 리뷰 문화가 자리 잡은 팀에서는 수명이 하루 이내인 짧은 브랜치를 쓴다. 브랜치를 만들고, 작은 변경을 커밋하고, 당일 안에 PR을 올려 리뷰받고 메인에 머지한다. 핵심은 브랜치가 하루를 넘기지 않도록 작업 단위를 잘게 쪼개는 것이다.

# 아침: 최신 트렁크에서 짧은 브랜치 시작
git checkout main
git pull --rebase origin main
git checkout -b add-email-validation

# 작은 변경 커밋
git add src/validators/email.py tests/test_email.py
git commit -m "Add email format validation"

# 당일 안에 push → PR → 리뷰 → 머지
git push -u origin add-email-validation

작업이 하루에 끝나지 않을 것 같으면, 기능을 완성된 상태가 아니라 안전하게 병합 가능한 조각으로 나눈다. 예를 들어 “새 결제 모듈”을 한 번에 만들지 않고, 인터페이스 정의 → 내부 로직 → API 연결 → UI 노출 순으로 각각 별개 PR로 올린다. 아직 호출되지 않는 코드는 메인에 있어도 사용자에게 영향을 주지 않는다.

피처 플래그: 미완성 기능을 안전하게 감추기

TBD의 가장 큰 오해는 “완성 안 된 기능이 사용자에게 노출된다”는 걱정이다. 답은 피처 플래그(feature flag)다. 코드는 메인에 있지만, 플래그로 실제 실행 여부를 제어해 준비되기 전까지 꺼둔다. 이렇게 하면 미완성 코드를 계속 통합하면서도 사용자에게는 보이지 않는다.

from dataclasses import dataclass

@dataclass
class FeatureFlags:
    new_checkout: bool = False
    dark_mode: bool = False

def load_flags(user_id: str) -> FeatureFlags:
    # 환경변수·설정 서비스·DB 등에서 로드
    return FeatureFlags(
        new_checkout=is_enabled("new_checkout", user_id),
        dark_mode=is_enabled("dark_mode", user_id),
    )

def checkout(cart, user_id: str):
    flags = load_flags(user_id)
    if flags.new_checkout:
        return new_checkout_flow(cart)   # 개발 중인 신규 경로
    return legacy_checkout_flow(cart)    # 기존 안정 경로

플래그는 단순 on/off를 넘어 점진적 롤아웃에도 쓰인다. 내부 직원 → 1% 사용자 → 10% → 전체 순으로 노출을 확대하며 문제를 조기에 발견한다.

import hashlib

def is_enabled(flag: str, user_id: str, rollout_pct: int = 0) -> bool:
    # user_id 해시를 0~99 버킷에 매핑 → 안정적 점진 노출
    bucket = int(hashlib.md5(f"{flag}:{user_id}".encode()).hexdigest(), 16) % 100
    return bucket < rollout_pct

다만 피처 플래그에는 비용이 있다. 플래그가 늘어나면 코드에 분기가 쌓이고, 조합 경우의 수가 폭증해 테스트가 어려워진다. 기능이 완전히 출시되면 플래그와 죽은 분기를 반드시 제거하는 청소 규율이 없으면, 피처 플래그 자체가 새로운 기술 부채가 된다.

CI 규율: TBD를 떠받치는 안전망

모든 변경을 자주 메인에 합치려면, 메인이 항상 배포 가능한 상태여야 한다. 이를 보장하는 것이 강력한 CI다. 모든 PR은 머지 전에 자동 테스트를 통과해야 하고, 빌드가 깨진 채로 메인에 들어가는 일이 없어야 한다.

# GitHub Actions: PR마다 테스트 강제
name: CI
on:
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install -r requirements.txt
      - run: pytest --maxfail=1 -q       # 하나라도 깨지면 즉시 실패
      - run: ruff check .                # 정적 분석

여기에 브랜치 보호 규칙(branch protection)을 걸어 CI 통과와 리뷰 승인 없이는 메인에 머지할 수 없게 강제한다. 규율을 사람의 의지가 아니라 도구로 강제하는 것이 핵심이다.

# GitHub CLI로 브랜치 보호 규칙 설정
gh api repos/:owner/:repo/branches/main/protection -X PUT -F \
  required_status_checks[strict]=true -F \
  required_status_checks[contexts][]=test -F \
  required_pull_request_reviews[required_approving_review_count]=1 -F \
  enforce_admins=true

빠른 피드백도 중요하다. CI가 20분씩 걸리면 개발자는 통합을 미루게 되고, TBD의 전제가 무너진다. 테스트 병렬화, 빌드 캐시, 변경 영향 범위만 테스트하는 선택적 실행 등으로 피드백 루프를 10분 이내로 유지하는 것이 TBD를 지속 가능하게 만든다.

릴리스 브랜치와 핫픽스는 어떻게

TBD가 모든 브랜치를 부정하는 것은 아니다. 출시 시점에 릴리스 브랜치를 트렁크에서 잘라내는 것은 허용되며 권장된다. 다만 이 릴리스 브랜치는 새 기능 개발을 위한 것이 아니라, 오직 안정화와 버그 수정만을 위한 것이다.

# 릴리스 시점의 트렁크에서 릴리스 브랜치 생성
git checkout main
git checkout -b release/1.8

# 긴급 버그는 트렁크에서 먼저 고치고 릴리스 브랜치로 체리픽
# (반대가 아님 — 트렁크가 항상 진실의 원천)
git checkout main
git cherry-pick     # 트렁크 커밋을 릴리스로
git checkout release/1.8
git cherry-pick 

핵심 원칙은 트렁크가 항상 진실의 원천이라는 것이다. 버그는 항상 트렁크에서 먼저 고치고, 필요하면 릴리스 브랜치로 체리픽한다. 릴리스 브랜치에서만 고치고 트렁크에 반영하지 않으면, 다음 릴리스에서 같은 버그가 되살아나는 회귀가 발생한다.

마무리

트렁크 기반 개발의 본질은 "브랜치를 없애는 것"이 아니라 "통합을 미루지 않는 것"이다. 변경을 작게 쪼개 하루 안에 메인에 합치면 충돌이 쌓일 틈이 없어 머지 지옥에서 벗어난다. 미완성 기능은 피처 플래그로 감추고, 강력한 CI와 브랜치 보호로 메인을 항상 배포 가능한 상태로 유지한다. 다만 TBD는 공짜가 아니다. 작업을 잘게 쪼개는 훈련, 빠른 CI에 대한 투자, 피처 플래그를 제때 청소하는 규율이 모두 필요하다. 이 규율이 없는 팀이 TBD만 흉내 내면 오히려 불안정한 메인과 플래그 부채라는 새로운 지옥을 만나게 된다. TBD는 도구가 아니라 규율이라는 점을 잊지 말아야 한다.

자주 묻는 질문

Q. 코드 리뷰를 제대로 하려면 브랜치가 어느 정도 살아야 하지 않나요?
A. 오히려 반대입니다. 작은 PR일수록 리뷰가 빠르고 정확합니다. 수백 줄짜리 큰 PR은 리뷰어가 대충 훑고 승인하기 쉬워 버그가 새어 나갑니다. TBD는 변경을 잘게 쪼개므로 각 PR이 작아 리뷰 품질이 오히려 올라갑니다. 짧은 브랜치와 코드 리뷰는 충돌하지 않습니다.

Q. 피처 플래그가 너무 많아지면 어떻게 관리하나요?
A. 플래그마다 생성일과 소유자, 제거 목표 시점을 기록하고, 기능이 완전히 출시되면 플래그와 죽은 분기를 즉시 제거하는 청소 작업을 정기적으로 수행해야 합니다. 플래그를 관리하는 별도 설정 서비스나 대시보드를 두고, 오래된 플래그를 주기적으로 점검하는 것이 부채 누적을 막는 실용적 방법입니다.

Q. 우리 팀은 테스트 커버리지가 낮은데 TBD를 바로 도입해도 되나요?
A. 위험합니다. TBD는 메인이 항상 배포 가능하다는 전제 위에서만 안전하며, 그 전제를 지키는 것이 자동화된 테스트입니다. 테스트가 부실한 상태로 트렁크에 자주 합치면 깨진 메인이 계속 쌓입니다. TBD 도입 전에 핵심 경로의 테스트 커버리지와 CI 파이프라인부터 다지는 것이 순서입니다.