GitHub Actions으로 레포 간 workflow 체이닝하기

커버 이미지

배경

사내 프로젝트 여러 곳에서 쓰이는 챗봇 공통 패키지를 만들어서 배포하고 있었습니다. 패키지에 수정 사항이 생길 때마다 아래 과정을 매번 거쳐야 했습니다.

  1. 패키지 레포에서 코드 수정 후 버전 bump
  2. npm publish
  3. 호스트 프로젝트에서 npm install @scope/패키지@새버전
  4. package.json, package-lock.json 커밋 후 push
  5. 배포

하루에도 몇 번씩 이걸 반복하다 보니, 어느 날 여러 패키지의 버전을 따로 올리다가 태그가 꼬여버렸습니다. 그때 이 과정을 자동화시켜보면 어떨까 하는 생각이 들었습니다.

  • 패키지 레포 (이하 A): 챗봇 위젯과 서버 로직을 담고 있는 모노레포. 두 개의 npm 패키지를 GitHub Packages에 publish하고 있습니다.
  • 호스트 프로젝트 (이하 B): Next.js 기반의 서비스 프로젝트. A의 패키지를 의존성으로 사용하고 있습니다.

두 프로젝트는 서로 다른 레포에 있고, 자동화의 핵심은 A 레포의 workflow가 끝난 뒤 B 레포의 workflow를 어떻게 트리거하느냐에 있었습니다.

트리거 방식 선택

GitHub Actions에서 레포 간에 이벤트를 전달하는 방법은 여러 가지가 있습니다.

방식특징제약
workflow_dispatch수동 실행 또는 API 호출커스텀 이벤트/payload 전달 불가
workflow_call재사용 가능한 workflow 호출private repo는 같은 org 내여야 함
repository_dispatchAPI 기반 이벤트 전달, 레포 간 통신PAT 토큰 필요

workflow_dispatch는 API로 다른 레포의 workflow를 트리거할 수는 있지만, 커스텀 이벤트 타입이나 자유로운 payload 전달이 불가능해서 "이 버전으로 업데이트해라"같은 지시를 보내기 어려웠습니다. workflow_call은 레포 간 호출이 가능하긴 하지만, 호출자-피호출자 관계가 고정되어 있어 호스트 프로젝트가 여러 개인 상황과는 맞지 않았습니다.

결국 repository_dispatch를 선택했습니다. 이벤트를 보내는 쪽과 받는 쪽이 완전히 독립적이라, 호스트 프로젝트가 늘어나더라도 발신 측 workflow를 수정할 필요가 없습니다. 게다가 이벤트와 함께 client_payload로 데이터를 전달할 수 있어서, "publish가 완료되었으니 이 버전으로 업데이트해라"와 같은 지시도 가능합니다.

1단계: 패키지 publish 후 이벤트 발송

repository_dispatch를 사용하려면 대상 레포에 쓰기 권한이 있는 PAT이 필요합니다. 기존에 사용하던 PAT을 호스트 프로젝트 레포의 Secrets에 GH_PAT으로 등록해두면, workflow에서 ${{ secrets.GH_PAT }}으로 참조할 수 있습니다.

패키지 레포의 publish.yml에서 패키지를 빌드하고 publish한 뒤, 마지막 스탭에서 이 토큰을 사용해 호스트 프로젝트로 이벤트를 보냅니다.

# 패키지 레포/.github/workflows/publish.yml
name: Publish Core Package
 
on:
  push:
    branches: [main]
    paths:
      - "packages/*/src/**"
 
jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      # ... 버전 bump, 빌드, publish 스탭 생략
 
      - name: Dispatch update to host project
        run: |
          curl -X POST \
            -H "Authorization: token ${{ secrets.GH_PAT }}" \
            -H "Accept: application/vnd.github.v3+json" \
            https://api.github.com/repos/your-org/host-project/dispatches \
            -d '{"event_type":"package-published","client_payload":{"version":"${{ steps.bump.outputs.version }}"}}'

