Docker 컨테이너의 데이터 지속성을 위한 볼륨 시스템을 완벽하게 이해하고, 개발과 프로덕션 환경에서 효과적으로 활용하는 방법을 알아봅니다
Docker 컨테이너의 가장 큰 특징 중 하나는 **일시성(Ephemeral)**입니다. 컨테이너를 삭제하면 그 안의 데이터도 함께 사라집니다. 이는 애플리케이션의 독립성을 보장하지만, 데이터베이스나 로그 파일처럼 영구적으로 보관해야 할 데이터에는 문제가 됩니다. 이번 포스트에서는 Docker의 볼륨 시스템을 통해 이 문제를 해결하는 방법을 자세히 알아보겠습니다.
먼저 실제 예제를 통해 문제를 확인해보겠습니다.
// feedback-app/server.js
const express = require('express');
const fs = require('fs').promises;
const app = express();
app.post('/feedback', async (req, res) => {
const { title, text } = req.body;
const fileName = title.toLowerCase().replace(/ /g, '-');
await fs.writeFile(`./feedback/${fileName}.txt`, text);
res.send('Feedback saved!');
});
app.listen(80);
이 애플리케이션을 Docker화하고 실행해보면:
# 컨테이너 실행
docker run -d --name feedback-app -p 3000:80 feedback:latest
# 피드백 저장 (성공!)
curl -X POST http://localhost:3000/feedback \
-d '{"title":"Great App", "text":"Love it!"}'
# 컨테이너 재시작
docker stop feedback-app
docker rm feedback-app
docker run -d --name feedback-app -p 3000:80 feedback:latest
# 데이터 확인... 사라졌다! 😱
Docker는 이 문제를 해결하기 위해 세 가지 유형의 볼륨을 제공합니다.
익명 볼륨은 컨테이너에 임시로 연결되는 볼륨입니다.
# Dockerfile에서 설정
VOLUME ["/app/feedback"]
# 또는 실행 시 설정
docker run -v /app/feedback feedback:latest
특징:
명명된 볼륨은 Docker가 관리하는 영구적인 저장소입니다.
# 명명된 볼륨으로 실행
docker run -d --name feedback-app \
-v feedback-data:/app/feedback \
-p 3000:80 \
feedback:latest
특징:
바인드 마운트는 호스트 파일 시스템의 특정 경로를 컨테이너에 연결합니다.
# 바인드 마운트로 실행
docker run -d --name feedback-app \
-v $(pwd):/app \
-p 3000:80 \
feedback:latest
특징:
| 특성 | 익명 볼륨 | 명명된 볼륨 | 바인드 마운트 |
|---|---|---|---|
| 생성 방법 | -v /app/data | -v name:/app/data | -v /host/path:/app/data |
| 저장 위치 | Docker 관리 영역 | Docker 관리 영역 | 호스트 지정 경로 |
| 생명 주기 | 컨테이너 종속 | 독립적 | 독립적 |
| 공유 가능 | ❌ | ✅ | ✅ |
| 호스트 접근 | 어려움 | 어려움 | 쉬움 |
| 주 용도 | 임시 데이터 | 영구 데이터 | 개발 환경 |
개발 환경에서는 코드 변경이 즉시 반영되어야 합니다. 이를 위한 완벽한 설정을 만들어보겠습니다.
바인드 마운트를 사용하면 흔히 겪는 문제가 있습니다:
# 전체 프로젝트를 마운트하면...
docker run -v $(pwd):/app myapp
# 에러 발생!
# Error: Cannot find module 'express'
왜일까요? 호스트의 빈 node_modules가 컨테이너의 node_modules를 덮어쓰기 때문입니다.
Docker는 더 구체적인 경로를 우선시합니다. 이를 활용해 문제를 해결할 수 있습니다:
docker run -d --name dev-app \
-v feedback:/app/feedback \ # 영구 데이터용 명명된 볼륨
-v $(pwd):/app \ # 전체 코드 바인드 마운트
-v /app/node_modules \ # node_modules 보호용 익명 볼륨
-p 3000:80 \
myapp:dev
우선순위: /app/node_modules > /app > /
실시간 재시작을 위해 Nodemon을 설정합니다:
// package.json
{
"scripts": {
"start": "node server.js",
"dev": "nodemon -L server.js" // -L은 polling 모드 (WSL2 필수)
},
"devDependencies": {
"nodemon": "^2.0.0"
}
}
# Dockerfile
FROM node:14
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
CMD ["npm", "run", "dev"] # 개발 모드로 실행
프로덕션 환경이나 보안이 중요한 경우, 읽기 전용 볼륨을 활용할 수 있습니다:
# 소스 코드는 읽기 전용으로
docker run -d \
-v $(pwd)/src:/app/src:ro \ # :ro = read-only
-v logs:/app/logs \ # 로그는 쓰기 가능
-v temp:/app/temp \ # 임시 파일도 쓰기 가능
myapp:prod
이렇게 하면 컨테이너가 해킹되더라도 소스 코드를 변경할 수 없습니다.
.dockerignore 파일을 통해 이미지 빌드 시 불필요한 파일을 제외할 수 있습니다:
# .dockerignore
node_modules
npm-debug.log
.git
.gitignore
README.md
.env
.vscode
.idea
*.swp
*.log
Dockerfile
.dockerignore
이렇게 하면:
# ARG: 빌드 시점에만 사용
ARG DEFAULT_PORT=80
# ENV: 빌드 + 런타임 모두 사용
ENV PORT=$DEFAULT_PORT
ENV NODE_ENV=production
# 사용 예시
EXPOSE $PORT
# Dockerfile
ARG NODE_VERSION=14
FROM node:${NODE_VERSION}
ARG DEFAULT_PORT=3000
ENV PORT=$DEFAULT_PORT
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]
# 개발 환경 빌드
docker build \
--build-arg NODE_VERSION=16 \
--build-arg DEFAULT_PORT=3000 \
-t myapp:dev .
# 프로덕션 환경 빌드
docker build \
--build-arg NODE_VERSION=14-alpine \
--build-arg DEFAULT_PORT=80 \
-t myapp:prod .
# .env.development
NODE_ENV=development
DB_HOST=localhost
DB_PORT=5432
API_KEY=dev-key-12345
# .env.production
NODE_ENV=production
DB_HOST=prod-db.example.com
DB_PORT=5432
API_KEY=prod-key-secure
# 환경별 실행
docker run --env-file .env.development myapp:dev
docker run --env-file .env.production myapp:prod
# 모든 볼륨 목록
docker volume ls
# 특정 볼륨 상세 정보
docker volume inspect feedback-data
# 볼륨이 사용하는 실제 경로 확인
docker volume inspect feedback-data --format '{{ .Mountpoint }}'
# 특정 볼륨 삭제
docker volume rm feedback-data
# 사용하지 않는 모든 볼륨 삭제
docker volume prune
# 강제 삭제 (주의!)
docker volume prune -f
실제 풀스택 애플리케이션의 볼륨 설정 예제를 살펴보겠습니다:
# docker-compose.yml
version: '3.8'
services:
frontend:
build: ./frontend
volumes:
- ./frontend/src:/app/src:ro # 소스 코드 (읽기 전용)
- /app/node_modules # 의존성 보호
environment:
- REACT_APP_API_URL=http://localhost:3001
backend:
build: ./backend
volumes:
- ./backend:/app # 전체 백엔드 코드
- /app/node_modules # 의존성 보호
- logs:/app/logs # 로그 영구 저장
environment:
- NODE_ENV=development
- DB_HOST=database
database:
image: postgres:13
volumes:
- db-data:/var/lib/postgresql/data # DB 데이터 영구 저장
environment:
- POSTGRES_PASSWORD=secret
volumes:
logs:
db-data:
# 최대한의 유연성
docker run -d \
-v $(pwd):/app \
-v /app/node_modules \
--env-file .env.dev \
myapp:dev
# 프로덕션과 유사하되 테스트 데이터 사용
docker run -d \
-v test-data:/app/data \
--env-file .env.test \
myapp:test
# 최소 권한, 최대 보안
docker run -d \
-v prod-data:/app/data \
-v prod-logs:/var/log/app \
--env-file .env.prod \
--read-only \
--tmpfs /tmp \
myapp:prod
# 문제: Permission denied
# 해결: 사용자 매핑
docker run --user $(id -u):$(id -g) -v $(pwd):/app myapp
# 문제: Nodemon이 파일 변경을 감지하지 못함
# 해결: polling 모드 사용
nodemon -L server.js
// 문제: fs.rename()이 다른 장치 간 작동 안함
// 해결: copy + delete 사용
const fs = require('fs').promises;
// 잘못된 방법
await fs.rename('/tmp/file', '/app/file');
// 올바른 방법
await fs.copyFile('/tmp/file', '/app/file');
await fs.unlink('/tmp/file');
용도에 맞는 볼륨 유형 선택
보안 강화
성능 최적화
데이터 관리
이번 포스트에서는 Docker의 데이터 관리와 볼륨 시스템에 대해 깊이 있게 다뤘습니다. 볼륨은 단순히 데이터를 저장하는 것 이상의 의미를 가집니다. 개발 생산성을 높이고, 애플리케이션의 안정성을 보장하며, 유연한 배포 전략을 가능하게 하는 핵심 기능입니다.
다음 포스트에서는 컨테이너 간 통신을 가능하게 하는 Docker 네트워킹에 대해 알아보겠습니다. 마이크로서비스 아키텍처의 핵심이 되는 내용이니 기대해주세요!
시리즈 네비게이션
로컬에 개발 도구를 설치하지 않고 Docker 컨테이너로 npm, composer, artisan 등을 실행하는 유틸리티 컨테이너 패턴을 알아봅니다
Docker 컨테이너 간 통신, 외부 네트워크 연결, 그리고 다양한 네트워크 드라이버를 활용한 효과적인 네트워킹 구성 방법을 알아봅니다
실제 프로덕션 환경과 같은 다중 컨테이너 애플리케이션을 Docker로 구축하는 방법을 React, Node.js, MongoDB를 활용한 실전 예제로 알아봅니다
Nginx, PHP-FPM, MySQL, Redis를 조합한 프로덕션급 Laravel 개발 환경을 Docker로 구축하는 방법을 상세히 알아봅니다