세 개의 병렬 작업 레일과 실행 버튼, 상태 표시등으로 GitHub Actions step 병렬화를 표현한 일러스트

GitHub Actions parallel steps 설정: fail-fast·needs·비용을 로컬 워크플로로 검증하기

2026년 6월 25일 공개된 GitHub Actions parallel steps 설정은 한 job 안의 lint, test, build를 별도 step 상태와 로그를 유지한 채 동시에 실행하게 한다. 다만 명령 이름이 서로 다르다는 이유만으로 묶으면 같은 workspace의 파일·포트·캐시를 덮어쓰거나, matrix의 fail-fast가 병렬 step도 즉시 취소할 것이라고 잘못 기대할 수 있다.

2026년 8월 12일 공식 문서와 Linux 7.0.0-1009-aws x86_64, Node.js v18.19.1, GNU bash 5.2.21 환경을 기준으로 확인했다. 실제 GitHub workflow나 유료 runner는 실행하지 않았으며, 로컬 fixture로 순차·병렬 wall time, 공유 파일 경합, 실패 join만 직접 검증했다. 이 글의 목적은 빠른 YAML을 제시하는 데서 끝나지 않고 어떤 step을 묶고 어디서 결과를 기다릴지 판단하게 하는 것이다.

20초 핵심 요약

  • 무엇: 독립 step은 parallel, 생명주기를 따로 제어할 작업은 backgroundwait로 실행한다.
  • 왜: 같은 workspace에 동시에 쓰면 결과가 손실되고, matrix fail-fast나 로컬 시간 단축을 step 취소·비용 절감으로 오해할 수 있다.
  • 어떻게: 공유 자원과 output join을 점검하고, 로컬에서 속도·race·비정상 exit를 반복 검증한 뒤 Actions의 실행 시간과 Usage를 비교한다.

먼저 세 명령이 정말 독립적인지 측정한다

parallel은 각 항목을 background step으로 시작하고 묶음 끝에서 모두 기다리는 축약 구문이다. 공식 문서 기준으로 한 job에서 동시에 실행할 수 있는 background step은 최대 10개이며, 추가 항목은 슬롯이 생길 때까지 대기한다. backgroundparallel은 composite action 내부에서 쓸 수 없지만 composite action 자체를 background step으로 실행하는 것은 가능하다.

다음 로컬 fixture는 700ms, 500ms, 300ms 작업을 같은 순서로 순차 실행한 뒤 Promise.all로 동시에 실행했다. 이어 두 작업이 같은 .race-counter를 읽고 증가시켜 다시 쓰는 비교를 10회 반복했다. 임시 파일은 지정된 주제 디렉터리 안에서만 만들고 실행 직후 삭제했다.

$ node <<'NODE'
# 700/500/300ms 동일 작업: 순차 실행, Promise.all 실행,
# 공유 counter의 순차 증가와 병렬 증가 10회 비교
NODE
{"sequentialMs":1503,"parallelMs":700,"speedup":"2.15","sequentialCounter":2,"parallelLostUpdates":"10/10","exit":0}
[process exit code: 0]

순차·병렬 wall time과 공유 counter lost update를 확인한 터미널 출력

독립 sleep 작업은 1,503ms에서 700ms로 줄어 최장 경로에 가까워졌다. 그러나 같은 카운터를 갱신한 병렬 실행은 10회 모두 lost update가 발생했다. 2.15배라는 값은 이 Node fixture의 통제 비교일 뿐 GitHub-hosted runner의 성능이나 비용 절감률이 아니다.

병렬화 후보마다 입력 파일, 출력 디렉터리, 포트, cache key, Docker Compose project name, 테스트 DB schema를 적어 본다. 하나라도 함께 쓰면 고유 경로나 포트로 나누고, 분기별 결과는 join 뒤 별도 step에서 합친다. CPU와 메모리도 같은 runner에서 경쟁하므로 공유 쓰기가 없어도 wall time이 반드시 줄어드는 것은 아니다.

독립 묶음은 parallel, 수명 제어는 background를 쓴다

