GitHub Actions의 concurrency, 배포 race 막는 한 줄
오늘 알게 된 건 아니고, 어제 팀 PR 리뷰하다가 "어 이거 아직도 모르는 분들 꽤 많은데" 싶어서 짧게 정리해둔다. 우리 팀에서도 작년 한 분기 동안 같은 사고를 두 번 냈다. 똑같은 워크플로가 동시에 두 개 뜨면서 한쪽이 helm release를 절반쯤 적용한 상태에서 다른 한쪽이 덮어쓰는 그림. 새벽 3시에 슬랙 알림으로 깨면 진짜 멘탈이 묘하게 무너진다.
한 줄이면 끝난다
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: false
이게 전부다. 같은 브랜치에서 deploy 워크플로가 한 번에 하나만 돌게 만든다. PR 빌드용 워크플로면 cancel-in-progress: true로 바꿔서 새 커밋이 들어올 때 진행 중인 빌드를 죽이면 된다. 근데 배포에는 절대 true를 쓰면 안 된다. 배포 중간에 워크플로가 cancel되면 절반만 적용된 채로 끝난다. 우리가 처음에 그렇게 만들어놨다가 한 번 데였다.
cancel-in-progress를 표현식으로
2026년 1월 즈음 GitHub Actions 공식 문서가 한 번 정리되면서 알게 된 건데, cancel-in-progress에 표현식을 넣을 수 있다. 사실 좀 됐는데 나도 최근에 봤다.
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
main 브랜치에서는 진행 중인 워크플로를 보존하고, 그 외 브랜치(feature, PR)에서는 새 커밋이 오면 이전 워크플로를 취소한다. 한 워크플로에서 PR/배포 분기 처리할 때 의외로 깔끔하다.
잘 안 알려진 함정
reusable workflow를 쓸 때, 호출자(caller)와 호출되는 쪽(callee)에 같은 concurrency group을 걸면 caller가 먼저 group을 잡아버려서 callee가 같은 group에 들어가는 순간 자기 자신을 cancel하는 경우가 있다. GitHub community 디스커션에 한참 묶여 있던 이슈인데, 해결은 단순하다. caller에만 concurrency를 걸고 callee에는 걸지 않는다. 또는 group 키에 ${{ github.run_id }} 같은 걸 섞어서 reusable 호출마다 키를 다르게 만든다.
마무리
별 거 아닌데 안 걸어두면 어느 날 새벽에 한 번씩 사고가 난다. 깃옵스 환경에서 ArgoCD가 reconcile loop을 돌리는 상황이라면 또 좀 다른 이야기지만, GHA가 직접 helm/kubectl apply 하는 워크플로라면 무조건 걸어두는 게 좋다. 한 줄이고 비용도 없다.
혹시 다른 패턴으로 이 race를 풀고 계신 분 있으면 댓글로 알려주시면 좋을 것 같다.