CI 테스트 병렬화로 피드백 루프 단축하기
개발팀이 빠르게 움직이려면 코드를 푸시한 후 “이 변경이 시스템을 망가뜨리지 않는다”는 확신을 최대한 빨리 얻어야 합니다. 그 확신을 주는 것이 CI 테스트이고, 그 확신을 얻는 데 걸리는 시간이 피드백 루프입니다. 테스트 스위트가 수십 분 걸리면 개발자는 다른 작업으로 컨텍스트를 전환하게 되고, 멀티태스킹 비용이 발생합니다. 피드백 루프가 5분 이내라면 한 집중 세션 안에 수정-테스트 사이클을 완결할 수 있습니다. 이 글은 테스트 샤딩, 매트릭스 병렬 실행, 플래키 테스트 격리, 변경 영향 기반 테스트 선택, 캐시 활용까지 CI 테스트 병렬화의 전체 그림을 실전 YAML 예시와 함께 설명합니다.
병렬화의 기초: 샤딩과 매트릭스 전략
가장 단순한 병렬화 방법은 매트릭스 실행입니다. 동일한 잡(Job)을 여러 파라미터 조합으로 동시에 실행합니다. 예를 들어 Node.js 프로젝트를 여러 버전으로 동시에 테스트하거나, 테스트 파일을 수동으로 묶어 여러 워커에 분배합니다.
# GitHub Actions — 매트릭스 전략으로 4개 샤드 병렬 실행
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false # 하나 실패해도 나머지 계속 실행
matrix:
shard: [1, 2, 3, 4] # 4개 워커로 분할
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Cache node_modules
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- run: npm ci
- name: Run tests (shard ${{ matrix.shard }} of 4)
run: |
npx jest
--shard=${{ matrix.shard }}/4
--ci
--coverage
--coverageDirectory=coverage-${{ matrix.shard }}
- name: Upload coverage artifact
uses: actions/upload-artifact@v4
with:
name: coverage-${{ matrix.shard }}
path: coverage-${{ matrix.shard }}
테스트 샤딩(Sharding)은 테스트 파일 목록을 균등하게 분할해 각 워커에 할당하는 방식입니다. Jest의 --shard=N/M, pytest-split, RSpec의 --format progress 등 주요 프레임워크가 네이티브 샤딩을 지원합니다. 문제는 샤드 간 실행 시간이 불균등할 수 있다는 점입니다. 파일 수로 단순 분할하면 무거운 통합 테스트가 몰린 샤드가 병목이 됩니다. 이를 해결하는 방법이 시간 기반 샤딩입니다. 이전 실행 타이밍 데이터를 참고해 각 샤드의 예상 실행 시간을 균등하게 맞춥니다. pytest-split의 --store-durations/--splitting-algorithm least_duration 조합, 또는 Knapsack Pro 같은 서드파티 도구가 이 역할을 합니다.
| 병렬화 전략 | 장점 | 단점 | 적합한 상황 |
|---|---|---|---|
| 매트릭스 (고정 분할) | 설정 단순, 추가 도구 불필요 | 샤드 간 불균형 가능 | 소규모, 동질적 테스트 |
| 시간 기반 샤딩 | 샤드 균형 최적화 | 타이밍 데이터 축적 필요 | 대규모, 실행 시간 편차 큰 경우 |
| 변경 영향 선택 실행 | 불필요한 테스트 생략, 최고 속도 | 분석 오류 시 버그 누락 위험 | 모노레포, 모듈 경계 명확한 프로젝트 |
| 서비스별 분리 잡 | 완전 독립 실행, 장애 격리 | 공통 설정 중복, 잡 수 증가 | MSA 모노레포 |
플래키 테스트 격리: 신뢰를 갉아먹는 주범 제거
플래키 테스트(Flaky Test)는 코드 변경 없이 통과·실패를 반복하는 테스트입니다. 병렬화를 늘릴수록 플래키 테스트는 더 자주 발화합니다. 타이밍 의존성, 공유 상태(DB, Redis, 파일 시스템), 외부 서비스 의존성, 테스트 순서 의존성이 주요 원인입니다.
플래키 테스트를 방치하면 팀이 CI 실패를 “어차피 플래키겠지”하고 무시하게 되어, 진짜 버그를 놓치는 최악의 상황이 발생합니다. 체계적인 대응이 필요합니다.
# GitHub Actions — 플래키 테스트 자동 재시도 + 격리 레이블
name: CI with Flaky Detection
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run tests with retry on flaky
uses: nick-fields/retry@v3
with:
timeout_minutes: 15
max_attempts: 2
retry_on: error
command: |
npx jest --ci --bail=3
--reporters=default
--reporters=jest-junit
- name: Report flaky tests
if: failure()
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: 'flaky-test',
color: 'e4e669'
}).catch(() => {});
근본 해결책은 테스트 격리입니다. 각 테스트가 독립적인 상태에서 시작하도록 보장합니다. DB 테스트는 트랜잭션 롤백이나 테스트마다 격리된 스키마/데이터베이스를 사용하고, 외부 서비스는 Mock이나 WireMock 같은 스텁으로 대체합니다. Docker Compose로 테스트 환경을 컨테이너화하면 워커 간 환경 차이도 제거됩니다. 플래키 테스트로 의심되는 항목은 @flaky 마커로 태그하고 별도 잡에서 실행해 메인 피드백 루프를 오염시키지 않도록 격리합니다.
변경 영향 기반 테스트 선택
PR이 프론트엔드 컴포넌트만 수정했는데 백엔드 통합 테스트 전체를 돌리는 것은 낭비입니다. 변경된 파일을 분석해 영향받는 테스트만 선택적으로 실행하면 피드백 루프를 극적으로 단축할 수 있습니다.
# GitHub Actions — 변경 파일 감지 후 선택적 테스트 실행
name: Smart CI
on:
pull_request:
branches: [main]
jobs:
changes:
runs-on: ubuntu-latest
outputs:
backend: ${{ steps.filter.outputs.backend }}
frontend: ${{ steps.filter.outputs.frontend }}
infra: ${{ steps.filter.outputs.infra }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
backend:
- 'services/**'
- 'packages/shared/**'
frontend:
- 'apps/web/**'
- 'packages/ui/**'
infra:
- 'infra/**'
- '.github/workflows/**'
backend-tests:
needs: changes
if: needs.changes.outputs.backend == 'true'
runs-on: ubuntu-latest
strategy:
matrix:
shard: [1, 2, 3]
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx jest services/ --shard=${{ matrix.shard }}/3 --ci
frontend-tests:
needs: changes
if: needs.changes.outputs.frontend == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx jest apps/web/ --ci
모노레포에서는 Nx, Turborepo, Bazel 같은 빌드 도구가 의존성 그래프를 추적해 “이 변경이 영향을 미치는 패키지”를 자동으로 계산합니다. nx affected --target=test나 turbo run test --filter=...[HEAD^1]처럼 단 한 줄로 영향받는 패키지의 테스트만 실행할 수 있습니다. 주의할 점은 의존성 분석이 완벽하지 않을 수 있다는 것입니다. 공유 인프라 변경(DB 스키마, 환경 변수 등)은 수동으로 전체 실행을 트리거하는 규칙을 두어야 안전합니다.
캐시 활용: 반복 작업 제거
빌드·테스트 시간의 상당 부분을 의존성 설치와 빌드 산출물 재생성이 차지합니다. 효과적인 캐싱 전략으로 이 시간을 제거합니다.
# GitHub Actions — 다단계 캐시 전략
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# 1. 의존성 캐시 (lock 파일 해시 키)
- name: Cache npm dependencies
uses: actions/cache@v4
id: npm-cache
with:
path: |
~/.npm
node_modules
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
- name: Install dependencies
if: steps.npm-cache.outputs.cache-hit != 'true'
run: npm ci
# 2. TypeScript 빌드 캐시 (소스 해시 키)
- name: Cache TypeScript build
uses: actions/cache@v4
with:
path: |
.tsbuildinfo
dist/
key: ${{ runner.os }}-tsc-${{ hashFiles('src/**/*.ts', 'tsconfig*.json') }}
- name: Build
run: npm run build
# 3. Jest 변환 캐시
- name: Cache Jest cache
uses: actions/cache@v4
with:
path: /tmp/jest-cache
key: ${{ runner.os }}-jest-${{ hashFiles('jest.config.*', 'src/**') }}
- name: Test
run: npx jest --cache --cacheDirectory=/tmp/jest-cache --ci
캐시 키 설계가 핵심입니다. 너무 넓은 키(예: OS만 포함)는 캐시 히트율이 높지만 낡은 캐시를 사용할 위험이 있고, 너무 좁은 키는 매번 미스가 납니다. 계층적 키 설계를 권장합니다. 기본 키에는 가장 안정적인 해시(lock 파일)를 사용하고, restore-keys로 부분 매칭 폴백을 지정합니다. Docker 이미지는 레이어 캐시를 활용하고, cache-from/cache-to 옵션으로 레지스트리에 캐시를 저장·복원합니다. GitHub Actions의 캐시 스토리지 한도(10 GB/레포)를 의식해 오래된 캐시를 자동으로 정리하는 정책도 필요합니다.
실전 팁
- fail-fast: false 설정: 매트릭스 잡에서 하나 실패해도 나머지가 계속 실행되어야 전체 실패 범위를 한 번에 파악할 수 있습니다. 기본값(true)은 디버깅을 어렵게 만듭니다.
- 테스트 타이밍 데이터 보관: pytest-split의
.test_durations파일이나 Jest의--json출력을 아티팩트로 저장하고 다음 실행에서 복원하면 시간 기반 샤딩의 정확도가 점점 높아집니다. - 병렬 실행 수 제한: 워커를 무한정 늘린다고 선형 단축이 되지 않습니다. 테스트 간 공유 리소스(DB 연결 풀, API 레이트 리밋)가 병목이 됩니다. 실제 환경에서 샤드 수를 4→8→16으로 늘려가며 실행 시간을 측정하고 수익 체감 지점을 찾아야 합니다.
- 플래키 테스트 대시보드: Buildkite Test Analytics, Datadog CI Visibility, GitHub의 테스트 요약 기능으로 플래키율을 시각화하면 우선순위를 데이터로 결정할 수 있습니다.
- 최소 보장 테스트 세트 정의: 변경 영향 선택 실행을 도입하더라도, 어떤 변경에도 반드시 실행해야 하는 스모크 테스트 세트를 별도로 유지하면 안전망이 됩니다.
- self-hosted runner 고려: 테스트가 네트워크 집약적(DB, Redis, 외부 서비스)이라면 self-hosted runner가 GitHub 호스티드 러너보다 빠를 수 있습니다. 특히 대용량 아티팩트 전송이 병목인 경우 효과가 큽니다.
마무리
CI 테스트 병렬화는 단순히 워커를 늘리는 것이 아닙니다. 테스트를 안정적으로 격리하고, 캐시로 반복 작업을 제거하고, 변경 영향 분석으로 불필요한 실행을 줄이는 것이 함께 이루어져야 실질적인 피드백 루프 단축이 가능합니다. 피드백 루프가 짧아지면 개발자는 더 작은 단위로 자주 커밋하게 되고, 이는 버그가 작을 때 발견되는 선순환으로 이어집니다. 완벽한 병렬화 설계를 한 번에 구현하려 하지 말고, 현재 가장 느린 병목(의존성 설치? 빌드? 테스트 자체?)을 측정하고 가장 큰 개선부터 적용하는 접근이 현실적입니다.
자주 묻는 질문
Q. 샤드 수를 늘릴수록 항상 빨라지나요?
A. 아닙니다. 각 샤드는 환경 초기화(checkout, 의존성 설치, 빌드) 오버헤드를 독립적으로 부담합니다. 이 오버헤드가 테스트 실행 시간보다 크다면 샤드를 늘려도 오히려 총 시간이 길어질 수 있습니다. 캐시로 오버헤드를 최소화한 후 샤딩 효과를 측정하는 순서가 올바릅니다. 경험적으로 테스트 실행 시간이 설정 오버헤드의 3배 이상일 때 샤딩이 의미 있습니다.
Q. 플래키 테스트를 발견하면 어떻게 처리해야 하나요?
A. 즉시 격리가 먼저입니다. @skip이나 @flaky 마커로 메인 파이프라인에서 분리하고, 별도 잡에서 주기적으로 실행해 상태를 모니터링합니다. 그 다음 원인을 분석합니다. 대부분은 테스트 간 공유 상태나 타이밍 의존성입니다. 원인이 명확해지면 격리 수정 후 마커를 제거합니다. “나중에 고치자”는 기술 부채로 누적되어 결국 팀 전체가 CI를 신뢰하지 않는 상황으로 이어집니다.
Q. 모노레포에서 변경 영향 선택 실행 시 잘못된 분석으로 버그를 놓칠 위험은 없나요?
A. 있습니다. 의존성 그래프 분석은 정적 분석 기반이라 동적 의존성(런타임에만 드러나는 결합, 환경 변수 의존 등)을 추적하지 못합니다. 이를 보완하기 위해 메인 브랜치 머지 시에는 반드시 전체 테스트를 실행하고, PR 단계에서만 선택 실행을 적용하는 정책이 실용적입니다. 또한 공유 패키지나 인프라 변경이 감지되면 자동으로 전체 실행으로 폴백하는 규칙을 paths-filter에 추가해두는 것이 안전합니다.