lint, unit test, 문서 build처럼 함께 시작하고 모두 끝난 뒤 packaging으로 넘어갈 작업은 parallel로 표현하기 쉽다.

jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - parallel:
          - name: Lint
            run: npm run lint
          - name: Unit test
            run: npm test
          - name: Build docs
            run: npm run build:docs
      - name: Package after all checks
        run: npm run package

서버를 띄워 integration test와 겹쳐 실행하고 마지막에 명시적으로 종료하려면 background: true가 맞다. 나중에 참조할 step에는 id를 붙인다.

steps:
  - name: Start app
    id: app
    run: npm start
    background: true
  - name: Integration test
    run: npm run test:integration
  - name: Stop app
    cancel: app

공식 문서에서 cancel은 대상 background step에 SIGTERM을 보내고 짧은 grace period 뒤에도 종료되지 않으면 SIGKILL한다. 이번 검증에서는 실제 runner의 신호와 grace period를 재현하지 않았다. 종료 중 데이터를 쓰는 서버라면 이 구문을 곧바로 운영 데이터에 적용하지 말고 임시 데이터와 고유 포트에서 종료 결과를 먼저 확인해야 한다.

실패는 matrix fail-fast가 아니라 join에서 판정한다

fail-fast라는 이름은 세 동작을 분리해서 봐야 한다.

  • run step 안에서는 shell의 non-zero exit가 실패를 만든다. GitHub가 bash를 실행할 때는 -o pipefail도 적용한다.
  • background step의 실패는 해당 step을 포함하는 wait 또는 wait-all에서 job 실패로 드러난다. 허용할 실패라면 background step 자체의 continue-on-error를 검토한다.
  • jobs.<job_id>.strategy.fail-fast는 matrix job 하나가 실패했을 때 진행 중이거나 queued인 다른 matrix job을 취소한다. 한 job의 parallel 그룹에 붙이는 옵션이 아니다.

로컬에서는 lint 120ms, 실패하는 test 60ms, build 180ms를 함께 시작하고 모든 결과를 settle한 뒤 join을 판정했다.

$ node <<'NODE'
# lint(120ms), test(60ms, throws "test exited 17"), build(180ms)를
# 동시에 시작하고 Promise.allSettled 결과를 wait-all join으로 판정
NODE
{"id":"test","status":"failure","elapsedMs":60}
{"id":"lint","status":"success","elapsedMs":120}
{"id":"build","status":"success","elapsedMs":180}
{"join":"wait-all","failed":1,"error":"test exited 17","exit":1}
[process exit code: 1]

실패한 분기와 나머지 완료 결과를 wait-all join이 exit 1로 판정한 터미널 출력

test가 먼저 실패했지만 이미 시작한 lint와 build는 완료됐고 join이 최종 exit 1을 냈다. 이는 공식 wait-all의 실패 전파를 설명하는 로컬 DAG 모델과 대응한다. 실제 parallel sibling을 GitHub runner가 언제 취소하거나 계속 스케줄하는지는 이번 관측으로 단정하지 않는다. 공식 문서에도 matrix와 같은 즉시 취소용 fail-fast 키가 병렬 step 그룹에 명시돼 있지 않다.

실패 뒤 진단이나 정리 step을 둔다면 조건식도 구분한다. status 함수가 없으면 기본 success()가 적용되고, failure()는 앞선 step 또는 의존 job 사슬의 실패를 검사한다. cancelled()는 workflow 취소를 검사한다. always()는 취소 때도 true이므로 공식 문서는 치명적 실패 가능 작업에서 대안으로 !cancelled()를 제시한다.

output을 읽는 위치가 step과 job을 가른다

한 job 안의 background step output과 환경 변경은 그 step을 포함한 wait 또는 wait-all이 끝난 뒤에만 공개된다. 따라서 steps.<step_id>.outputs를 소비하는 단계는 join 뒤에 둬야 한다. 기다리기 전에 값을 읽으려는 구조는 실제 데이터 의존성을 병렬 작업처럼 숨긴 것이다.

