라이브러리나 CLI 도구를 만들다 보면 “이게 Python 3.9에서도 돌아가나?”, “Windows에서는 경로 처리가 깨지지 않나?” 같은 질문이 계속 쌓인다. 이걸 손으로 하나씩 확인하는 건 현실적이지 않고, 워크플로 파일에 잡(job)을 여러 개 복사-붙여넣기 하면 관리가 순식간에 지옥이 된다. GitHub Actions의 매트릭스 빌드(matrix build)는 이 문제를 정면으로 해결한다.

매트릭스는 하나의 잡 정의를 여러 변수 조합으로 자동 확장해, 각 조합을 병렬 잡으로 돌려준다. 운영체제 3종 × 언어 버전 4종을 적으면 12개의 잡이 알아서 만들어진다. 이 글에서는 기본 문법부터 조합 제외·추가, 부분 실패 제어, 동적 매트릭스 생성, 그리고 잡이 수십 개로 불어날 때 파이프라인을 통제하는 실무 패턴까지 실제 워크플로 YAML과 함께 정리한다.

매트릭스의 기본 개념

매트릭스는 잡의 strategy.matrix 아래에 변수 이름과 값 배열을 나열하는 것으로 시작한다. 변수가 여러 개면 GitHub Actions는 모든 값의 데카르트 곱(cartesian product)을 계산해 조합마다 잡을 하나씩 생성한다. 아래 예시는 운영체제 3종과 Python 4종을 조합해 총 12개의 병렬 잡을 만들고, 각 잡은 matrix.osmatrix.python-version을 참조해 자기 환경을 구성한다.

name: test

on: [push, pull_request]

jobs:
  test:
    # runs-on 자체를 매트릭스 변수로 지정 → OS별 러너 선택
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
        python-version: ["3.9", "3.10", "3.11", "3.12"]
    # 3 x 4 = 12개 잡이 자동 생성되어 병렬 실행

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}
      - run: pip install -e ".[test]" && pytest -q

여기서 핵심은 runs-on: ${{ matrix.os }}다. 러너 라벨을 매트릭스 변수로 지정하면 조합별로 다른 OS에서 잡이 돌아, 크로스 플랫폼 검증을 잡 하나의 정의로 끝낼 수 있다.

조합 제외하기: exclude

데카르트 곱이 항상 원하는 조합만 만들어주지는 않는다. 특정 구버전 언어가 특정 OS에서 지원되지 않거나, macOS 러너 비용이 비싸서 일부 조합만 돌리고 싶을 수 있다. 이때 exclude로 불필요한 조합을 골라내서 제거한다.

아래는 12개 조합 중 “Windows + 3.9″와 “macOS + 3.9” 두 조합을 빼서 10개만 실행하는 예시다.

strategy:
  matrix:
    os: [ubuntu-latest, macos-latest, windows-latest]
    python-version: ["3.9", "3.10", "3.11", "3.12"]
    exclude:
      # 3.9는 리눅스에서만 검증 (다른 OS 조합 제외)
      - os: windows-latest
        python-version: "3.9"
      - os: macos-latest
        python-version: "3.9"
# 12 - 2 = 10개 잡 실행

주의할 점은 exclude명시한 키만 매칭한다는 것이다. python-version: "3.9"만 적으면 3.9인 모든 OS 조합이 제거되므로, 특정 조합만 집으려면 OS와 버전을 둘 다 명시해야 한다.

조합 추가하기: include

include는 두 가지 용도가 있다. 첫째는 기존 조합에 변수를 덧붙이는 것, 둘째는 매트릭스에 없던 새 조합을 추가하는 것이다. 판단 규칙은 이렇다. include 항목의 키-값이 이미 생성된 조합과 일부라도 겹치면 새 잡 대신 그 조합에 나머지 값을 병합하고, 겹치는 조합이 없으면 새 잡으로 추가한다. 이 차이가 실무에서 가장 많이 헷갈리는 지점이다.

strategy:
  matrix:
    os: [ubuntu-latest, windows-latest]
    node: [18, 20]
    include:
      # (1) 기존 조합에 변수 추가: ubuntu+20 잡에만 coverage 플래그 부여
      - os: ubuntu-latest
        node: 20
        coverage: true
      # (2) 완전히 새 조합 추가: 매트릭스에 없던 node 22 실험 잡
      - os: ubuntu-latest
        node: 22
        experimental: true
# 결과: (ubuntu,18) (ubuntu,20,coverage) (windows,18)
#       (windows,20) (ubuntu,22,experimental) = 총 5개 잡

