Published on

GitHub Actions 동시 실행 막힘 해결 - concurrency·cancel-in-progress

Authors

서로 다른 커밋을 푸시했는데 워크플로가 줄줄이 대기열에 쌓이거나, This workflow is already running처럼 보이면서 새 실행이 막히는 경험은 흔합니다. 반대로 동시 실행이 허용돼 버려서 같은 브랜치/환경에 배포가 겹치며 장애로 이어지기도 합니다. 이 글에서는 GitHub Actions의 concurrencycancel-in-progress를 이용해 **동시 실행 막힘(원치 않는 직렬화)**과 **중복 실행(원치 않는 병렬화)**을 모두 제어하는 방법을 정리합니다.

핵심은 두 가지입니다.

  1. 어떤 단위로 “동일한 작업”을 묶을지(concurrency group 키 설계)
  2. 기존 실행을 취소할지(cancel-in-progress)

같은 개념이지만 설정을 잘못하면 “동시 실행을 막으려다 전체 워크플로가 다 막히는” 상황이 생깁니다. 특히 group 키를 너무 넓게 잡으면, PR/브랜치/환경이 달라도 한 줄로 서게 됩니다.

concurrency가 실제로 하는 일

concurrency는 워크플로(또는 잡) 실행에 **뮤텍스(mutex)**를 거는 기능입니다.

  • concurrency.group: 같은 값이면 동일 그룹으로 간주
  • concurrency.cancel-in-progress: 동일 그룹에서 새 실행이 시작될 때 기존 실행을 취소할지

동작 방식은 다음과 같습니다.

  • 동일 그룹의 실행이 이미 돌고 있으면, 새 실행은 대기하거나(기본) 기존 실행을 취소시키고 자신이 실행됩니다(cancel-in-progress: true).
  • 그룹이 다르면 서로 영향 없이 병렬 실행됩니다.

즉, “동시 실행 막힘” 문제를 해결하려면 대부분 그룹 키를 더 좁게(정확하게) 설계해야 합니다.

가장 흔한 실수: group을 고정 문자열로 둠

아래처럼 group: deploy 같은 고정값을 쓰면, 저장소의 모든 브랜치/PR/태그에서 트리거된 실행이 전부 한 그룹으로 묶입니다.

name: CI
on:
  push:
  pull_request:

concurrency:
  group: ci
  cancel-in-progress: true

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

이 설정은 “어차피 테스트는 최신 커밋만 돌면 된다” 같은 경우엔 괜찮지만, 보통은 브랜치/PR 단위로만 취소되길 원합니다. 그렇지 않으면 다른 PR의 실행까지 서로 취소하거나 기다리게 됩니다.

해결 1) 브랜치/PR 단위로 그룹 분리하기

대부분의 CI는 “같은 브랜치(또는 같은 PR)에서 최신 실행만 남기기”가 정답입니다.

name: CI
on:
  push:
    branches: ["**"]
  pull_request:

concurrency:
  # PR이면 PR 번호, 아니면 브랜치 ref로 그룹을 분리
  group: ci-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: true

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: |
          echo "ref=${GITHUB_REF}"
          echo "pr=${{ github.event.pull_request.number }}"
      - run: npm ci
      - run: npm test

github.ref만 쓰면 안 될 때가 있나?

  • pull_request 이벤트에서 github.ref는 보통 refs/pull/<id>/merge 형태입니다.
  • 같은 PR이라도 이벤트/타입에 따라 ref가 달라질 수 있고, 리포지토리 설정에 따라 기대와 다르게 움직일 수 있습니다.

그래서 PR이면 PR 번호를 우선하고, 아니면 브랜치 ref를 쓰는 패턴이 안정적입니다.

해결 2) 배포는 “환경(environment)” 단위로 잠그기

CI는 브랜치 단위로 취소해도 되지만, 배포는 보통 환경 단위로 직렬화해야 안전합니다.

예:

  • staging에는 동시에 한 번만 배포
  • production에는 더 강하게(취소하지 않고 대기) 혹은 승인(Environments protection rules)까지
name: Deploy
on:
  push:
    branches: ["main"]

jobs:
  deploy-staging:
    runs-on: ubuntu-latest

    # 환경 단위로 잠금
    concurrency:
      group: deploy-staging
      cancel-in-progress: true

    steps:
      - uses: actions/checkout@v4
      - run: ./scripts/deploy-staging.sh

  deploy-prod:
    runs-on: ubuntu-latest
    needs: [deploy-staging]
    environment: production

    # 프로덕션은 안전하게: 새 배포가 와도 기존 배포를 취소하지 않고 대기
    concurrency:
      group: deploy-production
      cancel-in-progress: false

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

