GitHub Actions fail-fast 끄기 전에 확인할 것

3줄 요약
fail-fast는 기본값이true라 매트릭스 한 칸이 실패하면 진행 중·대기 중 잡이 전부 취소되고, 취소된 잡은 리포트를 남기지 않습니다.- 잡 실행 시간의 절반 지점에서 실패가 난다고 보면, 한 런에서 서로 다른 실패 원인이 2개 이상 나오는 팀부터
fail-fast: false가 러너 분에서 이득입니다. fail-fast를 건드리기 전에 리포트 업로드 스텝에if: always()를 붙이고 아티팩트 이름에 매트릭스 값을 넣으시면 됩니다.
지난주에 안드로이드 회귀 매트릭스 12칸이 3분 만에 회색으로 변했습니다. 빨간 칸은 하나였고 나머지 열한 칸은 전부 취소였습니다. 슬랙 알림에는 "1 job failed"라고만 떴고, 리포트를 보려고 워크플로 실행 페이지를 열었더니 아티팩트 목록이 비어 있었습니다. 로그 스크롤을 삼십 분 뒤졌고, 원인을 하나 고쳐 재실행했더니 이번엔 다른 칸이 깨졌습니다. 그날 그 파이프라인을 세 번 돌렸습니다.
손댈 곳은 세 갈래입니다. fail-fast를 꺼서 매트릭스를 전부 완주시키는 것, 취소된 잡에서도 리포트만은 건져 오도록 업로드 조건을 고치는 것, continue-on-error로 특정 칸의 실패를 워크플로 실패에서 빼는 것. 셋 다 한 줄이면 끝나는 설정이라 아무거나 먼저 대도 될 것 같지만, 순서를 잘못 잡으면 러너 분만 두 배로 쓰고 원인 파악 시간은 그대로입니다.
제 결론은 둘째를 먼저 하시라는 것입니다. fail-fast를 끄는 것은 러너 분을 더 써서 정보를 사는 거래이고, 그 거래가 이득인지는 팀의 실패 패턴에 달려 있습니다. 반면 취소된 잡에서 부분 리포트를 건지는 것은 러너 분을 한 톨도 더 쓰지 않으면서 정보를 늘립니다. 공짜인 쪽을 먼저 하고, 그래도 부족하면 그때 fail-fast를 계산해서 끄는 순서가 맞습니다.
1. 취소된 잡은 실패한 잡이 아닙니다
fail-fast는 누가 켠 게 아니라 원래 켜져 있습니다
저희 워크플로에는 fail-fast라는 키가 아예 없었습니다. 한동안 GitHub 쪽 변경이라고 오해했는데, 워크플로 문법 문서를 열어 보니 그냥 기본값이었습니다. fail-fast가 참이면 매트릭스 안의 어떤 잡이든 실패했을 때 진행 중이거나 대기 중인 잡을 전부 취소하며, 이 속성의 기본값은 참이라는 설명이 그대로 적혀 있었습니다.
키를 적지 않은 워크플로는 전부 이 동작을 하고 있다는 뜻입니다. 매트릭스를 처음 만들 때는 칸이 두세 개라 눈치채기 어렵고, OS 세 개와 런타임 네 개를 곱해 열두 칸이 된 다음에야 문제가 됩니다. 같은 문서가 매트릭스는 실행 하나당 최대 256개의 잡을 만든다고 규정해 둔 걸 보면, 칸을 늘리기는 쉽고 그 비용은 늘린 다음에 청구되는 구조입니다.
취소와 실패는 상태가 다르고, 조건식이 그 차이를 봅니다
여기가 이 글의 출발점입니다. 매트릭스에서 한 칸이 실패하면 나머지 칸은 실패가 아니라 취소 상태가 되고, 스텝의 if 조건은 이 두 상태를 다르게 읽습니다. GitHub 문서는 상태 확인 함수를 쓰지 않으면 success()가 기본으로 적용된다고 적어 두었습니다. 리포트 업로드 스텝에 if를 안 썼다면 그 스텝은 if: success()로 동작하고, 잡이 취소됐으면 당연히 돌지 않습니다.
취소 동작을 다룬 문서는 한 걸음 더 들어갑니다. 워크플로 실행을 취소할 때 서버가 실행 중인 모든 잡의 if 조건을 다시 평가하며, 조건이 참이면 그 잡은 취소되지 않는다는 설명입니다. if: always()는 참으로 평가되어 잡이 계속 돈다고 예까지 들어 두었습니다. 취소는 프로세스를 즉사시키는 게 아니라 조건을 한 번 더 물어보는 절차라는 뜻입니다.
| 잡·워크플로 상태 | 조건 없음(success()) |
always() |
!cancelled() |
failure() |
cancelled() |
|---|---|---|---|---|---|
| 앞 스텝 성공 | 실행 | 실행 | 실행 | 건너뜀 | 건너뜀 |
| 앞 스텝 실패 | 건너뜀 | 실행 | 실행 | 실행 | 건너뜀 |
| 잡이 취소됨 | 건너뜀 | 실행 | 건너뜀 | 건너뜀 | 실행 |
표의 세 번째 줄이 그날 제가 리포트를 한 개도 못 건진 이유입니다.
2. 첫 번째 함정 — always()와 !cancelled()는 목적이 다릅니다
공식 권장을 그대로 따르면 취소된 칸을 또 놓칩니다
always()를 검색하면 GitHub 공식 문서의 경고 문구가 먼저 나옵니다. 소스 체크아웃처럼 치명적으로 실패할 수 있는 작업에는 always를 쓰지 말라는 것, 그리고 성공·실패와 무관하게 실행하고 싶다면 권장 대안으로 if: ${{ !cancelled() }}를 쓰라는 것입니다. 워크플로가 타임아웃까지 매달릴 수 있다는 경고라 무겁게 읽혀서, 저도 처음에는 이 권장을 그대로 따랐습니다.
그랬더니 리포트가 여전히 안 나왔습니다. 당연합니다. !cancelled()는 이름 그대로 취소되지 않았을 때 실행하라는 조건이라 성공과 실패는 덮지만 취소는 덮지 않습니다. 우리가 건지려는 것은 정확히 그 취소된 열한 칸의 부분 결과인데 말입니다.
제 해석으로는 저 권장이 겨냥한 것은 "잡이 정상적으로 끝났든 깨졌든 후처리를 하고 싶다"는 흔한 요구이고, "취소된 잡의 잔해를 수거하고 싶다"는 다른 문제입니다. 같은 문서가 always를 두고 잡이 취소될 때도 실행되기를 기대하는 작업에 쓰는 것이 가장 좋으며 취소된 잡의 로그를 보낼 때가 그 예라고 적어 둔 것도 같은 맥락입니다. 로그를 보내라는 그 자리에 테스트 리포트를 넣으면 됩니다.
그래서 스텝 레벨에만, 짧은 작업에만 붙입니다
경고를 무시하자는 얘기는 아닙니다. 위험은 실재합니다. always()를 잡 레벨 if에 붙이면 취소 요청이 와도 그 잡이 계속 돌아가고, 잡 타임아웃 기본값은 360분입니다. 취소 버튼을 눌렀는데 여섯 시간 뒤에나 멈추는 파이프라인은 아무도 원하지 않습니다.
제가 정한 선은 이렇습니다. always()는 스텝 레벨에만, 그리고 네트워크를 오래 잡지 않는 짧은 스텝에만 붙입니다. 리포트 아티팩트 업로드와 테스트 요약 출력 정도가 여기 해당합니다. 그 스텝에는 timeout-minutes를 따로 걸어 최악의 경우를 잘라 둡니다. 소수점은 지원하지 않으니 업로드 스텝이면 3~5분 정수로 충분합니다.
# .github/workflows/regression.yml
jobs:
e2e:
strategy:
# fail-fast를 아직 건드리지 않았습니다. 이 단계에서는 리포트만 건집니다
matrix:
os: [ubuntu-latest, macos-latest]
node: [20, 22, 24]
steps:
- uses: actions/checkout@v5
- run: npm ci
- run: npx playwright test --reporter=junit,html
- name: 리포트 업로드
# 취소된 잡에서도 실행되어야 하므로 !cancelled() 가 아니라 always() 입니다
if: always()
# 짧은 스텝이라 always() 의 위험(타임아웃까지 매달림)을 3분으로 잘라 둡니다
timeout-minutes: 3
uses: actions/upload-artifact@v7
with:
# 매트릭스 값을 이름에 넣지 않으면 v4 이후 409 충돌이 납니다
name: report-${{ matrix.os }}-node${{ matrix.node }}
path: |
playwright-report/
results.xml
retention-days: 7
이걸 넣은 다음 날 같은 사고가 났을 때 아티팩트가 열두 개 나왔습니다. 취소된 칸의 리포트는 당연히 불완전했지만 취소 시점까지 돌아간 케이스의 통과·실패는 그대로 들어 있었습니다. 열한 칸 중 세 칸에 이미 실패가 기록돼 있었고 원인은 하나였습니다. 재실행을 한 번 아꼈습니다.
3. 두 번째 함정 — 이름 하나 때문에 매트릭스가 통째로 빨간불이 됩니다
v4부터 아티팩트는 불변이라 같은 이름을 두 번 못 씁니다
위 코드의 name에 matrix 값을 넣은 데는 이유가 있습니다. 이 규칙을 빼먹고 배포한 날, 다음 날 아침 매트릭스 열두 칸이 전부 빨간불이었습니다. 테스트가 아니라 업로드 스텝에서 이 문자열이 떴습니다.
Failed to CreateArtifact: Received non-retryable error: Failed request: (409)
Conflict: an artifact with this name already exists on the workflow run
actions/upload-artifact 문서는 이전 버전과 달리 여러 잡에서 같은 아티팩트로 업로드하는 것이 v4에서는 지원되지 않는다고 못 박아 두었습니다. 생성된 각 아티팩트가 멱등하기 때문에 여러 잡이 같은 아티팩트를 수정할 수 없고, 그래서 이름이 유일해야 한다는 설명입니다. 매트릭스 시나리오에서 실수로 같은 아티팩트에 업로드하지 않도록 주의하라는 경고도 따로 붙어 있습니다.
v3까지는 같은 이름을 허용했고 그 결과 예상치 못한 변형과 우발적 손상이 생겼다는 것이 변경 사유입니다. 버그가 아니라 의도된 파괴적 변경이고, v3에서 올라온 워크플로는 전부 이 지점을 밟습니다.
버전과 이름 규칙을 표로 고정해 두었습니다
릴리스 아톰 피드 기준 최신은 v7.0.1로 2026년 4월 10일 UTC 16:08, KST 4월 11일 새벽 1시 8분에 나왔습니다. v5는 2025년 10월 24일, v6은 2025년 12월 12일, v7은 2026년 3월 17일이 각각 첫 릴리스입니다. 메이저가 빠르게 올라간 편이라 사내에 v3 워크플로가 남아 있는지 한 번 훑어 보시길 권합니다.
| 항목 | v3까지 | v4 이후(v7 포함) |
|---|---|---|
| 같은 이름 중복 업로드 | 허용(뒤 업로드가 병합) | 409 충돌로 실패 |
| 아티팩트 변경 가능 여부 | 가능 | 불변(immutable) |
| 같은 이름 덮어쓰기 | 자동 병합 | overwrite: true 필요, 새 ID가 부여됨 |
| GHES 지원 | 지원 | 미지원, v3.2.2 계열을 써야 함 |
이름 규칙 자체는 단순합니다. 매트릭스 축을 전부 이름에 붙이면 됩니다. 축이 두 개면 둘 다, 세 개면 셋 다입니다. 축 하나를 빼먹으면 그 축의 값만 다른 칸끼리 충돌합니다. 저는 이 실수를 줄이려고 워크플로 린트에 검사를 하나 넣었는데, 뒤에 적어 두겠습니다.
4. 가장 비싼 함정 — continue-on-error로 초록불을 사면 실패가 조용해집니다
잡 레벨과 스텝 레벨은 범위가 다릅니다
fail-fast를 끄기 싫고 리포트도 귀찮을 때 사람들이 가장 먼저 손대는 것이 continue-on-error입니다. 그런데 이 키는 붙이는 위치에 따라 뜻이 완전히 다릅니다.
잡 레벨의 continue-on-error가 참이면 그 잡이 실패해도 매트릭스의 다른 잡들이 계속 실행됩니다. 동시에 이 키는 잡이 실패했을 때 워크플로 실행이 실패하는 것을 막습니다. 스텝 레벨은 범위가 한 칸 좁아서, 스텝이 실패했을 때 잡이 실패하는 것을 막습니다.
문제는 두 효과가 한 키에 묶여 있다는 점입니다. "이 칸 하나 때문에 나머지가 취소되는 게 싫다"는 이유로 잡 레벨에 붙이면 덤으로 "이 칸이 깨져도 워크플로는 초록불"이 따라옵니다. 초록불을 보고 머지한 변경이 특정 OS에서 깨져 있었다는 사실을, 그 OS 사용자가 제보할 때까지 아무도 모르게 됩니다.
| 목적 | 맞는 키 | 잘못 쓰면 |
|---|---|---|
| 다른 칸이 취소되지 않게 | strategy.fail-fast: false |
잡 레벨 continue-on-error를 쓰면 실패가 초록불로 숨음 |
| 특정 칸(실험 대상)의 실패를 눈감아 주기 | 잡 레벨 continue-on-error + 그 칸만 조건부 |
전 칸에 걸면 매트릭스가 경보 기능을 잃음 |
| 실패해도 후처리는 돌리기 | 스텝 if: always() |
!cancelled()를 쓰면 취소된 칸을 놓침 |
| 스텝 하나만 눈감기 | 스텝 continue-on-error |
잡 레벨에 걸면 범위가 잡 전체로 커짐 |
재실행이 취소된 잡을 다시 돌리는지는 문서에 없습니다
여기는 확인된 근거가 없어 그대로 적습니다. "Re-run failed jobs"가 어떤 잡을 다시 돌리는지에 대해 웹 UI 사용법 문서에는 대상 범위 설명 자체가 없고, REST API 레퍼런스가 실패한 잡과 그에 의존하는 잡을 전부 재실행한다고 규정한 정도입니다.
취소된 잡이 이 "failed jobs"에 포함되는지를 명시한 문장은 어디에도 없었습니다. 저희 저장소에서는 취소된 칸도 같이 돌아갔지만 그건 관찰이지 규정이 아닙니다. 그래서 fail-fast 사고가 난 런은 부분 재실행 대신 전체 재실행을 씁니다.
5. 러너 분으로 따진 fail-fast 손익
손익분기는 "한 런에 서로 다른 실패 원인이 몇 개냐"입니다
이제 처음 질문으로 돌아갑니다. fail-fast는 언제 끄는 게 이득일까요. 계산해 보면 답이 한 줄로 나옵니다.
칸 수를 N, 칸 하나의 정상 실행 시간을 T, 실패 시점을 t라 두고 러너가 넉넉해 N칸이 동시에 돈다고 가정하겠습니다. fail-fast: true인 런은 t 시점에 전부 잘리므로 N×t를 씁니다. 대신 원인이 하나만 보이므로 원인이 k개면 k번 고쳐 돌리고 마지막에 한 번 완주해야 합니다. false인 런은 항상 N×T를 쓰지만 원인 k개가 첫 런에 전부 보이므로 고친 뒤 확인용 한 번이면 끝납니다.
# fail-fast 손익 계산 — 조건을 바꿔 가며 직접 돌려 보십시오
def runner_minutes(n_cells: int, t_full: float, t_fail: float, k_causes: int):
"""n_cells: 매트릭스 칸 수 / t_full: 정상 완주 분 / t_fail: 실패 발생 시점(분)
k_causes: 그 런에 섞여 있는 서로 다른 실패 원인 개수"""
# true: 원인 하나씩 발견 → k번 잘린 런 + 마지막 완주 1회
ff_true = k_causes * n_cells * t_fail + n_cells * t_full
# false: 원인 전부를 첫 런에서 확인 → 진단 1회 + 확인 1회
ff_false = 2 * n_cells * t_full
return ff_true, ff_false, k_causes + 1, 2 # 러너분 2개, 사이클 수 2개
for k in (1, 2, 3, 4):
a, b, ca, cb = runner_minutes(12, 6, 3, k)
verdict = "false 유리" if b < a else ("동률" if a == b else "true 유리")
print(f"원인 {k}개 | true {a:>5.0f}분({ca}사이클) | false {b:>5.0f}분({cb}사이클) | {verdict}")
12칸·6분·실패 3분 조건으로 돌리면 아래가 나옵니다.
| 서로 다른 실패 원인 | fail-fast: true 러너분 |
사이클 | fail-fast: false 러너분 |
사이클 | 판정 |
|---|---|---|---|---|---|
| 1개 | 108분 | 2회 | 144분 | 2회 | true 유리 |
| 2개 | 144분 | 3회 | 144분 | 2회 | 동률, 사이클은 false 유리 |
| 3개 | 180분 | 4회 | 144분 | 2회 | false 유리 |
| 4개 | 216분 | 5회 | 144분 | 2회 | false 유리 |
일반화하면 손익분기는 k = T ÷ t입니다. 실패가 잡 실행 시간의 절반 지점에서 난다면 손익분기는 원인 2개, 3분의 1 지점이면 3개입니다. 설정 오류나 의존성 설치 실패처럼 실패가 이른 시점에 몰리는 팀일수록 fail-fast: true가 오래 유리합니다. 반대로 테스트가 거의 끝날 무렵에 깨지는 팀이라면 true로 아끼는 러너분이 얼마 안 되니 끄는 편이 낫습니다.
여기서 2절의 조치가 왜 먼저인지가 드러납니다. 취소된 칸에서 부분 리포트를 건지면 첫 런에서 보이는 원인 개수가 1에서 늘어납니다. 실제 k를 늘리는 게 아니라 첫 런에서 관측되는 k를 실제 k에 가깝게 만드는 것입니다. 러너분을 한 푼도 더 안 쓰고 fail-fast: true의 약점만 깎는 셈입니다.
도입 난이도 · 유지보수 비용 · 팀 학습곡선
QA 네 명에서 여섯 명 규모, 자동화가 아직 초기인 팀 기준으로 매겼습니다.
| 조치 | 도입 난이도 | 유지보수 비용 | 팀 학습곡선 |
|---|---|---|---|
업로드 스텝에 if: always() + 스텝 타임아웃 |
낮음. 워크플로당 3줄 | 낮음. 한 번 넣으면 손댈 일 없음 | 낮음. always()와 !cancelled()의 차이만 공유하면 됨 |
| 아티팩트 이름에 매트릭스 값 삽입 | 낮음. 문자열 한 줄 | 낮음. 단 매트릭스 축을 추가할 때 같이 고쳐야 함 | 낮음 |
fail-fast: false 전환 |
낮음. 한 줄 | 중간. 러너 분 청구서를 월 단위로 봐야 함 | 중간. 손익분기 개념을 팀이 공유해야 함 |
잡 레벨 continue-on-error |
낮음 | 높음. 초록불이 거짓말을 하기 시작함 | 높음. 범위 차이를 모르면 사고가 남 |
| 매트릭스 축 자체를 줄이기 | 중간. 커버리지 협의 필요 | 낮음 | 중간 |
표에서 가장 눈여겨보실 줄은 네 번째입니다. 도입 난이도가 가장 낮은 조치가 유지보수 비용은 가장 비쌉니다. 한 줄로 끝나니 급할 때 손이 먼저 가는데, 그렇게 붙인 continue-on-error가 반년 뒤에 초록불 뒤에 숨은 실패로 돌아옵니다. 저희는 이 키를 쓸 때 리뷰에서 사유를 주석으로 남기게 하고 있습니다.
6. 권할 만한 경우 / 권하지 않는 경우
권할 만한 경우
매트릭스가 여섯 칸을 넘고 실패가 잦은 파이프라인이라면 리포트 조치는 오늘 넣으셔도 됩니다. 러너 분이 늘지 않으니 계산할 것도 없습니다. 실패가 테스트 후반부에 몰리는 팀이라면 fail-fast: false까지 같이 가셔도 됩니다. 손익분기 k = T ÷ t에서 t가 T에 가까울수록 true로 아끼는 분이 줄어들기 때문입니다.
매트릭스에 명시적인 "실험 칸"이 있다면, 예를 들어 아직 지원 대상이 아닌 최신 런타임을 미리 돌려 보는 칸이라면 그 칸에만 잡 레벨 continue-on-error를 붙이십시오. 앞 표에서 이 키를 권하는 유일한 용법입니다.
권하지 않는 경우
러너를 셀프호스팅으로 소수만 운영하는 팀은 fail-fast: false를 신중히 보셔야 합니다. 러너 분이 아니라 슬롯이 병목이면 완주한 매트릭스가 다른 팀의 잡을 밀어냅니다. 이때는 max-parallel로 동시 실행 폭을 같이 제한하십시오. 문서가 기본적으로 러너 가용성에 따라 병렬 잡 수를 최대화한다고 적어 둔 만큼, 안 걸면 정말 최대로 잡습니다.
플레이키가 정리되지 않은 팀도 fail-fast: false부터 켜지 마십시오. 실제 원인 k가 1인데 플레이키 때문에 3으로 보이는 상태에서는 계산이 틀립니다. 그리고 잡 레벨 if: always()는 어느 경우에도 권하지 않습니다. 취소 버튼이 무력해지고 기본 타임아웃 360분이 상한이 됩니다.
내일 아침에 돌려 볼 점검 스크립트
저장소의 워크플로 파일을 훑어 위 함정 세 개를 잡아냅니다. yq가 없으면 grep만으로도 대략은 잡힙니다.
#!/usr/bin/env bash
# .github/workflows 의 함정 3종 점검 — CI에 붙여도 되고 로컬에서 한 번 돌려도 됩니다
set -uo pipefail
shopt -s nullglob
found=0
for f in .github/workflows/*.yml .github/workflows/*.yaml; do
# 1) 매트릭스가 있는데 fail-fast 를 명시하지 않은 워크플로
if grep -q "matrix:" "$f" && ! grep -q "fail-fast:" "$f"; then
echo "[기본값 주의] $f — matrix 는 있는데 fail-fast 명시가 없습니다 (기본 true)"
found=1
fi
# 2) upload-artifact 를 쓰면서 매트릭스 값을 이름에 안 넣은 경우 → 409 충돌 예약
if grep -q "upload-artifact" "$f" && grep -q "matrix:" "$f" \
&& ! grep -qE 'name:.*\$\{\{\s*matrix\.' "$f"; then
echo "[409 위험] $f — 아티팩트 이름에 matrix 값이 없습니다"
found=1
fi
# 3) 잡 레벨 continue-on-error (들여쓰기 4칸 = jobs.<id> 바로 아래)
if grep -qE '^ continue-on-error:\s*true' "$f"; then
echo "[초록불 위험] $f — 잡 레벨 continue-on-error 가 있습니다. 사유 주석이 있는지 확인하십시오"
found=1
fi
done
exit "$found"
세 검사 모두 워크플로를 고치라는 명령이 아니라 "여기 결정이 하나 필요하다"는 표시입니다. 첫 번째 검사는 fail-fast: true를 명시적으로 적어 두기만 해도 통과합니다. 기본값에 기대는 대신 팀이 한 번 결정하고 적어 두게 만드는 것이 목적이기 때문입니다.
댓글
댓글 쓰기