배포 자동화를 도입하면 코드가 병합되는 순간부터 프로덕션까지 사람 손을 거치지 않고 흘러갑니다. 대부분의 변경에는 이것이 바람직하지만, 결제 로직이나 데이터 마이그레이션처럼 실수의 대가가 큰 배포에는 중간에 멈춰 세우고 책임 있는 사람이 승인하는 관문, 즉 승인 게이트(approval gate)가 필요합니다.

그런데 승인 게이트를 “버튼 하나 눌러 통과” 정도로 만들면 형식만 남습니다. 누가·언제·무엇을 승인했는지 남지 않으면 사고 원인을 추적할 수 없고, 개발자가 자기 배포를 스스로 승인하면 통제가 무의미해집니다. 이 글에서는 승인 게이트를 실제로 작동하는 통제 수단으로 설계하는 방법과, 그 승인을 위·변조하기 어려운 감사 로그(audit log)로 남기는 구조를 정리합니다.

승인 게이트가 필요한 배포와 필요 없는 배포

모든 배포에 승인을 걸면 자동화의 이점이 사라지고 승인은 형식적 의례로 전락합니다. 첫 단계는 어떤 변경이 게이트를 통과해야 하는지를 나누는 것입니다. 되돌리기 쉬운 무상태 롤링 배포는 자동으로 흘려보내고, 되돌리기 어렵거나 폭발 반경이 큰 변경만 게이트를 겁니다.

  • 자동 통과: 무상태 API 롤링 업데이트, 기능 플래그로 감싼 코드, 카나리로 검증되는 변경
  • 승인 필요: DB 스키마 변경, 결제·정산 로직, 시크릿·권한 정책 수정, 인프라 파괴적 변경(테라폼 destroy 포함)

이 분류를 사람이 매번 판단하면 일관성이 무너지므로, 파이프라인이 변경 내용을 보고 게이트 필요 여부를 자동 판정하는 편이 안전합니다. 아래는 변경 파일 경로를 기준으로 결정하는 예시입니다.

#!/usr/bin/env bash
# 병합 대상 브랜치와의 diff 로 변경 파일을 뽑아 게이트 필요 여부 판정
set -euo pipefail

CHANGED=$(git diff --name-only "origin/${TARGET_BRANCH:-main}"...HEAD)

# 승인이 필요한 민감 경로 패턴
SENSITIVE='(^db/migrations/|^services/billing/|^infra/|.tfvars$|secrets/)'

if echo "$CHANGED" | grep -Eq "$SENSITIVE"; then
  echo "requires_approval=true"  >> "$GITHUB_OUTPUT"
  echo "민감 경로 변경 → 승인 게이트 활성화" >&2
else
  echo "requires_approval=false" >> "$GITHUB_OUTPUT"
  echo "일반 변경 → 자동 배포" >&2
fi

환경(environment) 보호 규칙으로 게이트 구현하기

대부분의 CI/CD 도구는 “환경”에 배포 전 승인을 붙이는 기능을 제공합니다. GitHub Actions의 environment protection rule이 대표적입니다. 잡(job)에 environment: production을 지정하면 등록된 리뷰어가 승인하기 전까지 잡이 대기 상태로 멈춥니다. 파이프라인 코드에 승인 로직을 넣는 대신 플랫폼이 실행을 붙잡아 준다는 점이 핵심입니다.

# .github/workflows/deploy.yml
deploy-prod:
  needs: [build, gate-check]
  # gate-check 가 승인 필요로 판정했을 때만 이 잡이 돈다
  if: needs.gate-check.outputs.requires_approval == 'true'
  runs-on: ubuntu-latest
  environment:
    name: production        # 이 환경에 required reviewers 를 설정
    url: https://app.example.com
  steps:
    - uses: actions/checkout@v4
    - run: ./scripts/deploy.sh   # 승인자·run_id 를 감사 로그로 기록
      env:
        ACTOR: ${{ github.actor }}
        RUN_ID: ${{ github.run_id }}

중요한 것은 승인 대기 상태의 잡이 아무 부작용도 일으키지 않아야 한다는 점입니다. 승인 전에 리소스를 절반쯤 만드는 잡이라면 거부 시 어정쩡한 중간 상태가 남으므로, 게이트 앞은 빌드와 계획(plan)까지만 하고 실제 반영은 게이트 뒤로 미뤄야 합니다.

self-approval을 막아야 통제가 성립한다

승인 게이트의 존재 이유는 변경한 사람과 승인한 사람을 분리(four-eyes principle)하는 데 있습니다. 배포를 트리거한 사람이 곧 승인자가 될 수 있다면 게이트는 이름뿐입니다. 기본 설정만으로 이 분리가 강제되지 않는 도구가 많으므로 파이프라인 안에서 명시적으로 검증해야 합니다.

