GitHub Actions 현명하게 사용하기(with Monorepo)
CI 시간을 줄이고 효율성을 높이는 방법에 대해 알아봅니다.
개요
입사하고 맡은 프로젝트는 꽤 오랜 시간동안 멀티레포의 형태로 운영되어왔습니다. 그 이유는 한 프로젝트는 가끔 아주 간단한 유지보수만 필요한 형태로 운영되고 있었기 때문입니다. 이후에 그렇게 유지보수만 하던 프로젝트는 더이상 운영되지 않았고, 이후에는 한 프로젝트에 대해서만 기능 개발 및 유지보수를 진행하고 있는 상황이었습니다.
시간이 지나 저는 또다른 프로젝트를 함께 시작하고 관리하게 되었고 그에 따라 레포지토리를 따로 생성하여
다시 한 번 멀티레포 형태로 운영을 해왔습니다. 여기서 문제는 두 레포지토리에 같은 변경사항이
반영되어야 하고, 생각보다 그 빈도가 잦다는 점이었습니다.
대표적인 예시로 두 프로젝트 모두 shadcn/ui
(opens in a new tab) 라는 Radix UI
컴포넌트 라이브러리 기반 UI 도구를 사용하고 있었고,
두 프로젝트의 UI 관련 공통점이 많았기 때문에 해당 UI 컴포넌트들의 수정도 두 번씩 해줘야 되는 문제가 있었습니다.
관리 포인트가 여러 개가 되는 것은 생각보다 간단한 문제가 아니었습니다. 한 쪽에 깜빡하고 변경사항을 적용하지 못해서 발생하는 휴먼 에러 등은 관리 포인트를 하나로 줄일 필요성을 높여줬습니다. 이러한 이유로 저는 UI 컴포넌트 프로젝트를 포함한 모든 프로젝트를 한 저장소에서 관리하는, 모노레포의 형태로 운영하기로 결정하였습니다.
pnpm workspace
저는 pnpm workspace를 사용하였기에 간단하게 pnpm-workspace.yaml
파일만 추가하여 모노레포 형태로 변경했고,
그에 따라 아래와 같은 구조를 갖게 되었습니다.
.
├── apps
│ ├── project1
│ └── project2
├── packages
│ └── ui
├── package.json
├── pnpm-lock.yaml
└── pnpm-workspace.yaml
UI 레포지토리를 다양하게 활용하기(with GitHub Actions)
라이브러리를 비공개로 배포하는 방법에는 npm private packages 외에도 GitHub Packages를 사용하는 방법이 있습니다. 배포하는 방법에 대해서는 이미 구글링을 통해 많은 글을 접할 수 있기 때문에, 저는 조금 다른 얘기를 해보고자 합니다.
저는 UI 레포지토리를 비공개 UI 컴포넌트 라이브러리로 사용함과 동시에 Storybook 환경을 구성하여 디자인 시스템 (opens in a new tab)을 배포하는 용도로도 사용하고 있습니다. 그렇기에 특정 루트의 파일의 변화에 대해 분기처리를 통해 GitHub Actions에서 다른 스크립트를 실행시키는 것이 중요했습니다.
이를 위해 저는 GitHub Actions 스크립트를 다음과 같이 구성했습니다.
jobs:
changes:
name: Compute file diff
runs-on: ubuntu-latest
permissions:
pull-requests: read
contents: read
outputs:
storybook: ${{ steps.compute_diff.outputs.storybook }}
ui_components: ${{ steps.compute_diff.outputs.ui_components }}
steps:
- uses: actions/checkout@v4
- name: Compute diff
uses: dorny/paths-filter@v3.0.2
id: compute_diff
with:
base: ${{ github.ref }}
filters: |
storybook:
- 'packages/ui/**/*.stories.tsx'
- 'packages/ui/.storybook/**/*'
ui_components:
- 'packages/ui/src/**/*'
- 'packages/ui/package.json'
build-and-deploy:
runs-on: ubuntu-latest
needs: changes
if: ${{ needs.changes.outputs.storybook == 'true' }}
steps:
# ...
publish-package:
runs-on: ubuntu-latest
needs: changes
if: ${{ needs.changes.outputs.ui_components == 'true' }}
steps:
# ...
dorny/paths-filter
액션 (opens in a new tab)은 filters
에 포함된 파일의 변화에 대해
변수 형태로 분기처리를 할 수 있도록 도와주는 액션입니다. 각각 storybook
과 ui_components
의 변수로
결과값을 리턴하고, 각 job의 if
조건에 해당 변수를 활용하였습니다.
현재 Storybook에는 Interaction Testing을 위한 테스트 코드가 적용되어있고, build-and-deploy
job에는
Storybook을 빌드하는 스크립트와 함께 Interaction Testing 코드를 Playwright 환경에서 실행하는 스크립트가 포함되어 있습니다.
Playwright 환경에서의 Storybook 테스팅 실행은 Playwright 종속성의 설치를 포함하고 있기에, 설치 > 빌드 > 테스팅의
과정으로 진행이 됩니다. 위와 같이 변경점을 감지하는 job이 없다면 Storybook 관련 코드를 수정하지 않는 경우에도
위와 같은 스크립트를 실행해야하는 문제가 발생하고, 이는 결과적으로 CI의 시간을 증가시키는 원인이 됩니다. 물론,
storybook
의 필터에 걸리는 파일들은 ui_components
필터에도 걸리게 되기에 동시에 실행이 되게 되지만,
build-and-deploy
관련 job의 실행 시간이 publish-package
의 실행 시간보다 훨씬 오래 걸리고, 또 병렬적으로
실행이 되기 때문에 실행 시간에는 영향을 주지 않습니다.
이렇게 명확하게 각 job의 실행 조건을 분기처리함으로써 각 작업을 배포하기까지의 시간을 줄이고 효율성을 높일 수 있었습니다. 다만 이 부분을 문제 없이 분기처리하고 실행하려면 특정 파일이 특정 관심사에만 포함되어있음을 명확하게 인지한 상태에서 필터링을 적용하켜야 합니다.
라벨링
Pull Request에서 정말 다양한 방법으로 라벨을 부착할 수 있는데, 그 중에서도 PR의 내용과 관련된 라벨로는 다음과 같은 방식으로 라벨을 부착할 수 있습니다:
- 특정 루트의 파일이 변경되었을 경우 특정 라벨을 부착하기 (ex:
packages/ui
관련 수정인 경우UI
라벨 부착) - Pull Request 타이틀의 컨벤션에 따라 라벨을 부착하기 (ex.
[FEAT]
으로 시작할 경우feature
라벨 부착) - 변경된 파일 갯수에 따라
Size: M
등의 라벨을 부착
현재 모노레포 프로젝트에는 첫 번째 방식으로 라벨을 부착하고 있습니다. PR의 제목으로 보통 fix
, chore
, feat
등의
컨벤션을 정하여 작성하고 있기 때문에, 따로 두 번째 방식의 라벨을 부착할 필요성은 느끼지 못했습니다.
name: "Pull Request Labeler"
on:
- pull_request
jobs:
triage:
permissions:
contents: read
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v4
with:
repo-token: "${{ secrets.GITHUB_TOKEN }}"
sync-labels: true
configuration-path: .github/labeler.yml
actions/labeler
액션 (opens in a new tab)을 사용하여 아래와 같은 configuration을 설정했고,
github-actions 봇으로 하여금 라벨을 추가할 수 있게 하였습니다.
CI:
- .github/**/*
UI:
- packages/ui/**/*
UI Unit Test:
- packages/ui/**/*.test.tsx
# ...
job을 실행시켰을 때와 마찬가지로 변경사항을 파악한 뒤 라벨을 추가합니다.
(packages/ui
레포지토리는 @beringlab/ui
라는 이름을 갖고 Publish하여 각 레포지토리에서 사용하고 있습니다.)
캐싱
이 부분은 정확히는 모노레포와 관련된 내용은 아니지만, CI 시간을 줄이기 위해서 반드시 필요합니다. 저는 각 레포지토리의 GitHub Actions 스크립트에서 다음과 같은 부분에 캐싱을 적용하여 시간을 줄이고 있습니다.
종속성 설치
pnpm 환경에서는 아래와 같이 cache: 'pnpm'
(opens in a new tab)
만 추가해주더라도 쉽게 캐싱할 수 있습니다.
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: "pnpm"
그 결과 아래와 같이 최초 설치 이후 종속성 설치 시간을 5초로 줄일 수 있었습니다.
Playwright 설치
상술한 것처럼 Storybook (opens in a new tab) 내 각 UI 컴포넌트에는 Interaction Testing이 적용되어 있습니다. 문제는 로컬에서 항상 모든 테스트를 실행하기엔 현실적으로 쉽지 않기 때문에(까먹음 이슈 존재), CI를 이용해서 자동화할 수 없을까 생각하며 찾아봤고 당연히도 자동화할 수 있는 방법이 있었습니다.
여기서 또 다른 문제가 발생했습니다. Storybook testing은 Storybook이 실행되어있는 환경에서만 가능합니다.
[test-storybook] It seems that your Storybook instance is not running at: http://127.0.0.1:6006. Are you sure it's running?
If you're not running Storybook on the default 6006 port or want to run the tests against any custom URL, you can pass the --url flag like so:
그래서 로컬환경에서도 Storybook을 실행시킨 이후에야 테스팅을 진행할 수 있습니다. 로컬환경에서 Storybook을 실행하지 않고 테스팅을 진행한다면 위와 같은 에러를 리턴합니다.
문제는 GitHub Actions 환경에서도 Storybook이 실행되어있는 상태여야 테스팅이 가능하다는 것입니다. 그리고 이를 위해 Playwright 종속성을 설치한 뒤
이 환경에서 Storybook 테스팅을 실행해야 했습니다. 문제는 Playwright 종속성을 설치하는 데만 매번 1분 이상의 시간이 소요되었던 것입니다.
이를 캐싱하여 문제를 해결하기 위해 Playwright 버전을 기준으로 캐시 키를 생성하고 ~/.cache/ms-playwright
경로의 바이너리 파일들을 캐싱함으로써,
최초 설치 이후에는 Playwright 설치 과정을 건너뛸 수 있게 되었습니다.
- name: Get installed Playwright version
id: playwright-version
run: |
PLAYWRIGHT_VERSION=$(node -e "console.log(require('./packages/ui/package.json').devDependencies['@playwright/test'])")
echo "PLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION" >> $GITHUB_ENV
- name: Cache playwright binaries
uses: actions/cache@v4
id: playwright-cache
with:
path: |
~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}
- name: Install Playwright browsers
run: pnpm dlx playwright install --with-deps
if: steps.playwright-cache.outputs.cache-hit != 'true'
- name: Build Storybook
run: cd packages/ui && pnpm run build-storybook --quiet
- name: Serve Storybook and run tests
run: |
pnpm dlx concurrently -k -s first -n "SB,TEST" -c "magenta,blue" \
"pnpm dlx http-server ./packages/ui/storybook-static --port 6006 --silent" \
"pnpm dlx wait-on tcp:127.0.0.1:6006 && cd packages/ui && pnpm test-storybook"
그 결과 Playwright 설치에 소요되던 1분 가량의 시간을 절약할 수 있었고, 전체 CI 파이프라인의 실행 시간도 크게 단축할 수 있었습니다.
이전: 53초 소요
이후: 6초 소요
마치며
글을 쓰다보니 결과적으로 꼭 모노레포에만 적용할 수 있는 내용은 아니었지만, GitHub Actions를 사용하여 굉장히 많은 걸 할 수 있다는 것을 다시 한 번 느꼈습니다. 추가적으로, 아직은 빌드 시간이 생산성을 저하시킬 만큼 오래 걸리지 않아 Remote Caching의 장점이 있는 Turborepo (opens in a new tab) 를 도입하지 않았습니다. Turborepo를 도입하면 시간을 조금 더 단축시킬 수 있을 것으로 보이며, 도입하게 될 경우 추가로 글을 작성해 보도록 하겠습니다.
© Sangmin Park .