CI/CD를 처음 셋업한 건 입사 3개월 차였다. 그 전까지 우리 팀의 배포 프로세스는 이랬다.
- 개발자가 로컬에서
npm run build실행 - 빌드된 파일을 S3에 수동 업로드
- CloudFront 캐시 무효화를 AWS 콘솔에서 클릭
매주 금요일 오후에 이 작업을 하는데, 한 번은 빌드를 잘못된 브랜치에서 하는 바람에 개발 중인 피처가 프로덕션에 배포된 적이 있다. 다행히 10분 만에 발견했지만, 그 뒤로 "이건 자동화해야 한다"는 공감대가 형성됐다.
린트 + 타입 체크
가장 먼저 한 건 PR에 린트와 타입 체크를 붙이는 거였다. 코드 리뷰에서 "여기 세미콜론 빠졌네요" 같은 코멘트를 안 달아도 되게 하는 게 목적이었다.
# .github/workflows/ci.yml
name: CI
on:
pull_request:
branches: [main, develop]
jobs:
lint-and-typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- run: npm ci
- name: ESLint
run: npx eslint . --max-warnings 0
- name: TypeScript
run: npx tsc --noEmit
- name: Prettier
run: npx prettier --check "src/**/*.{ts,tsx,css}"--max-warnings 0이 중요하다. 이걸 안 넣으면 warning이 수백 개 쌓여도 CI가 통과한다. warning은 "나중에 고칠 것"이 아니라 "지금 고칠 것"이어야 한다. 안 그러면 영원히 안 고친다.
npm ci는 npm install과 다르다. package-lock.json을 기준으로 정확한 버전을 설치한다. CI에서는 항상 npm ci를 써야 한다. npm install을 쓰면 lock 파일과 다른 버전이 설치되는 경우가 있다.
처음에 이것만 돌렸는데, 실행 시간이 3분 정도 걸렸다. 대부분 npm ci에서 소모되는 시간이었다. actions/setup-node의 cache: "npm" 옵션으로 node_modules 캐싱을 하면 1분 정도로 줄어든다.
테스트
다음으로 테스트를 추가했다. 테스트를 린트와 별개의 job으로 분리한 이유가 있다.
jobs:
lint-and-typecheck:
runs-on: ubuntu-latest
steps:
# ... 위와 동일
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- run: npm ci
- name: Unit & Integration Tests
run: npx vitest run --coverage
- name: Upload Coverage
uses: actions/upload-artifact@v4
if: always()
with:
name: coverage-report
path: coverage/job을 분리하면 병렬로 실행된다. 린트가 끝날 때까지 기다렸다가 테스트를 돌리는 게 아니라, 동시에 돌아간다. 전체 CI 시간이 줄어든다.
if: always()는 테스트가 실패해도 커버리지 리포트를 업로드하라는 뜻이다. 실패했을 때 어디가 문제인지 리포트를 보고 파악하기 위해서.
빌드
빌드는 테스트와 린트가 모두 통과한 뒤에 실행되어야 한다. needs 키워드를 쓴다.
build:
runs-on: ubuntu-latest
needs: [lint-and-typecheck, test]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- run: npm ci
- name: Build
run: npm run build
env:
NEXT_PUBLIC_API_URL: ${{ vars.API_URL }}
NEXT_PUBLIC_GA_ID: ${{ vars.GA_ID }}
- name: Upload Build
uses: actions/upload-artifact@v4
with:
name: build-output
path: .next/
retention-days: 1환경 변수를 GitHub의 Variables에서 관리한다. secrets는 로그에 마스킹되는 비밀 값(API 키 등)에 쓰고, vars는 공개되어도 괜찮은 설정 값에 쓴다. NEXT_PUBLIC_으로 시작하는 환경 변수는 클라이언트 번들에 포함되는 값이니까 vars로 충분하다.
빌드 아티팩트를 upload-artifact로 올리는 이유는, 배포 job에서 다시 빌드하지 않기 위해서다. 빌드는 한 번만.
배포
main 브랜치에 merge되면 자동 배포를 한다. Vercel을 쓰면 이 과정이 거의 필요 없는데, 우리는 AWS를 쓰고 있어서 직접 구성해야 했다.
deploy:
runs-on: ubuntu-latest
needs: [build]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment: production
steps:
- uses: actions/download-artifact@v4
with:
name: build-output
path: .next/
- name: Configure AWS
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ap-northeast-2
- name: Deploy to S3
run: aws s3 sync .next/static s3://${{ vars.S3_BUCKET }}/_next/static --delete
- name: Invalidate CloudFront
run: |
aws cloudfront create-invalidation \
--distribution-id ${{ vars.CF_DISTRIBUTION_ID }} \
--paths "/*"environment: production을 설정하면 GitHub에서 환경별로 시크릿을 관리할 수 있고, 배포 이력도 추적된다. 그리고 환경에 protection rule을 걸어서 특정 사람의 승인이 있어야 배포가 진행되게 할 수도 있다.
처음에는 --paths "/*"로 전체 캐시를 무효화했는데, 이러면 CDN 비용이 올라간다. 나중에 변경된 파일만 무효화하도록 개선했다. Next.js의 _next/static 경로는 해시가 포함되어 있어서 무효화할 필요가 없고, index.html 같은 진입점만 무효화하면 된다.
Preview 배포
PR마다 프리뷰 환경을 만드는 건 코드 리뷰의 질을 높여줬다. 코드만 보는 것과 실제 화면을 보는 건 차이가 크다.
preview:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
needs: [build]
steps:
- uses: actions/download-artifact@v4
with:
name: build-output
path: .next/
# 프리뷰 배포 로직 (Vercel, Netlify, 또는 S3 + 별도 도메인)
- name: Comment PR
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `🔗 Preview: https://preview-${context.issue.number}.example.com`
})PR에 프리뷰 URL이 코멘트로 달린다. 코드 리뷰어가 브랜치를 checkout 받아서 로컬에서 빌드할 필요가 없다. 링크만 클릭하면 된다. 디자이너나 PM도 바로 확인할 수 있어서 피드백 루프가 짧아졌다.
삽질했던 것들
node_modules 캐시가 안 맞는 문제. package-lock.json의 해시를 캐시 키로 쓰는데, OS 버전이 달라지면 네이티브 바이너리가 호환이 안 될 수 있다. runs-on을 바꿨더니 캐시가 터진 적이 있다.
환경 변수 빠뜨림. 로컬에서는 .env.local에 있어서 잘 되는데, CI에서 환경 변수를 안 넣어서 빌드가 깨지는 경우. 환경 변수가 추가될 때마다 GitHub Settings에도 넣어야 한다는 걸 잊는다. 이것 때문에 CI에서 빌드 직전에 필수 환경 변수가 있는지 체크하는 스크립트를 넣었다.
#!/bin/bash
# scripts/check-env.sh
required_vars=("NEXT_PUBLIC_API_URL" "NEXT_PUBLIC_GA_ID")
for var in "${required_vars[@]}"; do
if [ -z "${!var}" ]; then
echo "ERROR: $var is not set"
exit 1
fi
done
echo "All required environment variables are set"시크릿 노출. echo로 디버깅하다가 시크릿을 로그에 찍은 적이 있다. GitHub Actions는 secrets으로 등록된 값은 로그에서 자동 마스킹해주지만, 그 값을 가공한 결과는 마스킹이 안 될 수 있다. CI에서 디버깅할 때는 echo를 되도록 안 쓰는 게 좋다.
CI 시간 최적화
최종적으로 전체 CI 파이프라인이 PR 기준 4분, 배포 기준 6분 정도 걸린다. 처음에 10분 넘게 걸렸던 걸 줄인 방법은 이렇다.
npm ci캐싱: 2분 → 30초- 린트/테스트 병렬 실행: 순차 5분 → 병렬 3분
- 빌드 아티팩트 재사용: 배포 시 빌드 중복 제거
더 줄이려면 테스트를 sharding해서 여러 머신에서 나눠 돌리는 방법이 있다. 아직 테스트가 그 정도로 많지 않아서 안 했지만, 테스트가 200개를 넘어가면 고려해야 할 것 같다.
CI/CD를 한 번 제대로 구축해놓으면 팀 전체의 생산성이 올라간다. "빌드 돌리는 동안 기다리는 시간"이 사라지고, "배포하다가 실수하면 어쩌지"라는 불안감도 없어진다. 투자 대비 리턴이 확실한 작업 중 하나다.