# 승인 이벤트를 검증: 배포 트리거 주체와 승인자가 달라야 통과
import sys, json

def verify_four_eyes(deploy_actor: str, approvals: list[dict]) -> None:
    approved = [a for a in approvals if a["state"] == "approved"]

    # 승인자 중에 배포 트리거 주체가 섞여 있으면 거부
    if any(a["reviewer"] == deploy_actor for a in approved):
        sys.exit("거부: 배포 트리거 본인은 승인자가 될 수 없습니다")

    # 서로 다른 승인자 수가 정책 기준(예: 1명)을 만족해야 함
    distinct = {a["reviewer"] for a in approved}
    if len(distinct) < 1:
        sys.exit("거부: 유효한 승인이 없습니다")

    print(f"승인 검증 통과: {sorted(distinct)}")

if __name__ == "__main__":
    payload = json.load(sys.stdin)
    verify_four_eyes(payload["deploy_actor"], payload["approvals"])

규제 대상이라면 승인자 수를 2명 이상으로 올리고 소속 팀까지 확인합니다. 다만 요건을 지나치게 높이면 야간·주말 배포가 막혀 긴급 대응을 방해하므로, 뒤에 다룰 비상 우회(break-glass)와 짝을 이뤄야 합니다.

감사 로그에 무엇을 남길 것인가

감사 로그의 목적은 사고 후 “무슨 일이 있었는지 정확히 재구성”하는 것입니다. 그러려면 승인 사건 하나에 육하원칙 필드가 빠짐없이 담겨야 합니다. 흔히 승인 시각과 승인자만 남기는데, 정작 조사에서 필요한 것은 “무엇을” “어느 버전을” 승인했느냐입니다.

  • who: 승인자 식별자, 트리거 주체, 인증 방식(SSO 세션 등)
  • what: 서비스명, 커밋 SHA, 아티팩트 다이제스트, 변경 마이그레이션
  • when: 승인 시각(서버 신뢰 시각), 배포 시작·완료 시각
  • where / why: 대상 환경·리전, 연결 티켓 번호, 승인 코멘트

이 레코드는 클라이언트가 보낸 값을 그대로 믿으면 안 됩니다. 시각과 승인자 신원은 반드시 서버(플랫폼) 측 신뢰 소스에서 채워야 위·변조를 막습니다. 아래는 감사 이벤트를 정규화하고 무결성 해시를 붙이는 예시입니다.

import hashlib, json
from datetime import datetime, timezone

def build_audit_event(*, service, commit_sha, artifact_digest,
                      approver, actor, environment, ticket, comment):
    event = {
        "schema": "deploy.approval.v1", "service": service,
        "commit_sha": commit_sha, "artifact_digest": artifact_digest,
        "approver": approver, "actor": actor, "environment": environment,
        "ticket": ticket, "comment": comment,
        # 시각은 클라이언트 값이 아니라 서버 UTC 로 고정
        "approved_at": datetime.now(timezone.utc).isoformat(),
    }
    # 레코드 자체의 무결성 해시(뒤에서 체인으로 연결)
    canonical = json.dumps(event, sort_keys=True, separators=(",", ":"))
    event["record_hash"] = hashlib.sha256(canonical.encode()).hexdigest()
    return event

변조를 어렵게 만드는 append-only 저장

감사 로그는 추가만 가능하고 수정·삭제는 불가능해야 신뢰할 수 있습니다. 그래서 각 레코드가 이전 레코드의 해시를 품는 해시 체인으로 연결해, 중간을 손대면 이후 전체의 해시가 어긋나게 만듭니다.

