Step 2~3: CI 파이프라인 + Docker 컨테이너화

이전 단계에서 Express 앱을 만들고 GitHub에 push했다. 이제 “push할 때마다 자동으로 테스트”하고, “어디서든 똑같이 실행되는 상자”에 앱을 담는다.


Step 2. CI 파이프라인 — ② Build/Test (자동 빌드·테스트)

CI란 무엇인가

CI = Continuous Integration = “지속적 통합”

구분설명
영어 원래 뜻Continuous(끊임없는) + Integration(통합) = 끊임없이 합치기
IT에서의 뜻코드를 올릴 때마다 자동으로 테스트·빌드를 실행하는 시스템
비유식당 주방의 “주문 즉시 검수 시스템”. 재료(코드)가 들어오면 자동으로 신선도(테스트)를 검사한다
실무 용어”CI 파이프라인”, “빌드 파이프라인”
슬롯 이름② Build/Test (슬롯은 교육용 비유, 실무에서는 “CI 파이프라인”이라 부른다)

WHY — 왜 CI가 필요한가

CI 없이 개발하면 이런 일이 반복된다:

1. 개발자 A가 코드 수정 → 테스트 안 하고 push
2. 개발자 B가 pull → 앱이 깨져 있음
3. "누가 망가뜨린 거야?" → 범인 찾기 시작
4. 30분 낭비 → 알고 보니 오타 하나

CI가 있으면:
1. 개발자 A가 코드 수정 → push
2. CI가 자동으로 테스트 실행 → ❌ 실패
3. "A의 커밋에서 실패했습니다" 알림
4. A가 즉시 수정 → main 브랜치는 항상 안전

핵심: 수동으로 매번 테스트하면 빼먹기 쉽다. CI가 있으면 버그가 main에 들어가기 전에 잡힌다.

2-1. 간단한 테스트 파일 만들기

프로젝트 루트에 test.js를 만든다. 테스트 프레임워크 없이 Node.js 내장 assert만 쓴다.

// test.js — 최소한의 앱 검증 테스트
const assert = require('assert');  // Node.js 내장 assert 모듈 (별도 설치 불필요)
const app = require('./app');      // app.js를 로드한다 (문법 오류 시 여기서 실패)
assert.ok(app, 'app.js가 정상적으로 로드되어야 한다');  // truthy가 아니면 에러
console.log('✅ 테스트 통과: app.js가 정상적으로 로드됨');

참고: 이 테스트가 동작하려면 app.js 끝에 module.exports = app;이 있어야 한다. Step 0~1에서 만든 app.js에 없다면 추가하자.

package.json에 test 스크립트를 추가한다:

{
  "scripts": {
    "start": "node app.js",
    "test": "node test.js"
  }
}

로컬에서 먼저 확인:

# 테스트를 직접 실행해본다
npm test
# ✅ 테스트 통과: app.js가 정상적으로 로드됨 ← 이 메시지가 나오면 성공

2-2. GitHub Actions 워크플로우 만들기

GitHub Actions = GitHub에 내장된 CI 도구. .github/workflows/ 폴더에 YAML 파일을 넣으면 자동으로 인식한다.

# 워크플로우 폴더를 만든다 (-p: 중간 폴더가 없으면 함께 생성)
mkdir -p .github/workflows

.github/workflows/ci.yml 파일을 만든다:

# ci.yml — push할 때마다 자동으로 테스트를 실행하는 워크플로우

# name: GitHub Actions 탭에서 보이는 워크플로우 이름
name: CI

# on: "언제 이 워크플로우를 실행할 것인가"를 정의한다
on:
  # push: 코드가 push될 때 실행한다
  push:
    # branches: main 브랜치에 push될 때만 실행한다
    branches: [main]

