Published on

GitHub Actions 매트릭스 빌드로 CI 50% 줄이기

Authors

CI 시간이 길어질수록 개발 속도는 눈에 띄게 느려집니다. PR 하나 올릴 때마다 15분씩 기다리면 리뷰가 밀리고, 작은 수정도 배포 리드타임을 끌어올립니다. 다행히 GitHub Actions는 strategy.matrix로 작업을 병렬로 쪼갤 수 있고, 테스트 스위트나 런타임 버전, OS 조합을 잘 나누면 체감상 50% 이상 단축도 충분히 가능합니다.

이 글에서는 매트릭스 빌드의 핵심 개념부터, 병렬화 설계법, 캐시와 아티팩트로 중복 작업을 줄이는 법, 그리고 실제로 시간을 갉아먹는 병목을 제거하는 운영 팁까지 한 번에 정리합니다.

매트릭스 빌드가 CI 시간을 줄이는 원리

GitHub Actions는 워크플로우 안에서 여러 job을 병렬로 실행할 수 있습니다. 그런데 단순히 job을 여러 개 만들면 YAML이 금방 복잡해집니다. 매트릭스는 같은 형태의 job을 여러 변형으로 “복제 실행”해 주는 기능입니다.

예를 들어 아래처럼 Node 버전 2개와 테스트 샤드 4개를 조합하면 총 8개의 job이 동시에 실행됩니다.

  • Node 2022
  • 테스트 샤드 1..4

전체 테스트가 20분 걸리던 상황에서 샤드를 4개로 나누면, 이상적으로는 5분대로 떨어집니다. 물론 오버헤드(체크아웃, 의존성 설치, 캐시 미스)가 있어 선형으로 떨어지진 않지만, 구조를 잘 잡으면 “전체 대기 시간”은 크게 줄어듭니다.

어떤 작업을 매트릭스로 쪼갤지 결정하기

병렬화는 무조건 많이 쪼갠다고 좋은 게 아닙니다. 과도한 분할은 다음 문제를 만듭니다.

  • 캐시 키가 분산되어 캐시 적중률이 떨어짐
  • job 수가 늘어 큐 대기(동시 실행 제한)로 오히려 느려짐
  • 아티팩트 업로드·다운로드 오버헤드 증가

실무에서는 아래 우선순위로 쪼개는 것을 추천합니다.

1) 테스트 샤딩(가장 효과 큼)

  • 단위 테스트가 길고 병렬화가 가능한 경우
  • Jest, Vitest, pytest, Gradle test 등 대부분 샤딩 전략을 구성할 수 있음

2) 런타임/플랫폼 조합 검증

  • Node 2022 모두 지원
  • Linux, Windows, macOS 크로스 플랫폼

이 경우는 “시간 단축”보다는 “호환성 보장”이 목적이지만, 어차피 병렬로 돌기 때문에 전체 대기 시간을 늘리지 않는 장점이 있습니다.

3) 린트/타입체크/빌드 분리

린트와 타입체크는 CPU를 많이 쓰고, 빌드는 I/O와 캐시 효과를 많이 받습니다. 이들을 한 job에 묶으면 가장 느린 단계에 전체가 끌려갑니다. 분리하면 병렬화 + 실패 피드백 속도(빠른 실패)가 좋아집니다.

기본 매트릭스 예제: Node 테스트 샤딩

아래 예시는 Node 프로젝트에서 테스트를 4개 샤드로 나눠 병렬 실행하고, 모든 샤드가 성공하면 마지막에 결과를 합치는 형태입니다.

name: ci

on:
  pull_request:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        node: [20, 22]
        shard: [1, 2, 3, 4]
    steps:
      - uses: actions/checkout@v4

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

      - run: npm ci

      - name: Run tests (sharded)
        run: |
          npm test -- --shard=${{ matrix.shard }}/4

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

포인트는 다음과 같습니다.

  • fail-fast: false로 설정해 한 샤드가 실패해도 나머지 샤드를 계속 돌립니다. 테스트 실패 원인을 한 번에 더 많이 수집할 수 있어, 재실행 비용을 줄입니다.
  • actions/setup-nodecache: npm으로 기본 캐시를 켭니다.
  • 커버리지 결과를 샤드별 아티팩트로 업로드합니다.

테스트 러너가 --shard를 지원하지 않는다면, 테스트 파일 목록을 분할하는 스크립트를 만들어 npm test -- <file list> 형태로 실행해도 됩니다.

매트릭스의 중복 비용 줄이기: 캐시 설계

병렬화하면 가장 먼저 드러나는 비용이 “각 job의 의존성 설치”입니다. 이 비용을 줄이지 못하면 샤딩 효과가 반감됩니다.

