- Published on
GitHub Actions 동시 실행 막힘 해결 - concurrency·cancel-in-progress
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 커밋을 푸시했는데 워크플로가 줄줄이 대기열에 쌓이거나, This workflow is already running처럼 보이면서 새 실행이 막히는 경험은 흔합니다. 반대로 동시 실행이 허용돼 버려서 같은 브랜치/환경에 배포가 겹치며 장애로 이어지기도 합니다. 이 글에서는 GitHub Actions의 concurrency와 cancel-in-progress를 이용해 **동시 실행 막힘(원치 않는 직렬화)**과 **중복 실행(원치 않는 병렬화)**을 모두 제어하는 방법을 정리합니다.
핵심은 두 가지입니다.
- 어떤 단위로 “동일한 작업”을 묶을지(concurrency group 키 설계)
- 기존 실행을 취소할지(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가 너무 넓게 잡혀서 생깁니다. 해결 순서는 단순합니다.
- 무엇을 공유 자원으로 볼지(브랜치? PR? 환경? 서비스?) 결정
- 그 단위를
group키에 정확히 반영 - 최신 실행만 의미가 있는 구간은
cancel-in-progress: true - 취소가 위험한 구간(프로덕션 배포/마이그레이션)은
cancel-in-progress: false
중복 실행이 장애를 만들 수 있다는 점에서, 동시성 제어는 배포 파이프라인의 “락 설계” 문제입니다. 중복 실행/보상 처리 관점이 필요하다면 DDD에서 분산 트랜잭션 없이 SAGA 구현하기와 함께 읽어보는 것도 도움이 됩니다.