Published on

GitHub Actions 매트릭스 fail-fast 끄고 실패 수집하기

Authors
Binance registration banner

서로 다른 OS, 런타임 버전, 의존성 조합을 동시에 검증하려면 GitHub Actions의 매트릭스 전략이 가장 효율적입니다. 그런데 기본 설정(또는 팀에서 관성적으로 켜둔 설정) 때문에 한 조합이 실패하면 나머지 조합이 즉시 취소되어, 실패 원인 파악이 오히려 느려지는 경우가 많습니다. 특히 다음 상황에서 그렇습니다.

  • Node 18에서만 깨지는지, 20에서도 깨지는지 확인해야 할 때
  • ubuntu-latest만 실패하는지 windows-latest도 실패하는지 비교해야 할 때
  • 특정 테스트가 플래키해서 한 번 실패했다고 전체를 멈추면, 재현과 원인 분리가 더 어려울 때

이 글은 매트릭스의 fail-fast를 끄는 방법과, 실패를 “끝까지 실행한 뒤” 한 번에 수집해 요약/아티팩트로 남기는 패턴을 정리합니다.

참고로 장애를 빨리 수습하려면 “실패를 더 빨리 발견”하는 것만큼 “실패 정보를 더 많이 확보”하는 것도 중요합니다. 인프라/런타임 문제를 진단할 때 로그를 끝까지 수집하는 방식은 예를 들어 EKS에서 fluent-bit 로그 누락·지연 원인 9가지 같은 운영 진단 글에서도 동일한 철학으로 반복됩니다.

매트릭스 fail-fast가 실제로 하는 일

GitHub Actions에서 매트릭스는 하나의 job을 여러 조합으로 확장해 병렬 실행합니다. 이때 strategy.fail-fasttrue이면, 어떤 조합이 실패하는 순간 진행 중인 다른 조합을 취소합니다.

  • 장점: 비용 절감, “빨간불”을 빠르게 띄움
  • 단점: 실패가 연쇄적으로 발생하는지, 특정 조합에서만 발생하는지 파악하기 어려움

CI에서 중요한 질문은 종종 “하나라도 실패했나”가 아니라 “어떤 범위로 실패했나”입니다. 범위를 알아야 영향도를 추정하고, 롤백/핫픽스/우회 적용을 결정할 수 있습니다.

fail-fast 비활성화: 가장 기본적인 설정

아래처럼 strategy.fail-fast: false를 지정하면, 한 조합이 실패해도 나머지 조합은 계속 실행됩니다.

name: ci

on:
  push:
  pull_request:

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
        node: [18, 20]

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
          cache: npm

      - run: npm ci
      - run: npm test

여기까지가 “끝까지 실행”의 시작점입니다. 하지만 실무에서는 여기서 한 단계 더 필요합니다.

  • 실패한 조합의 로그를 나중에 빠르게 찾아야 함
  • 실패한 조합들을 한 눈에 요약해야 함
  • 필요하면 실패한 조합의 산출물(테스트 리포트, 스크린샷, 커버리지 등)을 모아야 함

실패를 수집하는 핵심: 실패해도 업로드는 실행되게 만들기

테스트 단계가 실패하면 기본적으로 이후 step은 실행되지 않습니다. 따라서 “실패했을 때도 실행되어야 하는 step”은 if: always()를 붙여야 합니다.

예를 들어 JUnit 리포트를 업로드하고 싶다면 다음 패턴이 필요합니다.

- name: Run tests
  run: npm test -- --reporter=junit --reporter-options output=reports/junit.xml

- name: Upload test report (always)
  if: always()
  uses: actions/upload-artifact@v4
  with:
    name: junit-${{ matrix.os }}-node${{ matrix.node }}
    path: reports/junit.xml
    if-no-files-found: warn

이렇게 하면 테스트가 실패해도 reports/junit.xml이 생성되어 있는 한 아티팩트가 남습니다. 이후에는 “어떤 조합이 실패했는지”를 자동으로 모으는 단계가 필요합니다.

패턴 1: 매트릭스 job은 계속, 최종 요약 job에서 실패 목록 만들기

