GitHub Actions로 Node.js CI/CD 파이프라인 만들기 | 테스트·빌드·Docker·배포

GitHub Actions로 Node.js CI/CD 파이프라인 만들기 | 테스트·빌드·Docker·배포

이 글의 핵심

Node.js 앱에 대해 테스트·빌드·컨테이너 이미지·배포를 GitHub Actions 한 파이프라인으로 묶는 실전 워크플로입니다.

들어가며

GitHub Actions는 저장소 이벤트(push, PR, schedule 등)에 반응해 테스트·빌드·배포를 자동화하는 데 널리 쓰입니다. Node.js 서비스는 패키지 설치·린트·유닛 테스트·프런트 빌드·Docker 이미지 생성·레지스트리 푸시·런타임 배포까지 단계가 길어, 이를 한 파이프라인으로 묶어두면 휴먼 에러배포 시간을 동시에 줄일 수 있습니다.

프로덕션에서는 브랜치 보호, 시크릿 관리, 캐시 전략, 실패 시 롤백이 CI/CD 품질을 가릅니다. 이 글은 “로컬에서 되던 것”을 재현 가능한 워크플로 YAML로 옮기는 데 초점을 둡니다.

같은 GitHub Actions 패턴은 언어를 넘어 확장됩니다. 테스트 단계는 Node.js Jest·Python pytest·매트릭스 예제·Go go test와 비교하고, C++ 멀티 OS·캐시는 C++ GitHub Actions를, 배포는 Docker Compose·minikube·C++ Docker와 이어서 읽으면 그림이 맞습니다.

비유로 말씀드리면, CI/CD 파이프라인은 공장 라인에 가깝습니다—원료(clone) → 가공(install·lint·test·build) → 출고(image push) → 납품(deploy) 순서가 문서화되어 있어야 동일 품질이 반복됩니다.

이 글을 읽으면

  • 테스트 → 빌드 → Docker 이미지 → 배포까지 한 흐름으로 설계하는 방법을 익히실 수 있습니다
  • actions/cache, 멀티 스테이지 빌드, 환경별 job 분리 패턴을 배우실 수 있습니다
  • 흔한 실패 원인(권한, 태그, 레지스트리 인증)과 디버깅 팁정리해 두었습니다

목차

  1. 개념: CI/CD와 GitHub Actions
  2. 실전: 단일 워크플로 템플릿
  3. 고급: 재사용 워크플로·매트릭·캐시
  4. 성능: 캐시와 병렬화
  5. 실무 사례
  6. 트러블슈팅
  7. 마무리

개념: CI/CD와 GitHub Actions

기본 개념

  • CI(Continuous Integration): 코드 병합 시마다 자동으로 설치·빌드·테스트해 통합 시점의 문제를 빨리 드러냅니다.
  • CD(Continuous Delivery/Deployment): 검증된 산출물을 스테이징·프로덕션으로 자동(또는 승인 후) 배포합니다.
  • Workflow: YAML로 정의된 이벤트 → 잡(job) → 스텝(step) 의 실행 그래프입니다.
  • Runner: GitHub 호스티드 러너(ubuntu-latest 등) 또는 셀프호스티드 머신에서 잡이 돕니다.

왜 Node.js에 특히 필요한가

패키지 생태계는 lockfile, Node 버전, 네이티브 모듈에 민감합니다. 동일한 package-lock.json과 동일한 Node 버전으로 CI에서 돌리지 않으면 “내 PC에서는 됨”이 반복됩니다. Docker까지 포함하면 런타임 OS·의존성까지 고정할 수 있습니다.


실전: 단일 워크플로 템플릿

아래는 PR에서는 테스트만, main 병합 시 이미지 빌드·레지스트리 푸시·배포(SSH 예시)까지 이어지는 예시입니다. 프로젝트 루트에 .github/workflows/ci-cd.yml로 둡니다.

# .github/workflows/ci-cd.yml
name: CI/CD Node.js

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]
  workflow_dispatch: {}

env:
  NODE_VERSION: '22'
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  test:
    name: Lint & Test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Use Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Lint
        run: npm run lint --if-present

      - name: Unit tests
        run: npm test --if-present

      - name: Build (if applicable)
        run: npm run build --if-present

  build-and-push:
    name: Docker Build & Push
    needs: test
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Docker metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=
            type=raw,value=latest,enable={{is_default_branch}}

      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}

  deploy:
    name: Deploy (example SSH)
    needs: build-and-push
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Deploy via SSH
        uses: appleboy/[email protected]
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.DEPLOY_SSH_KEY }}
          script: |
            docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
            cd /opt/app && docker compose up -d --no-deps api

