Published on

GitHub Actions 동시성 꼬임으로 배포 겹침 막기

Authors

서로 다른 커밋/브랜치에서 워크플로우가 연달아 트리거되면, 이전 배포가 끝나기 전에 다음 배포가 시작되어 인프라 상태가 꼬이거나(예: Terraform state lock, Helm upgrade 충돌), 더 나쁜 경우 오래된 커밋이 마지막에 적용되는 역전 현상이 발생합니다. GitHub Actions는 기본적으로 “큐잉(대기열)”이 아니라 “병렬 실행”에 가깝기 때문에, 배포 단계에 명시적으로 동시성 제어(concurrency) 를 걸지 않으면 겹침은 언젠가 터집니다.

이 글에서는 GitHub Actions의 concurrency를 중심으로, “배포는 항상 1개만”이라는 불변식을 강제하는 방법과, 취소/대기 전략, 그룹 키 설계, 환경 보호까지 실전에서 자주 쓰는 패턴을 정리합니다.

> AWS로 OIDC 배포를 쓰는 경우 권한/토큰 문제가 함께 터지는 경우가 많습니다. 배포 겹침을 잡은 뒤에도 AccessDenied가 난다면 GitHub Actions OIDC AWS 배포 AccessDenied 해결도 같이 확인해 두면 좋습니다.

배포 겹침이 생기는 전형적인 시나리오

1) push가 연속으로 들어와 “최신 커밋”이 아닌 것이 마지막에 배포됨

  • main에 A 커밋 push → workflow #1 배포 시작
  • 곧바로 B 커밋 push → workflow #2 배포 시작
  • #2가 빨리 끝나서 B가 배포됨
  • #1이 뒤늦게 끝나며 A를 다시 배포해버림(최악)

2) PR 머지/태그/수동 실행이 서로 다른 워크플로우에서 배포를 동시에 수행

  • deploy.yml(push)과 release.yml(tag)이 둘 다 프로덕션 배포
  • 같은 시점에 트리거되면 서로 덮어쓰기

3) 매트릭스(matrix)나 재시도(re-run)로 배포 잡이 여러 개 뜸

  • 빌드/테스트는 매트릭스가 유용하지만, 배포 단계까지 매트릭스가 흘러가면 재앙

이런 문제는 “배포 단계만” 직렬화해도 대부분 해결됩니다.

GitHub Actions concurrency 핵심 개념

GitHub Actions의 concurrency는 크게 두 가지를 제공합니다.

  • group: 같은 그룹에 속한 워크플로우 실행(run)들은 동시에 실행되지 않음
  • cancel-in-progress: 같은 그룹에서 새 실행이 시작될 때, 기존 실행을 취소할지 여부

정리하면:

  • 대기(Queue) 를 원하면 cancel-in-progress: false
  • 항상 최신만 실행하고 이전 것은 중단하려면 cancel-in-progress: true

배포는 보통 “최신만”이 안전해 보이지만, 실제로는 상황에 따라 다릅니다.

  • DB 마이그레이션/인프라 변경이 포함된 배포: 중간에 취소되면 더 위험할 수 있어 대기가 낫습니다.
  • 단순 애플리케이션 롤링 배포(멱등성 높음): 이전 실행을 취소하고 최신만 남기는 전략이 효율적입니다.

가장 간단한 해결: 워크플로우 레벨 concurrency

프로덕션 배포 워크플로우에서 “항상 1개만”을 강제하는 최소 설정입니다.

name: Deploy Production

on:
  push:
    branches: ["main"]

concurrency:
  group: deploy-production
  cancel-in-progress: false

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Deploy
        run: |
          echo "deploying..."
  • group을 고정 문자열로 두면 리포지토리 내 모든 실행이 같은 락을 공유합니다.
  • cancel-in-progress: false는 “먼저 온 배포가 끝나야 다음 배포가 시작”되는 직렬화입니다.

언제 cancel-in-progress를 true로 할까?

concurrency:
  group: deploy-production
  cancel-in-progress: true

이렇게 하면 main에 커밋이 연속으로 들어올 때, 이전 배포가 자동 취소되어 리소스를 아끼고 최신 상태로 빠르게 수렴합니다.

다만 다음을 반드시 점검하세요.

  • 배포 스크립트가 중간 취소에 안전한가? (부분 적용/락/임시 파일)
  • IaC(Terraform 등)라면 state lock이 남지 않는가?
  • Helm upgrade 중단 시 릴리스가 pending-upgrade로 남지 않는가?

더 안전한 실전 패턴: “배포 job만” concurrency 걸기

테스트/빌드는 병렬로 돌리고, 배포 job만 직렬화하는 방식이 흔합니다.

name: CI/CD

on:
  push:
    branches: ["main"]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test

  deploy:
    needs: [test]
    runs-on: ubuntu-latest

    concurrency:
      group: deploy-production
      cancel-in-progress: false

    steps:
      - uses: actions/checkout@v4
      - name: Deploy
        run: ./scripts/deploy-prod.sh

장점:

  • 테스트는 커밋마다 빨리 피드백
  • 배포만 줄 세우기 → 프로덕션 보호

그룹 키(group) 설계: “무엇을 기준으로 1개만”인가

group을 어떻게 잡느냐가 동시성 제어의 전부입니다. 흔한 설계는 다음과 같습니다.

1) 환경 단위로 직렬화 (prod/stage)