가장 많이 쓰는 방식은 다음 구조입니다.

  • test 매트릭스 job: 각 조합을 실행하고, 결과/리포트를 아티팩트로 업로드
  • summarize 단일 job: 모든 아티팩트를 다운로드하고, 실패 조합을 표로 요약

중요 포인트는 두 가지입니다.

  1. summarizeneeds: test를 걸되 if: always()로 실행되어야 함
  2. test가 실패하면 기본적으로 워크플로 전체가 실패로 표시되므로, 요약 job이 실행되도록 조건을 명시해야 함

아래 예시는 “각 매트릭스 조합에서 상태 파일을 남기고”, 마지막에 이를 모아 GitHub Step Summary에 표로 출력합니다.

name: matrix-ci

on:
  pull_request:

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, windows-latest]
        node: [18, 20]

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
          cache: npm

      - run: npm ci

      - name: Run tests
        id: run_tests
        run: npm test

      - name: Write status marker (always)
        if: always()
        shell: bash
        run: |
          mkdir -p status
          if [ "${{ steps.run_tests.outcome }}" = "success" ]; then
            echo "ok" > "status/result.txt"
          else
            echo "fail" > "status/result.txt"
          fi
          echo "os=${{ matrix.os }}" > status/meta.txt
          echo "node=${{ matrix.node }}" >> status/meta.txt

      - name: Upload status marker (always)
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: status-${{ matrix.os }}-node${{ matrix.node }}
          path: status/

  summarize:
    runs-on: ubuntu-latest
    needs: [test]
    if: always()

    steps:
      - name: Download all status artifacts
        uses: actions/download-artifact@v4
        with:
          path: artifacts

      - name: Build summary
        shell: bash
        run: |
          echo "## Matrix 결과 요약" >> "$GITHUB_STEP_SUMMARY"
          echo "" >> "$GITHUB_STEP_SUMMARY"
          echo "| OS | Node | Result |" >> "$GITHUB_STEP_SUMMARY"
          echo "|---|---:|---|" >> "$GITHUB_STEP_SUMMARY"

          failed=0

          for d in artifacts/status-*; do
            os=$(grep '^os=' "$d/meta.txt" | cut -d= -f2)
            node=$(grep '^node=' "$d/meta.txt" | cut -d= -f2)
            result=$(cat "$d/result.txt")

            if [ "$result" = "fail" ]; then
              failed=1
            fi

            echo "| $os | $node | $result |" >> "$GITHUB_STEP_SUMMARY"
          done

          if [ $failed -eq 1 ]; then
            echo "" >> "$GITHUB_STEP_SUMMARY"
            echo "일부 조합이 실패했습니다. 각 조합의 로그와 아티팩트를 확인하세요." >> "$GITHUB_STEP_SUMMARY"
            exit 1
          fi

이 방식의 장점은 단순합니다.

  • 매트릭스는 실패해도 끝까지 진행
  • 마지막에 실패 조합을 표로 보여줌
  • 요약 job에서 exit 1로 전체 워크플로 실패를 명확하게 유지

즉, “정보 수집은 끝까지, 최종 상태는 실패로”를 동시에 만족합니다.

패턴 2: 테스트 단계 실패를 허용하고, 마지막에만 실패 처리하기

상황에 따라서는 매트릭스 job 자체가 실패로 찍히면(빨간 X) 팀이 과민 반응하거나, 후속 job이 조건 때문에 스킵되는 문제가 생깁니다. 이때는 테스트 step에 continue-on-error: true를 적용하고, 대신 상태 마커로 실패를 기록한 뒤 마지막에 한 번만 실패 처리할 수 있습니다.

주의할 점은 “실패를 숨기지 말고” 마지막에 반드시 실패로 종료해야 한다는 것입니다.

- name: Run tests (do not stop job)
  id: run_tests
  continue-on-error: true
  run: npm test

- name: Fail job if tests failed (after uploads)
  if: ${{ steps.run_tests.outcome != 'success' }}
  run: exit 1

