GitHub Actions로 Node.js 프로젝트 CI 구축하기

1. 개요

누군가가 코드를 푸시하거나 풀 리퀘스트를 열 때마다, 그 코드가 여전히 빌드되고 테스트가 통과하는지 즉시 알아야 합니다. 이것은 자동으로 이루어져야 하며, 각 개인의 컴퓨터에 의존해서는 안 됩니다. 이 글은 GitHub Actions를 사용하여 Node.js 프로젝트의 CI 파이프라인을 구축하는 방법을 안내합니다. 이 글을 읽으면, 캐시가 적용된 의존성 설치, 린트 실행, 여러 Node 버전에서의 테스트 실행, 커버리지 리포트 생성, 그리고 오래된 실행을 자동으로 취소하는 워크플로우를 갖게 될 것입니다.

2. 샘플 프로젝트 준비

주문의 총액을 계산하는 간단한 모듈을 사용하겠습니다. 먼저 package.json입니다:

{
  "name": "order-service",
  "version": "1.0.0",
  "type": "module",
  "engines": {
    "node": ">=22"
  },
  "scripts": {
    "lint": "eslint src test",
    "test": "node --test",
    "test:coverage": "node --test --experimental-test-coverage --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=lcov.info"
  }
}

이 프로젝트는 Node의 내장 테스트 러너(node:test)를 사용하므로 Jest나 Mocha를 설치할 필요가 없습니다. "type": "module"은 import/export 구문을 사용할 수 있게 해줍니다. test:coverage 스크립트는 두 개의 리포터를 동시에 사용합니다. 한 리포터는 결과를 콘솔에 출력합니다. 다른 리포터는 lcov.info 파일에 작성하여 나중에 보관합니다.

비즈니스 로직은 src/order.js에 있습니다:

export function calculateTotal(items, discountPercent = 0) {
  if (discountPercent < 0 || discountPercent > 100) {
    throw new RangeError('할인율은 0–100 범위에 있어야 합니다');
  }
  const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  return Math.round(subtotal * (100 - discountPercent)) / 100;
}

이 함수는 각 항목의 price * quantity를 더한 다음, 할인 금액을 뺍니다. 할인율이 0–100 범위를 벗어나면 함수는 RangeError를 발생시킵니다.

해당 테스트는 test/order.test.js에 있습니다:

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { calculateTotal } from '../src/order.js';

test('할인이 있는 주문 합계 계산', () => {
  const items = [
    { price: 100000, quantity: 2 },
    { price: 50000, quantity: 1 },
  ];
  assert.equal(calculateTotal(items, 10), 225000);
});

test('할인율이 유효하지 않으면 오류 발생', () => {
  assert.throws(() => calculateTotal([], 120), RangeError);
});

node --test 명령은 자동으로 .test.js 확장명을 가진 파일을 찾습니다. 로컬 머신에서 테스트해보세요:

npm install --save-dev eslint @eslint/js
npm test
✔ 할인이 있는 주문 합계 계산 (0.9ms)
✔ 할인율이 유효하지 않으면 오류 발생 (0.3ms)
ℹ tests 2
ℹ pass 2
ℹ fail 0

ESLint 9는 flat config를 사용합니다. 프로젝트 루트 디렉토리에 eslint.config.js 파일을 생성하세요:

import js from '@eslint/js';

export default [js.configs.recommended];

마지막으로, package-lock.json 파일을 커밋하세요. 다음 섹션에서 보시겠지만, CI가 이 파일을 필요로 합니다.

3. 첫 번째 CI 워크플로우

GitHub Actions는 .github/workflows/ 디렉토리의 YAML 파일을 읽습니다. .github/workflows/ci.yml 파일을 생성하세요:

name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm

      - run: npm ci
      - run: npm test

파일의 각 부분의 역할은 다음과 같습니다:

  • on: 워크플로우는 main으로의 푸시와 모든 브랜치로의 풀 리퀘스트 시에 실행됩니다.
  • runs-on: ubuntu-latest: 작업은 GitHub에서 제공하는 새로운 Linux 가상 머신에서 실행됩니다.
  • actions/checkout@v4: 소스 코드를 가상 머신으로 복제합니다.
  • actions/setup-node@v4: Node 22를 설치합니다. cache: npm 옵션은 npm의 캐시 디렉토리를 저장하며, package-lock.json의 해시를 키로 사용합니다. 이후 실행 시, 패키지는 레지스트리에서 다시 다운로드하는 대신 캐시에서 가져옵니다.
  • npm ci: lockfile과 정확히 같은 의존성을 설치합니다.
  • npm test: 테스트를 실행합니다. 명령이 0이 아닌 exit 코드를 반환하면, 작업이 실패하고 풀 리퀘스트가 빨간색으로 표시됩니다.

이 파일을 푸시한 후, 저장소의 Actions 탭으로 이동하여 로그를 확인하세요. 두 번째 실행에서는 setup-node 단계가 다음과 유사한 줄을 출력할 것입니다:

Cache restored successfully
Cache restored from key: node-cache-Linux-x64-npm-3f9a1c...

