GitHub Actions CI/CD — 워크플로우 작성부터 실전 배포까지
GitHub Actions로 CI/CD 파이프라인 구축하기. 워크플로우 문법, 실전 예제, 비용 절약 팁까지 정리.
코드를 push하면 테스트가 자동으로 돌고, main에 머지하면 프로덕션에 배포된다. CI/CD의 기본 개념인데, 이걸 직접 구축하려고 하면 Jenkins 같은 별도 서버를 세팅해야 했다. GitHub Actions는 이 과정을 GitHub 안에서 해결해준다.
리포지토리에 YAML 파일 하나 추가하면 끝. 별도 서버 없이, GitHub 계정만으로 CI/CD가 돌아간다.
워크플로우 기본 구조
.github/workflows/ 폴더에 YAML 파일을 넣으면 GitHub가 자동으로 인식한다. 가장 단순한 형태부터 보자.
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm test
on이 트리거, jobs가 실제 작업, steps가 각 단계. 이 파일을 리포지토리에 커밋하는 순간부터 push나 PR이 올라올 때마다 테스트가 자동으로 돌아간다.
한 가지 주의할 점 — npm install이 아니라 npm ci를 쓴다. ci는 package-lock.json 기준으로 정확한 버전을 설치하고, node_modules를 처음부터 새로 만든다. CI 환경에서는 재현성이 중요하니까 ci가 맞다.
트리거 — 언제 실행할 건지
on 필드에서 워크플로우가 실행되는 조건을 정한다. 자주 쓰는 패턴 몇 가지:
# 특정 경로가 변경됐을 때만 실행
on:
push:
paths:
- 'src/**'
- 'package.json'
# 스케줄 (크론) — 매일 새벽 3시 UTC
on:
schedule:
- cron: '0 3 * * *'
# 수동 실행 (workflow_dispatch)
on:
workflow_dispatch:
inputs:
environment:
description: '배포 환경'
required: true
default: 'staging'
type: choice
options:
- staging
- production
paths 필터는 모노레포에서 특히 유용하다. 프론트엔드 코드만 바꿨는데 백엔드 테스트까지 돌 필요는 없으니까. workflow_dispatch는 버튼 하나로 수동 실행할 수 있게 해주는데, 배포 타이밍을 직접 제어하고 싶을 때 쓴다.
잡(Job)과 스텝(Step)
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run lint
test:
runs-on: ubuntu-latest
needs: lint # lint가 성공해야 실행
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test
deploy:
runs-on: ubuntu-latest
needs: test
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
- run: ./deploy.sh
needs로 의존 관계를 설정한다. 위 예시에서는 lint → test → deploy 순서로 실행된다. needs가 없는 잡들은 병렬로 돌아간다.
if 조건으로 특정 상황에서만 실행할 수도 있다. deploy 잡은 main 브랜치에 push될 때만 실행되게 했다. PR에서는 테스트만 돌리고 배포는 안 하는 거다.
시크릿과 환경 변수
API 키나 배포 토큰 같은 민감한 값은 리포지토리 Settings → Secrets에 등록하고, 워크플로우에서 참조한다.
steps:
- name: Deploy
env:
API_KEY: ${{ secrets.API_KEY }}
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: |
echo "Deploying with token..."
./deploy.sh
시크릿은 로그에 자동으로 마스킹된다. echo $API_KEY 해도 ***로 표시됨. 근데 시크릿을 base64 인코딩한다거나 문자열을 쪼개서 출력하면 마스킹이 안 될 수 있으니까, 시크릿 값을 직접 출력하는 코드는 아예 쓰지 않는 게 좋다.
환경(Environment)을 분리해서 관리할 수도 있다. production 환경에는 승인(approval)을 걸어두면 실수로 프로덕션에 배포하는 걸 방지할 수 있다.
캐싱 — 빌드 시간 단축
매번 npm ci로 패키지를 처음부터 설치하면 시간이 꽤 걸린다. 캐싱을 쓰면 이전 실행의 node_modules를 재사용할 수 있다.
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm' # 이 한 줄이면 된다
- run: npm ci
- run: npm test
actions/setup-node@v4에 cache: 'npm'만 추가하면 package-lock.json 기준으로 자동 캐싱된다. 캐시가 있으면 npm ci 시간이 몇 십 초에서 몇 초로 줄어든다.
Turbo나 nx 같은 빌드 도구를 쓰는 경우 빌드 캐시도 별도로 캐싱하면 효과가 크다.
- uses: actions/cache@v4
with:
path: .turbo
key: turbo-${{ runner.os }}-${{ hashFiles('**/turbo.json') }}
실전 예제 — Next.js 프로젝트
실제 Next.js 프로젝트에서 쓸 법한 워크플로우 예시.
name: CI/CD
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- name: Lint
run: npm run lint
- name: Type Check
run: npx tsc --noEmit
- name: Build
run: npm run build
preview:
runs-on: ubuntu-latest
needs: quality
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
- uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
production:
runs-on: ubuntu-latest
needs: quality
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
vercel-args: '--prod'
PR이 올라오면 lint + 타입 체크 + 빌드를 돌리고, 통과하면 Vercel 프리뷰 배포. main에 머지되면 프로덕션 배포. Vercel을 안 쓴다면 preview/production 잡을 자기 배포 방식에 맞게 바꾸면 된다.
매트릭스 전략
여러 Node.js 버전이나 OS에서 테스트해야 할 때:
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm ci
- run: npm test
이렇게 하면 2(OS) × 3(Node 버전) = 6개 조합이 병렬로 돌아간다. 라이브러리를 만들 때 호환성 테스트에 유용하고, 일반 웹 앱이라면 보통 하나의 조합만으로 충분하다.
비용과 제한
퍼블릭 리포지토리는 무료다. 시간 제한도 거의 없다. 오픈소스라면 걱정할 게 없다.
프라이빗 리포지토리는 월 2,000분이 무료로 제공된다 (Free 플랜 기준). 넘으면 분당 과금. 근데 2,000분이면 웬만한 개인 프로젝트에서는 넉넉하다. 빌드 시간이 5분짜리 워크플로우를 하루에 10번 돌려도 한 달에 1,500분.
비용을 줄이는 팁:
paths필터로 불필요한 실행 줄이기- 캐싱 적극 활용
concurrency설정으로 같은 브랜치의 이전 실행 자동 취소
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
이 설정이면 같은 브랜치에서 새 push가 오면 이전에 돌던 워크플로우가 자동으로 취소된다. PR에 push를 연속으로 할 때 불필요한 실행을 막아준다.
자주 하는 실수
시크릿을 fork한 리포지토리의 PR에서 접근하려는 것. 보안상 fork된 PR에서는 시크릿에 접근할 수 없다. 이건 의도된 동작이다. fork PR에서 시크릿이 필요한 작업을 하려면 pull_request_target 이벤트를 써야 하는데, 이것도 보안 위험이 있어서 신중하게 써야 한다.
워크플로우 파일에 문법 오류. YAML은 들여쓰기에 민감하다. 스페이스 하나 틀리면 워크플로우가 아예 실행이 안 된다. VS Code의 GitHub Actions 확장 프로그램을 쓰면 문법 검증을 해주니까 설치해두는 게 좋다.
actions/checkout을 빠뜨리는 것. 당연히 있을 거라고 생각하기 쉬운데, 명시적으로 써줘야 한다. 이게 없으면 코드가 아예 없는 상태에서 스텝이 실행된다.
GitHub Actions는 한번 세팅해두면 계속 돌아가는 인프라다. 처음에 YAML 작성하는 게 좀 번거롭지만, 수동으로 테스트 돌리고 배포하는 시간을 생각하면 초기 투자 대비 효과가 크다.