왜 워크플로 중복이 문제인가
마이크로서비스나 모노레포를 운영하다 보면 각 서비스마다 build → test → lint → docker push 파이프라인이 거의 똑같이 반복된다. 처음엔 복사·붙여넣기가 빠르지만, 시간이 지나면 문제가 드러난다. Node 버전을 20으로 올리거나 캐시 키 전략을 바꾸려면 저장소 수십 개의 YAML을 일일이 고쳐야 한다. 한두 개를 빠뜨리면 서비스마다 CI 동작이 미묘하게 달라지고, 이 차이는 "왜 A는 통과하고 B는 실패하지" 같은 디버깅 비용으로 돌아온다.
즉 중복은 코드 라인 수의 문제가 아니라 변경 전파 비용과 일관성의 문제다. GitHub Actions는 이를 위해 두 가지 재사용 수단을 제공한다.
Composite Action vs Reusable Workflow
둘 다 재사용을 위한 것이지만 재사용 단위와 실행 모델이 다르다. 선택을 잘못하면 오히려 우회 코드가 늘어난다.
| 구분 | Composite Action | Reusable 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의
outputs를workflow_call.outputs로 다시 노출해야 한다. step 출력이 자동으로 바깥까지 올라가지 않는다. - 시크릿 범위:
secrets: inherit는 편하지만 필요 이상으로 시크릿을 넘긴다. 최소 권한이 중요하면 개별 시크릿만 명시적으로 전달한다. - 비공개 저장소: 조직 설정에서 다른 저장소의 워크플로 접근을 허용해야 호출이 동작한다.
정리
재사용 워크플로는 "복사한 YAML을 한 번 더 붙여넣기 전에" 도입하는 것이 가장 이득이다. step 묶음이면 Composite Action, job 단위면 Reusable Workflow를 쓰고, 버전은 major 태그나 SHA로 고정한다. 이렇게 하면 CI 정책 변경이 저장소 순회 작업에서 태그 릴리스 한 번으로 바뀐다.