정리하면, include 각 항목은 먼저 기존 조합에 병합을 시도하고 병합할 대상이 없으면 새 잡으로 추가한다. 그래서 coverage: true(ubuntu, 20)에 얹혔지만, node: 22는 매트릭스에 없던 값이라 새 잡이 됐다.

부분 실패 제어: fail-fast와 continue-on-error

매트릭스 잡 중 하나가 실패하면 GitHub Actions는 기본적으로 나머지 진행 중인 잡을 즉시 취소한다. 이게 fail-fast: true(기본값)의 동작으로, 러너 시간을 아끼는 장점이 있지만 “어떤 조합들이 깨지는지 한 번에 다 보고 싶다”는 상황에서는 방해가 된다. 전체 조합의 결과를 모두 확인하고 싶다면 fail-fast: false로 끄면, 한 잡이 실패해도 나머지가 끝까지 돌아 한 번의 실행으로 모든 실패 지점을 파악할 수 있다.

strategy:
  fail-fast: false   # 한 조합이 깨져도 나머지 조합은 끝까지 실행
  matrix:
    os: [ubuntu-latest, macos-latest, windows-latest]
    python-version: ["3.9", "3.10", "3.11", "3.12"]

continue-on-error와의 차이도 구분해야 한다. fail-fast는 “형제 잡을 취소할지”를, continue-on-error는 “이 잡의 실패를 파이프라인 전체 실패로 볼지”를 결정한다. 실험용 버전은 실패해도 전체를 빨간불로 만들고 싶지 않을 때, 앞서 만든 experimental 플래그와 조합해 쓴다.

jobs:
  test:
    runs-on: ubuntu-latest
    # experimental 조합은 실패해도 워크플로 전체는 성공 처리
    continue-on-error: ${{ matrix.experimental == true }}
    strategy:
      fail-fast: false
      matrix:
        node: [18, 20]
        include:
          - node: 22
            experimental: true

동시성 제한: max-parallel

매트릭스가 커지면 잡 수십 개가 동시에 러너를 요구한다. 동시 실행 러너 수에 한도가 있거나, 공유 데이터베이스나 레이트 리밋이 있는 API에 동시에 붙으면 곤란한 경우가 있다. max-parallel로 한 번에 실행할 잡 수를 제한한다.

strategy:
  max-parallel: 4    # 매트릭스 잡을 한 번에 최대 4개씩만 실행
  matrix:
    shard: [1, 2, 3, 4, 5, 6, 7, 8]

이 값은 매트릭스 확장 자체를 줄이지는 않는다. 조합은 그대로 다 만들어지되, 러너 점유를 슬롯 단위로 나눠 순차 소화한다. 테스트를 여러 샤드(shard)로 쪼갤 때 특히 유용한데, 각 샤드가 pytest --splits 8 --group ${{ matrix.shard }}처럼 일부만 담당하게 하면 총 실행 시간을 크게 줄일 수 있다.

동적 매트릭스: 매트릭스를 코드로 생성하기

때로는 매트릭스 조합을 YAML에 하드코딩할 수 없다. 예를 들어 모노레포에서 변경된 패키지만 테스트해야 하는 경우다. GitHub Actions는 매트릭스 값으로 JSON 문자열을 받을 수 있어, 앞선 잡에서 매트릭스를 생성해 fromJSON으로 넘기는 패턴이 가능하다. 핵심은 두 잡으로 나누는 것이다. 첫 번째 잡이 매트릭스 JSON을 출력(output)으로 내보내고, 두 번째 잡이 그 output을 strategy.matrix에 주입한다.