job 사이의 의존성은 needs.<job_id>.outputs로 표현한다. needs context에는 현재 job이 직접 의존한다고 선언한 job만 들어가며, 그 선행 job의 의존 job까지 자동으로 포함하지 않는다. 선행 job이 실패하거나 skip되면 후속 job도 기본적으로 skip되므로 실패와 무관하게 실행할 단계에는 명시적 조건이 필요하다.

선택 기준은 runner 경계다. 같은 runner와 workspace를 공유해야 하고 쓰기 대상까지 분리된 작업은 step 병렬화가 맞다. 다른 OS, matrix, runner 격리 또는 명시적 artifact 전달이 필요하면 job을 나누고 needs로 연결한다.

비용은 줄어든 초가 아니라 과금 구간으로 확인한다

한 job의 step을 병렬화해도 runner 세 대를 쓰는 구조가 되지는 않는다. 같은 runs-on runner environment 안에서 여러 step이 겹쳐 실행된다. 그러므로 step 개수만 세어 비용이 늘거나 준다고 판단할 수 없다.

GitHub의 job 실행 시간 화면은 private repository의 GitHub-hosted runner에 대해 billable job execution minutes를 보여주며 다음 분으로 올림한다. public repository와 self-hosted runner에는 이 billable minutes가 없다. 화면의 billable time에는 OS minute multiplier가 반영되지 않으므로 전체 Usage도 따로 확인해야 한다.

예를 들어 70초를 50초로 줄여도 같은 올림 구간에 남으면 표시되는 billable minute가 기대만큼 줄지 않을 수 있다. 이는 공식 올림 규칙에서 얻은 판단이며 실제 계정의 청구 결과를 측정한 값은 아니다. 작은 branch에서 변경 전후 job execution time과 Usage를 함께 보고, CPU·메모리 경합으로 가장 느린 경로가 오히려 길어지지 않았는지도 확인해야 한다.

workflow 또는 job의 concurrency 그룹은 충돌과 예상 밖의 Actions minutes 소비를 제어하는 별도 층이다. 이는 한 job에서 background step을 최대 10개 동시에 실행하는 제한과 같은 설정이 아니다.

전환과 롤백은 같은 판정표로 준비한다

운영 workflow를 바꾸기 전 다음 조건을 같은 입력으로 확인한다.

판정 항목 채택 조건 보류 또는 롤백 조건
공유 자원 공유 쓰기가 없거나 고유 경로·포트·key로 분리됨 같은 파일, cache, DB, 포트를 함께 변경함
결과 공개 output 소비가 wait/wait-all 뒤에 있음 background output을 join 전에 읽음
실패 전파 한 분기의 non-zero exit가 join의 non-zero로 이어짐 실패가 허용되지 않았는데 성공으로 끝남
결과 안정성 반복 실행의 hash·개수와 race count가 기준과 같음 lost update나 비결정적 산출물이 발생함
시간과 비용 critical path가 줄고 실제 실행 시간·Usage도 개선됨 경합으로 느려지거나 같은 과금 구간에 머묾

롤백은 parallel 그룹을 원래 순서의 일반 step 목록으로 되돌리거나 backgroundwait를 제거하는 YAML 변경이다. 공유 cache, DB, 배포 작업은 처음부터 실데이터로 시험하지 않고 고유 key와 임시 데이터로 검증한다.

GitHub-hosted runner의 10개 초과 step 큐잉 로그, 실제 cancel 신호, background $GITHUB_OUTPUT의 wait 전후 UI, 분 단위 청구 변화는 아직 확인하지 않았다. GitHub Enterprise Server와 오래된 self-hosted runner의 지원 버전도 이번 범위 밖이다. 이 조건을 확인할 수 없다면 순차 실행을 유지하거나 job 분리와 artifact 전달을 택하는 편이 안전하다.

운영 workflow에 적용하기 전, 작은 fixture와 Actions의 Usage 화면에서 wall time과 billable job execution minutes를 함께 확인한다. race가 0이고 실패 join이 비정상 종료하며 실제 critical path가 의미 있게 줄었을 때만 병렬화를 유지한다.

참고 링크

비슷한 글

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다