- Published on
GitHub Actions 병렬·매트릭스로 CI 50% 단축
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
CI 시간이 길어지는 순간, 개발팀의 리듬이 깨집니다. PR 하나 올릴 때마다 20~30분을 기다리면 작은 수정도 “묶어서” 올리게 되고, 그 결과 충돌과 리스크가 커집니다. GitHub Actions는 기본만 써도 편하지만, 병렬 실행과 매트릭스 전략을 제대로 설계하면 같은 러너 자원에서 체감 시간을 크게 줄일 수 있습니다.
이 글에서는 “CI 시간을 50% 줄이는” 데 실제로 도움이 되는 패턴을 중심으로, 다음을 다룹니다.
- 병렬화 대상(테스트, 린트, 빌드, e2e)을 어떻게 쪼갤지
matrix를 어디까지 쓰고, 어디서 멈춰야 하는지needs로 DAG(의존 그래프)를 설계하는 방법- 캐시와 병렬이 충돌할 때의 회피 전략
- 실패 격리(어느 조각이 실패했는지 즉시 알기)와 재실행 비용 절감
또한 캐시 관련 이슈는 병렬화 시 더 자주 터지므로, 필요하면 아래 글도 함께 참고하세요.
1) “50% 단축”이 가능한 구조부터 만들기
CI 시간을 줄이는 가장 확실한 방법은 “전체를 더 빠르게”가 아니라 “느린 덩어리를 나눠 동시에 돌리는 것”입니다. 보통 병목은 아래 중 하나입니다.
- 단위 테스트가 방대하고 직렬 실행
- e2e 테스트가 느리고 환경 준비가 오래 걸림
- 빌드/패키징이 무겁고 캐시가 비효율
- 린트/타입체크가 테스트와 한 줄로 연결되어 직렬화
핵심은 파이프라인을 다음처럼 재구성하는 것입니다.
- 빠른 검증(린트/포맷/타입체크)은 초반에 즉시 실행
- 빌드/테스트/e2e는 서로 가능한 한 병렬
- “배포” 같은 후속 단계는
needs로 필요한 결과만 기다리기
즉, 한 줄짜리 워크플로를 DAG로 바꾸는 것이 출발점입니다.
2) 병렬화 1단계: Job 병렬(needs)로 큰 덩어리 나누기
가장 먼저 할 일은 단일 Job에 몰아넣은 단계를 분리하는 것입니다.
lintjobunit-testjobbuildjob
그리고 배포나 아티팩트 업로드 같은 단계만 needs로 묶습니다.
name: ci
on:
pull_request:
push:
branches: [ main ]
concurrency:
group: ci-${{ github.workflow }}-${{ 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: 20
cache: npm
- run: npm ci
- run: npm run lint
unit_test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm test
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run build
package:
runs-on: ubuntu-latest
needs: [ lint, unit_test, build ]
steps:
- run: echo "package only after checks"
이 단계만 적용해도 “린트가 끝날 때까지 테스트가 대기” 같은 낭비가 제거됩니다.
병렬화의 함정: 설치(npm ci)가 모든 Job에서 반복된다
위 구조는 병렬이지만, 각 Job에서 의존성 설치가 반복됩니다. 여기서 시간이 다시 늘어납니다. 해결은 두 가지입니다.
- 패키지 매니저 캐시를 적극 활용(
setup-node의cache) - 또는 “설치 결과물”을 아티팩트로 공유(보통은 비추천, 캐시가 더 안정적)
캐시는 병렬에서 특히 중요합니다. 캐시 키 설계를 잘못하면 “가끔 실패”가 생기거나, 오히려 캐시가 안 먹어 시간이 늘어납니다. 캐시 키 전략은 위 내부 링크 글을 권합니다.
3) 병렬화 2단계: 매트릭스로 테스트를 ‘샤딩’하기
CI 시간의 대부분이 테스트라면, 테스트를 여러 조각으로 나눠 동시에 돌리는 게 가장 효과적입니다. GitHub Actions의 strategy.matrix는 이를 매우 쉽게 만듭니다.
(A) 테스트 파일을 그룹으로 나눠 실행하는 패턴
테스트 러너가 샤딩을 지원하지 않아도, 파일 패턴으로 나눌 수 있습니다.
jobs:
unit_test_sharded:
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
cache: npm
- run: npm ci
- name: Run shard
run: |
echo "Running shard ${{ matrix.shard }}"
npm test -- --shard=${{ matrix.shard }}/4
fail-fast: false는 한 조각이 실패해도 나머지 조각 결과를 끝까지 수집하게 해줍니다.- 샤딩 옵션은 테스트 프레임워크마다 다릅니다. Jest라면
--runInBand나 프로젝트별 샤딩 도구를 붙이기도 하고, Playwright는 자체 샤딩 옵션이 있습니다.
(B) DB/서비스 컨테이너를 쓰는 테스트도 샤딩 가능
다만 서비스 컨테이너(예: Postgres)를 각 Job이 따로 띄우면, 병렬 수만큼 자원 사용량이 증가합니다. 이때는 다음을 같이 고려해야 합니다.
- 샤드 수를 무작정 늘리지 말고, “테스트 시간 vs 러너 병목” 균형을 잡기
- 통합 테스트만 따로 분리해 샤딩 수를 줄이기
4) 매트릭스를 ‘환경 조합’으로 쓰되, PR에서는 최소화하기
매트릭스는 샤딩뿐 아니라 “Node 18/20”, “OS별”, “Python 3.10/3.12” 같은 호환성 검증에도 좋습니다. 하지만 모든 PR에서 전체 조합을 돌리면 비용이 폭증합니다.
현실적인 전략은 이렇습니다.
- PR에서는 대표 조합 1~2개만 실행
main머지나 릴리스 태그에서 전체 매트릭스 실행
on:
pull_request:
push:
branches: [ main ]
jobs:
test_matrix:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node: [ 20 ]
include:
- node: 18
run_on_main_only: true
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: npm
- run: npm ci
- run: npm test
if: |
matrix.run_on_main_only != true || github.ref == 'refs/heads/main'
이 패턴은 PR 피드백 속도를 지키면서도, 메인 브랜치 품질 게이트는 강화합니다.
5) 빌드도 매트릭스로 나누고, 아티팩트로 합치기
프론트엔드/모노레포에서는 “패키지별 빌드”가 병목이 됩니다. 매트릭스는 패키지 단위 병렬 빌드에 특히 강합니다.
jobs:
build_packages:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
pkg: [ web, admin, api ]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run build -- --scope=${{ matrix.pkg }}
- uses: actions/upload-artifact@v4
with:
name: dist-${{ matrix.pkg }}
path: dist/${{ matrix.pkg }}
assemble:
runs-on: ubuntu-latest
needs: [ build_packages ]
steps:
- uses: actions/download-artifact@v4
with:
path: dist
- run: ls -R dist
이렇게 하면 “가장 느린 패키지 빌드 시간”이 전체 빌드 시간을 결정하게 되어, 직렬 빌드 대비 큰 폭의 단축이 가능합니다.
6) 캐시를 병렬 친화적으로 설계하기
병렬과 캐시는 서로 영향을 줍니다. 특히 다음 상황에서 문제가 자주 납니다.
- 여러 Job이 동일 키로 캐시를 동시에 저장하려고 함
restore-keys가 너무 넓어서 엉뚱한 캐시를 가져옴- 브랜치별로 캐시가 섞여 재현이 어려운 실패 발생
안전한 키 설계의 기본
- OS, 런타임 버전, 락파일 해시를 키에 포함
- PR과 main을 분리하고 싶으면
github.ref_name또는github.base_ref등을 적절히 포함
- uses: actions/cache@v4
with:
path: |
~/.npm
key: npm-${{ runner.os }}-node20-${{ hashFiles('package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-node20-
캐시를 “많이” 쓰는 것보다 “안정적으로” 쓰는 게 중요합니다. 캐시가 간헐적으로 깨지면, 절약한 시간보다 디버깅 시간이 더 커집니다. 이 주제는 아래 글에서 훨씬 깊게 다뤘습니다.
7) fail-fast와 재실행 전략으로 디버깅 비용 줄이기
CI 시간을 줄이는 목적은 단지 “초 단위 단축”이 아니라, 실패 시 회복 시간을 줄이는 것입니다.
- 샤딩된 테스트는 실패 위치를 좁혀줌(어느 조각이 실패했는지)
fail-fast: false는 전체 실패 지도를 제공- flaky 테스트가 있으면 “재시도”를 테스트 러너 레벨에서 제한적으로 적용
Job 자체 재시도는 GitHub Actions에서 기본 제공이 제한적이라, 보통은 테스트 러너(예: Playwright의 retries)나 스텝 수준에서 제어합니다.
8) 실전 조합 예시: 빠른 게이트 + 샤딩 + 빌드 병렬
아래는 자주 쓰는 “체감 50% 단축” 조합의 뼈대입니다.
lint는 즉시unit_test는 4샤드build_packages는 패키지 매트릭스package는 위 결과를 기다렸다가 수행
name: ci
on:
pull_request:
push:
branches: [ main ]
concurrency:
group: ci-${{ github.workflow }}-${{ 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: 20
cache: npm
- run: npm ci
- run: npm run lint
unit_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: 20
cache: npm
- run: npm ci
- run: npm test -- --shard=${{ matrix.shard }}/4
build_packages:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
pkg: [ web, admin, api ]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run build -- --scope=${{ matrix.pkg }}
- uses: actions/upload-artifact@v4
with:
name: dist-${{ matrix.pkg }}
path: dist/${{ matrix.pkg }}
package:
runs-on: ubuntu-latest
needs: [ lint, unit_test, build_packages ]
steps:
- uses: actions/download-artifact@v4
with:
path: dist
- run: |
echo "All checks passed, packaging..."
ls -R dist
이 구조에서 전체 CI 시간은 보통 다음 중 큰 값으로 수렴합니다.
max(린트 시간, 단위테스트 샤드 중 최장 시간, 패키지 빌드 중 최장 시간)
즉, 직렬 합산이 아니라 “가장 느린 레인”만 남습니다. 여기서 50% 단축은 충분히 현실적인 목표입니다.
9) 어디까지 병렬화할 것인가: 비용, 큐, 자원 병목
병렬을 늘리면 무조건 빨라질 것 같지만, 다음 병목이 생깁니다.
- GitHub-hosted runner 동시 실행 제한(조직 플랜에 따라 다름)
- 외부 의존(예: 테스트 DB, SaaS API rate limit)
- 캐시 저장 경쟁과 네트워크 병목
권장 접근은 “단계적으로”입니다.
- Job 분리로 직렬 낭비 제거
- 테스트 샤딩 2~4부터 시작
- 빌드 병렬은 패키지 단위로
- 병렬 수를 늘릴 때마다 실패율, 큐 대기, 캐시 히트율을 같이 관찰
10) 체크리스트: CI를 절반으로 줄이는 우선순위
- 린트/타입체크/테스트/빌드를 Job으로 분리했는가
- 테스트가 가장 느리다면
matrix샤딩을 적용했는가 -
fail-fast: false로 실패 격리를 강화했는가 - 캐시 키에 OS/런타임/락파일 해시가 포함되어 있는가
- PR과 main에서 매트릭스 범위를 다르게 운영하는가
-
concurrency로 중복 실행을 취소해 낭비를 줄였는가
병렬과 매트릭스는 “설정 몇 줄”로 끝나는 기능처럼 보이지만, 실제 효과는 워크플로의 구조(DAG)와 캐시 안정성에서 갈립니다. 먼저 큰 덩어리를 병렬 Job으로 나누고, 가장 느린 테스트를 매트릭스로 샤딩한 뒤, 캐시 키를 병렬 친화적으로 다듬으면 CI는 체감상 확실히 빨라집니다.