Published on

모노레포 GitHub Actions 재사용 워크플로 설계

Authors

모노레포는 코드 공유와 리팩터링이 쉬운 대신, 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

이때도 contextfile 을 서비스별로 고정해야 캐시 오염이 줄어듭니다.

패턴 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.sh
  • scripts/ci/test.sh
  • scripts/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가 안정적으로 유지됩니다.

다음 단계로는, 재사용 워크플로에 품질 게이트(예: 프론트 성능, 보안 스캔, 릴리즈 노트 자동화)를 점진적으로 추가해 “중앙 표준”의 가치를 키우는 전략이 효과적입니다.