Node 캐시 키를 안정적으로 만들기

actions/setup-node의 캐시는 편하지만, 모노레포나 pnpm, yarn berry 같은 구성에서는 더 세밀한 키가 필요할 수 있습니다.

- name: Cache node_modules
  uses: actions/cache@v4
  with:
    path: |
      ~/.npm
      node_modules
    key: npm-${{ runner.os }}-node${{ matrix.node }}-${{ hashFiles('package-lock.json') }}
    restore-keys: |
      npm-${{ runner.os }}-node${{ matrix.node }}-

주의할 점은 job이 많아질수록 캐시 쓰기 경쟁이 생길 수 있다는 것입니다. 일반적으로는 캐시가 “읽기 중심”이라 큰 문제는 없지만, 대규모 매트릭스에서는 캐시 키를 지나치게 세분화하지 않는 편이 적중률에 유리합니다.

Docker 기반 빌드라면 buildx 캐시까지

컨테이너 빌드가 CI 시간을 잡아먹는 경우, 테스트 병렬화만으로는 한계가 있습니다. 이때는 Docker 레이어 캐시까지 엮어야 합니다. 멀티아키텍처 이미지나 exec format error 같은 이슈를 다루는 경우도 많으니, 컨테이너 빌드 최적화는 별도로 점검하는 것을 추천합니다.

관련해서는 Docker buildx 멀티아키 이미지 exec format error 해결도 함께 참고하면 좋습니다.

아티팩트로 병렬 job 결과를 “합치기”

샤딩을 하면 결과물(커버리지, 테스트 리포트)을 합쳐야 합니다. 보통은 다음 패턴이 안정적입니다.

  1. 매트릭스 job에서 결과를 아티팩트로 업로드
  2. 별도 job에서 다운로드해서 병합
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm test -- --shard=${{ matrix.shard }}/4
      - if: always()
        uses: actions/upload-artifact@v4
        with:
          name: junit-${{ matrix.shard }}
          path: reports/junit.xml

  merge-reports:
    runs-on: ubuntu-latest
    needs: [test]
    if: always()
    steps:
      - uses: actions/checkout@v4
      - uses: actions/download-artifact@v4
        with:
          pattern: junit-*
          path: artifacts
          merge-multiple: true
      - run: node scripts/merge-junit.mjs artifacts reports/junit-merged.xml
      - uses: actions/upload-artifact@v4
        with:
          name: junit-merged
          path: reports/junit-merged.xml

needs: [test]는 매트릭스의 모든 조합이 끝난 뒤에 실행됩니다. if: always()를 붙이면 일부 샤드 실패 시에도 리포트를 최대한 수집할 수 있습니다.

include/exclude로 “필요한 조합만” 실행하기

매트릭스는 조합 폭발이 쉽게 일어납니다. 예를 들어 OS 3개, Node 3개, 샤드 4개면 36개 job입니다. 이럴 때는 excludeinclude를 적극 사용합니다.

strategy:
  matrix:
    os: [ubuntu-latest, windows-latest, macos-latest]
    node: [20, 22]
    shard: [1, 2, 3, 4]
    exclude:
      - os: windows-latest
        shard: 2
      - os: windows-latest
        shard: 3
      - os: windows-latest
        shard: 4
    include:
      - os: windows-latest
        node: 22
        shard: 1
        smoke: true

의도는 보통 이런 식입니다.

  • Linux에서만 샤딩을 크게 돌려 시간을 줄인다.
  • Windows와 macOS는 “스모크 테스트”만 최소로 돌려 호환성만 확인한다.

이 패턴만으로도 총 job 수를 크게 줄이면서, 전체 대기 시간은 짧게 유지할 수 있습니다.

동시성 제어로 불필요한 실행 낭비 줄이기

CI 시간을 줄이는 또 다른 방법은 “어차피 무의미해진 실행”을 중단하는 것입니다. PR에 커밋을 연달아 푸시하면 이전 실행은 결과가 나와도 쓸모가 없습니다.

concurrency:
  group: ci-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

이 설정은 같은 브랜치(또는 PR ref)에서 이전에 돌던 워크플로우를 자동 취소합니다. 매트릭스 병렬화와 결합하면 러너 사용량과 대기열을 동시에 줄일 수 있습니다.

fail-fast를 언제 끄고 언제 켤까

  • fail-fast: true는 “빨리 실패하고 빨리 끝내기”에 유리합니다.
  • fail-fast: false는 “실패 정보를 한 번에 모으기”에 유리합니다.

실무에서는 다음처럼 나눠 쓰는 경우가 많습니다.

  • 린트/타입체크는 fail-fast: true로 빠르게 막기
  • 테스트 샤딩은 fail-fast: false로 실패 케이스를 더 수집

