왜 수동 승인과 감사 로그가 문제가 되는가
많은 조직이 프로덕션 배포 전에 Slack 메시지나 이메일로 "승인 부탁드립니다"를 주고받는다. 문제는 이 승인 기록이 흩어져 있고, 나중에 "누가 언제 무엇을 승인했는가"를 재구성하기 어렵다는 점이다. SOC2, ISO 27001, 내부 감사에서 요구하는 것은 변경 불가능한(immutable), 순서가 보장된, 검증 가능한 기록이다. 채팅 로그는 편집·삭제가 가능하고, 배포 파이프라인과 연결이 끊겨 있어 증거로서 신뢰도가 낮다.
또한 수동 승인은 병목이 된다. 승인자가 자리를 비우면 배포가 멈추고, 급하면 규칙을 우회하게 된다. 우회의 흔적조차 남지 않으면 사고 후 원인 추적이 불가능하다.
승인 게이트를 파이프라인 안으로 끌어들이기
핵심은 승인을 대화가 아니라 배포 파이프라인의 필수 단계로 만드는 것이다. GitHub Actions의 Environment protection rules는 이를 선언적으로 처리한다. 특정 환경에 지정된 리뷰어의 승인 없이는 job이 진행되지 않으며, 승인 이벤트가 자동으로 기록된다.
name: deploy-prod
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: production # 이 환경에 required reviewers 설정
url: https://app.example.com
steps:
- uses: actions/checkout@v4
- name: Deploy
run: ./scripts/deploy.sh
Settings > Environments에서 production에 required reviewers와 wait timer를 지정하면, 배포는 승인 전까지 대기 상태로 멈춘다. 승인·거부·타임아웃이 모두 API로 조회 가능한 이벤트가 된다.
승인 이벤트를 감사 로그로 수집하기
파이프라인이 남긴 승인 이벤트를 별도 감사 저장소로 옮겨 장기 보관해야 한다. GitHub의 deployment 및 review API를 폴링하거나 webhook으로 받아 정규화한다.
import os, requests, hashlib, json
def fetch_reviews(repo, run_id, token):
url = f"https://api.github.com/repos/{repo}/actions/runs/{run_id}/approvals"
r = requests.get(url, headers={"Authorization": f"Bearer {token}"})
r.raise_for_status()
records = []
for a in r.json():
rec = {
"run_id": run_id,
"user": a["user"]["login"],
"state": a["state"], # approved / rejected
"environment": a["environments"][0]["name"],
"commented_at": a["comment"],
}
rec["hash"] = hashlib.sha256(
json.dumps(rec, sort_keys=True).encode()).hexdigest()
records.append(rec)
return records
각 레코드에 해시를 붙여 이후 위변조 여부를 검증할 수 있게 한다.
변경 불가능성을 어떻게 보장할 것인가
감사 로그의 가치는 "고칠 수 없다"는 데서 나온다. 애플리케이션 DB의 일반 테이블은 UPDATE·DELETE가 가능하므로 부족하다. 실무에서 쓰는 방법을 비교하면 다음과 같다.
| 방식 | 변경 방지 | 운영 비용 | 적합 상황 |
|---|---|---|---|
| append-only 테이블 + 트리거 | 중 (DBA 우회 가능) | 낮음 | 내부 감사 |
| 해시 체이닝 | 높음 (변조 탐지) | 중 | 규정 준수 |
| WORM 스토리지(S3 Object Lock) | 매우 높음 | 중 | 외부 감사·법적 증거 |
해시 체이닝은 이전 레코드의 해시를 현재 레코드에 포함시켜, 중간 한 건만 바꿔도 이후 전체 체인이 깨지게 만든다.
SQL로 append-only 감사 테이블 구성하기
CREATE TABLE deploy_audit (
id BIGSERIAL PRIMARY KEY,
run_id BIGINT NOT NULL,
actor TEXT NOT NULL,
action TEXT NOT NULL, -- approved / deployed / rejected
prev_hash TEXT,
row_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- UPDATE/DELETE 차단
REVOKE UPDATE, DELETE ON deploy_audit FROM app_role;
-- 삽입 시 이전 해시 연결
CREATE OR REPLACE FUNCTION chain_hash() RETURNS trigger AS $$
BEGIN
SELECT row_hash INTO NEW.prev_hash
FROM deploy_audit ORDER BY id DESC LIMIT 1;
NEW.row_hash := encode(digest(
NEW.run_id || NEW.actor || NEW.action ||
coalesce(NEW.prev_hash,''), 'sha256'), 'hex');
RETURN NEW;
END; $$ LANGUAGE plpgsql;
CREATE TRIGGER trg_chain BEFORE INSERT ON deploy_audit
FOR EACH ROW EXECUTE FUNCTION chain_hash();
애플리케이션 계정에서 수정·삭제 권한을 회수하고, 체인 검증은 배치 작업에서 주기적으로 실행한다.
실무 적용 시 주의점
첫째, 승인자와 배포 실행자를 분리하라. 자기 코드를 스스로 승인하는 구조는 감사에서 지적받는다. GitHub의 "prevent self-review" 옵션을 켠다.
둘째, 긴급 배포(break-glass) 경로를 미리 설계하되 반드시 로그를 남겨라. 규칙을 우회하는 경로가 없으면 사람들은 몰래 우회한다. 승인을 건너뛰되 "긴급 사유"를 필수 입력으로 받고 사후 리뷰를 강제하는 것이 현실적이다.
셋째, 시각 정보는 반드시 서버 시각(UTC)으로 통일하고, 클라이언트가 보낸 타임스탬프를 신뢰하지 마라. 감사 로그의 시간 순서가 흔들리면 체인 검증 자체가 무의미해진다.
넷째, 감사 로그도 데이터다. 보존 기간, 접근 권한, 개인정보(승인자 이름·이메일) 마스킹 정책을 함께 정의해야 완결된 시스템이 된다.