- Published on
모노레포 GitHub Actions 재사용 워크플로 설계
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
모노레포는 코드 공유와 리팩터링이 쉬운 대신, CI/CD가 금방 복잡해집니다. 서비스가 10개만 넘어가도 워크플로 파일이 폭증하고, 각 팀이 조금씩 다른 방식으로 빌드·테스트·배포를 붙이면서 유지보수 비용이 급격히 증가합니다. GitHub Actions의 재사용 워크플로(reusable workflows) 는 이 문제를 구조적으로 해결하는 도구입니다.
이 글은 모노레포에서 재사용 워크플로를 설계할 때 자주 등장하는 패턴을 입력 계약(inputs), 시크릿 경계, 변경 감지, 캐시/동시성, 환경별 배포 관점에서 정리합니다. 예시는 Node.js와 Docker를 섞어 설명하지만, 핵심은 언어와 무관한 구조입니다.
왜 재사용 워크플로가 모노레포에 특히 유리한가
모노레포에서 반복되는 CI/CD 로직은 대략 아래로 수렴합니다.
- 변경된 패키지나 서비스만 선택적으로 빌드/테스트
- 표준화된 품질 게이트(린트, 유닛 테스트, SAST 등)
- 동일한 배포 절차(이미지 빌드, 태그 전략, 롤아웃)
- 공통 캐시 전략과 동시성 제어
- 시크릿 최소 노출과 권한 최소화
재사용 워크플로를 사용하면, 각 서비스는 “자기 서비스에 필요한 입력값만” 넘기고, 실제 구현은 중앙에서 관리합니다. 즉 워크플로를 코드처럼 모듈화 하는 셈입니다.
추가로, 재사용 워크플로는 “조직 표준 CI”를 만들기 좋습니다. 예를 들어 성능 회귀를 막기 위해 프론트엔드에 INP/CLS 관련 품질 체크를 붙이고 싶다면, 표준 워크플로에 체크 단계를 추가하는 것만으로 전체 서비스에 일괄 적용할 수 있습니다. 관련해서 프론트 성능 진단 관점은 Chrome INP 200ms 이상? Long Task 추적·개선, Chrome CLS 급증 원인 7가지와 실전 해결도 함께 보면 “품질 게이트를 CI에 녹이는 방식”을 떠올리기 쉽습니다.
기본 전제: 재사용 워크플로의 호출 구조
재사용 워크플로는 .github/workflows 아래에 두고, on: workflow_call 로 호출됩니다. 호출하는 쪽은 jobs.<job_id>.uses 로 워크플로를 참조합니다.
주의할 점은 다음입니다.
- 호출 워크플로와 재사용 워크플로는 권한과 시크릿 전달이 명시적 입니다.
workflow_call입력은 타입이 제한적이며, 복잡한 구조는 JSON 문자열로 넘기는 패턴이 자주 쓰입니다.- 모노레포에서는 “변경 감지 결과를 다음 잡에 전달”하는 설계가 핵심입니다.
패턴 1: 변경 감지(Changed Files) 결과를 표준 인터페이스로 만들기
모노레포 CI의 출발점은 “무엇이 바뀌었는가”입니다. 변경 감지 결과를 표준화해두면, 이후 단계는 단순해집니다.
변경 감지 워크플로 예시
아래는 변경된 경로를 기준으로 api, web, worker 를 판별해 JSON으로 내보내는 예시입니다.
# .github/workflows/ci.yml
name: ci
on:
pull_request:
push:
branches: [main]
jobs:
detect:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set.outputs.matrix }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- id: diff
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
BASE="origin/${{ github.base_ref }}"
git fetch origin ${{ github.base_ref }} --depth=1
else
BASE="${{ github.event.before }}"
fi
echo "base=$BASE" >> $GITHUB_OUTPUT
echo "files<<EOF" >> $GITHUB_OUTPUT
git diff --name-only "$BASE" "${{ github.sha }}" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- id: set
run: |
FILES="${{ steps.diff.outputs.files }}"
SERVICES=()
echo "$FILES" | grep -q '^apps/api/' && SERVICES+=("api") || true
echo "$FILES" | grep -q '^apps/web/' && SERVICES+=("web") || true
echo "$FILES" | grep -q '^apps/worker/' && SERVICES+=("worker") || true
if [ ${#SERVICES[@]} -eq 0 ]; then
MATRIX='{"include":[]}'
else
JSON='{"include":['
for SVC in "${SERVICES[@]}"; do
JSON+="{\"service\":\"$SVC\"},"
done
JSON=${JSON%,}
JSON+=']}'
MATRIX="$JSON"
fi
echo "matrix=$MATRIX" >> $GITHUB_OUTPUT
build_test:
needs: [detect]
if: ${{ fromJson(needs.detect.outputs.matrix).include[0] != null }}
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.detect.outputs.matrix) }}
uses: ./.github/workflows/reusable-build-test.yml
with:
service: ${{ matrix.service }}
핵심은 detect 잡의 출력이 이후 모든 잡의 입력 계약이 된다는 점입니다. 이 계약이 흔들리면 모노레포 전체 CI가 흔들립니다.
패턴 2: 재사용 워크플로는 “서비스 독립”이 아니라 “계약 중심”으로
재사용 워크플로는 범용적으로 보이지만, 모노레포에서는 오히려 조직의 표준 계약 을 담는 게 더 중요합니다.
예를 들어 service 입력만 받되, 내부에서 서비스별 규칙을 case 로 분기하면 중앙집중이 과해집니다. 대신 다음처럼 “서비스별 차이는 입력으로 주고”, 워크플로는 그 입력만 해석하는 구조가 안정적입니다.
재사용 워크플로 예시: 빌드/테스트
# .github/workflows/reusable-build-test.yml
name: reusable-build-test
on:
workflow_call:
inputs:
service:
required: true
type: string
workdir:
required: false
type: string
node_version:
required: false
type: string
default: '20'
secrets:
NPM_TOKEN:
required: false
jobs:
build_test:
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
shell: bash
working-directory: ${{ inputs.workdir || format('apps/{0}', inputs.service) }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node_version }}
cache: 'npm'
cache-dependency-path: |
package-lock.json
${{ inputs.workdir || format('apps/{0}', inputs.service) }}/package-lock.json
- name: Configure npm auth
if: ${{ secrets.NPM_TOKEN != '' }}
run: |
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc
- run: npm ci
- run: npm run lint
- run: npm test --if-present
- run: npm run build
여기서 중요한 설계 포인트는 다음입니다.
workdir를 입력으로 열어두면 레이아웃 변경에 유연합니다.permissions를 최소화해 기본 보안 수준을 올립니다.- 캐시 키의 기준 파일(
cache-dependency-path)을 모노레포 구조에 맞게 지정합니다.
패턴 3: 시크릿과 권한 경계 설계(특히 배포)
모노레포에서 가장 흔한 사고는 “한 서비스의 CI가 다른 서비스의 배포 권한까지 갖는 것”입니다. 재사용 워크플로는 이를 줄이는 데 유리하지만, 설계를 잘못하면 오히려 전파 범위가 커집니다.
권장 원칙:
- 재사용 워크플로의
permissions는 기본적으로 최소 - 배포는 별도의 재사용 워크플로로 분리
secrets: inherit는 가급적 피하고, 필요한 시크릿만 명시적으로 전달- 클라우드 인증은 OIDC 기반으로 전환하고, 환경별로 role을 분리
EKS를 쓰는 경우 OIDC 연동 이슈가 자주 나오는데, 실전 트러블슈팅 관점은 EKS OIDC Provider 400 invalid_client 해결 가이드도 참고할 만합니다.
배포 재사용 워크플로 예시: OIDC 기반
# .github/workflows/reusable-deploy.yml
name: reusable-deploy
on:
workflow_call:
inputs:
service:
required: true
type: string
environment:
required: true
type: string
image_tag:
required: true
type: string
secrets:
AWS_REGION:
required: true
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.AWS_ROLE_TO_ASSUME }}
aws-region: ${{ secrets.AWS_REGION }}
- name: Deploy
run: |
echo "Deploying ${{ inputs.service }} with tag ${{ inputs.image_tag }} to ${{ inputs.environment }}"
# 예: helm upgrade, kubectl set image 등
포인트는 environment 를 강제하고, 해당 환경에만 존재하는 vars.AWS_ROLE_TO_ASSUME 를 쓰게 만드는 것입니다. 이렇게 하면 프로덕션 배포 권한이 개발 환경에서 새어나가기 어렵습니다.
패턴 4: 캐시와 아티팩트는 “레이어를 분리”해야 한다
모노레포에서는 캐시를 공격적으로 쓰지 않으면 비용이 급증합니다. 하지만 캐시를 잘못 공유하면 “다른 서비스의 캐시가 섞여” 재현 불가능한 빌드가 나옵니다.
권장 패턴:
- 의존성 캐시(예: npm, pnpm store)는 공유 가능
- 빌드 산출물 캐시(예: Next.js
.next/cache)는 서비스 단위로 분리 - Docker 레이어 캐시는 서비스별 Dockerfile 경로와 함께 키를 구성
Docker 빌드 캐시 예시
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build
uses: docker/build-push-action@v6
with:
context: ${{ format('apps/{0}', inputs.service) }}
file: ${{ format('apps/{0}/Dockerfile', inputs.service) }}
push: false
tags: ${{ format('local/{0}:{1}', inputs.service, github.sha) }}
cache-from: type=gha
cache-to: type=gha,mode=max
이때도 context 와 file 을 서비스별로 고정해야 캐시 오염이 줄어듭니다.
패턴 5: 동시성(Concurrency)로 배포 충돌을 막기
모노레포는 PR이 동시에 많이 열리고, 같은 서비스로 배포가 겹치기 쉽습니다. GitHub Actions concurrency 는 “같은 그룹은 하나만 실행”시키는 장치입니다.
- PR 검증은 최신 커밋만 남기고 이전 실행을 취소
- 배포는 환경 단위로 직렬화(특히 prod)
동시성 예시
concurrency:
group: ${{ format('ci-{0}-{1}', github.workflow, github.ref) }}
cancel-in-progress: true
배포는 보통 아래처럼 서비스와 환경을 묶습니다.
concurrency:
group: ${{ format('deploy-{0}-{1}', inputs.service, inputs.environment) }}
cancel-in-progress: false
패턴 6: “워크플로 호출 계층”을 2단으로 제한하기
재사용 워크플로를 만들다 보면, 재사용 워크플로가 또 다른 재사용 워크플로를 부르는 형태로 깊어지기 쉽습니다. 깊이가 깊어질수록 디버깅이 어려워집니다.
권장:
- 최상위 오케스트레이션 워크플로(서비스 선택, 매트릭스 구성)
- 재사용 워크플로(빌드/테스트, 배포 등 목적 단위)
이 2단 구조를 넘기지 않는 것을 원칙으로 두면, 장애 시 추적이 쉬워집니다.
패턴 7: 입력값을 늘리기보다 “표준 스크립트”를 레포에 둔다
재사용 워크플로가 모든 것을 알게 만들면 입력이 끝없이 늘어납니다. 대신 레포에 표준 스크립트를 두고, 워크플로는 그 스크립트를 호출하는 방식이 유지보수에 유리합니다.
예:
scripts/ci/lint.shscripts/ci/test.shscripts/ci/build.sh
이렇게 하면 CI 로직 변경이 YAML 수정이 아니라 스크립트 수정으로 수렴하고, 로컬에서도 동일하게 실행할 수 있습니다.
패턴 8: 실패를 “서비스 단위로 격리”하고, 신호를 강하게 만들기
모노레포에서는 한 서비스 실패가 전체 실패로 보이기 때문에, 신호가 약해질 수 있습니다. 매트릭스 전략을 쓰되 다음을 권장합니다.
fail-fast: false로 다른 서비스 결과도 끝까지 수집- 체크 이름을 서비스명 포함으로 통일
- 테스트/빌드 로그에 서비스 경로를 항상 포함
이렇게 하면 PR에서 어떤 서비스가 깨졌는지 빠르게 판단됩니다.
실전 템플릿: PR 검증과 main 배포를 분리
마지막으로 가장 많이 쓰는 형태를 정리하면 아래와 같습니다.
ci.yml: PR, push에서 변경 감지 후 빌드/테스트 재사용 워크플로 호출release.yml: main 머지 시 태그 전략 수립 후 배포 재사용 워크플로 호출
main 배포 오케스트레이션 예시
# .github/workflows/release.yml
name: release
on:
push:
branches: [main]
jobs:
detect:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set.outputs.matrix }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- id: set
run: |
# 예시 단순화: main에서는 항상 전체 배포한다고 가정
echo 'matrix={"include":[{"service":"api"},{"service":"web"}]}' >> $GITHUB_OUTPUT
deploy:
needs: [detect]
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.detect.outputs.matrix) }}
uses: ./.github/workflows/reusable-deploy.yml
with:
service: ${{ matrix.service }}
environment: production
image_tag: ${{ github.sha }}
secrets:
AWS_REGION: ${{ secrets.AWS_REGION }}
체크리스트: 모노레포 재사용 워크플로 설계 시 흔한 함정
- 변경 감지 기준이 불명확해 “필요한 서비스가 빌드되지 않는” 문제
secrets: inherit남용으로 권한 경계 붕괴- 캐시 키가 서비스 간 공유되어 비결정적 빌드 발생
- 동시성 미설정으로 같은 환경에 배포가 겹침
- 재사용 워크플로의 입력 계약이 자주 바뀌어 호출부가 깨짐
결론
모노레포에서 GitHub Actions 재사용 워크플로는 단순한 “YAML 중복 제거”가 아니라, 조직의 CI/CD 계약을 코드화하는 작업입니다. 변경 감지 결과를 표준 인터페이스로 만들고, 빌드/테스트와 배포를 분리하며, 권한과 시크릿 경계를 명확히 하고, 캐시와 동시성을 서비스 단위로 격리하면 규모가 커져도 CI가 안정적으로 유지됩니다.
다음 단계로는, 재사용 워크플로에 품질 게이트(예: 프론트 성능, 보안 스캔, 릴리즈 노트 자동화)를 점진적으로 추가해 “중앙 표준”의 가치를 키우는 전략이 효과적입니다.