재실행은 예외가 아니라 일상이다

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 createkubectl apply
인프라쉘 스크립트로 리소스 추가Terraform plan/apply
DB 스키마날 것의 ALTER 실행버전 테이블 기반 마이그레이션
웹훅/알림매번 POSTIdempotency-Key 또는 아웃박스

주의점

몇 가지를 놓치기 쉽다. 첫째, 부분 실패다. 5단계 중 3단계에서 죽었을 때 앞 단계가 멱등해야 처음부터 다시 돌릴 수 있다. 각 단계를 독립적으로 멱등하게 만들어야 한다. 둘째, 상태 저장소 자체의 동시성이다. 같은 파이프라인이 병렬로 두 번 뜨면 마이그레이션 테이블에 락(예: Postgres advisory lock)이 없을 때 경쟁 조건이 생긴다. 셋째, 롤백도 멱등해야 한다. 롤백 스크립트를 재실행했을 때 이미 롤백된 상태를 다시 건드리지 않아야 한다.

정리하면, 결정적 입력(SHA 고정), 조건부·선언형 실행, 부수효과의 멱등성 키, 그리고 단계별 독립성 — 이 네 가지를 지키면 파이프라인 재실행은 사고가 아니라 신뢰할 수 있는 복구 도구가 된다.