CREATE TABLE deploy_audit (
    id          BIGSERIAL PRIMARY KEY,
    event       JSONB       NOT NULL,
    record_hash TEXT        NOT NULL,   -- 레코드 자체 해시
    prev_hash   TEXT        NOT NULL,   -- 직전 행의 chain_hash
    chain_hash  TEXT        NOT NULL,   -- sha256(prev_hash || record_hash)
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- UPDATE / DELETE 를 원천 차단 (append-only 강제)
CREATE OR REPLACE FUNCTION forbid_mutation() RETURNS trigger AS $$
BEGIN RAISE EXCEPTION '감사 로그는 수정/삭제할 수 없습니다'; END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER no_update BEFORE UPDATE ON deploy_audit
    FOR EACH ROW EXECUTE FUNCTION forbid_mutation();
CREATE TRIGGER no_delete BEFORE DELETE ON deploy_audit
    FOR EACH ROW EXECUTE FUNCTION forbid_mutation();

체인 무결성은 chain = sha256(prev_chain + record_hash)를 처음(GENESIS 앵커)부터 끝까지 재계산해 검증합니다. 해시가 어긋나는 첫 지점이 곧 변조 의심 레코드이고, 그 이후 전체가 손상된 것으로 간주합니다. 다만 애플리케이션 권한으로 트리거를 우회할 여지가 있으므로, 규제 수준의 불변성이 필요하다면 객체 잠금(object lock, WORM) 스토리지에 로그를 이중 적재하기를 권합니다.

승인 대상과 배포 대상을 반드시 묶어라

가장 흔하고 치명적인 허점은 승인한 산출물과 실제 배포된 산출물이 다를 수 있다는 점입니다. 승인 후 누군가 이미지를 다시 빌드하거나 태그를 옮겨치면 승인은 A를 대상으로 했는데 프로덕션에는 B가 올라갑니다. 승인은 불변 식별자(다이제스트, 커밋 SHA)를 대상으로 하고, 배포 직전에 일치 여부를 재확인해야 이를 막습니다.

#!/usr/bin/env bash
# 승인 시점에 기록해 둔 다이제스트와 실제 배포할 이미지가 같은지 검증
set -euo pipefail

APPROVED_DIGEST="$1"                            # 감사 로그의 승인 대상
DEPLOY_DIGEST=$(crane digest "${IMAGE}:${TAG}") # 지금 배포할 실제 이미지

if [[ "$APPROVED_DIGEST" != "$DEPLOY_DIGEST" ]]; then
  echo "중단: 승인된 이미지($APPROVED_DIGEST)와 배포 대상($DEPLOY_DIGEST)이 다릅니다" >&2
  exit 1
fi
echo "다이제스트 일치 → 배포 진행"

:latest나 이동 가능한 태그를 배포 대상으로 삼으면 이 검증 자체가 무의미해집니다. 게이트를 운영한다면 배포는 언제나 다이제스트로 고정(pin)하는 것이 전제이며, 그렇지 않으면 앞의 모든 감사 로그가 “무엇을 승인했는지 알 수 없는” 껍데기가 됩니다.

비상 우회(break-glass)는 없애지 말고 기록하라

야간 장애 중 승인자를 기다리다 서비스가 더 망가지는 것은 통제의 실패입니다. 그래서 게이트에는 비상 우회 경로가 있어야 합니다. 다만 우회를 조용히 허용하면 안 되고, 그 사실을 최고 심각도로 감사 로그에 남기고 즉시 알림을 보내 사후 리뷰가 강제되게 만듭니다.

# break-glass 배포: 승인은 건너뛰되, 대신 강한 흔적을 남긴다
def break_glass_deploy(*, actor, reason, incident_id, notify):
    if not reason or not incident_id:
        raise ValueError("비상 우회에는 사유와 인시던트 번호가 필수입니다")

    event = build_audit_event(
        service="billing", commit_sha=current_sha(),
        artifact_digest=current_digest(),
        approver="BREAK_GLASS",          # 승인자 없음을 명시
        actor=actor, environment="production",
        ticket=incident_id, comment=f"[BREAK-GLASS] {reason}",
    )
    append_audit(event, severity="critical")
    notify(channel="#sec-audit",        # 즉시 통지 → 24h 내 사후 리뷰 의무화
           text=f"비상 우회 배포: {actor} / {incident_id}")
    return event

핵심은 비상 우회를 예외가 아니라 정식 절차의 일부로 설계하는 것입니다. 우회를 아예 막으면 사람들은 더 은밀하고 추적 불가능한 방법(로컬 직접 배포 등)을 찾아냅니다. 우회를 열어두되 대가를 비싸게 만들어 정말 필요할 때만 쓰이게 유도하는 편이 통제에 이롭습니다.

마무리

제대로 된 승인 게이트는 “버튼 누르는 절차”가 아니라 변경자와 승인자를 분리하고, 승인 대상을 불변 식별자로 고정하며, 그 사실을 변조 불가능하게 남기는 통제 체계입니다. 게이트 필요 여부를 자동 판정하고, self-approval을 코드로 막고, 감사 로그는 서버 신뢰 시각으로 채워 해시 체인으로 잠그고, 승인·배포 다이제스트를 배포 직전에 대조하며, 비상 우회는 비싸게 만들어 흔적을 강제합니다.

이 장치들의 목적은 사고를 막는 것 자체라기보다, 사고가 났을 때 무엇이 어떻게 진행됐는지 신뢰할 수 있게 재구성하는 것입니다. 감사 로그를 신뢰할 수 없으면 게이트도 신뢰할 수 없습니다. “누군가 악의를 가지면 이 기록을 지울 수 있는가”를 스스로 물어 답이 ‘가능’이라면, 아직 게이트가 완성되지 않은 것입니다.