Docker를 이용한 컨테이너화
지난 10장에서는 NestJS 애플리케이션의 보안을 강화하기 위한 다양한 모범 사례와 취약점 스캐닝 방법에 대해 알아보았습니다. 이제 11장에서는 개발이 완료된 NestJS 애플리케이션을 실제 서비스 가능한 환경에 배포하고 안정적으로 운영하는 방법에 대해 다루며, 그 첫 번째 주제로 현대적인 배포의 핵심 기술인 Docker를 이용한 컨테이너화에 대해 살펴보겠습니다.
소프트웨어 개발에서 "내 컴퓨터에서는 잘 돌아가는데..."라는 말처럼 개발 환경과 운영 환경의 불일치로 인한 문제는 흔히 발생합니다. Docker는 이러한 환경 불일치 문제를 해결하고, 애플리케이션을 빠르고 안정적으로 배포할 수 있도록 돕는 강력한 도구입니다.
컨테이너(Container)란 무엇인가?
컨테이너는 애플리케이션과 해당 애플리케이션을 실행하는 데 필요한 모든 구성 요소(코드, 런타임, 시스템 도구, 시스템 라이브러리 등)를 포함하는 경량의 독립적인 실행 단위입니다. 컨테이너는 운영체제 수준에서 격리되어 실행되며, 호스트 운영체제의 커널을 공유합니다.
컨테이너의 주요 특징
- 격리성: 각 컨테이너는 독립적인 환경에서 실행되므로, 다른 컨테이너나 호스트 시스템에 영향을 주지 않습니다.
- 이식성(Portability): 한 번 빌드된 컨테이너 이미지는 어떤 환경(개발자 노트북, 온프레미스 서버, 클라우드)에서든 동일하게 실행됩니다. "Build once, run anywhere."
- 경량성: 가상 머신(VM)과 달리 운영체제를 통째로 포함하지 않고, 호스트 OS의 커널을 공유하므로 시작 시간이 빠르고 리소스 소모가 적습니다.
- 일관성: 개발, 테스트, 운영 환경 간의 일관된 실행 환경을 보장하여 "내 컴퓨터에서는 되는데..." 문제를 해결합니다.
컨테이너와 가상 머신(VM)의 비교
특징 | 컨테이너 | 가상 머신 (VM) |
---|---|---|
격리 수준 | OS 레벨 가상화 (커널 공유) | 하드웨어 레벨 가상화 (게스트 OS 포함) |
오버헤드 | 낮음 (경량) | 높음 (무겁고 시작 시간 김) |
리소스 | 적은 CPU, 메모리, 스토리지 사용 | 많은 CPU, 메모리, 스토리지 사용 |
이식성 | 컨테이너 이미지로 높은 이식성 | VM 이미지로 이식성, 하지만 컨테이너보다는 낮음 |
시작 시간 | 빠름 (초 단위) | 느림 (분 단위) |
예시 | Docker, containerd | VMware, VirtualBox, KVM, Hyper-V |
컨테이너는 특히 마이크로서비스 아키텍처와 CI/CD(지속적 통합/배포) 파이프라인에서 핵심적인 역할을 합니다.
Docker란?
Docker는 컨테이너 기반 가상화를 위한 플랫폼으로, 애플리케이션을 컨테이너 이미지로 만들고, 이를 실행하며, 관리하는 데 필요한 도구들을 제공합니다. Docker는 컨테이너 기술의 사실상 표준이 되었습니다.
Docker의 주요 구성 요소
- Dockerfile: 컨테이너 이미지를 빌드하기 위한 명령어들을 정의한 텍스트 파일입니다.
- Docker Image: Dockerfile에 정의된 대로 빌드된 읽기 전용 템플릿으로, 컨테이너를 생성하는 데 사용됩니다.
- Docker Container: Docker Image를 기반으로 실행되는 애플리케이션 인스턴스입니다.
- Docker Daemon: Docker Image를 빌드하고, 컨테이너를 실행하며, 관리하는 백그라운드 서비스입니다.
- Docker CLI: 사용자가 Docker Daemon과 상호작용하기 위한 커맨드 라인 인터페이스입니다.
- Docker Hub / Registry: Docker Image를 저장하고 공유하는 중앙 저장소입니다.
NestJS 애플리케이션 컨테이너화
NestJS 애플리케이션을 Docker 컨테이너로 만드는 과정은 Dockerfile을 작성하고, 이미지를 빌드한 다음, 컨테이너를 실행하는 순서로 진행됩니다.
Dockerfile 작성
프로젝트의 루트 디렉터리에 Dockerfile
이라는 이름으로 파일을 생성하고 다음 내용을 작성합니다. 효율적인 Docker 이미지 빌드를 위해 **멀티스테이지 빌드(Multi-stage Build)**를 사용하는 것이 좋습니다. 이는 빌드 환경과 실행 환경을 분리하여 최종 이미지 크기를 최소화합니다.
# Dockerfile
# --- Build Stage (빌드 단계) ---
# Node.js 20 버전의 Alpine Linux 기반 이미지 사용
FROM node:20-alpine AS builder
# 작업 디렉토리 설정
WORKDIR /app
# package.json과 package-lock.json (또는 yarn.lock) 복사
# 의존성 설치가 변경될 때만 이 레이어가 재빌드되도록 캐싱 활용
COPY package*.json ./
# Node.js 의존성 설치 (npm ci는 package-lock.json 기반으로 안정적인 설치 보장)
RUN npm ci --omit=dev # 개발 의존성 제외 (실행 환경에는 필요 없음)
# 프로젝트 소스 코드 복사
COPY . .
# NestJS 애플리케이션 빌드
# tsconfig.build.json이 있는 경우, 해당 설정을 사용
RUN npm run build
# --- Production Stage (실행 단계) ---
# 경량 Node.js 20 버전의 Alpine Linux 기반 이미지 사용 (런타임에 최적화)
FROM node:20-alpine
# 작업 디렉토리 설정
WORKDIR /app
# 빌드 단계에서 설치된 node_modules와 빌드된 dist 디렉토리 복사
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package*.json ./ # 패키지 정보 (npm start 등 실행 명령어에 필요)
# 애플리케이션이 수신 대기할 포트 노출
EXPOSE 3000
# NestJS 애플리케이션 시작 명령어
# "dist/main"은 NestJS 빌드 후의 진입점 파일 경로
CMD ["node", "dist/main"]
FROM node:20-alpine AS builder
: Node.js 20 버전이 설치된 경량 Alpine Linux 이미지를 빌드 환경으로 사용하고,builder
라는 이름을 부여합니다.WORKDIR /app
: 컨테이너 내부의 작업 디렉토리를/app
으로 설정합니다.COPY package*.json ./
:package.json
과package-lock.json
을 먼저 복사하여 NPM 의존성 레이어를 캐싱합니다. 이 파일들이 변경되지 않는 한npm ci
단계는 재실행되지 않아 빌드 속도를 높입니다.RUN npm ci --omit=dev
: 개발(devDependencies)을 제외한 프로덕션 의존성만 설치합니다.COPY . .
: 나머지 애플리케이션 소스 코드를 복사합니다.RUN npm run build
: NestJS 프로젝트를 JavaScript로 빌드합니다.FROM node:20-alpine
: 두 번째FROM
구문으로 새로운 이미지를 시작합니다. 이 이미지는 최종 실행 환경이 됩니다.COPY --from=builder /app/node_modules ./node_modules
: 첫 번째builder
단계에서 설치된node_modules
디렉토리를 최종 이미지로 복사합니다.COPY --from=builder /app/dist ./dist
: 빌드된 NestJS JavaScript 코드(dist
폴더)를 최종 이미지로 복사합니다.EXPOSE 3000
: 컨테이너가 3000번 포트에서 수신 대기함을 외부에 알립니다. 이는 문서화 역할이며, 실제로 포트를 외부에 노출하려면docker run -p
옵션을 사용해야 합니다.CMD ["node", "dist/main"]
: 컨테이너가 시작될 때 실행될 기본 명령어입니다. NestJS 애플리케이션의 빌드된 진입점(dist/main.js
)을 Node.js로 실행합니다.
.dockerignore
파일 작성
.gitignore
와 유사하게, Docker 이미지에 포함되지 않아야 할 파일이나 디렉토리를 지정합니다. 이미지 크기를 줄이고 불필요한 파일을 제거하는 데 중요합니다.
# .dockerignore
node_modules
dist
.env
.git
.gitignore
Dockerfile
docker-compose.yml
README.md
npm-debug.log
yarn-error.log
node_modules
,dist
: 빌드 단계에서 복사되므로 최종 단계에서는 제외됩니다..env
: 비밀 정보가 포함될 수 있으므로 절대 이미지에 포함시키지 않습니다.
Docker 이미지 빌드
Dockerfile이 있는 디렉토리에서 다음 명령어를 실행하여 Docker 이미지를 빌드합니다.
docker build -t nestjs-app:latest .
-t nestjs-app:latest
: 빌드된 이미지에nestjs-app
이라는 이름과latest
라는 태그를 부여합니다..
: Dockerfile이 현재 디렉토리에 있음을 나타냅니다.
빌드 과정에서 Docker는 각 RUN
, COPY
명령어마다 레이어(Layer)를 생성하고 캐싱합니다.
Docker 컨테이너 실행
빌드된 이미지를 사용하여 컨테이너를 실행합니다.
docker run -p 3000:3000 --name my-nestjs-container nestjs-app:latest
-p 3000:3000
: 호스트의 3000번 포트를 컨테이너의 3000번 포트에 매핑합니다. 이를 통해 호스트 머신에서http://localhost:3000
으로 NestJS 애플리케이션에 접근할 수 있습니다.--name my-nestjs-container
: 컨테이너에my-nestjs-container
라는 이름을 부여하여 나중에 쉽게 관리할 수 있도록 합니다.nestjs-app:latest
: 실행할 이미지의 이름과 태그입니다.
컨테이너가 백그라운드에서 실행되도록 하려면 -d
(detached) 옵션을 추가합니다.
docker run -d -p 3000:3000 --name my-nestjs-container nestjs-app:latest
환경 변수 주입
컨테이너 실행 시 환경 변수를 주입해야 합니다. .env
파일을 이미지에 포함시키지 않았으므로, docker run
명령이나 Docker Compose, Kubernetes 등에서 환경 변수를 명시적으로 전달해야 합니다.
docker run -d -p 3000:3000 \
--name my-nestjs-container \
-e DATABASE_HOST=your_db_host \
-e DATABASE_PORT=5432 \
-e DATABASE_USER=your_db_user \
-e DATABASE_PASSWORD=your_db_password \
-e DATABASE_NAME=your_db_name \
-e JWT_SECRET=your_jwt_secret \
nestjs-app:latest
-e KEY=VALUE
: 환경 변수를 컨테이너 내부로 전달합니다.
Docker Compose를 이용한 다중 컨테이너 관리
실제 애플리케이션은 NestJS 백엔드뿐만 아니라 데이터베이스(PostgreSQL, MongoDB), 캐시(Redis) 등 여러 서비스로 구성되는 경우가 많습니다. Docker Compose는 이러한 다중 컨테이너 애플리케이션을 정의하고 실행하기 위한 도구입니다.
docker-compose.yml
파일 작성
프로젝트 루트에 docker-compose.yml
파일을 생성합니다.
# docker-compose.yml
version: '3.8' # Docker Compose 파일 형식 버전
services:
# NestJS 백엔드 서비스
app:
build:
context: . # 현재 디렉토리에서 Dockerfile 찾기
dockerfile: Dockerfile # 사용할 Dockerfile 이름
ports:
- "3000:3000" # 호스트 3000 -> 컨테이너 3000 포트 매핑
environment: # NestJS 앱에 주입할 환경 변수
DATABASE_HOST: db # Docker Compose 네트워크에서 서비스 이름은 호스트명으로 사용 가능
DATABASE_PORT: 5432
DATABASE_USER: ${DB_USER} # .env 파일에서 값 가져오기
DATABASE_PASSWORD: ${DB_PASSWORD}
DATABASE_NAME: ${DB_NAME}
JWT_SECRET: ${JWT_SECRET}
NODE_ENV: development
volumes:
- .:/app # 로컬 코드 변경 시 컨테이너에 즉시 반영 (개발 시 유용)
- /app/node_modules # node_modules는 볼륨에 매핑하지 않음 (컨테이너 내부 node_modules 사용)
depends_on: # app 서비스가 db 서비스보다 먼저 시작되도록 의존성 설정
- db
restart: unless-stopped # 컨테이너가 종료될 경우 다시 시작
# PostgreSQL 데이터베이스 서비스
db:
image: postgres:16-alpine # PostgreSQL 이미지 사용
ports:
- "5432:5432" # 호스트 5432 -> 컨테이너 5432 포트 매핑 (선택 사항, 내부 통신만 필요하면 삭제 가능)
environment: # DB에 주입할 환경 변수 (PostgreSQL 공식 이미지 설정)
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_NAME}
volumes:
- db_data:/var/lib/postgresql/data # 데이터 영속성을 위한 볼륨 마운트
restart: unless-stopped
# Redis 캐시 서비스 (선택 사항)
redis:
image: redis:7-alpine # Redis 이미지 사용
ports:
- "6379:6379"
volumes:
- redis_data:/data # 데이터 영속성을 위한 볼륨 마운트
restart: unless-stopped
volumes:
db_data: # db 서비스에서 사용할 볼륨 정의
redis_data: # redis 서비스에서 사용할 볼륨 정의
version
: Docker Compose 파일 형식 버전입니다.services
: 정의할 서비스들을 나열합니다 (app
,db
,redis
등).app
서비스build: .
: 현재 디렉토리의 Dockerfile을 사용하여 이미지를 빌드합니다.ports
: 포트 매핑.environment
: 환경 변수.${VARIABLE}
형식으로.env
파일의 변수를 가져올 수 있습니다.volumes
: 개발 시 코드 변경을 즉시 반영하기 위해 로컬 코드 디렉토리를 컨테이너에 마운트합니다.node_modules
를 따로 명시하여 호스트의node_modules
가 컨테이너를 덮어쓰는 것을 방지합니다.depends_on
: 서비스 간의 시작 순서를 정의합니다.app
은db
가 시작된 후에 시작됩니다.
db
및redis
서비스: Docker Hub에서 공식 이미지를 가져와 사용합니다.volumes
를 통해 데이터를 컨테이너 외부의 호스트에 영구적으로 저장하여 컨테이너가 삭제되더라도 데이터가 보존되도록 합니다.volumes
: Docker Compose가 관리할 볼륨을 정의합니다.
Docker Compose .env
파일 생성
docker-compose.yml
에서 사용할 환경 변수를 정의하는 .env
파일을 docker-compose.yml
과 동일한 디렉토리에 생성합니다.
# .env (Docker Compose용)
DB_USER=myuser
DB_PASSWORD=mypassword
DB_NAME=mydb
JWT_SECRET=anothersecretjwtkey
이 .env
파일도 .gitignore
에 반드시 포함하여 버전 관리 시스템에 커밋되지 않도록 해야 합니다.
Docker Compose 실행
docker-compose.yml
파일이 있는 디렉토리에서 다음 명령어를 실행합니다.
docker compose up # 또는 docker-compose up (구 버전)
docker compose up
:docker-compose.yml
파일에 정의된 모든 서비스를 빌드하고 시작합니다.-d
옵션을 추가하면 백그라운드에서 실행됩니다. (docker compose up -d
)
이제 http://localhost:3000
으로 접속하면 NestJS 애플리케이션이 실행되고, PostgreSQL 데이터베이스와 Redis 캐시도 함께 컨테이너화되어 연동됩니다.
Docker를 이용한 컨테이너화는 NestJS 애플리케이션의 배포를 훨씬 빠르고 일관되게 만들어 줍니다. 개발 환경과 운영 환경 간의 불일치 문제를 해소하고, CI/CD 파이프라인과의 통합을 용이하게 하며, 확장성과 고가용성 있는 시스템을 구축하기 위한 필수적인 첫 단계입니다. Docker Compose는 여러 컨테이너로 구성된 애플리케이션을 로컬 개발 환경에서 쉽게 관리하고 실행할 수 있도록 돕습니다.