GitHub의 POST /repos/{owner}/{repo}/dispatches API를 직접 호출하여 호스트 프로젝트에 이벤트를 보냅니다. event_type으로 커스텀 이벤트 이름을 지정하고, client_payload로 publish된 버전 정보를 함께 전달합니다.

이 workflow를 만들면서 처음 겪은 문제는 버전 불일치였습니다. 모노레포에 패키지가 여러 개 있을 때, 각각의 버전을 따로 관리하면 태그와 실제 버전이 어긋나는 일이 잦았습니다. 이를 해결하기 위해 하나의 workflow에서 모든 패키지를 동시에 bump하도록 통합했습니다.

- name: Bump patch version
  id: bump
  working-directory: packages/package-a
  run: |
    npm version patch --no-git-tag-version
    echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
 
- name: Sync other packages
  working-directory: packages/package-b
  run: npm version ${{ steps.bump.outputs.version }} --no-git-tag-version

하나의 패키지에서 patch 버전을 올리면 나머지 패키지가 그 버전을 따라가는 구조입니다. 이렇게 하면 모든 패키지가 항상 같은 버전을 유지합니다.

2단계: 이벤트를 받아 패키지 업데이트

호스트 프로젝트에서는 repository_dispatch 이벤트를 수신하여 패키지를 자동으로 업데이트합니다.

# 호스트 프로젝트/.github/workflows/update-package-staging.yml
name: Update Package (Staging)
 
on:
  repository_dispatch:
    types: [package-published]
  workflow_dispatch:

on.repository_dispatch.types에서 앞서 1단계에서 작성한 이벤트명인 package-published를 지정하면, 해당 이벤트만 수신합니다. workflow_dispatch도 함께 추가해두었는데, 이것이 나중에 디버깅 역할을 하게 됩니다.

steps:
  - uses: actions/checkout@v4
    with:
      ref: develop
      token: ${{ secrets.GH_PAT }}

ref: develop으로 develop 브랜치를 체크아웃합니다. 스테이징 환경 전용이므로 main이 아닌 develop에서 작업합니다. tokenGH_PAT을 넣는 이유는 이 workflow가 커밋을 push해야 하기 때문입니다. 기본 GITHUB_TOKEN으로 push하면 다른 workflow(배포 workflow)가 트리거되지 않습니다.

- name: Update packages
  run: |
    VERSION=${{ github.event.client_payload.version }}
    npm install @scope/package-a@${VERSION} @scope/package-b@${VERSION}
  env:
    NODE_AUTH_TOKEN: ${{ secrets.GH_PAT }}

client_payload에 담긴 버전 정보로 정확한 버전을 설치합니다. 한 가지 주의할 점은, workflow_dispatch로 수동 실행하면 client_payload가 없어서 VERSION이 빈 문자열이 된다는 것입니다. 명시적으로 처리하고 싶다면 fallback을 추가할 수 있습니다.

- name: Update packages
  run: |
    VERSION=${{ github.event.client_payload.version }}
    if [ -z "$VERSION" ]; then
      npm update @scope/package-a @scope/package-b
    else
      npm install @scope/package-a@${VERSION} @scope/package-b@${VERSION}
    fi

중복 트리거 문제

여기서 두 번째 문제가 발생했습니다. 처음에는 update-package.yml 하나로 staging과 production을 모두 처리했는데, 환경 분리를 위해 update-package-staging.ymlupdate-package-production.yml을 추가하면서 기존 파일을 삭제하지 않았습니다. repository_dispatchtypes 필터는 이벤트를 수신할지 여부만 결정하기 때문에, 같은 이벤트를 구독하는 세 개의 workflow가 모두 실행되어 3번 중복 트리거되었습니다. 기존 update-package.yml을 삭제하는 것으로 해결했지만, 이 동작 방식을 모르면 원인을 찾기 어려운 문제입니다.

3단계: 배포까지 체이닝

호스트 프로젝트에는 이미 develop 브랜치에 push가 발생하면 스테이징 서버로 배포하는 workflow가 있었습니다.

