Published on

GitHub Actions 매트릭스로 CI 시간 50% 줄이기

Authors

서로 독립적인 작업을 한 줄로 세워 순차 실행하면, CI는 팀의 개발 속도를 갉아먹는 병목이 됩니다. GitHub Actions는 기본적으로 job 단위 병렬 실행이 가능하고, strategy.matrix로 같은 작업을 여러 축으로 쪼개 실행할 수 있습니다. 문제는 “그냥 매트릭스를 쓰면 빨라진다”가 아니라, 어떤 축으로 나누고, 어떤 단계는 공유하고, 어떤 결과물을 재사용하느냐에 따라 실제 체감 속도가 크게 달라진다는 점입니다.

이 글에서는 CI 시간을 50% 수준으로 줄이기 위해 실무에서 자주 쓰는 병렬·매트릭스 설계 패턴을 정리합니다. Node, Java, Python 등 어떤 스택에도 적용 가능한 개념 중심으로 설명하고, 바로 복붙 가능한 YAML 예제도 함께 제공합니다.

CI 시간을 갉아먹는 3가지 전형적 원인

1) 모든 것을 한 job에서 순차 실행

lintunit testintegration testbuilddocker builddeploy를 한 job에 몰아 넣으면, 가장 느린 단계가 전체를 지배합니다. 특히 테스트가 길어질수록 PR 피드백 루프가 늘어집니다.

2) “중복 설치”와 “캐시 미사용”

매 job마다 의존성 설치를 반복하면 병렬화 이득이 줄어듭니다. 캐시를 쓰더라도 키 설계가 잘못되면 캐시 미스가 잦아집니다.

3) 같은 커밋에 대해 중복 실행

PR에 커밋을 여러 번 푸시하면 이전 실행들이 계속 돌아가면서 러너를 잡아먹습니다. 이때는 concurrency가 효과적입니다.

큰 그림: 병렬화와 매트릭스의 역할 분담

  • 병렬 job 분리: 성격이 다른 작업(예: linttest, build)을 job으로 쪼개 병렬 실행
  • 매트릭스 분할: 같은 성격의 작업을 여러 축(예: OS, 언어 버전, 테스트 그룹)으로 쪼개 분산 실행
  • 공유 결과물: 공통 빌드 산출물이나 테스트 셋업을 artifact로 공유해 중복을 줄임

핵심은 “병렬화로 시간을 줄이되, 중복 비용을 최소화”입니다.

1단계: job 분리로 즉시 체감 속도 얻기

가장 먼저 lint, unit test, build를 분리합니다. needs로 의존 관계만 최소한으로 걸면 됩니다.

name: ci

on:
  pull_request:
  push:
    branches: [ main ]

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - uses: actions/cache@v4
        with:
          path: |
            ~/.npm
            node_modules
          key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
      - run: npm ci
      - run: npm run lint

  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - uses: actions/cache@v4
        with:
          path: |
            ~/.npm
            node_modules
          key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
      - run: npm ci
      - run: npm test

  build:
    runs-on: ubuntu-latest
    needs: [ lint, test ]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - uses: actions/cache@v4
        with:
          path: |
            ~/.npm
            node_modules
          key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
      - run: npm ci
      - run: npm run build

이 구조만으로도 linttest가 동시에 돌아가 PR 피드백이 빨라집니다.

2단계: 매트릭스로 테스트를 “수평 분할”하기

테스트가 길어질수록 job 분리만으로는 한계가 있습니다. 이때는 테스트를 그룹으로 나누는 매트릭스가 가장 강력합니다.

테스트 그룹 분할(샤딩) 예시

테스트가 jest라면 --testPathPattern 또는 커스텀 태그로 분리할 수 있고, pytest라면 디렉터리 단위로 나누는 식이 흔합니다.

jobs:
  unit-tests:
    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: '20'
      - uses: actions/cache@v4
        with:
          path: |
            ~/.npm
            node_modules
          key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
      - run: npm ci
      - name: Run tests (shard)
        run: |
          node ./scripts/run-tests-sharded.js --shard ${{ matrix.shard }} --total 4

fail-fast: false는 한 샤드가 실패해도 나머지 샤드 결과를 끝까지 수집하게 해, 원인 파악 시간을 줄입니다.

샤딩 스크립트 예시

아래는 “테스트 파일 목록을 정렬한 뒤 N등분”하는 단순 샤딩 예시입니다.

// scripts/run-tests-sharded.js
const { execSync } = require('node:child_process');

function parseArg(name) {
  const idx = process.argv.indexOf(name);
  return idx >= 0 ? process.argv[idx + 1] : undefined;
}

const shard = Number(parseArg('--shard'));
const total = Number(parseArg('--total'));

if (!shard || !total) {
  console.error('Usage: node run-tests-sharded.js --shard 1 --total 4');
  process.exit(2);
}

// 예: jest 테스트 파일을 git으로 수집
const files = execSync('git ls-files "**/*.test.js"', { encoding: 'utf8' })
  .split('\n')
  .filter(Boolean)
  .sort();

const selected = files.filter((_, i) => i % total === (shard - 1));

if (selected.length === 0) {
  console.log('No tests selected for this shard');
  process.exit(0);
}

const cmd = `npx jest ${selected.map(f => `"${f}"`).join(' ')}`;
console.log(cmd);
execSync(cmd, { stdio: 'inherit' });