특히 flaky 테스트가 있는 환경에서는 fail-fast: false가 원인 파악 시간을 줄여줍니다.

병렬화해도 느리다면: Long Task처럼 병목을 먼저 찾기

매트릭스는 “전체를 쪼개서 동시에” 돌리는 방식이지만, 근본적으로 느린 단계가 하나라도 남아 있으면 체감 개선이 제한됩니다. 예를 들어 다음이 대표적 병목입니다.

  • 번들링 단계가 단일 스레드로 오래 걸림
  • 통합 테스트가 외부 리소스(DB, Redis, S3 mock)에 묶여 직렬화됨
  • E2E가 브라우저 부팅 비용 때문에 느림

이때는 단순히 샤드 수를 늘리기보다, 느린 작업을 찾아 쪼개거나 줄이는 것이 먼저입니다. 성능 병목을 찾는 접근 자체는 프론트엔드의 INP 최적화에서 Long Task를 쪼개는 방식과 사고가 비슷합니다.

관련해서는 Chrome INP 급락 - Long Task 찾고 쪼개기도 참고할 만합니다.

운영 팁: 50% 단축을 현실화하는 체크리스트

1) 먼저 측정부터

Actions UI의 job별 시간을 보고, 상위 2~3개 병목을 고릅니다.

  • 의존성 설치가 긴가
  • 테스트가 긴가
  • 빌드가 긴가

병렬화는 병목에 먼저 적용해야 효과가 큽니다.

2) 샤딩은 “균등 분배”가 핵심

샤드 4개로 나눴는데 샤드 1만 10분, 나머지는 2분이면 전체 시간은 10분입니다. 테스트 파일을 단순 라운드로빈으로 나누기보다, 과거 실행 시간을 기반으로 분배하는 전략이 더 좋습니다.

  • Jest는 프로젝트에 따라 커스텀 분배 스크립트가 필요할 수 있음
  • pytest는 pytest-xdist로 병렬화하되, 테스트 간 공유 자원 충돌을 점검

3) 러너 동시 실행 제한을 고려

GitHub 호스티드 러너는 플랜에 따라 동시 실행 제한이 있습니다. 매트릭스 job을 20개로 늘렸는데 동시 실행이 5개면, 나머지 15개는 큐에서 기다립니다. 이 경우는 다음 중 하나를 선택해야 합니다.

  • 샤드 수를 동시 실행 한도에 맞춰 조정
  • 중요 워크플로우만 병렬화, 덜 중요한 것은 직렬화
  • 셀프 호스티드 러너 도입

4) PR과 main 빌드를 분리

PR에서는 빠른 피드백이 중요하므로 최소 세트만 돌리고, main에서는 전체 매트릭스를 돌리는 방식이 효과적입니다.

on:
  pull_request:
  push:
    branches: [main]

jobs:
  test:
    strategy:
      matrix:
        shard: ${{ github.event_name == 'pull_request' && fromJson('[1,2]') || fromJson('[1,2,3,4]') }}

이런 조건부 매트릭스는 강력하지만 가독성이 떨어질 수 있으니, 복잡해지면 워크플로우를 분리하는 것도 방법입니다.

예시: 린트·타입체크·테스트를 병렬로 구성하기

마지막으로, 실무에서 자주 쓰는 “빠른 실패 + 테스트 샤딩” 조합을 한 번에 보여주는 예시입니다.

name: ci

on:
  pull_request:

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm run lint

  typecheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm run typecheck

  test:
    runs-on: ubuntu-latest
    needs: [lint, typecheck]
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm test -- --shard=${{ matrix.shard }}/4

구성 의도는 명확합니다.

  • 린트/타입체크가 깨졌으면 테스트를 돌릴 가치가 없으니 먼저 막는다.
  • 테스트는 샤딩해서 전체 대기 시간을 줄인다.
  • PR에서 불필요한 중복 실행은 concurrency로 취소한다.

마무리

GitHub Actions 매트릭스 빌드는 “작업을 잘게 쪼개서 동시에 돌리는 것” 이상의 의미가 있습니다. 제대로 설계하면 다음을 동시에 얻습니다.

  • 전체 CI 대기 시간 단축(체감 50% 가능)
  • 실패 피드백 속도 개선
  • 호환성 검증 범위 확장
  • 불필요한 실행 취소로 러너 비용 절감

핵심은 샤딩 대상 선정, 캐시 적중률, 조합 폭발 제어, 그리고 병목 측정입니다. 먼저 가장 오래 걸리는 단계 하나를 매트릭스로 쪼개고, 캐시와 아티팩트 병합까지 붙여 “한 번에 끝나는 CI”를 만드는 것부터 시작해 보세요.