이 패턴은 리포트 업로드, 로그 수집, 디버그 정보 출력 등을 충분히 수행한 뒤에 실패를 확정할 수 있어, “실패 수집” 관점에서 매우 유용합니다.

실패 수집을 더 쓸모 있게 만드는 실전 팁

1) 조합별 로그를 구조화해서 남기기

단순히 콘솔 로그만으로는 비교가 어렵습니다. 조합별로 파일로 남기고 아티팩트로 업로드하면, 재현 없이도 비교가 쉬워집니다.

- name: Run tests and capture log
  shell: bash
  run: |
    mkdir -p logs
    npm test 2>&1 | tee "logs/test-${{ matrix.os }}-node${{ matrix.node }}.log"

- name: Upload logs (always)
  if: always()
  uses: actions/upload-artifact@v4
  with:
    name: logs-${{ matrix.os }}-node${{ matrix.node }}
    path: logs/

2) 플래키 테스트는 “재시도”와 “실패 수집”을 분리

플래키를 재시도로 덮으면 근본 원인이 묻힙니다. 재시도는 하되, 첫 실패 로그도 함께 남겨야 합니다. 예를 들어 테스트 러너가 재시도를 지원한다면 “재시도 전 로그”를 별도 파일로 저장하거나, 실패 시점의 환경 정보를 출력하는 step을 if: failure()로 추가하세요.

- name: Dump env on failure
  if: failure()
  shell: bash
  run: |
    node -v
    npm -v
    env | sort | sed -n '1,120p'

3) 매트릭스가 커질수록 include로 “의미 있는 조합”만 돌리기

fail-fast를 끄면 비용이 늘 수 있습니다. 따라서 조합을 무작정 늘리기보다 include로 “검증 가치가 있는 조합”을 명시하고, 나머지는 제외하는 편이 좋습니다.

strategy:
  fail-fast: false
  matrix:
    include:
      - os: ubuntu-latest
        node: 20
      - os: windows-latest
        node: 20
      - os: ubuntu-latest
        node: 18

4) 실패 요약을 PR 코멘트로 남기기

Step Summary는 Actions 화면에서만 보입니다. 리뷰어가 PR에서 바로 확인하게 하려면 코멘트 자동화를 고려할 수 있습니다. 다만 토큰 권한, 중복 코멘트 방지, 포크 PR 제한 등 운영 포인트가 있어 팀 정책에 맞춰 적용하세요.

이런 “결과를 한 번에 요약해 커뮤니케이션 비용을 줄이는” 접근은 프론트 성능 튜닝에서도 유사합니다. 예를 들어 Next.js 14 RSC 느림? TTFB 급증 7가지 해결처럼 관측 지표를 모아서 병목을 좁히는 방식이 결국 시간을 절약합니다.

언제 fail-fast를 끄는 게 좋은가

  • 릴리즈 직전, “영향 범위”를 빠르게 파악해야 할 때
  • 런타임/OS/아키텍처별 차이를 비교해야 할 때
  • 실패가 연쇄적일 수 있어, 첫 실패만으로 원인을 특정하기 어려울 때
  • 플래키가 있는 구간에서 재현 단서를 최대한 모아야 할 때

반대로 다음 상황에서는 fail-fast: true가 더 낫습니다.

  • 비용이 매우 민감하고, “하나라도 실패하면 어차피 머지 불가”인 단순 게이트
  • 실패 원인이 거의 항상 동일해서 추가 조합 실행이 의미 없을 때
  • 병렬 실행이 많아 러너 쿼터를 쉽게 소진할 때

정리

  • 매트릭스에서 한 조합 실패가 나머지를 취소하는 것이 문제라면 strategy.fail-fast: false가 출발점입니다.
  • “실패 수집”의 핵심은 if: always()로 아티팩트 업로드/상태 기록을 보장하는 것입니다.
  • 가장 실용적인 구조는 “매트릭스는 끝까지 실행 + 마지막 요약 job에서 실패 목록을 표로 출력 + 최종 실패 처리”입니다.

이 패턴을 적용하면 CI가 단순히 빨간불을 켜는 도구가 아니라, 실패의 범위와 단서를 자동으로 모아주는 진단 도구로 바뀝니다.