왜 브랜치 보호가 필요한가

main 브랜치에 검증되지 않은 코드가 직접 병합되면 배포 파이프라인이 언제든 깨질 수 있다. "PR에서 리뷰했으니 괜찮겠지"라는 암묵적 신뢰는 사람이 실수하는 순간 무너진다. 리뷰어가 대충 승인하거나, CI가 아직 안 끝났는데 병합하거나, 관리자가 급하다며 force push하는 상황이 반복된다. 브랜치 보호 규칙은 이런 인간의 판단을 정책으로 강제해, 통과해야 할 조건을 명시적으로 만든다.

핵심 개념: 필수 상태 체크

필수 상태 체크(required status checks)는 특정 CI 잡이 성공(success)을 보고해야만 병합을 허용하는 장치다. 여기서 자주 놓치는 부분이 strict 모드다. strict 모드가 켜지면 PR 브랜치가 base 브랜치의 최신 커밋과 동기화된 상태에서 체크가 통과해야 한다. 즉 오래된 브랜치는 rebase/merge 후 재검증이 강제된다. 이 옵션 하나가 "각자 브랜치에선 통과했는데 합치니 깨지는" 문제를 상당수 막아준다.

GitHub API로 규칙 코드화하기

UI에서 클릭으로 설정하면 재현이 안 되고 감사(audit)도 어렵다. 브랜치 보호 규칙은 API로 관리해 코드로 남기는 것이 좋다.

curl -X PUT \
  -H "Authorization: Bearer $GH_TOKEN" \
  -H "Accept: application/vnd.github+json" \
  https://api.github.com/repos/int4/momentnote/branches/main/protection \
  -d '{
    "required_status_checks": {
      "strict": true,
      "contexts": ["build", "unit-test", "lint"]
    },
    "enforce_admins": true,
    "required_pull_request_reviews": {
      "required_approving_review_count": 1,
      "dismiss_stale_reviews": true
    },
    "restrictions": null,
    "allow_force_pushes": false,
    "allow_deletions": false
  }'

enforce_admins: true가 중요하다. 관리자를 예외로 두면 가장 바쁜 사람이 규칙을 우회하게 되고, 정책은 사실상 무력화된다.

체크 이름을 파이프라인과 일치시키기

required status checks의 contexts에 넣는 이름은 실제 CI 잡의 context 이름과 정확히 일치해야 한다. 이름이 다르면 존재하지 않는 체크를 영원히 기다리며 병합이 막힌다. GitHub Actions에서는 잡 이름이 곧 context가 된다.

name: ci
on:
  pull_request:
    branches: [main]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: make build
  unit-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pytest -q
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: ruff check .

여기서 잡 id가 build, unit-test, lint이므로 보호 규칙의 contexts와 정확히 맞아떨어진다.

조건부 잡과 required check의 함정

가장 흔한 사고는 경로 필터(paths filter)로 특정 잡을 건너뛰게 만든 경우다. 문서만 수정한 PR에서 unit-test 잡이 아예 트리거되지 않으면, required check는 "성공"이 아니라 "미보고" 상태로 남아 PR이 병합되지 않고 멈춘다. 해결책은 잡을 건너뛰는 대신 항상 실행하되 내부에서 빠르게 통과시키는 것이다.

  unit-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - id: changed
        run: |
          if git diff --name-only origin/main... | grep -qvE '\.(md|txt)$'; then
            echo "run=true" >> "$GITHUB_OUTPUT"
          else
            echo "run=false" >> "$GITHUB_OUTPUT"
          fi
      - if: steps.changed.outputs.run == 'true'
        run: pytest -q

잡 자체는 항상 돌고 success를 보고하므로 required check가 영원히 멈추는 일이 없다.

규칙 방식 비교

방식적용 범위버전 관리권장 상황
UI 설정단일 브랜치어려움일회성 실험
Branch protection API패턴별 브랜치스크립트로 가능레포 단위 표준화
Rulesets조직/여러 레포JSON 내보내기조직 전체 정책

운영 시 주의점

필수 체크는 많을수록 좋은 것이 아니다. 느린 통합 테스트를 required로 걸면 병합 대기 시간이 길어져 개발자들이 우회 방법을 찾기 시작한다. required에는 빠르고 결정적인(deterministic) 체크만 넣고, 무겁거나 flaky한 테스트는 병합 후 배포 파이프라인이나 야간 잡으로 분리하는 편이 현실적이다. 또한 dismiss_stale_reviews를 켜두면 새 커밋이 푸시될 때 이전 승인이 무효화되어, 실제 병합되는 코드가 리뷰된 코드와 일치하도록 보장된다. 규칙은 한 번 세팅하고 끝이 아니라, CI 잡 이름을 바꿀 때마다 보호 규칙의 contexts도 함께 갱신하는 절차를 팀 문서에 남겨두어야 한다.