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
확인 방법:
- GitHub 저장소 페이지로 간다
- Actions 탭을 클릭한다
- 방금 push한 커밋에 대한 워크플로우가 실행 중인 것이 보인다
- 초록색 체크(✓)가 뜨면 성공, 빨간색 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
// admin login