Skip to content

필사 모드: GitHub Actions 고급 패턴: Reusable Workflows·Composite Actions·모노레포 CI/CD 최적화 전략

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

들어가며

조직의 리포지토리가 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` 패턴을 계층적으로 설정한다.

디버깅 체크리스트

문제 발생 시 다음 순서로 점검한다:

1. **워크플로우 권한 확인**: `permissions` 블록이 필요한 권한을 모두 포함하는가

2. **시크릿 스코프 확인**: Organization 시크릿인지, Repository 시크릿인지, Environment 시크릿인지

3. **캐시 키 패턴 확인**: `hashFiles` 경로가 실제 존재하는 파일을 가리키는가

4. **Reusable Workflow 접근 권한**: 피호출 리포지토리의 Settings에서 Actions 접근을 허용했는가

5. **matrix 값 검증**: `fromJSON`에 전달되는 문자열이 유효한 JSON인지 확인

6. **환경변수 스코프**: 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 라이브러리는 선행 빌드로 분리한다.

참고자료

- [Reusable Workflows | GitHub Docs](https://docs.github.com/en/actions/concepts/workflows-and-actions/reusable-workflows)

- [Creating a Composite Action | GitHub Docs](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-composite-action)

- [GitHub Actions November 2025 Releases](https://github.blog/changelog/2025-11-06-new-releases-for-github-actions-november-2025/)

- [dorny/paths-filter | GitHub](https://github.com/dorny/paths-filter)

- [actions/cache | GitHub](https://github.com/actions/cache)

- [Monorepo with GitHub Actions | Graphite](https://graphite.com/guides/monorepo-with-github-actions)

- [Composite Actions vs Reusable Workflows | DEV](https://dev.to/n3wt0n/composite-actions-vs-reusable-workflows-what-is-the-difference-github-actions-11kd)

현재 단락 (1/565)

조직의 리포지토리가 10개를 넘어가면 CI/CD 파이프라인의 중복이 눈에 띄기 시작한다. 빌드-테스트-배포 YAML이 리포지토리마다 복사-붙여넣기로 퍼져 있고, 하나의 보안 패치를...

작성 글자: 0원문 글자: 16,881작성 단락: 0/565