jobs:
  # 1단계: 변경된 패키지 목록으로 매트릭스 JSON 생성
  set-matrix:
    runs-on: ubuntu-latest
    outputs:
      matrix: ${{ steps.gen.outputs.matrix }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0   # diff 계산을 위해 전체 히스토리 필요
      - id: gen
        run: |
          # main 대비 변경된 packages/* 만 추려 JSON 배열로
          changed=$(git diff --name-only origin/main... 
            | grep '^packages/' | cut -d/ -f2 | sort -u)
          json=$(echo "$changed" | jq -R . | jq -sc '{package: .}')
          echo "matrix=$json" >> "$GITHUB_OUTPUT"

  # 2단계: 생성된 JSON을 매트릭스로 주입
  test:
    needs: set-matrix
    runs-on: ubuntu-latest
    strategy:
      matrix: ${{ fromJSON(needs.set-matrix.outputs.matrix) }}
    steps:
      - uses: actions/checkout@v4
      - run: npm --workspace packages/${{ matrix.package }} test

동적 매트릭스에는 주의할 함정이 있다. 변경된 패키지가 하나도 없으면 매트릭스가 빈 배열이 되고, 그러면 test 잡은 아예 생성되지 않는다. 이 잡을 필수 상태 체크로 걸어뒀다면 잡이 존재하지 않아 브랜치 보호 규칙이 영원히 통과되지 않는 교착이 생긴다. 이 방어는 뒤에서 다룬다.

잡 이름과 결과 집계

매트릭스 잡은 기본적으로 test (ubuntu-latest, 3.11)처럼 변수 값이 괄호에 붙은 이름으로 표시된다. 조합이 많아지면 name에 표현식을 써서 읽기 쉬운 라벨을 붙이는 편이 좋다. 또한 앞서 언급한 동적 매트릭스의 교착을 피하려면, 매트릭스 잡 전체의 성공 여부를 하나로 모으는 집계 잡(gate job)을 두고 이 잡을 브랜치 보호의 필수 체크로 지정한다. 그러면 매트릭스가 비었든 조합이 몇 개든 단일한 통과 신호를 얻을 수 있다.

jobs:
  test:
    name: py${{ matrix.python-version }} on ${{ matrix.os }}
    # ... (위의 매트릭스 잡)

  # 모든 매트릭스 잡의 결과를 하나로 집계하는 게이트
  all-tests-passed:
    if: always()
    needs: [test]
    runs-on: ubuntu-latest
    steps:
      # needs.test.result 는 매트릭스 전체가 성공해야 'success'
      - run: |
          if [ "${{ needs.test.result }}" != "success" ]; then
            echo "일부 매트릭스 잡이 실패했습니다"; exit 1
          fi

마무리

매트릭스 빌드는 “잡 하나를 여러 조합으로 확장한다”는 단순한 아이디어지만, 실무에서는 그 확장을 얼마나 정교하게 통제하느냐가 관건이다. 데카르트 곱으로 조합을 만들고, exclude로 불필요한 조합을 걷어내며, include로 예외 조합과 추가 변수를 얹는다. 전체 실패 지점을 한 번에 보려면 fail-fast: false, 실험 조합의 실패를 눈감아 주려면 continue-on-error, 러너나 외부 자원을 아끼려면 max-parallel을 쓴다.

다만 모든 편의에는 대가가 있다. 매트릭스는 잡 수를 곱셈으로 늘리기 때문에, 무심코 변수를 하나 추가하면 러너 사용 시간과 비용이 배로 뛴다. 그래서 조합은 꼭 필요한 것만 남기고, 집계 게이트 잡으로 통과 신호를 단일화하며, 값비싼 OS 조합은 exclude로 최소화하는 절제가 뒤따라야 한다. 결국 좋은 매트릭스는 “많이 돌리는 것”이 아니라 “필요한 것만 정확히 돌리는 것”이다.

자주 묻는 질문

Q. exclude와 include를 함께 쓰면 어떤 순서로 적용되나요?
A. GitHub Actions는 먼저 데카르트 곱으로 기본 조합을 만들고, 그다음 exclude로 조합을 제거한 뒤, 마지막에 include를 적용합니다. 즉 exclude로 지운 조합이라도 include에서 다시 명시하면 되살아납니다. 헷갈릴 때는 실행 화면의 잡 목록으로 실제 확장 결과를 확인하는 것이 가장 확실합니다.

Q. 매트릭스 변수 값으로 객체 같은 복잡한 값을 넣을 수 있나요?
A. 넣을 수 있습니다. 매트릭스 값에는 문자열뿐 아니라 객체도 올 수 있어 한 변수에 여러 설정을 묶어 전달할 수 있습니다. 예를 들어 config: [{ name: prod, url: ... }]처럼 정의하고 ${{ matrix.config.url }}로 참조하면 됩니다. 다만 잡 이름 표시가 지저분해지므로 namematrix.config.name을 써서 라벨을 명시적으로 지정하는 편이 좋습니다.

Q. 동적 매트릭스에서 조합이 없을 때 워크플로가 실패하나요?
A. 매트릭스가 빈 배열이면 그 잡은 실패하는 게 아니라 아예 생성되지 않습니다. 문제는 이 잡을 필수 상태 체크로 걸어둔 경우인데, 존재하지 않는 체크는 통과 신호를 보내지 않아 병합이 막힐 수 있습니다. 이를 피하려면 if: always()를 건 집계 게이트 잡을 필수 체크로 지정하거나, 매트릭스 생성 단계에서 변경분이 없을 때 최소 하나의 스킵용 조합을 넣어 잡이 항상 존재하도록 만드는 것이 실용적입니다.