본 글은 2024.12.23에 마지막으로 수정되었습니다.
CI를 왜 구축하는 거야?
운영 브랜치, 또는 개발 브랜치로 병합하기 전 해당 브랜치가 잘 동작하는지, 병합 시 이상은 없는지 확인하는 단계가 필요합니다. 주로 테스트, lint, 빌드가 이상 없이 완료되는지 확인을 해야 하는데요. 매번 PR을 올리고 추가 커밋을 할 때마다 이 단계를 모두 수작업으로 하기에는 번거로우니, CI라는 단계로 자동화하여 시스템을 구축할 수 있습니다.
CI 구축 시작하기
"메리 트리스마스" 서비스에서 프론트엔드 CI 구축을 담당하며 어떤 전략이 필요할지 검토해 보았습니다. 먼저 저희 서비스에서는 jest를 아직 도입하지 않았기에 CI 과정에 lint, 빌드 단계가 필요했고, 추가로 Storybook 자동 배포를 추가하고자 했습니다. 디자인 변경 사항이 생길 때마다 Storybook에 배포하는 과정이 번거롭기도 했고, 특히 Storybook에 배포를 하더라도 사이트에 접속하여 변경된 부분을 검토하여 'verified' 버튼을 클릭해 주어야만 배포가 완료되었습니다. 따라서 Storybook 배포도 CI 단계에 추가했습니다.
그전에, 본 글에서는 Github Actions를 "GA"로 축약하여 작성하도록 하겠습니다.
Workflow 세팅
name: ✨ Frontend Dev CI
on:
pull_request:
types: [opened, reopened, synchronize]
먼저 Workflow 세팅으로 PR이 열릴 때, 닫히고 난 뒤 다시 열렸을 때, 새로운 커밋이 등록될 때 CI가 동작하도록 지정합니다.
새로운 커밋 내역을 실시간으로 반영하기 위함입니다.
이제 Workflow를 하나씩 지정해 볼까요?
jobs:
FE_CI:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
actions: write
defaults:
run:
working-directory: ./frontend
outputs:
lint: ${{ steps.yarn_lint_result.outputs.result }}
build: ${{ steps.yarn_build_result.outputs.result }}
* GA 스크립트는 단락 별로 나누어지기 때문에 들여 쓰기에도 오타가 없어야 합니다.
jobs를 통해 Workflow 단계가 등록됩니다. 아래 그림에서 FE_CI, PUBLICH STORYBOOK, GITHUB_BOT_STORYBOOK 단계를 보실 수 있습니다.
permissions
permissions를 통해 레포지토리를 읽고, 빌드를 위한 패키지 작업을 write 권한을 준 뒤, GA 상태를 업데이트하기 위한 actions: write 권한을 주었습니다.
outputs
outputs는 각 단계의 결과물이 저장되는 장소라고 보시면 됩니다. 각 단계별 여러 곳에서 이 결과물들이 쓰이게 됩니다.
jobs:
FE_CI:
..
outputs:
..
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.ref }}
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20.15.1'
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Run lint
id: yarn_lint
run: yarn lint
- name: Save Run lint result
id: yarn_lint_result
run: echo "result=${{steps.yarn_lint.outcome}}" >> $GITHUB_OUTPUT
- name: Run build
id: yarn_build
run: yarn build
- name: Save Run build result
id: yarn_build_result
run: echo "result=${{steps.yarn_build.outcome}}" >> $GITHUB_OUTPUT
FE_CI의 steps를 자세히 살펴보면, 프로젝트를 처음 클론받아 실행할 때와 거의 유사합니다.
PR의 브랜치에 맞게 checkout을 한 뒤, node 버전을 세팅하고 yarn install (npm install)을 통해 의존성을 설치합니다.
--frozen-lockfile
yarn.lock 파일과 프로젝트의 package.json이 일치하지 않는 경우 설치를 실패하게 합니다. 의존성 버전의 일관성을 강제하기 위해 yarn install 시 이 조건을 추가했습니다.
Run lint
lint를 검사하고, error가 발생하게 되면 CI가 종료됩니다.
디버깅을 위해 echo 명령어로 lint 단계의 결과가 GA에 출력되도록 했습니다.
Run build
배포를 위한 빌드를 진행하는 단계입니다.
이곳에서도 디버깅을 위해 echo 명령어로 결과가 GA에 출력되도록 했습니다.
스토리북 배포
jobs:
FE_CI:
...
PUBLISH_STORYBOOK:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./frontend
needs: FE_CI
steps:
- name: Checkout Repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20.15.1'
- name: Verify dependencies
if: steps.cache-deps.outputs.cache-hit != 'true'
run: yarn install --frozen-lockfile
- name: Publish Storybook
id: publish_storybook
run: |
npx chromatic \
--project-token=${{ secrets.CHROMATIC_PROJECT_TOKEN }} \
--storybook-config-dir=.storybook \
--only-changed \
--auto-accept-changes \
| tee chromatic-log.txt
needs 조건을 통해 FE_CI가 통과된 뒤 위 과정이 진행되도록 설정했습니다.
fetch-depth
기본값은 1이며, 이때는 가장 최신 커밋만 가져오게 됩니다. 하지만 변경된 코드들을 스토리북에 반영해야 하므로, 이를 0으로 설정하여 모든 git 히스토리를 가져오도록 설정합니다.
스토리북 배포 과정도 FE_CI 과정과 매우 유사합니다. yarn install (npm install)을 통해 의존성을 설치하고, 스토리북에 배포합니다.
이때, 스토리북에 다른 조건 없이 npx chromatic --project-token ~ 형식으로 기본적으로 제공되는 명령어로만 배포하게 되면 스토리북 배포 과정 판정이 fail이 됩니다. 배포 시 한 번 더 확인을 거치는 단계가 있는데요. 이 과정을 항상 넘길 수 있도록 --auto-accept-changes를 추가합니다.
추가로, --only-changed를 입력하여 변경된 부분만 배포하여 시간을 단축시킬 수 있도록 합니다.
깃허브 봇을 통해 배포된 스토리북 주소를 자동으로 업데이트하기
스토리북의 목적은 코드 결과물을 눈으로 확인하고 자유롭게 소통할 수 있다는 것이 있죠. 이를 수행하기 위해서는 배포된 스토리북 주소가 필요합니다. 하지만 매번 Chromatic에 접속하여 배포된 스토리북 주소 중 내가 배포한 링크를 가져오고 붙여 넣는 과정은 번거롭습니다. 따라서 깃허브 봇을 통해 이를 해결할 수 있습니다.
jobs:
...
PUBLISH_STORYBOOK:
..
steps:
..
- name: Extract Storybook URL
id: set_storybook_url
run: |
storybook_url=$(grep -o 'https://{배포된 스토리북의 고유한 주소 중 앞 부분}-[a-z0-9]*\.chromatic\.com/' chromatic-log.txt | head -n 1)
echo "storybook_url=${storybook_url}" >> $GITHUB_OUTPUT
- name: Get Current Time
uses: josStorer/get-current-time@v2
id: current_time
with:
format: 'YYYY년 MM월 DD일 HH:mm:ss'
utcOffset: '+09:00'
outputs:
storybook_url: ${{ steps.set_storybook_url.outputs.storybook_url }}
current_time: ${{ steps.current_time.outputs.formattedTime }}
스토리북 주소를 storybook_url로 저장하고, 배포된 시각을 current_time에 저장합니다.
스토리북 주소가 배포될 때마다 바뀌어서, 이를 정규식으로 해결했습니다. GA의 로그에 스토리북 배포 주소가 나타나는데,
https://{각 계정/프로젝트마다 고유한 주소}-{문자와 숫자 값}.chromatic.com 으로 이루어져 있습니다.
{문자와 숫자 값}은 스토리북 배포마다 달라지므로 정규식으로 확인하고, 고유한 주소 앞 부분은 스크립트 파일에 작성합니다.
배포된 시각은 말 그대로 스토리북 배포가 된 시각을 의미하는데요. 스토리북이 최신 상태인지, 언제 배포된 버전인지 확인하고 싶을 때를 대비하기 위함입니다. 출력 포맷은 원하는 형태에 따라 변경할 수 있습니다.
jobs:
...
GITHUB_BOT_STORYBOOK:
runs-on: ubuntu-latest
needs: [PUBLISH_STORYBOOK]
permissions:
pull-requests: write
steps:
- name: Record PR Comment
uses: thollander/actions-comment-pull-request@v2
with:
comment_tag: ${{github.event.number}}-storybook
message: |
**Storybook Published**
💄 [View Storybook](${{ needs.PUBLISH_STORYBOOK.outputs.storybook_url }})
🕖 Update Time : ${{ needs.PUBLISH_STORYBOOK.outputs.current_time }}
앞 단계에서 저장한 스토리북 주소와 시각을 출력할 차례입니다.
GITHUB_BOT_STORYBOOK 단계에서 깃허브 봇을 이용하여 PR에 댓글을 달 예정입니다.
needs를 통해 PUBLISH_STORYBOOK 단계가 끝나면 깃허브 봇을 실행합니다.
thollander/actions-comment-pull-request@v2를 이용하여 단계를 진행하고, 이 단계가 실행하게 되면 아래처럼 PR에 댓글이 추가됩니다.
이때, 커밋이 푸시될 때마다 댓글을 다는 것이 아닌, 기존의 댓글을 업데이트 하는 방식으로 진행됩니다. 새로운 댓글로 계속 달게 되는 경우 PR이 지저분해질 수 있기 때문입니다.
CI 최적화
커밋이 반복적으로 쌓여 CI Workflow가 스택에 쌓이고 있을 때 (중복된 CI 통합하기)
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
이전 CI가 종료되기 전 커밋이 반복적으로 push되어 PR에 쌓이게 되면, 그에 따라 새로운 CI Workflow가 GA에 스택처럼 쌓이게 됩니다. 따라서 마지막 CI가 종료될 때까지 해당 PR은 CI가 통과되지 않고 무한히 기다려야 하는 것이죠. 이를 해결하기 위해 concurrency 조건을 넣어 Workflow를 그룹화했습니다. PR의 번호로 그룹화하여 같은 PR에 CI가 여러 개 쌓일 때 기존에 실행되고 있던 Workflow가 있다면 종료하고 새롭게 생성되는 Workflow만 동작하는 과정을 추가했습니다.
의존성 캐싱하기
패키지 install 과정을 매 CI마다 하기에는 시간이 많이 소요됩니다. 특히 스토리북을 배포하는 과정 자체가 시간이 걸리는 작업인데, FE_CI에서 진행했던 의존성 패키지 설치 작업을 스토리북 배포에서도 진행하게 됩니다. 이 과정을 줄이기 위해 FE_CI 단계에서 의존성을 캐싱해 보려고 합니다.
jobs:
FE_CI:
..
steps:
..
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20.15.1'
- name: Cache node_modules
uses: actions/cache@v3
id: cache-deps
with:
path: ./frontend/node_modules
key: ${{ runner.os }}-node_modules-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-node_modules-
- name: Install dependencies
run: yarn install --frozen-lockfile
node 버전을 세팅한 뒤 캐싱을 진행하겠습니다. yarn.lock 파일의 해시를 기반으로 해시 key를 생성하고 node_modules를 GA 캐시로 저장합니다. 저장한 결과물은 Actions의 Caches 탭에서 확인할 수 있습니다.
FE_CI가 성공적으로 진행되어 Storybook 배포 단계가 되었을 때, 캐싱된 의존성을 사용합니다.
jobs:
..
PUBLISH_STORYBOOK:
..
steps:
..
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20.15.1'
- name: Restore node_modules cache
uses: actions/cache@v3
id: cache-deps
with:
path: ./frontend/node_modules
key: ${{ runner.os }}-node_modules-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-node_modules-
- name: Verify dependencies
if: steps.cache-deps.outputs.cache-hit != 'true'
run: yarn install --frozen-lockfile
node 버전을 설정한 뒤, 캐싱된 의존성 파일을 가져옵니다.
이 과정에서 캐싱된 파일이 손상되었거나 누락되어 의존성 파일을 가져올 수 없는 경우가 있는데요. 이 경우 if: steps.cache-deps.outputs.cache-hit != 'true' 조건을 확인하여 캐시가 없는 경우 yarn install을 통해 의존성 패키지를 설치합니다.
이렇게 캐싱된 의존성을 재사용함으로써 CI 시간이 크게 단축되었고, 프로덕션 배포에 사용되는 의존성과 스토리북 배포에 사용되는 의존성을 동일하게 관리할 수 있게 되었습니다.
FE_CI 단계 시간 비교
기존 : 평균 1분
개선 이후 : 평균 23초
스토리북 배포 시간 비교
기존 : 평균 1분 55초
개선 이후 : 1분 ~ 1분 30초 (스토리북에 업데이트 되는 코드에 따라 시간 차이 존재)
의존성 캐싱의 단점
기존에 캐싱된 데이터가 없거나, 새로운 패키지가 추가되는 경우 시간이 크게 증가할 수 있습니다. yarn install을 통해 패키지를 설치하고, 결과물을 GA에 캐싱하는 작업에 시간이 추가로 소요되기 때문입니다.
전체 워크플로우
name: ✨ Frontend Dev CI
on:
pull_request:
types: [opened, reopened, synchronize]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
FE_CI:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
actions: write
defaults:
run:
working-directory: ./frontend
outputs:
lint: ${{ steps.yarn_lint_result.outputs.result }}
build: ${{ steps.yarn_build_result.outputs.result }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.ref }}
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20.15.1'
- name: Cache node_modules
uses: actions/cache@v3
id: cache-deps
with:
path: ./frontend/node_modules
key: ${{ runner.os }}-node_modules-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-node_modules-
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Run lint
id: yarn_lint
run: yarn lint
- name: Save Run lint result
id: yarn_lint_result
run: echo "result=${{steps.yarn_lint.outcome}}" >> $GITHUB_OUTPUT
- name: Run build
id: yarn_build
run: yarn build
- name: Save Run build result
id: yarn_build_result
run: echo "result=${{steps.yarn_build.outcome}}" >> $GITHUB_OUTPUT
PUBLISH_STORYBOOK:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./frontend
needs: FE_CI
steps:
- name: Checkout Repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20.15.1'
- name: Restore node_modules cache
uses: actions/cache@v3
id: cache-deps
with:
path: ./frontend/node_modules
key: ${{ runner.os }}-node_modules-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-node_modules-
- name: Verify dependencies
if: steps.cache-deps.outputs.cache-hit != 'true'
run: yarn install --frozen-lockfile
- name: Publish Storybook
id: publish_storybook
run: |
npx chromatic \
--project-token=${{ secrets.CHROMATIC_PROJECT_TOKEN }} \
--storybook-config-dir=.storybook \
--only-changed \
--auto-accept-changes \
| tee chromatic-log.txt
- name: Extract Storybook URL
id: set_storybook_url
run: |
storybook_url=$(grep -o 'https://{배포된 스토리북의 고유한 주소 중 앞 부분}-[a-z0-9]*\.chromatic\.com/' chromatic-log.txt | head -n 1)
echo "storybook_url=${storybook_url}" >> $GITHUB_OUTPUT
- name: Get Current Time
uses: josStorer/get-current-time@v2
id: current_time
with:
format: 'YYYY년 MM월 DD일 HH:mm:ss'
utcOffset: '+09:00'
outputs:
storybook_url: ${{ steps.set_storybook_url.outputs.storybook_url }}
current_time: ${{ steps.current_time.outputs.formattedTime }}
GITHUB_BOT_STORYBOOK:
runs-on: ubuntu-latest
needs: [PUBLISH_STORYBOOK]
permissions:
pull-requests: write
steps:
- name: Record PR Comment
uses: thollander/actions-comment-pull-request@v2
with:
comment_tag: ${{github.event.number}}-storybook
message: |
**Storybook Published**
💄 [View Storybook](${{ needs.PUBLISH_STORYBOOK.outputs.storybook_url }})
🕖 Update Time : ${{ needs.PUBLISH_STORYBOOK.outputs.current_time }}
'IT (프론트엔드)' 카테고리의 다른 글
패턴으로 알아보는 전역 상태 라이브러리 (1) | 2024.12.23 |
---|---|
Lexical Environment (렉시컬 환경)에 대해 알아보자 (+호이스팅, TDZ) (0) | 2024.12.01 |
[React Deep Dive] 5. 상태 관리 (3) | 2024.11.06 |
CSR vs SSR: 웹 성능 최적화와 혼합 렌더링 방식, Hydration의 역할 (3) | 2024.10.13 |
프론트엔드 에러는 왜 추적해야 할까? (Sentry) (0) | 2024.08.18 |