# 호스트 프로젝트/.github/workflows/deploy-staging.yml
on:
  push:
    branches: [develop]
    paths-ignore:
      - "docs/**"
      - "*.md"

2단계에서 develop 브랜치에 커밋을 push하면, 이 workflow가 자동으로 트리거됩니다. 기존에 이미 존재하던 배포 파이프라인에 별도의 수정 없이 자연스럽게 연결된 것입니다.

체이닝 구조 다이어그램

다만 체이닝이 길어지면 중간 단계의 실패를 놓치기 쉽습니다. 배포 workflow에는 Slack 알림이 설정되어 있었지만, 그 앞 단계인 패키지 업데이트 workflow에는 없었습니다. 배포가 트리거되지 않아도 Slack에는 아무 메시지가 오지 않으니, 문제가 발생했다는 사실 자체를 인지하지 못할 수 있습니다. 배포 workflow에 이미 Slack 알림 패턴이 있었기 때문에, 동일한 방식으로 패키지 업데이트 workflow에도 실패 알림을 추가해서 해결했습니다.

응용 - 스토리북 빌드를 체이닝에 합류시키기

기본적인 체이닝이 안정된 후, 스토리북 자동 빌드를 추가하고 싶었습니다.

기존에는 위젯의 스토리북을 확인하려면 패키지 레포를 클론받아 로컬에서 npm run storybook을 실행해야 했습니다. 디자이너가 위젯의 현재 상태를 확인하려면 매번 개발 환경을 세팅해야 하는 상황이었습니다. 스테이징 서버의 URL에서 바로 스토리북을 볼 수 있다면 이 병목이 사라질 것이라 판단했습니다.

별도의 스토리북 서버를 띄우는 대신, 정적으로 빌드해서 호스트 서버(Next.js)의 public/ 폴더에 넣는 방식을 선택했습니다. Next.js는 public/ 폴더를 별도 설정 없이 정적으로 서빙하므로, public/my-storybook/index.html을 넣으면 /my-storybook/으로 접근할 수 있습니다. 추가 인프라 비용이 0이고, 이미 만들어둔 체이닝에 스탭만 추가하면 되니 자연스러운 선택이었습니다.

물론 트레이드오프가 있습니다. 빌드 결과물을 git에 커밋하므로 레포 크기가 증가하고, 스토리북 업데이트 주기가 패키지 publish 주기에 묶입니다. 하지만 현재 규모에서는 충분히 합리적인 선택이라 판단했습니다.

2단계의 workflow에 스토리북 빌드 스탭을 추가했습니다.

- name: Checkout package repo for storybook build
  uses: actions/checkout@v4
  with:
    repository: your-org/package-repo
    token: ${{ secrets.GH_PAT }}
    path: package-repo
 
- name: Install package repo dependencies
  working-directory: package-repo
  run: npm ci
  env:
    NODE_AUTH_TOKEN: ${{ secrets.GH_PAT }}
 
- name: Clean previous storybook build
  run: rm -rf public/storybook public/my-storybook
 
- name: Build storybook
  run: npx storybook build -o ${{ github.workspace }}/public/my-storybook
  working-directory: package-repo

그런데 이 코드에 도달하기까지 순탄하지 않았습니다.

빌드는 되는데 파일이 없다

처음에는 상대경로를 사용했습니다.

- name: Build storybook
  working-directory: package-repo
  run: npx storybook build -o ../../public/storybook

스토리북 빌드 자체는 성공했습니다. 그런데 커밋 스탭에서 에러가 발생했습니다.

fatal: pathspec 'public/storybook' did not match any files

빌드는 성공했는데 파일이 없다니, 처음에는 이해가 되지 않았습니다. 빌드 로그를 다시 살펴보니 스토리북 빌드 명령이 실행되면서 실제 작업 디렉토리가 패키지 레포의 하위 경로까지 내려간 것이었습니다. 거기서 ../../를 하면 호스트 프로젝트의 public/이 아니라 패키지 레포의 루트를 가리키게 됩니다.

