왜 워크플로 중복이 문제인가

마이크로서비스나 모노레포를 운영하다 보면 각 서비스마다 build → test → lint → docker push 파이프라인이 거의 똑같이 반복된다. 처음엔 복사·붙여넣기가 빠르지만, 시간이 지나면 문제가 드러난다. Node 버전을 20으로 올리거나 캐시 키 전략을 바꾸려면 저장소 수십 개의 YAML을 일일이 고쳐야 한다. 한두 개를 빠뜨리면 서비스마다 CI 동작이 미묘하게 달라지고, 이 차이는 "왜 A는 통과하고 B는 실패하지" 같은 디버깅 비용으로 돌아온다.

즉 중복은 코드 라인 수의 문제가 아니라 변경 전파 비용과 일관성의 문제다. GitHub Actions는 이를 위해 두 가지 재사용 수단을 제공한다.

Composite Action vs Reusable Workflow

둘 다 재사용을 위한 것이지만 재사용 단위와 실행 모델이 다르다. 선택을 잘못하면 오히려 우회 코드가 늘어난다.

구분Composite ActionReusable Workflow
재사용 단위여러 step 묶음job 전체(여러 job 포함)
호출 위치job 안의 step워크플로의 job
매트릭스/러너 지정불가(호출한 job에 종속)가능(자체 runs-on, strategy)
Secrets 전달입력으로 수동 전달secrets: inherit 지원

"몇 개의 step을 묶고 싶다"면 Composite Action, "테스트 job 자체를 통째로 공유하고 싶다"면 Reusable Workflow가 맞다. 아래에서는 후자를 중심으로 다룬다.

재사용 워크플로 정의하기

핵심은 on: workflow_call 트리거다. 입력(inputs)과 시크릿(secrets)을 명시적으로 선언해야 호출 측에서 계약처럼 사용할 수 있다.

# .github/workflows/reusable-ci.yml
name: reusable-ci
on:
  workflow_call:
    inputs:
      node-version:
        type: string
        default: "20"
      working-directory:
        type: string
        default: "."
    secrets:
      NPM_TOKEN:
        required: false

jobs:
  build-test:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: ${{ inputs.working-directory }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
          cache: npm
      - run: npm ci
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
      - run: npm run lint
      - run: npm test

호출 측은 이렇게 줄어든다

각 서비스의 워크플로는 이제 job 하나로 끝난다. uses로 재사용 워크플로를 가리키고 필요한 입력만 넘긴다. secrets: inherit를 쓰면 호출 측 시크릿을 그대로 물려줄 수 있다.

# service-a/.github/workflows/ci.yml
name: ci
on:
  pull_request:
  push:
    branches: [main]

jobs:
  ci:
    uses: my-org/ci-templates/.github/workflows/reusable-ci.yml@v1
    with:
      node-version: "20"
      working-directory: apps/service-a
    secrets: inherit

이제 Node 버전 정책을 바꾸려면 reusable-ci.yml 한 곳만 고치고 태그를 올리면 된다. 20개 서비스가 자동으로 새 정책을 따른다.

버전 고정과 변경 롤아웃

재사용 워크플로를 @main으로 참조하면 편하지만 위험하다. 템플릿 저장소의 커밋 하나가 모든 서비스의 CI를 동시에 깨뜨릴 수 있기 때문이다. 실무에서는 @v1처럼 이동 가능한 major 태그를 쓰고, 파괴적 변경 시 v2로 올려 호출 측이 스스로 마이그레이션하도록 한다. 재현성이 중요한 조직이라면 커밋 SHA로 고정한다.

# 안정성 우선: 이동형 major 태그
uses: my-org/ci-templates/.github/workflows/reusable-ci.yml@v1

# 재현성 우선: 커밋 SHA 고정
uses: my-org/ci-templates/.github/workflows/reusable-ci.yml@3f2a1b9

실무에서 자주 걸리는 주의점

  • 중첩 제한: 재사용 워크플로는 최대 4단계까지만 중첩된다. 너무 깊은 계층은 디버깅을 어렵게 하니 2단계 이내를 권장한다.
  • 출력 전달: 재사용 워크플로의 결과를 호출 측에서 쓰려면 job의 outputsworkflow_call.outputs로 다시 노출해야 한다. step 출력이 자동으로 바깥까지 올라가지 않는다.
  • 시크릿 범위: secrets: inherit는 편하지만 필요 이상으로 시크릿을 넘긴다. 최소 권한이 중요하면 개별 시크릿만 명시적으로 전달한다.
  • 비공개 저장소: 조직 설정에서 다른 저장소의 워크플로 접근을 허용해야 호출이 동작한다.

정리

재사용 워크플로는 "복사한 YAML을 한 번 더 붙여넣기 전에" 도입하는 것이 가장 이득이다. step 묶음이면 Composite Action, job 단위면 Reusable Workflow를 쓰고, 버전은 major 태그나 SHA로 고정한다. 이렇게 하면 CI 정책 변경이 저장소 순회 작업에서 태그 릴리스 한 번으로 바뀐다.