동작 요약

  • on.pull_request / on.push: 이벤트·브랜치에 맞춰 워크플로가 돕니다. PR과 main 푸시 모두에서 test 잡이 실행됩니다.
  • workflow_dispatch: 수동 실행 버튼을 열어 배포·재시도에 씁니다.
  • jobs.testbuild-and-pushdeploy 순: needs: test테스트 통과 후에만 빌드가 이어집니다.
  • build-and-push.if: main 브랜치에 push일 때만 이미지를 만들어 PR에선 레지스트리 푸시를 생략합니다.
  • permissions.packages: write: 기본 GITHUB_TOKEN으로 GHCR 푸시가 가능하도록 씁니다.
  • docker/metadata-actiontags: 커밋 SHA 태그latest를 동시에 쓰면 추적·롤백이 쉬워집니다.
  • deploy 잡은 예시로 SSH 후 docker compose up -d --no-deps apiAPI만 재기동합니다(-d는 백그라운드, --no-deps의존 서비스(Postgres 등)는 건드리지 않음). 실제로는 Kubernetes kubectl, PaaS CLI 등으로 바꿉니다.

실행 가능한 최소 앱 가정

package.json에 다음 스크립트가 있다고 가정합니다(없으면 해당 step을 제거하세요).

{
  "scripts": {
    "lint": "eslint .",
    "test": "node --test",
    "build": "tsc",
    "start": "node dist/index.js"
  }
}

Dockerfile 예시 (멀티 스테이지)

# Dockerfile
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]
  • deps 스테이지: npm ci --omit=dev프로덕션 의존성만 깔아 최종 이미지에 넣습니다.
  • build 스테이지: devDependencies 포함으로 tsc 등 빌드 도구를 쓰고, 산출물은 dist에만 남깁니다.
  • runner: 런타임에 필요한 파일·모듈만 복사해 이미지 크기와 공격 면을 줄입니다.

고급: 재사용 워크플로·매트릭·캐시

재사용 워크플로

여러 저장소에서 동일한 테스트 단계를 쓰려면 .github/workflows/reusable-test.ymlworkflow_call을 두고 호출합니다.

# .github/workflows/reusable-test.yml
on:
  workflow_call:
    inputs:
      node-version:
        required: true
        type: string

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
          cache: npm
      - run: npm ci && npm test

Node·OS 매트릭 (크로스 테스트)

strategy:
  fail-fast: false
  matrix:
    node: [20, 22]
    os: [ubuntu-latest, windows-latest]

npm 캐시

actions/setup-nodecache: npm만으로도 대부분 충분합니다. 모노레포에서는 cache-dependency-path로 lockfile 경로를 지정합니다.


성능: 캐시와 병렬화

항목효과비고
npm ci + lockfile설치 재현성CI 표준
actions/setup-node cachenode_modules 복원 시간 단축대부분의 Node 워크플로에 적용
Docker BuildKit + 레이어 캐시이미지 빌드 시간 단축cache-from/cache-to 또는 레지스트리 캐시
잡 병렬화PR 피드백 시간 단축lint/test를 분리할 때 유의

트레이드오프: 매트릭을 늘리면 분 단위 요금(유료 한도)큐 대기가 늘 수 있습니다. 릴리스 브랜치에만 넓게 쓰는 편이 경제적일 때가 많습니다.


실무 사례

  • 스테이징 자동 + 프로덕션 수동 승인: environment: productionRequired reviewers를 두면 CD 단계가 승인 후에만 진행됩니다.
  • 태그 릴리스: on: push: tags: ['v*']로 시맨틱 버전 태그에만 이미지 v1.2.3을 박아 배포합니다.
  • 마이그레이션: 배포 직전 잡에서 npm run migrate를 실행하되, 락 타임아웃롤백 전략을 문서화합니다.

트러블슈팅

증상원인해결
permission denied GHCR 푸시GITHUB_TOKEN 권한 부족잡에 permissions: packages: write
npm ci 실패lockfile 불일치로컬에서 npm install 후 lockfile 커밋
다른 Node에서만 실패버전 차이engines 필드 + setup-node 버전 고정
Docker 빌드만 느림캐시 없음BuildKit 캐시 또는 --cache-from 이미지
배포는 됐는데 구 버전태그 latest만 당김SHA 태그 배포 또는 이미지 다이제스트 고정

디버깅 팁: 실패한 잡에서 Re-run with debug logging을 켜고, 동일 컨테이너를 로컬에서 act(도구)로 재현할 수 있는지 확인합니다.


마무리

  • 한 파이프라인으로 테스트·빌드·이미지·배포를 묶으시면 반복 작업과 실수를 줄일 수 있습니다.
  • npm ci, Node 버전 고정, Docker 멀티 스테이지는 프로덕션 CI/CD의 기본 삼종 세트에 가깝습니다.
  • 다음 단계로는 Docker Compose로 Node API·DB·Redis 한 번에 띄우기로 로컬·스테이징 환경을 맞추고, Node.js 배포 가이드와 함께 읽으면 운영 그림이 완성됩니다.

프로덕션 체크리스트

  • 시크릿: DEPLOY_HOST 등은 저장소 Secrets에만 두고, 로그에 노출되지 않게 합니다.
  • 이미지 참조: 가능하면 latest이 아니라 SHA 또는 다이제스트로 배포해 재현성을 높입니다.
  • 마이그레이션: 배포 직전 DB 마이그레이션 실패 시 롤백 절차를 팀에 공유합니다.