이렇게 하면 테스트 시간이 대략 1/total에 수렴합니다(물론 테스트 분포가 균등하다는 전제). 불균등하다면 “무거운 테스트를 별도 그룹으로 격리”하거나, 과거 실행 시간을 기반으로 분배하는 전략으로 고도화할 수 있습니다.

3단계: OS·런타임 버전 매트릭스는 “필요한 만큼만”

매트릭스는 쉽게 폭발합니다. 예를 들어 OS 3종, Node 3버전, DB 2종이면 18개 job이 됩니다. 실제로 필요한 조합만 남기세요.

strategy:
  matrix:
    os: [ ubuntu-latest, macos-latest ]
    node: [ '18', '20' ]
    include:
      - os: ubuntu-latest
        node: '22'
    exclude:
      - os: macos-latest
        node: '18'
  • 최신 버전은 ubuntu에서만 “스모크 테스트”
  • macos는 배포 타깃이거나 네이티브 의존성이 있을 때만 유지

이런 식으로 조합을 줄이면 병렬성은 유지하면서 비용과 대기열 시간을 줄일 수 있습니다.

4단계: 공통 빌드 산출물을 artifact로 공유하기

매트릭스 job이 많아질수록 “매번 빌드”가 반복됩니다. 빌드 결과를 한 번 만들고, 다른 job에서 받아 쓰면 중복이 줄어듭니다.

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run build
      - uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist

  e2e:
    runs-on: ubuntu-latest
    needs: [ build ]
    strategy:
      matrix:
        shard: [ 1, 2 ]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/download-artifact@v4
        with:
          name: dist
          path: dist
      - run: npm ci
      - run: npm run e2e -- --shard=${{ matrix.shard }} --total=2

주의할 점은 artifact는 캐시보다 느릴 수 있다는 것입니다. 하지만 “빌드가 매우 무겁고, downstream job이 많다”면 artifact 공유가 전체 시간을 줄이는 경우가 많습니다.

5단계: concurrency로 중복 실행을 끊어 CI 대기열 줄이기

PR에 커밋을 여러 번 푸시할 때 이전 워크플로를 자동 취소하면, 러너 점유 시간이 줄어 전체가 빨라집니다.

concurrency:
  group: ci-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true
  • 같은 브랜치(또는 PR ref)에서 새 실행이 오면 이전 실행을 취소
  • 특히 테스트가 긴 레포에서 효과가 큼

6단계: 캐시 키 설계로 “병렬화의 역효과” 막기

병렬 job이 많아지면 캐시 경쟁과 캐시 미스가 늘 수 있습니다. 키 설계는 다음 원칙을 권합니다.

  • lockfile 해시를 키에 포함(hashFiles)해 정확도를 높임
  • OS, 런타임 버전을 키에 포함해 호환성 문제를 피함
  • restore-keys로 부분 히트를 허용하되, 오염 가능성 있는 경로는 신중히
- uses: actions/cache@v4
  with:
    path: |
      ~/.cache/pip
    key: pip-${{ runner.os }}-py-${{ matrix.python }}-${{ hashFiles('requirements.txt') }}
    restore-keys: |
      pip-${{ runner.os }}-py-${{ matrix.python }}-
      pip-${{ runner.os }}-

7단계: 매트릭스에서 자주 부딪히는 권한 이슈

매트릭스로 job 수가 늘면, 토큰 권한 문제도 더 자주 노출됩니다. 예를 들어 checkout은 되는데 패키지 다운로드나 릴리즈 업로드에서 403이 나는 케이스가 있습니다. 이때는 permissionsGITHUB_TOKEN 스코프를 점검해야 합니다.

권한을 최소화하면서도 필요한 작업(예: packages: write, contents: write)만 열어주는 식으로 정리하면, 매트릭스 확장 시에도 안정적입니다.

“CI 시간 50% 단축”을 현실로 만드는 체크리스트

가장 효과가 큰 순서

  1. lint/test/build job 분리로 즉시 병렬화
  2. 테스트 샤딩 매트릭스로 가장 느린 구간을 수평 분할
  3. concurrency로 중복 실행 제거
  4. 캐시 키 정교화로 병렬화로 인한 중복 설치 비용 최소화
  5. 필요 시 artifact 공유로 빌드 중복 제거

측정 없이는 최적화도 없다

  • GitHub Actions UI에서 job별 시간을 기록하고, 병목 job을 1순위로 분할
  • 샤딩 후에는 “가장 느린 샤드”를 기준으로 재분배(불균형이 있으면 50% 단축이 안 나옴)

결론: 병렬화는 “분할”이 아니라 “설계”다

GitHub Actions에서 병렬·매트릭스는 단순히 job을 늘리는 기능이 아니라, 병목을 분해하고 결과물을 재사용하는 설계 도구입니다. 테스트를 샤딩하고, 불필요한 매트릭스 조합을 줄이며, 캐시와 concurrency로 중복을 제거하면 CI 시간 50% 단축은 충분히 현실적인 목표입니다.

다음 단계로는 변경 파일 기반 조건 실행(paths-filter류), self-hosted runner 도입, 통합 테스트 환경을 서비스 컨테이너로 표준화하는 방식까지 확장할 수 있습니다. 다만 그 전에, 이 글의 7단계만 제대로 적용해도 대부분의 레포에서 눈에 띄는 개선을 얻을 수 있습니다.