GitHub Actions에서는 ${{ github.workspace }}로 워크스페이스 루트의 절대경로를 참조할 수 있습니다. 상대경로 대신 절대경로를 사용하니 문제가 해결되었습니다.

스탭을 추가했는데 실행이 안 된다

경로를 수정하고 workflow 파일을 GitHub API로 업데이트한 뒤, 트리거를 기다렸습니다. workflow가 실행되었고, 성공으로 표시되었습니다. 그런데 실행 로그에 스토리북 빌드 스탭이 아예 없었습니다.

한참을 헤맨 끝에 원인을 찾았는데, GitHub Actions는 이벤트가 트리거된 시점의 커밋에 있는 workflow 파일을 사용하는 것이 원인이었습니다. workflow를 수정하기 전에 이미 트리거된 run은 수정 전 버전으로 실행됩니다. workflow를 수정하면 즉시 반영될 것이라 생각했는데, 그렇지 않았습니다.

이때 workflow_dispatch가 빛을 발했습니다. 수동으로 workflow를 실행하면 현재 브랜치의 최신 커밋에 있는 workflow가 실행됩니다. 수동 실행으로 최신 workflow를 돌리자 스토리북 빌드 스탭이 정상적으로 나타났습니다. 이 경험 이후로, 레포 간 체이닝 workflow에는 반드시 workflow_dispatch를 함께 추가해두고 있습니다.

기존 URL이 살아 있다

스토리북이 정상적으로 빌드되고 배포까지 완료되었습니다. 그런데 출력 경로를 public/storybook에서 public/my-storybook으로 변경했음에도 불구하고, 기존 URL(/storybook/)로 여전히 접속이 가능했습니다.

원인은 단순했습니다. 이전에 커밋된 public/storybook/ 폴더가 git에 그대로 남아 있었기 때문입니다. 빌드 전에 이전 폴더를 삭제하는 clean 스탭을 추가하여 해결했습니다.

- name: Clean previous storybook build
  run: rm -rf public/storybook public/my-storybook

경로를 변경할 때는 이전 경로의 잔재도 함께 정리해야 한다는 것을 다시 한번 깨닫게 되었습니다.

최종 체이닝 구조

패키지 레포 (main push)
│
├─ publish.yml
│  ├─ 버전 bump (패키지 간 동기화)
│  ├─ 패키지 빌드 & publish
│  └─ repository_dispatch → 호스트 프로젝트
│
└─► 호스트 프로젝트 (update-package-staging.yml)
    ├─ develop 체크아웃
    ├─ 패키지 버전 업데이트
    ├─ 스토리북 빌드 → public/ (응용)
    ├─ develop 브랜치에 커밋 & push
    │
    └─► deploy-staging.yml (develop push 트리거)
        └─ 빌드 & 배포

패키지 레포에 코드를 push하면, 패키지 publish → 호스트 프로젝트 업데이트 → 스테이징 배포까지 사람의 개입 없이 완료됩니다. 현재 이 구조는 staging(develop 브랜치)에만 적용되어 있고, production은 별도의 workflow에서 스토리북 빌드 없이 패키지 업데이트만 수행합니다. 스토리북은 내부 개발용이라 굳이 상용 환경에 배포할 필요가 없어서였습니다.

마무리

이번 작업을 돌이켜보면 반복되는 교훈은 하나였습니다. 자동화에서 문제가 생기면, "내가 의도한 것"과 "실제로 실행되는 것"의 차이를 의심해야 한다는 것이었습니다.

상대경로가 가리키는 곳이 내가 생각한 곳이 아니었고, workflow 수정이 즉시 반영되지 않았고, 삭제하지 않은 workflow가 중복 실행되었고, 이전 빌드 결과물이 git에 남아 있었습니다. 모두 "당연히 이렇게 동작할 것"이라는 가정이 어긋난 데서 비롯된 문제였습니다.


참고 자료