재실행은 예외가 아니라 일상이다
CI/CD 파이프라인은 생각보다 자주 다시 돈다. 러너가 타임아웃으로 죽거나, 네트워크 순단으로 아티팩트 업로드가 실패하거나, 리뷰어가 "그냥 한 번 더 돌려봐"라고 말한다. 문제는 많은 배포 스크립트가 "처음 한 번만 실행된다"는 가정 위에 짜여 있다는 점이다. 재실행 시 리소스가 중복 생성되거나, 마이그레이션이 두 번 적용되거나, 알림이 두 번 나가면 그때부터 운영 사고가 시작된다.
멱등성(idempotency)은 "같은 입력으로 몇 번을 실행해도 결과 상태가 동일하다"는 성질이다. 배포 파이프라인의 모든 단계를 이 기준으로 다시 설계하면, 재실행이 두려운 작업에서 안전한 복구 수단으로 바뀐다.
어디서 멱등성이 깨지는가
실무에서 재실행이 사고로 이어지는 지점은 대체로 정해져 있다.
- DB 마이그레이션:
ALTER TABLE ADD COLUMN이 두 번 실행되어 실패하고, 그 실패가 롤백을 유발한다. - 아티팩트 태깅: 같은 커밋에
latest가 아닌 순번 태그를 매번 새로 붙여 이미지가 중복된다. - 외부 부수효과: 배포 완료 Slack 알림, 결제 정산 트리거, 이메일 발송이 재실행마다 반복된다.
- 인프라 프로비저닝: 선언형이 아닌 명령형 스크립트가 리소스를 계속 추가한다.
핵심 원칙: 상태 확인 후 조건부 실행
멱등성의 뼈대는 단순하다. "만들기 전에 이미 있는지 본다." 명령형 create 대신 선언형 apply, 무조건 실행 대신 조건부 실행으로 바꾼다. 아래는 Kubernetes 배포에서 흔히 쓰는 패턴이다.
#!/usr/bin/env bash
set -euo pipefail
IMAGE_TAG="${GIT_SHA}" # 가변 태그(latest) 금지, 커밋 SHA로 고정
# create는 이미 존재하면 실패한다. apply는 없으면 만들고 있으면 맞춘다.
kubectl apply -f manifests/
# 이미 원하는 이미지면 rollout이 no-op이 되도록 이미지 기준 비교
CURRENT=$(kubectl get deploy api -o jsonpath='{.spec.template.spec.containers[0].image}')
if [ "$CURRENT" != "registry.internal/api:${IMAGE_TAG}" ]; then
kubectl set image deploy/api api="registry.internal/api:${IMAGE_TAG}"
kubectl rollout status deploy/api --timeout=120s
else
echo "이미 ${IMAGE_TAG} 배포됨 — 건너뜀"
fi
커밋 SHA를 이미지 태그로 쓰는 것이 중요하다. 입력이 결정적이어야 "같은 파이프라인 실행"이 성립하고, 재실행이 진짜 no-op이 될 수 있다.
마이그레이션은 버전 테이블로 지킨다
DB 스키마 변경은 재실행에 가장 취약하다. 적용 이력을 별도 테이블에 남기고, 이미 적용된 버전은 건너뛰는 방식이 표준이다. Flyway·Liquibase 같은 도구가 이 일을 하지만, 원리는 아래와 같다.
-- 적용 이력 테이블 (없으면 생성)
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 마이그레이션 본문을 트랜잭션으로 감싸고,
-- 이미 적용됐으면 아무것도 하지 않는다.
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM schema_migrations WHERE version = '2026_09_15_add_status') THEN
ALTER TABLE orders ADD COLUMN status TEXT NOT NULL DEFAULT 'pending';
INSERT INTO schema_migrations(version) VALUES ('2026_09_15_add_status');
END IF;
END $$;
DDL과 이력 기록을 한 트랜잭션에 묶는 것이 핵심이다. 중간에 죽더라도 "적용됨 표시만 남고 실제 변경은 안 된" 어긋난 상태가 생기지 않는다.
외부 부수효과는 멱등성 키로 막는다
알림·결제·웹훅처럼 되돌릴 수 없는 부수효과는 조건부 실행만으로 부족하다. 각 실행에 고유하지 않고 결정적인 키를 부여해, 같은 키의 요청은 서버가 한 번만 처리하게 한다. 파이프라인 실행 ID가 아니라 커밋 SHA + 환경 조합처럼 재실행해도 동일한 값을 키로 써야 한다.
import hashlib, requests
def notify_deploy(git_sha: str, env: str):
# 재실행해도 동일한 키 → 서버가 중복을 흡수
key = hashlib.sha256(f"deploy:{env}:{git_sha}".encode()).hexdigest()
resp = requests.post(
"https://hooks.internal/deploy-done",
json={"env": env, "sha": git_sha},
headers={"Idempotency-Key": key},
timeout=5,
)
resp.raise_for_status()
수신 측이 멱등성 키를 지원하지 않는다면, 배포 결과를 먼저 상태 저장소에 기록하고 "아직 알림 안 보냄" 플래그를 확인한 뒤 발송하는 아웃박스 패턴으로 대체한다.
명령형 vs 선언형 접근 비교
| 대상 | 멱등성 깨지는 방식 | 멱등한 대안 |
|---|---|---|
| K8s 리소스 | kubectl create | kubectl apply |
| 인프라 | 쉘 스크립트로 리소스 추가 | Terraform plan/apply |
| DB 스키마 | 날 것의 ALTER 실행 | 버전 테이블 기반 마이그레이션 |
| 웹훅/알림 | 매번 POST | Idempotency-Key 또는 아웃박스 |
주의점
몇 가지를 놓치기 쉽다. 첫째, 부분 실패다. 5단계 중 3단계에서 죽었을 때 앞 단계가 멱등해야 처음부터 다시 돌릴 수 있다. 각 단계를 독립적으로 멱등하게 만들어야 한다. 둘째, 상태 저장소 자체의 동시성이다. 같은 파이프라인이 병렬로 두 번 뜨면 마이그레이션 테이블에 락(예: Postgres advisory lock)이 없을 때 경쟁 조건이 생긴다. 셋째, 롤백도 멱등해야 한다. 롤백 스크립트를 재실행했을 때 이미 롤백된 상태를 다시 건드리지 않아야 한다.
정리하면, 결정적 입력(SHA 고정), 조건부·선언형 실행, 부수효과의 멱등성 키, 그리고 단계별 독립성 — 이 네 가지를 지키면 파이프라인 재실행은 사고가 아니라 신뢰할 수 있는 복구 도구가 된다.