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

이 글의 핵심

배포 잡은 성공했는데 서버에는 예전 버전이 떠 있는 문제처럼 CI/CD는 초록 체크만으로 안심할 수 없습니다. 이미지 태그를 커밋 SHA로 고정해야 하는 이유, 시크릿을 환경별로 분리하는 방법, 캐시와 병렬화로 파이프라인 시간을 줄이는 방법을 짚고 프로덕션 체크리스트로 정리합니다.

들어가며

GitHub Actions는 저장소 이벤트(push, PR, schedule 등)에 반응해 테스트·빌드·배포를 자동화하는 데 널리 사용됩니다. Node.js 서비스는 패키지 설치·린트·유닛 테스트·프런트 빌드·Docker 이미지 생성·레지스트리 푸시·런타임 배포까지 단계가 길어, 이를 한 파이프라인으로 묶어두면 휴먼 에러와 배포 시간을 동시에 줄일 수 있습니다. 프로덕션에서는 브랜치 보호, 시크릿 관리, 캐시 전략, 실패 시 롤백이 CI/CD 품질을 가릅니다. 이 글은 “로컬에서 되던 것”을 재현 가능한 워크플로 YAML로 옮기는 데 초점을 둡니다. 같은 GitHub Actions 패턴은 언어를 넘어 확장됩니다. 테스트 단계는 Node.js Jest·Python 환경 설정·Go go test와 비교하며, C++ 멀티 OS·캐시는 C++ GitHub Actions를, 배포는 Docker Compose·minikube·C++ Docker와 이어서 읽으면 그림이 맞습니다.


개념: 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=,format=long
            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 }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
  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
    concurrency:
      group: deploy-production
      cancel-in-progress: false
    steps:
      - name: Deploy via SSH
        uses: appleboy/[email protected]
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.DEPLOY_SSH_KEY }}
          # compose 파일의 api 이미지는 ghcr.io/<owner>/<repo>:${IMAGE_TAG} 형태로 둔다고 가정
          script: |
            cd /opt/app
            export IMAGE_TAG=${{ github.sha }}
            docker compose pull api
            docker compose up -d --no-deps api

동작 요약

  • on.pull_request / on.push: 이벤트·브랜치에 맞춰 워크플로가 돕니다. PR과 main 푸시 모두에서 test 잡이 실행됩니다.
  • workflow_dispatch: 수동 실행 버튼을 열어 배포·재시도에 씁니다.
  • jobs.test → build-and-push → deploy 순: needs: test로 테스트 통과 후에만 빌드가 이어집니다.
  • build-and-push.if: main 브랜치에 push일 때만 이미지를 만들어 PR에선 레지스트리 푸시를 생략합니다.
  • permissions.packages: write: 기본 GITHUB_TOKEN으로 GHCR 푸시가 가능하도록 씁니다.
  • docker/metadata-action의 tags: 커밋 SHA 태그와 latest를 동시에 쓰면 추적·롤백이 쉬워집니다.
  • deploy 잡은 예시로 SSH 후 docker compose up -d --no-deps api로 API만 재기동합니다(-d는 백그라운드, --no-deps는 의존 서비스(Postgres 등)는 건드리지 않음). 실제로는 Kubernetes kubectl, PaaS CLI 등으로 바꿉니다.
  • cache-from/cache-to: type=gha: BuildKit 레이어 캐시를 GitHub Actions 캐시에 저장해, 의존성이 바뀌지 않은 빌드에서는 npm ci 레이어를 다시 실행하지 않습니다.
  • concurrency: main에 연달아 두 번 머지되었을 때 배포 두 개가 동시에 서버를 건드리지 않도록 순서대로 실행합니다.

배포 스크립트에서 latest 대신 커밋 SHA 태그를 당기도록 한 것이 이 워크플로의 핵심입니다. latest를 쓰면 “배포 잡은 초록색인데 서버에는 예전 버전”이라는 증상이 자주 생깁니다. 서버에 이미 latest 이미지가 있으면 compose가 새로 받지 않거나, 두 배포가 겹쳐서 늦게 끝난 빌드의 이미지가 앞선 배포를 덮어쓰기 때문입니다. SHA로 고정하면 지금 서버에서 도는 코드가 어느 커밋인지 docker ps만 봐도 알 수 있고, 롤백은 이전 SHA로 IMAGE_TAG만 바꿔 다시 up하면 됩니다. format=long을 준 이유는 github.sha(40자)와 태그를 정확히 맞추기 위해서입니다.

