실전 프로젝트로 완성하는 컨테이너 개발 환경
목표: 한 번에 실행되는 팀 개발 환경 만들기
Docker를 업무에 적용할 때 가장 먼저 얻어야 할 결과물은 거창한 운영 플랫폼이 아니다. 새 팀원이 저장소를 클론한 뒤 몇 개 명령으로 프론트엔드, 백엔드, 데이터베이스, 캐시를 모두 실행할 수 있는 기본 틀이다.
이 장에서는 작은 쇼핑몰 API를 예로 든다. 구성은 다음과 같다.
frontend: Vite 또는 React 기반 프론트엔드 개발 서버api: Node.js 기반 백엔드 API 서버db: PostgreSQL 데이터베이스redis: 세션, 캐시, 짧은 임시 데이터를 저장하는 Redis
브라우저는 프론트엔드에만 접속한다. 프론트엔드는 /api 요청을 백엔드로 프록시한다. 백엔드는 데이터 저장이 필요하면 db에 접속하고, 캐시가 필요하면 redis에 접속한다. 이때 백엔드는 localhost가 아니라 Compose 서비스 이름인 db, redis를 사용한다.
AI 생성 이미지
프로젝트 구조 잡기
먼저 저장소 구조를 명확히 만든다.
docker-shop/
compose.yaml
compose.dev.yaml
compose.prod.yaml
.env.example
.gitignore
README.md
frontend/
Dockerfile
nginx.conf
vite.config.js
package.json
src/
backend/
Dockerfile
package.json
healthcheck.js
src/
db/
init/
001_schema.sql파일을 나누는 기준은 단순하다.
compose.yaml에는 개발과 배포에서 공통으로 쓰는 서비스 관계를 둔다. compose.dev.yaml에는 핫 리로드, 바인드 마운트, 개발 포트를 둔다. compose.prod.yaml에는 멀티 스테이지 빌드 결과와 재시작 정책 같은 배포용 설정을 둔다.
이렇게 나누면 같은 프로젝트 안에서 개발과 배포를 함께 관리할 수 있다. 동시에 개발용 편의 설정이 운영 이미지에 섞이는 문제를 줄일 수 있다.
환경 변수 파일도 처음부터 구분한다. 저장소에는 예시 파일인 .env.example만 커밋하고, 실제 실행에 쓰는 .env는 각자 로컬에서 만든다. .gitignore에는 최소한 다음 항목을 넣어 둔다.
.env공통 Compose 파일 작성하기
공통 파일은 서비스의 뼈대를 정의한다. 어떤 서비스가 있고, 서로 어떤 이름으로 연결되며, 어떤 데이터가 볼륨에 저장되는지가 핵심이다.
name: docker-shop
services:
frontend:
build:
context: ./frontend
environment:
API_BASE_URL: /api
depends_on:
api:
condition: service_healthy
networks:
- app-net
api:
build:
context: ./backend
environment:
PORT: 3000
DATABASE_URL: postgres://${POSTGRES_USER:-app}:${POSTGRES_PASSWORD:-change-me}@db:5432/${POSTGRES_DB:-appdb}
REDIS_URL: redis://redis:6379
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ['CMD', 'node', 'healthcheck.js']
interval: 10s
timeout: 3s
retries: 5
start_period: 10s
networks:
- app-net
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER:-app}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me}
POSTGRES_DB: ${POSTGRES_DB:-appdb}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./backend/db/init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}']
interval: 10s
timeout: 5s
retries: 5
networks:
- app-net
redis:
image: redis:7-alpine
command: ['redis-server', '--appendonly', 'yes']
volumes:
- redis_data:/data
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 10s
timeout: 3s
retries: 5
networks:
- app-net
volumes:
postgres_data:
redis_data:
networks:
app-net:여기서 중요한 점은 db와 redis에 호스트 포트를 열지 않았다는 것이다. 데이터베이스와 캐시는 외부 브라우저가 직접 접근할 대상이 아니다. 같은 Compose 네트워크 안의 api만 db:5432, redis:6379로 접근하면 된다.
또 하나 눈여겨볼 부분은 환경 변수다. 위 Compose 파일은 ${POSTGRES_USER} 같은 값을 사용한다. Docker Compose는 기본적으로 현재 프로젝트 디렉터리의 .env 파일을 읽어 이런 값을 치환한다. 그래서 새 팀원은 .env.example을 복사해 .env를 만든 뒤 실행하면 된다.
데이터는 named volume에 저장한다. postgres_data와 redis_data는 컨테이너를 삭제해도 유지된다. 반면 애플리케이션 코드는 이미지나 바인드 마운트로 관리한다. 실행 환경과 보존 데이터를 분리하는 원칙은 실제 업무에서도 그대로 적용된다.
개발용 설정: 핫 리로드와 바인드 마운트
개발 환경의 목표는 빠른 반복이다. 코드를 수정할 때마다 이미지를 다시 빌드하면 흐름이 끊긴다. 그래서 개발용 Compose 파일에서는 소스 코드를 바인드 마운트하고, node_modules는 named volume으로 분리한다.
services:
frontend:
build:
target: dev
ports:
- '5173:5173'
volumes:
- ./frontend:/app
- frontend_node_modules:/app/node_modules
command: npm run dev -- --host 0.0.0.0
environment:
NODE_ENV: development
api:
build:
target: dev
volumes:
- ./backend:/app
- backend_node_modules:/app/node_modules
command: npm run dev
environment:
NODE_ENV: development
volumes:
frontend_node_modules:
backend_node_modules:이 설정으로 프론트엔드는 호스트의 localhost:5173에서 열린다. 백엔드는 호스트 포트를 열지 않는다. 브라우저가 /api/products로 요청하면 프론트엔드 개발 서버가 내부 네트워크의 api:3000으로 넘기는 방식이 좋다.
예를 들어 Vite를 사용한다면 frontend/vite.config.js에 다음처럼 프록시를 둘 수 있다.
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 5173,
proxy: {
'/api': {
target: 'http://api:3000',
changeOrigin: true,
},
},
},
});이 구조는 포트 충돌을 줄인다. 여러 프로젝트를 동시에 실행하더라도 외부에 공개하는 포트는 최소화할 수 있다. 또한 백엔드 API 주소를 브라우저에 직접 노출하지 않고, 개발과 배포 모두 /api라는 같은 경로를 사용할 수 있다.
개발 환경 실행 명령은 다음과 같다.
cp .env.example .env
docker compose -f compose.yaml -f compose.dev.yaml up --build상태를 확인할 때도 같은 Compose 파일 조합을 사용하는 편이 가장 헷갈리지 않는다.
docker compose -f compose.yaml -f compose.dev.yaml ps
docker compose -f compose.yaml -f compose.dev.yaml logs -f api
docker compose -f compose.yaml -f compose.dev.yaml exec db psql -U app -d appdb
docker compose -f compose.yaml -f compose.dev.yaml exec redis redis-cli ping작업을 끝낼 때는 컨테이너만 내린다.
docker compose -f compose.yaml -f compose.dev.yaml down데이터까지 완전히 초기화해야 할 때만 -v를 붙인다.
docker compose -f compose.yaml -f compose.dev.yaml down -v-v는 PostgreSQL과 Redis 볼륨을 삭제한다. 테스트 데이터가 모두 사라질 수 있으므로 습관적으로 붙이지 않는 것이 좋다.
배포용 설정: 멀티 스테이지 이미지 사용하기
배포용 이미지는 개발용 컨테이너와 달라야 한다. 개발 서버, 핫 리로드 도구, 테스트 파일, 불필요한 패키지를 포함할 필요가 없다. Dockerfile에서 dev, build, runtime 단계를 나누면 하나의 Dockerfile로 개발과 배포를 함께 처리할 수 있다.
백엔드 Dockerfile의 기본 형태는 다음과 같다.
FROM node:22-alpine AS base
WORKDIR /app
COPY package*.json ./
FROM base AS dev
RUN npm ci
COPY . .
CMD ["npm", "run", "dev"]
FROM base AS build
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY /app/dist ./dist
COPY healthcheck.js ./healthcheck.js
USER node
CMD ["node", "dist/server.js"]이 예시는 package-lock.json을 기준으로 의존성을 설치하고, 빌드 결과가 dist/에 만들어지는 Node.js 프로젝트를 가정한다. 실제 프로젝트의 빌드 명령이나 실행 파일 이름이 다르면 그 부분만 맞춰 바꾸면 된다.
프론트엔드는 개발 때는 Vite 개발 서버로 실행하고, 배포 때는 정적 파일을 빌드한 뒤 Nginx로 제공할 수 있다.
FROM node:22-alpine AS base
WORKDIR /app
COPY package*.json ./
FROM base AS dev
RUN npm ci
COPY . .
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
FROM base AS build
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine AS runtime
COPY /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf배포용 Nginx는 정적 파일을 제공하면서 /api 요청만 백엔드로 넘기면 된다. frontend/nginx.conf는 다음처럼 시작할 수 있다.
server {
listen 80;
location / {
try_files $uri /index.html;
}
location /api/ {
proxy_pass http://api:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}배포용 Compose 파일은 runtime 단계만 사용한다.
services:
frontend:
build:
target: runtime
ports:
- '8080:80'
restart: unless-stopped
api:
build:
target: runtime
environment:
NODE_ENV: production
restart: unless-stopped
db:
restart: unless-stopped
redis:
restart: unless-stopped실행 명령은 다음과 같다.
docker compose -f compose.yaml -f compose.prod.yaml up --build -d브라우저에서는 http://localhost:8080으로 접속한다. 프론트엔드 Nginx는 /api 요청을 내부의 api:3000으로 전달한다. 개발 환경과 마찬가지로 브라우저는 백엔드 컨테이너 이름을 몰라도 된다.
헬스체크와 의존성 대기 전략 통합하기
depends_on은 실행 순서를 도와준다. 하지만 데이터베이스 프로세스가 시작되었다고 해서 바로 쿼리를 받을 준비가 끝난 것은 아니다. 그래서 db에는 pg_isready, redis에는 redis-cli ping, api에는 /health 엔드포인트를 확인하는 헬스체크를 붙였다.
백엔드의 healthcheck.js는 단순해도 된다.
fetch('http://127.0.0.1:3000/health')
.then((res) => {
if (!res.ok) process.exit(1);
process.exit(0);
})
.catch(() => process.exit(1));이 파일은 컨테이너 안에서 실행된다. 그래서 api 자신을 확인할 때는 서비스 이름이 아니라 127.0.0.1:3000을 사용한다. 반대로 api가 PostgreSQL이나 Redis에 접속할 때는 db, redis라는 Compose 서비스 이름을 사용한다.
여기에 더해 백엔드 애플리케이션 자체도 데이터베이스 연결 재시도를 가져야 한다. Compose 설정은 컨테이너 실행 흐름을 안정화하지만, 네트워크 지연이나 데이터베이스 재시작까지 모두 해결하지는 못한다. 실무에서는 애플리케이션 시작 시 일정 횟수 재시도하고, 실패하면 명확한 로그를 남긴 뒤 종료하는 방식이 좋다. 그러면 restart: unless-stopped 같은 정책이 다시 기회를 줄 수 있다.
환경 변수도 같은 원칙으로 관리한다. 예제 비밀번호는 .env.example에 넣을 수 있지만 실제 비밀번호는 커밋하지 않는다.
POSTGRES_USER=app
POSTGRES_PASSWORD=change-me
POSTGRES_DB=appdb.env.example은 문서다. 새 팀원이 어떤 값이 필요한지 알게 해 준다. 실제 값은 로컬의 .env나 CI/CD 비밀 값 저장소에서 주입한다. 운영 환경에서는 예제 비밀번호를 그대로 쓰지 말고, 배포 환경에 맞는 안전한 값으로 반드시 바꿔야 한다.
README에 실행 흐름 문서화하기
좋은 Docker 프로젝트는 Compose 파일만 있는 프로젝트가 아니다. 실행 방법이 짧고 명확하게 문서화된 프로젝트다. README.md에는 최소한 다음 내용을 넣는다.
## 개발 환경 실행
```bash
cp .env.example .env
docker compose -f compose.yaml -f compose.dev.yaml up --build
```
프론트엔드: http://localhost:5173
API 경로: http://localhost:5173/api
## 로그 확인
```bash
docker compose -f compose.yaml -f compose.dev.yaml logs -f api
```
## 데이터 초기화
```bash
docker compose -f compose.yaml -f compose.dev.yaml down -v
```
## 배포 형태로 로컬 실행
```bash
docker compose -f compose.yaml -f compose.prod.yaml up --build -d
```
접속: http://localhost:8080문서에는 "어떤 명령을 실행해야 하는가"뿐 아니라 "언제 데이터를 삭제해도 되는가"도 적어야 한다. 특히 down -v, system prune --volumes 같은 명령은 데이터 손실을 만들 수 있으므로 팀 문서에서 분명히 구분한다.
완성 기준 점검하기
마지막으로 프로젝트가 제대로 구성되었는지 확인한다.
cp .env.example .env
docker compose -f compose.yaml -f compose.dev.yaml up --build다른 터미널에서 상태를 본다.
docker compose -f compose.yaml -f compose.dev.yaml psdb, redis, api가 healthy 상태가 되고, frontend가 실행 중이면 기본 흐름은 완성된 것이다. 브라우저에서 프론트엔드에 접속하고, 상품 목록 같은 API 요청이 정상적으로 돌아오는지 확인한다.
이 프로젝트에는 지금까지 배운 핵심이 모두 들어 있다. 이미지는 Dockerfile로 만들고, 개발과 배포는 빌드 타깃으로 분리한다. 데이터는 볼륨에 저장하고, 서비스는 Compose 네트워크에서 이름으로 연결한다. 환경 변수는 파일과 설정으로 분리하고, 헬스체크와 재시작 정책으로 실행 흐름을 안정화한다. 새 팀원은 저장소를 클론한 뒤 문서의 명령을 따라 전체 환경을 실행할 수 있다.
이 정도의 틀이 있으면 실제 업무에 Docker와 Docker Compose를 적용할 준비가 된 것이다. 프로젝트가 커지면 서비스가 늘고, CI/CD와 레지스트리, 보안 스캔, 모니터링이 추가된다. 하지만 기본 구조는 변하지 않는다. 실행 환경은 코드로 남기고, 데이터는 컨테이너와 분리하며, 여러 서비스를 Compose로 재현 가능하게 묶는다.
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.