cancel-in-progress를 환경별로 다르게 두는 이유

  • staging: 빠르게 최신 상태로 덮어쓰는 게 중요 → 이전 배포는 취소해도 됨
  • production: 롤아웃/마이그레이션 중 취소는 위험 → 대기(또는 승인) 선호

이건 분산 시스템에서 “중복 실행”이 버그를 만드는 전형적인 사례와 닮았습니다. 배포/마이그레이션은 멱등성이 약하면 중복 실행이 치명적이라, 직렬화 전략이 필요합니다. 비슷한 관점의 글로 MSA 사가(Saga) 패턴 - 중복 실행·보상처리 버그 해결도 함께 참고하면 설계 감이 빨리 잡힙니다.

해결 3) 워크플로 전체가 아니라 “특정 job만” 잠그기

워크플로 상단에 concurrency를 걸면 모든 job이 같은 락의 영향을 받습니다. 테스트/빌드/린트까지 같이 막히는 게 싫다면, 병목이 되는 job(예: 배포, e2e, DB 마이그레이션)만 잠그는 편이 좋습니다.

name: CI + Deploy
on:
  push:
    branches: ["main"]

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

  e2e:
    runs-on: ubuntu-latest
    needs: [build]

    # e2e는 리소스(테스트 환경)를 공유한다면 job 단위로 잠금
    concurrency:
      group: e2e-${{ github.ref }}
      cancel-in-progress: true

    steps:
      - uses: actions/checkout@v4
      - run: npm run test:e2e

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

    # 배포는 환경 단위로 잠금
    concurrency:
      group: deploy-production
      cancel-in-progress: false

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

이렇게 하면 빌드는 계속 병렬로 돌리고, 공유 자원이 걸린 구간만 직렬화할 수 있습니다.

“동시 실행이 막히는 것처럼 보이는데” 실제 원인이 다른 경우

concurrency가 없는데도 실행이 밀리거나 멈춘다면, 아래를 같이 점검해야 합니다.

1) Runner 가용성/조직 동시 실행 제한

  • GitHub-hosted runner는 플랜/조직 정책에 따라 동시 실행 수가 제한됩니다.
  • self-hosted runner는 라벨에 매칭되는 runner가 1대뿐이면 자연스럽게 직렬화됩니다.

워크플로 Run 화면에서 Waiting for a runner to pick up this job... 메시지가 보이면 이 케이스가 많습니다.

2) Environment protection rules(승인/대기)

environment: production을 쓰면 승인 대기나 배포 잠금이 걸릴 수 있습니다. 이 경우는 concurrency와 별개로 “막힌 것처럼” 보입니다.

3) 워크플로가 서로를 호출하며 같은 그룹을 공유

workflow_call, workflow_run으로 체인을 구성했을 때, 상위/하위 워크플로가 같은 group을 쓰면 예상보다 넓게 락이 걸립니다.

디버깅: 실제 group 값 출력하기

가장 빠른 방법은 group에 쓰는 표현식을 로그로 찍어보는 겁니다.

- name: Debug concurrency key
  run: |
    echo "group=ci-${{ github.event.pull_request.number || github.ref }}"
    echo "event=${{ github.event_name }}"
    echo "ref=${{ github.ref }}"
    echo "sha=${{ github.sha }}"

그리고 Actions UI에서 해당 실행이 “대기 중”일 때, 대기 이유가 concurrency인지 runner 부족인지 메시지로 구분합니다.

실전 패턴 모음

패턴 A: PR/브랜치별 최신 실행만 유지 (가장 흔함)

concurrency:
  group: ci-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: true

패턴 B: main 배포는 직렬화 + 취소 금지

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

패턴 C: 모노레포에서 서비스별로 분리

서비스 A/B가 서로 배포를 방해하지 않게 service를 group에 포함합니다.

concurrency:
  group: deploy-${{ matrix.service }}-${{ github.ref_name }}
  cancel-in-progress: true

마무리: “막힘”을 없애려면 그룹을 좁혀라

GitHub Actions에서 동시 실행이 막히는 문제는 대체로 concurrency.group가 너무 넓게 잡혀서 생깁니다. 해결 순서는 단순합니다.

  1. 무엇을 공유 자원으로 볼지(브랜치? PR? 환경? 서비스?) 결정
  2. 그 단위를 group 키에 정확히 반영
  3. 최신 실행만 의미가 있는 구간은 cancel-in-progress: true
  4. 취소가 위험한 구간(프로덕션 배포/마이그레이션)은 cancel-in-progress: false

중복 실행이 장애를 만들 수 있다는 점에서, 동시성 제어는 배포 파이프라인의 “락 설계” 문제입니다. 중복 실행/보상 처리 관점이 필요하다면 DDD에서 분산 트랜잭션 없이 SAGA 구현하기와 함께 읽어보는 것도 도움이 됩니다.