# jobs: 실행할 작업 목록을 정의한다
jobs:
  # test: 작업의 이름 (자유롭게 정할 수 있다)
  test:
    # runs-on: 어떤 환경에서 실행할 것인가 (ubuntu-latest = 최신 우분투 리눅스)
    runs-on: ubuntu-latest

    # steps: 작업 안에서 순서대로 실행할 단계들
    steps:
      # 1단계: 내 코드를 CI 서버로 가져온다 (checkout = 코드를 꺼내온다)
      - uses: actions/checkout@v4

      # 2단계: Node.js 환경을 설정한다
      - uses: actions/setup-node@v4
        with:
          # node-version: 사용할 Node.js 버전
          node-version: '20'

      # 3단계: 의존성(라이브러리)을 설치한다
      - run: npm install

      # 4단계: 테스트를 실행한다
      - run: npm test

2-3. Push해서 CI 동작 확인

# 변경된 파일들을 스테이징한다
git add test.js package.json .github/workflows/ci.yml

# 커밋한다
git commit -m "add CI pipeline with basic test"

# GitHub에 push한다 — 이 순간 CI가 자동으로 시작된다
git push origin main

확인 방법:

  1. GitHub 저장소 페이지로 간다
  2. Actions 탭을 클릭한다
  3. 방금 push한 커밋에 대한 워크플로우가 실행 중인 것이 보인다
  4. 초록색 체크(✓)가 뜨면 성공, 빨간색 X가 뜨면 실패
성공하면 이런 모습:
┌─────────────────────────────┐
│ ✓ CI  ·  add CI pipeline... │
│   test  ✓  (12s)           │
└─────────────────────────────┘

Step 3. Docker 컨테이너화

Docker 핵심 용어

Container = 컨테이너

구분설명
영어 원래 뜻Container = 물건을 담는 상자, 화물 컨테이너
IT에서의 뜻앱 + 실행에 필요한 모든 것(OS, 라이브러리, 설정)을 하나의 격리된 상자에 담은 것
비유해외이사 컨테이너. 가구·옷·식기를 한 상자에 넣으면, 어느 나라에 도착해도 그대로 꺼내 쓸 수 있다

Image = 이미지

구분설명
영어 원래 뜻Image = 그림, 복제본, 틀
IT에서의 뜻컨테이너를 만들기 위한 설계도(읽기 전용 템플릿)
비유붕어빵 틀. 이미지(틀)로 컨테이너(붕어빵)를 여러 개 찍어낼 수 있다

WHY — 왜 Docker가 필요한가

Docker 없이 배포하면:

개발자 PC:  Node 20 + npm 10 + macOS   → ✅ 잘 돌아감
서버:       Node 18 + npm 9  + Ubuntu   → ❌ 안 돌아감
동료 PC:    Node 21 + npm 10 + Windows  → ❌ 다르게 동작

"내 PC에서는 되는데 서버에서는 안 돼!" ← 가장 흔한 배포 사고

Docker 있으면:
어디서든 동일한 컨테이너 = 동일한 결과
개발자 PC, 서버, 동료 PC 전부 같은 환경에서 실행

3-1. Dockerfile 만들기

프로젝트 루트에 Dockerfile을 만든다:

# Dockerfile — 앱을 컨테이너 이미지로 만드는 설계도

# FROM: 기반이 될 이미지를 지정한다
# node:20-alpine = Node.js 20 버전 + Alpine Linux
# alpine = 경량 리눅스 배포판 (일반 이미지 ~900MB → alpine ~150MB)
FROM node:20-alpine

# WORKDIR: 컨테이너 안에서 작업할 디렉토리를 설정한다
# 이후 명령어는 모두 /app 디렉토리 안에서 실행된다
WORKDIR /app

# COPY: 호스트(내 PC)의 파일을 컨테이너 안으로 복사한다
# package*.json = package.json과 package-lock.json 둘 다 복사
# 의존성 파일만 먼저 복사하는 이유: Docker 캐시를 활용하기 위함
COPY package*.json ./

