- Published on
GitHub Actions 고급 패턴: Reusable Workflows·Composite Actions·모노레포 CI/CD 최적화 전략
- Authors
- Name
- 들어가며
- Reusable Workflows 심화
- Composite Actions 구축
- Reusable Workflows vs Composite Actions 비교
- 모노레포 CI/CD 최적화
- 프로덕션 운영 가이드
- 실패 사례와 트러블슈팅
- 운영 시 주의사항
- 참고자료

들어가며
조직의 리포지토리가 10개를 넘어가면 CI/CD 파이프라인의 중복이 눈에 띄기 시작한다. 빌드-테스트-배포 YAML이 리포지토리마다 복사-붙여넣기로 퍼져 있고, 하나의 보안 패치를 적용하려면 수십 개의 워크플로우를 일일이 수정해야 한다. Node.js 버전을 18에서 20으로 올리는 단순한 변경이 30개 리포지토리에 걸린 PR로 번지는 경험을 해본 적이 있을 것이다.
GitHub Actions는 이 문제를 해결하기 위해 두 가지 재사용 메커니즘을 제공한다. Reusable Workflows는 전체 워크플로우를 템플릿화하여 호출할 수 있게 해주고, Composite Actions는 여러 스텝을 하나의 액션으로 묶어 모듈화할 수 있게 해준다.
여기에 모노레포 환경까지 겹치면 복잡도가 급격히 올라간다. 변경된 패키지만 빌드하고, 의존성 그래프를 추적하며, 캐시를 패키지별로 격리해야 한다. 이 글에서는 이 세 가지 주제를 실전 코드와 함께 깊이 다룬다.
Reusable Workflows 심화
workflow_call 트리거의 구조
Reusable Workflow는 on: workflow_call 트리거를 정의하는 것으로 시작한다. 호출자(caller)는 uses 키워드로 이 워크플로우를 참조하며, inputs, secrets, outputs를 통해 데이터를 주고받는다.
2025년 11월 업데이트 이후 주요 제약 사항은 다음과 같다:
- 중첩 호출 최대 10단계 까지 지원 (A가 B를 호출하고, B가 C를 호출하는 체인)
- 하나의 워크플로우 파일에서 최대 50개 Reusable Workflow 호출 가능
- 호출자와 피호출자는 같은 조직이거나, 피호출자 리포지토리가 퍼블릭이어야 함
env컨텍스트는 피호출 워크플로우에 전달되지 않음
중앙 집중식 워크플로우 관리 패턴
대규모 조직에서는 .github 리포지토리나 별도의 platform-workflows 리포지토리에 Reusable Workflow를 모아두고, 모든 팀이 이를 참조하게 한다. 이 패턴의 핵심은 버전 태그 관리다.
org-platform/
.github/
workflows/
build-node.yml # Node.js 빌드 파이프라인
build-python.yml # Python 빌드 파이프라인
deploy-k8s.yml # Kubernetes 배포 파이프라인
security-scan.yml # 보안 스캔 공통 파이프라인
Reusable Workflow 정의 예시
아래는 Node.js 애플리케이션의 빌드-테스트-배포를 템플릿화한 Reusable Workflow다.
# org-platform/.github/workflows/build-node.yml
name: Reusable Node.js Build
on:
workflow_call:
inputs:
node-version:
description: 'Node.js 버전'
required: false
type: string
default: '20'
working-directory:
description: '작업 디렉토리'
required: false
type: string
default: '.'
run-e2e:
description: 'E2E 테스트 실행 여부'
required: false
type: boolean
default: false
artifact-name:
description: '빌드 아티팩트 이름'
required: false
type: string
default: 'build-output'
secrets:
NPM_TOKEN:
description: 'npm 레지스트리 토큰'
required: false
SONAR_TOKEN:
description: 'SonarQube 분석 토큰'
required: false
outputs:
build-version:
description: '빌드된 버전'
value: ${{ jobs.build.outputs.version }}
test-coverage:
description: '테스트 커버리지'
value: ${{ jobs.test.outputs.coverage }}
jobs:
build:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: 'npm'
cache-dependency-path: ${{ inputs.working-directory }}/package-lock.json
- name: Install dependencies
working-directory: ${{ inputs.working-directory }}
run: npm ci
- name: Build
working-directory: ${{ inputs.working-directory }}
run: npm run build
- name: Extract version
id: version
working-directory: ${{ inputs.working-directory }}
run: echo "version=$(node -p 'require(\"./package.json\").version')" >> "$GITHUB_OUTPUT"
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.artifact-name }}
path: ${{ inputs.working-directory }}/dist/
retention-days: 7
test:
runs-on: ubuntu-latest
needs: build
outputs:
coverage: ${{ steps.coverage.outputs.percentage }}
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: 'npm'
- name: Install dependencies
working-directory: ${{ inputs.working-directory }}
run: npm ci
- name: Run unit tests
working-directory: ${{ inputs.working-directory }}
run: npm run test -- --coverage
- name: Extract coverage
id: coverage
working-directory: ${{ inputs.working-directory }}
run: |
COVERAGE=$(cat coverage/coverage-summary.json | jq '.total.lines.pct')
echo "percentage=$COVERAGE" >> "$GITHUB_OUTPUT"
- name: SonarQube analysis
if: secrets.SONAR_TOKEN != ''
uses: SonarSource/sonarqube-scan-action@v3
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
e2e:
if: inputs.run-e2e
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v4
- name: Download build artifact
uses: actions/download-artifact@v4
with:
name: ${{ inputs.artifact-name }}
path: ${{ inputs.working-directory }}/dist/
- name: Run E2E tests
working-directory: ${{ inputs.working-directory }}
run: npm run test:e2e
Caller Workflow 예시
위의 Reusable Workflow를 호출하는 caller 워크플로우는 다음과 같다.
# my-service/.github/workflows/ci.yml
name: CI Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
build-and-test:
uses: org-platform/.github/workflows/build-node.yml@v2.3.0
with:
node-version: '20'
working-directory: '.'
run-e2e: ${{ github.ref == 'refs/heads/main' }}
artifact-name: 'my-service-build'
secrets:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
deploy-staging:
needs: build-and-test
if: github.ref == 'refs/heads/develop'
uses: org-platform/.github/workflows/deploy-k8s.yml@v2.3.0
with:
environment: staging
image-tag: ${{ needs.build-and-test.outputs.build-version }}
secrets: inherit
deploy-production:
needs: build-and-test
if: github.ref == 'refs/heads/main'
uses: org-platform/.github/workflows/deploy-k8s.yml@v2.3.0
with:
environment: production
image-tag: ${{ needs.build-and-test.outputs.build-version }}
secrets: inherit
secrets: inherit를 사용하면 caller의 모든 시크릿이 자동으로 전달된다. 보안이 중요한 환경에서는 명시적으로 필요한 시크릿만 전달하는 것이 좋다.
Composite Actions 구축
action.yml 구조와 핵심 개념
Composite Action은 여러 스텝을 하나의 재사용 가능한 액션으로 묶는다. Reusable Workflow와 달리 잡 수준이 아닌 스텝 수준에서 동작하며, 하나의 잡 안에 여러 Composite Action을 포함할 수 있다.
JavaScript/Docker/Composite 액션 비교
| 항목 | JavaScript Action | Docker Action | Composite Action |
|---|---|---|---|
| 실행 환경 | Node.js 런타임 | Docker 컨테이너 | 러너 직접 실행 |
| 시작 시간 | 빠름 (수 초) | 느림 (이미지 풀) | 빠름 (수 초) |
| 플랫폼 호환 | Linux/macOS/Windows | Linux 전용 | 러너 OS에 따라 다름 |
| 복잡한 로직 | 최적 | 적합 | 셸 스크립트 수준 |
| 기존 도구 활용 | npm 패키지 사용 | 어떤 도구든 설치 가능 | 다른 액션 조합 가능 |
| 유지보수 난이도 | 중간 (빌드 필요) | 낮음 (Dockerfile) | 낮음 (YAML만) |
| 시크릿 접근 | 직접 접근 가능 | 직접 접근 가능 | 환경변수로만 전달 |
Composite Action 정의 예시
아래는 Docker 이미지 빌드와 푸시를 모듈화한 Composite Action이다.
# .github/actions/docker-build-push/action.yml
name: 'Docker Build and Push'
description: 'Docker 이미지를 빌드하고 레지스트리에 푸시합니다'
inputs:
registry:
description: '컨테이너 레지스트리 URL'
required: true
image-name:
description: '이미지 이름'
required: true
dockerfile:
description: 'Dockerfile 경로'
required: false
default: './Dockerfile'
context:
description: '빌드 컨텍스트 경로'
required: false
default: '.'
build-args:
description: '빌드 인자 (줄바꿈 구분)'
required: false
default: ''
push:
description: '이미지 푸시 여부'
required: false
default: 'true'
outputs:
image-digest:
description: '빌드된 이미지 다이제스트'
value: ${{ steps.build.outputs.digest }}
image-tag:
description: '이미지 태그'
value: ${{ steps.meta.outputs.tags }}
runs:
using: 'composite'
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ inputs.registry }}/${{ inputs.image-name }}
tags: |
type=sha,prefix=
type=ref,event=branch
type=ref,event=tag
type=semver,pattern=v{{version}}
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
id: build
uses: docker/build-push-action@v6
with:
context: ${{ inputs.context }}
file: ${{ inputs.dockerfile }}
push: ${{ inputs.push }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: ${{ inputs.build-args }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: true
sbom: true
- name: Print image info
shell: bash
run: |
echo "Image digest: ${{ steps.build.outputs.digest }}"
echo "Image tags: ${{ steps.meta.outputs.tags }}"
Composite Action 사용 예시
# my-service/.github/workflows/build-image.yml
name: Build Docker Image
on:
push:
branches: [main]
paths:
- 'src/**'
- 'Dockerfile'
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
steps:
- uses: actions/checkout@v4
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and Push
id: docker
uses: ./.github/actions/docker-build-push
with:
registry: ghcr.io
image-name: ${{ github.repository }}
build-args: |
APP_VERSION=${{ github.sha }}
BUILD_DATE=${{ github.event.head_commit.timestamp }}
- name: Deploy notification
run: |
echo "Deployed image with digest: ${{ steps.docker.outputs.image-digest }}"
Reusable Workflows vs Composite Actions 비교
두 메커니즘은 용도가 다르다. 선택 기준을 명확히 해야 파이프라인 아키텍처가 깔끔해진다.
| 항목 | Reusable Workflows | Composite Actions |
|---|---|---|
| 동작 수준 | 잡(Job) 수준 | 스텝(Step) 수준 |
| 시크릿 접근 | secrets 키워드로 직접 전달 | 환경변수로만 전달 가능 |
| 중첩 호출 | 최대 10단계 | 최대 10단계 |
| 조건부 실행 | 잡 단위 if 가능 | 스텝 단위 if 가능 |
| 러너 지정 | 피호출 측에서 지정 가능 | 호출자의 러너에서 실행 |
| 호출 방식 | jobs.xxx.uses: | steps.xxx.uses: |
| outputs 전달 | 워크플로우 outputs 지원 | 스텝 outputs 지원 |
| 환경(environment) | 별도 environment 지정 가능 | 호출자의 environment 공유 |
| 최대 호출 수 | 워크플로우당 50개 | 제한 없음 |
| 적합한 시나리오 | 전체 파이프라인 템플릿 | 공통 스텝 모듈 |
선택 가이드:
- 전체 CI/CD 파이프라인을 표준화하고 싶다면 Reusable Workflows를 사용한다. 빌드-테스트-배포 전 과정을 하나의 템플릿으로 만들고, 팀마다 inputs만 다르게 넘긴다.
- 특정 작업(Docker 빌드, Slack 알림, 캐시 복원 등)을 모듈화하고 싶다면 Composite Actions를 사용한다. 여러 워크플로우에서 스텝 수준으로 재사용한다.
- 두 가지를 조합하는 것이 가장 강력하다. Reusable Workflow 안에서 Composite Action을 호출하면, 파이프라인 전체 흐름은 워크플로우로 관리하고 세부 스텝은 액션으로 모듈화할 수 있다.
모노레포 CI/CD 최적화
모노레포에서 가장 중요한 원칙은 변경된 패키지만 빌드하는 것이다. 모든 변경에 대해 전체 리포지토리를 빌드하면 CI 시간이 기하급수적으로 증가한다.
dorny/paths-filter를 활용한 변경 감지
dorny/paths-filter는 PR이나 push에서 변경된 파일 경로를 기반으로 boolean 필터를 생성한다. 이 필터를 후속 잡의 조건으로 사용하면, 변경된 패키지만 선택적으로 빌드할 수 있다.
동적 matrix 전략과 fromJSON 패턴
정적 matrix는 모든 조합을 항상 실행한다. 모노레포에서는 변경된 패키지 목록을 동적으로 생성하고 이를 matrix에 주입하는 패턴이 필수다. fromJSON 함수를 사용하면 JSON 문자열을 matrix 값으로 변환할 수 있다.
모노레포 path-filter + dynamic matrix YAML
아래는 모노레포에서 변경 감지와 동적 matrix를 결합한 실전 워크플로우다.
# .github/workflows/monorepo-ci.yml
name: Monorepo CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
detect-changes:
runs-on: ubuntu-latest
outputs:
packages: ${{ steps.filter.outputs.changes }}
api-changed: ${{ steps.filter.outputs.api }}
web-changed: ${{ steps.filter.outputs.web }}
shared-changed: ${{ steps.filter.outputs.shared }}
steps:
- uses: actions/checkout@v4
- name: Detect changed packages
uses: dorny/paths-filter@v3
id: filter
with:
filters: |
api:
- 'packages/api/**'
- 'packages/shared/**'
web:
- 'packages/web/**'
- 'packages/shared/**'
shared:
- 'packages/shared/**'
docs:
- 'packages/docs/**'
build-matrix:
needs: detect-changes
if: needs.detect-changes.outputs.packages != '[]'
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- name: Build dynamic matrix
id: set-matrix
run: |
CHANGES='${{ needs.detect-changes.outputs.packages }}'
MATRIX=$(echo "$CHANGES" | jq -c '{package: .}')
echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT"
build:
needs: build-matrix
if: needs.build-matrix.outputs.matrix != ''
runs-on: ubuntu-latest
strategy:
matrix: ${{ fromJSON(needs.build-matrix.outputs.matrix) }}
fail-fast: false
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Build package
run: npm run build --workspace=packages/${{ matrix.package }}
- name: Test package
run: npm run test --workspace=packages/${{ matrix.package }}
integration-test:
needs: [detect-changes, build]
if: >-
needs.detect-changes.outputs.api-changed == 'true' ||
needs.detect-changes.outputs.web-changed == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install all dependencies
run: npm ci
- name: Run integration tests
run: npm run test:integration
deploy:
needs: [detect-changes, build, integration-test]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
strategy:
matrix:
include:
- package: api
changed: ${{ needs.detect-changes.outputs.api-changed }}
- package: web
changed: ${{ needs.detect-changes.outputs.web-changed }}
fail-fast: false
steps:
- name: Skip if not changed
if: matrix.changed != 'true'
run: echo "Skipping deploy for ${{ matrix.package }} (no changes)"
- uses: actions/checkout@v4
if: matrix.changed == 'true'
- name: Deploy
if: matrix.changed == 'true'
run: |
echo "Deploying ${{ matrix.package }}..."
# 실제 배포 로직
캐싱 최적화 YAML
모노레포에서 캐시 전략은 패키지별 격리가 핵심이다. 전역 캐시와 패키지별 캐시를 계층적으로 관리한다.
# .github/workflows/cache-optimized.yml
name: Cache Optimized Build
on:
push:
branches: [main]
pull_request:
env:
TURBO_CACHE_DIR: .turbo
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
# 1단계: npm 의존성 캐시 (package-lock.json 기반)
- name: Setup Node.js with dependency cache
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
# 2단계: Turborepo 원격 캐시 대신 로컬 캐시 사용
- name: Cache Turborepo
uses: actions/cache@v4
with:
path: .turbo
key: turbo-${{ runner.os }}-${{ github.sha }}
restore-keys: |
turbo-${{ runner.os }}-
# 3단계: Next.js 빌드 캐시 (pages별 증분 빌드)
- name: Cache Next.js build
uses: actions/cache@v4
with:
path: packages/web/.next/cache
key: nextjs-${{ runner.os }}-${{ hashFiles('packages/web/**/*.ts', 'packages/web/**/*.tsx') }}
restore-keys: |
nextjs-${{ runner.os }}-
# 4단계: ESLint 캐시
- name: Cache ESLint
uses: actions/cache@v4
with:
path: .eslintcache
key: eslint-${{ runner.os }}-${{ hashFiles('.eslintrc*') }}-${{ github.sha }}
restore-keys: |
eslint-${{ runner.os }}-${{ hashFiles('.eslintrc*') }}-
eslint-${{ runner.os }}-
# 5단계: Jest 캐시
- name: Cache Jest
uses: actions/cache@v4
with:
path: /tmp/jest_rs
key: jest-${{ runner.os }}-${{ hashFiles('**/jest.config.*') }}
restore-keys: |
jest-${{ runner.os }}-
- name: Install dependencies
run: npm ci
- name: Build with Turborepo
run: npx turbo run build --cache-dir=.turbo
- name: Test with Turborepo
run: npx turbo run test --cache-dir=.turbo
- name: Lint with cache
run: npx turbo run lint --cache-dir=.turbo
위 캐시 전략을 적용하면 일반적인 모노레포에서 다음과 같은 효과를 얻는다:
| 캐시 대상 | 캐시 미스 시 | 캐시 히트 시 | 절감 비율 |
|---|---|---|---|
| npm 의존성 | 45~90초 | 5~10초 | 80~90% |
| Turborepo 빌드 | 120~300초 | 3~8초 | 95%+ |
| Next.js 증분 빌드 | 60~180초 | 10~30초 | 70~85% |
| ESLint 분석 | 30~60초 | 5~15초 | 60~75% |
| Jest 테스트 | 캐시 불가 | transform 캐시 | 20~40% |
프로덕션 운영 가이드
워크플로우 버전 관리 전략
Reusable Workflow와 Composite Action의 참조 방식에 따라 안정성과 보안이 크게 달라진다.
| 참조 방식 | 예시 | 장점 | 단점 |
|---|---|---|---|
| 브랜치 | uses: org/repo/.github/workflows/ci.yml@main | 항상 최신 버전 | 예고 없이 깨질 수 있음 |
| 태그 | uses: org/repo/.github/workflows/ci.yml@v2.3.0 | 안정적, SemVer 의미 전달 | 태그 덮어쓰기 가능 |
| 커밋 SHA | uses: org/repo/.github/workflows/ci.yml@a1b2c3d4 | 가장 안전, 변경 불가 | 가독성 낮음 |
권장 전략: 개발 중에는 브랜치 참조, 프로덕션에서는 SHA 참조를 사용한다. Dependabot이나 Renovate를 설정하면 SHA가 업데이트될 때 자동으로 PR을 생성해준다.
조직 전체 거버넌스와 표준화
조직 수준에서 다음을 강제한다:
- 필수 워크플로우(Required Workflows): Organization Settings에서 특정 Reusable Workflow를 모든 리포지토리에 강제 실행할 수 있다. 보안 스캔, 라이선스 검사 등에 활용한다.
- CODEOWNERS:
.github/workflows/디렉토리에 CODEOWNERS를 설정하여 워크플로우 변경 시 플랫폼 팀의 리뷰를 강제한다. - 워크플로우 권한 제한: Organization 수준에서
GITHUB_TOKEN의 기본 권한을read로 설정하고, 필요한 권한만 워크플로우에서 명시하게 한다.
보안: OIDC와 최소 권한 원칙
클라우드 배포 시 장기 자격 증명(Access Key 등)을 시크릿에 저장하는 대신, **OIDC(OpenID Connect)**를 사용하여 임시 토큰을 발급받는다.
# OIDC를 사용한 AWS 배포 예시
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
aws-region: ap-northeast-2
role-session-name: github-actions-${{ github.run_id }}
- name: Deploy to ECS
run: |
aws ecs update-service \
--cluster production \
--service my-api \
--force-new-deployment
이 방식은 시크릿 유출 위험을 근본적으로 제거하며, IAM Role의 trust policy에서 특정 리포지토리와 브랜치만 허용할 수 있다.
실패 사례와 트러블슈팅
사례 1: 시크릿 전달 실패로 배포 중단
증상: Reusable Workflow에서 AWS 배포가 실패. 에러 메시지는 "credentials not found".
원인: Caller 워크플로우에서 secrets: inherit 대신 명시적 시크릿 전달을 사용했는데, 새로 추가된 AWS_ROLE_ARN 시크릿을 전달하지 않았다.
교훈: secrets: inherit를 사용하면 편리하지만, 어떤 시크릿이 전달되는지 추적하기 어렵다. 프로덕션 환경에서는 명시적 전달을 권장하되, 새로운 시크릿 추가 시 caller 업데이트를 잊지 않도록 CI에서 검증 스텝을 추가하라.
해결 패턴:
# 시크릿 전달 검증을 포함한 caller 워크플로우
jobs:
validate:
runs-on: ubuntu-latest
steps:
- name: Validate required secrets
run: |
MISSING=""
if [ -z "$AWS_ROLE" ]; then MISSING="$MISSING AWS_ROLE_ARN"; fi
if [ -z "$NPM_TK" ]; then MISSING="$MISSING NPM_TOKEN"; fi
if [ -n "$MISSING" ]; then
echo "::error::Missing required secrets:$MISSING"
exit 1
fi
env:
AWS_ROLE: ${{ secrets.AWS_ROLE_ARN }}
NPM_TK: ${{ secrets.NPM_TOKEN }}
deploy:
needs: validate
uses: org-platform/.github/workflows/deploy-k8s.yml@v2.3.0
secrets:
AWS_ROLE_ARN: ${{ secrets.AWS_ROLE_ARN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
사례 2: 캐시 미스로 CI 시간 폭증
증상: 월요일 아침마다 모노레포 CI 시간이 평소 5분에서 25분으로 증가.
원인: GitHub Actions 캐시는 7일간 미사용 시 자동 삭제된다. 주말 동안 캐시가 만료되어 월요일 첫 빌드에서 모든 캐시가 미스 처리되었다.
해결: 스케줄 워크플로우로 주말에도 캐시를 갱신하고, restore-keys 패턴을 계층적으로 설정한다.
디버깅 체크리스트
문제 발생 시 다음 순서로 점검한다:
- 워크플로우 권한 확인:
permissions블록이 필요한 권한을 모두 포함하는가 - 시크릿 스코프 확인: Organization 시크릿인지, Repository 시크릿인지, Environment 시크릿인지
- 캐시 키 패턴 확인:
hashFiles경로가 실제 존재하는 파일을 가리키는가 - Reusable Workflow 접근 권한: 피호출 리포지토리의 Settings에서 Actions 접근을 허용했는가
- matrix 값 검증:
fromJSON에 전달되는 문자열이 유효한 JSON인지 확인 - 환경변수 스코프: Reusable Workflow에는 caller의
env가 전달되지 않는다는 점을 기억
운영 시 주의사항
호출 제한과 동시성 관리
GitHub Actions는 다음과 같은 제한이 있다:
- 동시 실행 잡: Free 플랜 20개, Team 플랜 60개, Enterprise 플랜 180개
- 워크플로우 실행 큐: 리포지토리당 최대 500개
- matrix 최대 조합: 256개
- 워크플로우 실행 시간: 최대 6시간 (self-hosted는 무제한)
동시성 문제를 방지하려면 concurrency 그룹을 설정한다:
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: false
같은 브랜치에 대한 배포가 동시에 실행되는 것을 방지하며, cancel-in-progress: false로 현재 실행 중인 배포를 취소하지 않는다.
환경변수 스코프 주의
Reusable Workflow에서 가장 흔한 실수는 환경변수 스코프를 혼동하는 것이다:
env키워드로 정의한 환경변수는 Reusable Workflow에 전달되지 않는다vars컨텍스트(Repository/Organization 변수)는 Reusable Workflow에서 접근 가능하다github컨텍스트는 Reusable Workflow에서 caller의 값이 전달된다
필요한 값은 반드시 inputs로 명시적으로 전달해야 한다.
대규모 모노레포 성능 팁
- Sparse checkout 사용: 변경된 패키지 디렉토리만 체크아웃하여 checkout 시간을 단축한다.
- uses: actions/checkout@v4
with:
sparse-checkout: |
packages/api
packages/shared
package.json
package-lock.json
sparse-checkout-cone-mode: false
- Turborepo Remote Cache: Vercel의 Remote Cache나 자체 호스팅 캐시 서버를 사용하면 로컬 개발과 CI 간 빌드 캐시를 공유할 수 있다.
- Affected 패키지만 실행: Nx의
nx affected나 Turborepo의 변경 감지를 활용하여 의존성 그래프 기반으로 영향받은 패키지만 빌드한다. - 병렬 잡 분산: matrix 전략으로 패키지별 빌드를 병렬화하되, shared 라이브러리는 선행 빌드로 분리한다.