주의: CI에서 npm install을 사용하지 마세요. npm install은 lockfile을 업데이트하고 머신의 버전과 다른 버전을 설치할 수 있습니다. npm ci는 node_modules를 삭제하고, lockfile에 따라 정확히 설치하며, package.json과 package-lock.json이 일치하지 않으면 즉시 오류를 보고합니다. 이 동작이 있기 때문에 누군가가 lockfile을 커밋하는 것을 잊은 경우 조기에 발견할 수 있습니다.

4. 여러 Node 버전에서 테스트 실행

라이브러리 또는 서비스가 여러 Node 버전에서 실행되어야 할 수 있습니다. matrix 전략은 각 버전에 대해 작업을 복제하는 데 도움이 됩니다:

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        node-version: [22, 24]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: npm
      - run: npm ci
      - run: npm test

GitHub는 두 개의 병렬 작업을 생성하는데, 이름은 test (22)와 test (24)입니다. 표현식 ${{ matrix.node-version }}은 각 작업에서 해당 값으로 대체됩니다.

기본적으로 fail-fast는 true입니다: 한 작업만 실패해도 나머지 작업이 즉시 취소됩니다. fail-fast: false를 설정하면 전체 그림을 볼 수 있습니다. 예를 들어, 오류가 Node 24에서만 발생하고 Node 22는 여전히 통과하는 것을 알 수 있습니다.

행렬을 여러 운영 체제로 확장할 수 있습니다:

    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest]
        node-version: [22, 24]
    runs-on: ${{ matrix.os }}

이 구성은 2 × 2 = 4개의 작업을 생성합니다.

주의: 행렬에 추가하는 각 차원은 작업 수를 곱합니다. Linux로만 배포하고 Node 24를 사용하는 서비스의 경우, 큰 행렬은 CI 실행 시간만 낭비할 뿐 이점을 주지 않습니다. 여러 환경을 정말로 지원할 때만 행렬을 사용하세요. 예를 들어 npm 패키지를 릴리스할 때입니다.

프로젝트가 한 버전의 Node만 사용한다면, CI와 dev 머신이 같은 소스에서 읽도록 하세요. 24를 포함하는 .nvmrc 파일을 생성한 다음 다음을 선언하세요:

      - uses: actions/setup-node@v4
        with:
          node-version-file: .nvmrc
          cache: npm

5. Lint 작업 분리 및 작업 간 의존성

Lint는 테스트보다 빠르고, 각 Node 버전에서 다시 실행할 필요가 없습니다. 따라서 lint를 별도의 작업으로 분리하고 test 작업이 lint 통과를 기다리도록 하는 것이 좋습니다:

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npm run lint

  test:
    needs: lint
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        node-version: [22, 24]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: npm
      - run: npm ci
      - run: npm test

needs: lint은 test 작업이 lint가 성공한 후에만 시작되도록 합니다. 누군가가 사용하지 않는 변수를 남겨두었다고 가정하세요:

const unusedTax = 0.1;

이 경우 lint 작업은 다음 메시지와 함께 실패합니다:

/home/runner/work/order-service/src/order.js
  1:7  error  'unusedTax' is assigned a value but never used  no-unused-vars

✖ 1 problem (1 error, 0 warnings)

test 작업은 실행되지 않으므로, 전체 행렬의 시간을 절약합니다.

여기서는 트레이드오프를 고려해야 합니다. needs를 사용하면 모든 것이 통과할 때 전체 시간이 더 길어집니다. 왜냐하면 두 작업이 순차적으로 실행되기 때문입니다. 빠른 응답을 우선시한다면, lint와 test가 병렬로 실행되도록 needs를 제거하세요.

6. 커버리지 리포트 및 아티팩트 저장

가상 머신에서 생성된 모든 파일은 작업이 종료되면 손실됩니다. 커버리지 리포트를 유지하려면 actions/upload-artifact를 사용하세요:

  coverage:
    needs: lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npm run test:coverage
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: coverage-report
          path: lcov.info
          retention-days: 7

test:coverage 스크립트는 커버리지 테이블을 작업 로그에 인쇄합니다:

ℹ start of coverage report
ℹ file           | line % | branch % | funcs %
ℹ src/order.js   | 100.00 |   100.00 |  100.00
ℹ all files      | 100.00 |   100.00 |  100.00
ℹ end of coverage report

if: always() 조건은 테스트가 실패해도 아티팩트가 업로드되도록 보장합니다. 이것이 리포트가 가장 필요한 때입니다. lcov.info 파일은 워크플로우 실행의 요약 페이지 끝에 나타나며, 7일 후에 삭제됩니다.

커버리지가 낮아질 때 CI가 실패하도록 하려면, Node 22.8부터 스크립트에 임계값 플래그를 추가할 수 있습니다:

node --test --experimental-test-coverage --test-coverage-lines=80

이 명령은 라인 커버리지가 80% 미만일 때 exit 코드 1을 반환합니다.

7. 워크플로우 최적화

다음 세 가지 설정은 대부분의 CI 워크플로우에 있어야 합니다. 파일의 최상위 레벨에 배치하세요:

name: CI