# RUN: 이미지를 만드는 동안 실행할 명령어
# npm ci = npm install과 비슷하지만 차이점이 있다:
#   npm install: package.json 기준으로 설치, 버전이 달라질 수 있음
#   npm ci: package-lock.json 기준으로 정확히 설치, 재현성 보장
# --omit=dev: 개발용 패키지(devDependencies)를 제외하고 설치 (이미지 크기 절약)
RUN npm ci --omit=dev

# COPY . .: 나머지 소스 코드 전체를 컨테이너 안으로 복사한다
# 왜 두 번에 나눠서 COPY하는가?
#   → package*.json이 바뀌지 않으면 npm ci 단계를 캐시에서 재사용한다
#   → 소스 코드만 바뀌면 빌드가 훨씬 빨라진다
COPY . .

# EXPOSE: 컨테이너가 사용할 포트를 문서화한다
# 실제로 포트를 여는 것은 아니고, "이 컨테이너는 3000번 포트를 쓴다"는 안내
EXPOSE 3000

# CMD: 컨테이너가 시작될 때 실행할 명령어
# 배열 형태(exec form)를 쓰는 이유: 신호(Ctrl+C 등)가 정확히 전달된다
CMD ["node", "app.js"]

3-2. .dockerignore 만들기

빌드할 때 불필요한 파일을 제외한다. .gitignore의 Docker 버전이라고 보면 된다.

# .dockerignore — Docker 이미지에 포함하지 않을 파일 목록
node_modules   # 컨테이너 안에서 npm ci로 새로 설치하므로 제외
.git           # git 히스토리는 실행에 불필요
*.md           # 문서 파일은 실행에 불필요

3-3. 이미지 빌드 및 실행

# 이미지를 빌드한다
# docker build = Dockerfile을 읽어서 이미지를 만든다
# -t my-infra-lab = 이미지에 "my-infra-lab"이라는 이름(tag)을 붙인다
# . = 현재 디렉토리의 Dockerfile을 사용한다
docker build -t my-infra-lab .

# 빌드된 이미지를 확인한다
# docker images = 내 PC에 있는 이미지 목록을 보여준다
docker images | grep my-infra-lab
# 컨테이너를 실행한다
# docker run = 이미지로부터 컨테이너를 만들어 실행한다
# -p 3000:3000 = 내 PC의 3000번 포트를 컨테이너의 3000번 포트에 연결한다
# -d = detached 모드 (백그라운드에서 실행, 터미널을 돌려받는다)
docker run -d -p 3000:3000 my-infra-lab
# 앱이 정상 동작하는지 확인한다
curl http://localhost:3000/health
# {"status":"ok"} ← 이 응답이 오면 성공

3-4. 컨테이너 관리 기본 명령

# 실행 중인 컨테이너 목록을 확인한다
docker ps
# CONTAINER ID   IMAGE          STATUS         PORTS
# a1b2c3d4e5f6   my-infra-lab   Up 10 seconds  0.0.0.0:3000->3000/tcp

# 컨테이너를 멈춘다 (CONTAINER ID의 앞 몇 글자만 입력해도 된다)
docker stop a1b2
# a1b2 ← 정지 완료

# 멈춘 컨테이너까지 모두 확인한다 (-a = all)
docker ps -a

체크리스트

작업 완료 후 아래 항목을 확인한다:

  • npm test가 로컬에서 통과한다
  • GitHub Actions가 push마다 자동 실행된다 (Actions 탭에서 초록색 체크)
  • docker build로 이미지를 빌드할 수 있다
  • docker run으로 컨테이너를 실행하고 /health에 응답이 온다

다음 단계

03-registry-and-compose.md — 이미지 저장소(Registry)에 올리고, Docker Compose로 여러 컨테이너를 함께 관리한다.

Comments

  • // 댓글을 불러오는 중...
main ⚠ 0 ✕ 0 Ln 1, Col 1 Spaces: 2 UTF-8 LF Markdown