concurrency:
  group: deploy-${{ inputs.environment || 'prod' }}
  cancel-in-progress: false
  • 스테이징과 프로덕션은 서로 다른 락을 가짐
  • 스테이징 배포가 프로덕션 배포를 막지 않음

2) 브랜치 단위로 직렬화

concurrency:
  group: deploy-${{ github.ref_name }}
  cancel-in-progress: true
  • 브랜치별로 최신만 남김
  • 단, mainrelease/*가 같은 환경을 배포한다면 브랜치 기준은 위험(동시에 prod 배포 가능)

3) “리소스” 단위로 직렬화 (클러스터/네임스페이스)

EKS 같은 멀티 클러스터/멀티 네임스페이스 운영에서 특히 유용합니다.

concurrency:
  group: eks-${{ vars.CLUSTER_NAME }}-${{ vars.NAMESPACE }}
  cancel-in-progress: false

이렇게 하면 같은 클러스터/네임스페이스에 대한 배포만 직렬화되고, 다른 타깃은 병렬로 진행됩니다.

GitHub Environments로 “사람 승인 + 동시성”까지 묶기

concurrency는 기술적으로 겹침을 막지만, 운영에서는 승인/보호 규칙도 같이 필요합니다. GitHub Environments(환경)를 쓰면:

  • 특정 환경(Production)에 배포 시 승인자 필요
  • secrets를 환경 단위로 분리
  • 환경 자체가 배포의 관문 역할

워크플로우에서:

jobs:
  deploy:
    environment:
      name: production
      url: https://example.com

    concurrency:
      group: env-production
      cancel-in-progress: false

    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: ./scripts/deploy-prod.sh

여기서 포인트:

  • 승인 대기 중에도 실행(run)은 생성되므로, concurrency가 없으면 승인 후 동시에 배포가 터질 수 있습니다.
  • 환경 보호 + concurrency를 같이 걸면 “승인된 것부터 차례대로” 같은 운영 규칙을 만들기 쉽습니다.

자주 하는 실수와 디버깅 체크리스트

1) group에 run마다 달라지는 값을 넣음

예를 들어 github.run_id를 group에 넣으면 매번 다른 그룹이 되어 동시성 제어가 무력화됩니다.

나쁜 예:

concurrency:
  group: deploy-${{ github.run_id }}

좋은 예(환경/리소스 기준으로 고정):

concurrency:
  group: deploy-production

2) 워크플로우가 여러 개인데, 각각 group이 다름

  • deploy.ymldeploy-production
  • release.ymlprod-deploy

이러면 서로 다른 락이라 동시에 배포됩니다. 같은 환경을 건드리는 워크플로우끼리는 group을 통일하세요.

3) cancel-in-progress=true로 인프라 변경이 중단됨

Terraform/Helm/Kubectl apply 같은 작업은 중간 취소에 취약합니다. 배포 겹침을 막는 목적이라면, 오히려 대기 전략(false) 이 안전한 경우가 많습니다.

EKS에 배포하는 파이프라인이라면, 애플리케이션 이슈가 로그에서만 드러나는 경우도 많습니다. 배포 직렬화 후에도 “왜 특정 버전에서만 장애가 났는지”를 추적할 때는 EKS에서 fluent-bit 로그 누락·지연 원인 9가지 같은 로그 파이프라인 점검도 도움이 됩니다.

고급 패턴: 워크플로우 호출(workflow_call)로 배포를 단일화

조직이 커지면 “배포는 여기서만 한다”를 강제하는 게 중요합니다. 여러 워크플로우가 각자 배포하지 말고, 재사용 워크플로우(reusable workflow) 하나로 배포를 모으고 그 안에 concurrency를 걸면, 실수 확률이 확 내려갑니다.

/.github/workflows/deploy-reusable.yml:

name: Reusable Deploy

on:
  workflow_call:
    inputs:
      environment:
        required: true
        type: string

concurrency:
  group: deploy-${{ inputs.environment }}
  cancel-in-progress: false

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}
    steps:
      - uses: actions/checkout@v4
      - run: ./scripts/deploy.sh ${{ inputs.environment }}

각 파이프라인에서는 호출만:

name: Main Pipeline

on:
  push:
    branches: ["main"]

jobs:
  deploy-prod:
    uses: ./.github/workflows/deploy-reusable.yml
    with:
      environment: production

장점:

  • 동시성/승인/시크릿 정책이 한 곳에 모임
  • 배포 정책 변경 시 수정 포인트가 1개

결론: “배포는 공유 자원”이라는 전제로 설계하자

배포는 CPU 작업이 아니라 공유 자원(클러스터/로드밸런서/DB/도메인/상태 저장소)을 변경하는 작업입니다. 따라서 기본값(병렬 실행)에 기대면 언젠가 배포가 겹치고, 가장 나쁜 타이밍에 역전 배포나 상태 꼬임이 발생합니다.

실무에서 추천하는 우선순위는 다음과 같습니다.

  1. 배포 job(또는 배포 워크플로우)에 concurrency.group을 환경/리소스 기준으로 고정
  2. 취소가 안전하지 않으면 cancel-in-progress: false로 “대기열” 구성
  3. GitHub Environments로 승인/보호 규칙을 더해 운영 안전장치 마련
  4. 배포를 재사용 워크플로우로 단일화해 정책을 중앙집중화

이 4가지만 적용해도 “동시성 꼬임으로 배포 겹침” 문제는 대부분 사라지고, 장애의 원인이 배포 파이프라인이 아니라 애플리케이션/인프라 자체로 명확히 분리됩니다.