IMAGE_NAME을 github.repository로 두면 조직이나 저장소 이름에 대문자가 있을 때 repository name must be lowercase 에러가 납니다. metadata-action이 만드는 태그는 자동으로 소문자로 바뀌지만, 스크립트에서 직접 이미지 이름을 조합하는 곳에서는 이 변환이 일어나지 않으므로 compose 파일이나 스크립트에 이미지 이름을 적을 때는 소문자로 적어야 합니다.

실행 가능한 최소 앱 가정

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.yml에 workflow_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-node의 cache: npm만으로도 대부분 충분합니다. 모노레포에서는 cache-dependency-path로 lockfile 경로를 지정합니다.

이 캐시가 저장하는 것은 node_modules가 아니라 npm의 다운로드 캐시(~/.npm)입니다. 그래서 캐시가 맞아도 npm ci는 매번 node_modules를 지우고 다시 설치하며, 줄어드는 것은 네트워크 다운로드 시간입니다. node_modules 자체를 캐시하는 방법도 있지만, Node 버전이나 OS가 바뀌면 네이티브 모듈이 맞지 않아 알 수 없는 런타임 에러가 날 수 있으므로 캐시 키에 Node 버전과 OS를 반드시 포함해야 합니다. lockfile이 없으면 setup-node가 캐시 키를 만들지 못해 Dependencies lock file is not found 에러로 멈추는 것도 흔히 겪는 문제입니다.


성능: 캐시와 병렬화

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

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


실무 사례

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

트러블슈팅

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

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

보안에서 자주 놓치는 것

포크에서 올라온 PR의 pull_request 이벤트에는 저장소 시크릿이 전달되지 않고 GITHUB_TOKEN도 읽기 권한만 받습니다. 그래서 외부 기여자의 PR에서 배포 단계가 실패하는 것은 정상이며, 이를 우회하려고 pull_request_target으로 바꾸고 PR 코드를 체크아웃해 실행하면 외부인이 작성한 코드가 시크릿에 접근할 수 있게 됩니다. GitHub가 공식 문서에서 경고하는 가장 대표적인 사고 유형입니다.

서드파티 액션을 @v1 같은 태그로 참조하는 것도 위험 요소입니다. 태그는 옮길 수 있기 때문에, 액션 저장소가 탈취되면 같은 태그가 악성 코드를 가리킬 수 있습니다. 2025년 tj-actions/changed-files 액션이 탈취되어 여러 저장소의 CI 로그에 시크릿이 노출된 사건이 실제 사례입니다. SSH 키처럼 민감한 시크릿을 넘기는 액션은 커밋 SHA로 고정(uses: appleboy/ssh-action@<commit-sha>)하고, Dependabot으로 갱신을 관리하는 편이 안전합니다. 워크플로 최상단에 permissions: contents: read를 두고 필요한 잡에서만 권한을 올리는 것도 같은 맥락입니다.


마무리

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

프로덕션 체크리스트

  • 시크릿: DEPLOY_HOST 등은 저장소 Secrets에만 두며, 로그에 노출되지 않게 합니다.
  • 이미지 참조: 가능하면 latest만이 아니라 SHA 또는 다이제스트로 배포해 재현성을 높입니다.
  • 마이그레이션: 배포 직전 DB 마이그레이션 실패 시 롤백 절차를 팀에 공유합니다.
  • 정적 사이트: Astro·Hugo 같은 정적 생성기는 Astro 블로그 가이드와 Cloudflare Pages로 더 간단하게 배포할 수 있습니다.

자주 묻는 질문 (FAQ)

Q. 배포는 성공했는데 서버에 예전 버전이 떠 있으면 왜 그런가요?

A. 이미지를 latest 태그로만 푸시하고 당기면, 노드에 캐시된 이미지가 그대로 쓰이거나 어느 빌드인지 추적이 안 되는 문제가 생깁니다. 커밋 SHA로 태그를 붙여 배포하거나 이미지 다이제스트를 고정하면 어떤 코드가 배포됐는지 명확해지고 롤백도 쉬워집니다. GHCR 푸시에서 permission denied가 난다면 잡에 permissions: packages: write가 있는지도 확인합니다.


같이 보면 좋은 글