on:
  push:
    branches: [main]
    paths-ignore:
      - '**.md'
      - 'docs/**'
  pull_request:
    paths-ignore:
      - '**.md'
      - 'docs/**'

permissions:
  contents: read

concurrency:
  group: ci-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

각 설정의 의미는 다음과 같습니다:

  • paths-ignore: README나 문서만 수정하는 커밋은 CI를 트리거하지 않습니다.
  • permissions: contents: read: GITHUB_TOKEN의 권한을 읽기 전용 수준으로 제한합니다. 악의적인 의존성이 CI에서 스크립트를 실행하더라도 코드를 푸시하거나 릴리스를 수정할 수 없습니다.
  • concurrency: 같은 풀 리퀘스트로 연속해서 세 개의 커밋을 푸시하면, 가장 최신의 실행만 유지됩니다. 이전 실행은 취소됩니다. 그 결과는 더 이상 가치가 없기 때문입니다.

또한 각 작업에 timeout-minutes를 추가해야 합니다:

  test:
    runs-on: ubuntu-latest
    timeout-minutes: 10

기본적으로 작업은 6시간까지 실행될 수 있습니다. 테스트 하나가 교착 상태에 빠지면, 예를 들어 데이터베이스 연결을 닫는 것을 잊으면, 당신의 분 할당량을 모두 소진할 수 있습니다.

주의: paths-ignore는 branch protection과 결합할 때 함정이 있습니다. test 체크가 통과해야만 병합할 수 있도록 강제한다고 가정하세요. .md 파일만 수정하는 풀 리퀘스트는 워크플로우를 실행하지 않으므로, 해당 체크는 “Expected — Waiting” 상태에 머물러 있습니다. 이 경우 두 가지 선택이 있습니다: paths-ignore를 제거하거나, 해당 체크의 필수 요구 사항을 제거하세요.

8. 일반적인 오류

Lockfile 누락. package-lock.json이 커밋되지 않았거나 .gitignore에 나열되어 있으면, setup-node 단계가 오류를 보고합니다:

Error: Dependencies lock file is not found in /home/runner/work/order-service/order-service.
Supported file patterns: package-lock.json,npm-shrinkwrap.json,yarn.lock

해결책은 lockfile을 커밋하는 것입니다. 프로젝트가 pnpm 또는 yarn을 사용하는 경우, cache 값을 패키지 매니저에 맞게 변경하세요.

Watch 모드에서 테스트 실행. Vitest나 Jest와 같은 일부 도구가 watch로 구성되면, 파일 변경을 기다리며 영원히 대기합니다. GitHub Actions는 자동으로 CI=true 환경 변수를 설정하며, 대부분의 도구는 이를 기반으로 watch를 비활성화합니다. 그럼에도 불구하고, 명시적으로 한 번만 실행하는 명령을 호출해야 합니다. 예를 들어 vitest run입니다.

Fork에서의 풀 리퀘스트에는 시크릿이 없습니다. 저장소 fork로부터의 pull_request 이벤트의 경우, 시크릿이 워크플로우로 전달되지 않습니다. 이는 의도적인 보안 메커니즘입니다. 실제 API 키가 필요한 테스트는 이 경우 실패합니다. 단위 테스트에서 외부 서비스를 mock하세요. “불을 끄기” 위해 pull_request_target으로 전환하지 마세요. 이 이벤트는 검토되지 않은 코드에 대해 전체 권한으로 실행되기 때문입니다.

운영 체제 간 차이. macOS에서는 통과하지만 Ubuntu에서는 실패하는 테스트는 종종 경로의 대소문자 구분 때문입니다. 예를 들어, 파일의 실제 이름이 order.js인데 import './Order.js'를 하는 경우입니다. Linux 파일 시스템은 대소문자를 구분하므로, CI가 프로덕션에 가기 전에 이 오류를 감지합니다.

이 전체 파이프라인이 필요 없을 때. 몇십 줄만 있는 내부 스크립트의 경우, 단일 작업 npm ci && npm test으로 충분합니다. 프로젝트가 커지거나 많은 사람이 기여할 때만 행렬, 커버리지 및 분리된 작업을 추가해야 합니다.

9. 결론

이 글에서는 GitHub Actions를 사용하여 Node.js 프로젝트의 완전한 CI 파이프라인을 구축했습니다. 파이프라인은 npm ci와 setup-node의 캐시를 사용하여 의존성을 빠르고 안정적으로 설치합니다. matrix 전략은 여러 Node 버전을 확인하는 데 도움이 됩니다. Lint 작업은 분리되어 있으며 needs를 통해 test 작업과 연결됩니다. 커버리지 리포트는 아티팩트를 사용하여 저장됩니다. 마지막으로, 워크플로우는 concurrency, permissions 및 timeout-minutes로 최적화됩니다.

핵심 원칙은 CI를 빠르고 신뢰할 수 있게 유지하는 것입니다. 15분 동안 실행되거나 때때로 무작위로 실패하는 파이프라인은 전체 팀이 그 결과를 무시하기 시작하게 합니다. 섹션 3의 최소 워크플로우로 시작한 다음, 프로젝트가 실제로 필요할 때 각 부분을 추가하세요.

github-actionsnodejscitestingdevops