Nginx + WAS 풀스택 배포와 반복 배포 워크플로우
개발 서버에서는 잘 동작하던 API가 배포 직후 Nginx 502를 반환하고, 핫픽스를 올려도 반영이 늦습니다. 문제는 코드 자체보다 Web/WAS 역할 분리와 배포 루프가 팀 내에서 표준화되지 않은 데 있습니다. Nginx 앞단과 WAS 뒷단을 Compose로 정확히 묶고, 수정→빌드→검증→롤백 흐름을 고정하면 장애 대응 속도가 달라집니다. 이 모듈은 그 운영 루프를 실습 단위로 체득하게 만듭니다.
웹 서비스는 보통 두 개의 레이어로 구성됩니다. **정적 파일과 리버스 프록시를 담당하는 Nginx(Web Server)**와 **비즈니스 로직을 처리하는 WAS(Web Application Server)**입니다. Docker Compose로 이 두 컨테이너를 함께 구성하고, 코드를 수정한 뒤 재빌드하여 반영하는 전체 워크플로우를 실습합니다.
실제 서비스 구조(Nginx + WAS)를 Docker Compose로 로컬에 재현하고, 개발 중 코드 수정을 즉시 반영하는 방법과 프로덕션 배포 시 이미지를 재빌드해 반영하는 방법을 모두 익힙니다.
- 1Nginx 리버스 프록시 — proxy_pass로 WAS에 요청 전달하는 구조
- 2Docker Compose로 Nginx + WAS 두 서비스 연결하기
- 3개발 환경: bind mount + hot reload로 재빌드 없이 코드 반영
- 4프로덕션 환경: docker compose up --build로 변경사항 빌드·배포
- 5수정 → 빌드 → 확인 반복 사이클을 빠르게 돌리는 실전 패턴
이 챕터는 로컬 파일 편집과 Docker Compose를 반복해서 사용합니다. 실습 디렉토리를 미리 만들고 시작하세요.
docker compose versionmkdir -p ~/web-was-lab && cd ~/web-was-labdocker pull node:20-alpinedocker pull nginx:alpineNginx + WAS 아키텍처 — 왜 두 개로 나누는가

리버스 프록시 패턴의 역할 분리
단일 WAS(예: Node.js Express)만으로도 HTTP 서버를 만들 수 있습니다. 그런데 실무에서는 왜 Nginx를 앞에 두는 걸까요?
인터넷
│
▼
[Nginx :80/:443] ← Web Server (정적 파일, SSL, 로드밸런싱)
│ proxy_pass
▼
[WAS :3000] ← App Server (API, 비즈니스 로직, DB 연결)
│
▼
[DB :5432] ← 데이터베이스
Nginx가 처리하는 것:
- 정적 파일 서빙 (HTML, CSS, JS, 이미지) — WAS보다 10~100배 빠름
- SSL/TLS 종단 처리 — WAS는 HTTP만 처리
- 요청 버퍼링 — 느린 클라이언트로부터 WAS 보호
- 여러 WAS 인스턴스로 로드 밸런싱
WAS가 처리하는 것:
- API 요청 처리 (
/api/*) - 데이터베이스 연결과 쿼리
- 인증/인가 로직
- 비즈니스 규칙
Docker Compose에서의 연결 방식
두 서비스는 같은 Compose 네트워크 안에서 서비스 이름으로 서로를 찾습니다.
# docker-compose.yml
services:
nginx:
image: nginx:alpine
ports:
- "80:80" # 외부에서 80포트로 접근
depends_on:
- app
app:
build: ./app
# ports를 외부에 노출하지 않음 — nginx를 통해서만 접근 가능
# nginx.conf
server {
listen 80;
# /api 로 시작하는 요청은 WAS로 전달
location /api {
proxy_pass http://app:3000; # 서비스 이름 'app' 사용
}
# 나머지는 정적 파일 서빙
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
}
}
proxy_pass http://app:3000에서 app은 docker-compose.yml에 정의한 서비스 이름입니다. Docker의 내장 DNS가 app을 해당 컨테이너 IP로 자동 해석합니다.
프로젝트 구조 설계
Nginx와 WAS를 연동할 때 가장 먼저 막히는 것은 "파일을 어디에 두어야 하는가"입니다. nginx.conf를 컨테이너에 어떻게 전달하는지, app 코드는 이미지에 넣는지 볼륨으로 마운트하는지, docker-compose.yml은 어디서 nginx를 참조하는지—이 구조를 처음부터 일관되게 잡지 않으면 나중에 파일 경로 오류와 mount 충돌로 디버깅이 복잡해집니다. 이 ConceptBlock에서는 Nginx + WAS 실습 프로젝트의 디렉토리 레이아웃과 각 파일의 역할을 다룹니다.

디렉토리 레이아웃
실습에서 사용할 프로젝트 구조입니다.
web-was-lab/
├── docker-compose.yml # 전체 서비스 구성
├── docker-compose.dev.yml # 개발 환경 override
├── nginx/
│ ├── Dockerfile # (또는 config만 mount)
│ └── nginx.conf # 리버스 프록시 설정
└── app/
├── Dockerfile # WAS 이미지 빌드
├── package.json
└── src/
└── index.js # 애플리케이션 코드
WAS 코드 (Node.js Express)
// app/src/index.js
const express = require('express')
const app = express()
const PORT = process.env.PORT || 3000
app.use(express.json())
// 헬스체크 엔드포인트
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', version: process.env.APP_VERSION || '1.0.0' })
})
// 메인 API
app.get('/api/hello', (req, res) => {
res.json({ message: '안녕하세요, Docker WAS입니다!' })
})
app.listen(PORT, () => {
console.log(`WAS 서버 시작: 포트 ${PORT}`)
})
// app/package.json
{
"name": "was-app",
"version": "1.0.0",
"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js"
},
"dependencies": {
"express": "^4.18.2"
},
"devDependencies": {
"nodemon": "^3.0.2"
}
}
WAS Dockerfile
# app/Dockerfile
FROM node:20-alpine
WORKDIR /app
# 의존성 먼저 복사 (레이어 캐시 최적화)
COPY package*.json ./
RUN npm ci --only=production
# 소스 코드 복사
COPY src/ ./src/
ENV PORT=3000
EXPOSE 3000
CMD ["node", "src/index.js"]
Nginx 설정
# nginx/nginx.conf
upstream was_backend {
server app:3000;
}
server {
listen 80;
server_name localhost;
# 액세스 로그 포맷
access_log /var/log/nginx/access.log;
# 정적 파일 서빙
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
# API 요청을 WAS로 전달
location /api/ {
proxy_pass http://was_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 10s;
proxy_read_timeout 30s;
}
}
개발 환경 vs 프로덕션 환경 구성
개발 중에는 코드를 수정할 때마다 이미지를 다시 빌드하면 너무 느립니다. 반대로 프로덕션에서 소스 코드를 bind mount로 제공하면 서버 파일 시스템에 직접 노출되는 보안 문제가 생깁니다. 두 환경의 요구가 다르기 때문에 Compose는 base 파일과 override 파일을 분리하는 패턴을 제공합니다. "개발할 때 잘 됐는데 서버에 올리면 안 돼요"라는 상황의 절반은 개발/프로덕션 설정이 분리되지 않아서 생깁니다. 이 ConceptBlock에서는 docker-compose.yml과 docker-compose.dev.yml을 분리해 환경별로 다른 설정을 적용하는 방법을 다룹니다.

기본 docker-compose.yml (프로덕션 기준)
# docker-compose.yml
services:
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ./nginx/html:/usr/share/nginx/html:ro
depends_on:
app:
condition: service_healthy
restart: unless-stopped
app:
build:
context: ./app
dockerfile: Dockerfile
environment:
- NODE_ENV=production
- PORT=3000
- APP_VERSION=1.0.0
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 10s
restart: unless-stopped
networks:
default:
name: web-was-network
개발 환경 override 파일
# docker-compose.dev.yml
# 사용법: docker compose -f docker-compose.yml -f docker-compose.dev.yml up
services:
app:
build:
target: development # 멀티 스테이지 dev 스테이지 사용 시
volumes:
# 소스 코드를 bind mount — 파일 변경이 즉시 반영됨
- ./app/src:/app/src
environment:
- NODE_ENV=development
- APP_VERSION=dev
command: npm run dev # nodemon으로 hot reload
ports:
- "3000:3000" # 개발 중 직접 접근 허용
개발 환경에서는 소스 코드 디렉토리를 bind mount하기 때문에, 호스트에서 파일을 수정하면 컨테이너 내부에 즉시 반영됩니다. nodemon이 파일 변경을 감지해 서버를 자동 재시작합니다.
기본 실습
실습 디렉토리 구조를 만들고 위의 파일들을 각 경로에 생성합니다.
실습 전 디렉토리와 예제 파일을 먼저 준비합니다.
# 실습 디렉토리 준비
mkdir -p /tmp/docker/part5/exam_18 && cd /tmp/docker/part5/exam_18
# 실습 디렉토리 준비
mkdir -p /tmp/docker/part3/exam_2 && cd /tmp/docker/part3/exam_2
# Nginx + WAS 연동 실습용 기본 구조 생성
mkdir -p nginx/html app/src
cat > nginx/nginx.conf << 'EOF'
upstream was {
server app:3000;
}
server {
listen 80;
location / {
root /usr/share/nginx/html;
}
location /api/ {
proxy_pass http://was/;
}
}
EOF
이제 실습을 진행합니다.
cd ~/web-was-lab
# nginx/html/index.html 생성 (간단한 정적 페이지)
cat > nginx/html/index.html << 'EOF'
<!DOCTYPE html>
<html>
<head><title>Docker Web+WAS 실습</title></head>
<body>
<h1>Nginx 정적 파일 서빙</h1>
<button onclick="fetch('/api/hello').then(r=>r.json()).then(d=>alert(d.message))">
WAS API 호출
</button>
</body>
</html>
EOF
mkdir -p ~/web-was-lab/{nginx/html,app/src}--build 플래그로 app 이미지를 빌드하고 전체 스택을 실행합니다.
docker compose up --build -d
# 실행 상태 확인
docker compose ps
# 예상 출력:
# NAME SERVICE STATUS PORTS
# web-was-lab-app-1 app running 3000/tcp
# web-was-lab-nginx-1 nginx running 0.0.0.0:80->80/tcp
# 헬스체크 상태 확인 (healthy가 될 때까지 대기)
docker compose ps --format "table {{.Service}}\t{{.Status}}"
브라우저에서 http://localhost 접속 — Nginx가 정적 파일을 서빙합니다.
/api/hello 호출 — Nginx가 WAS로 요청을 전달합니다.
docker compose up --build -dapp/src/index.js를 수정하고 WAS 서비스만 재빌드해서 반영합니다.
# 1. 코드 수정: 응답 메시지 변경
cat > app/src/index.js << 'EOF'
const express = require('express')
const app = express()
const PORT = process.env.PORT || 3000
app.use(express.json())
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', version: process.env.APP_VERSION || '1.0.0' })
})
app.get('/api/hello', (req, res) => {
// 메시지 수정!
res.json({ message: '수정된 버전입니다!', timestamp: new Date().toISOString() })
})
// 새 엔드포인트 추가
app.get('/api/items', (req, res) => {
res.json({ items: ['사과', '바나나', '체리'] })
})
app.listen(PORT, () => console.log(`포트 ${PORT} 실행 중`))
EOF
# 2. app 서비스만 재빌드 + 재시작 (nginx는 그대로)
docker compose up --build -d app
# 3. 빌드 과정 로그 확인
docker compose logs app --tail=20
# 4. 변경 확인
curl http://localhost/api/hello
# {"message":"수정된 버전입니다!","timestamp":"..."}
curl http://localhost/api/items
# {"items":["사과","바나나","체리"]}
docker compose up --build -d appbind mount + nodemon으로 재빌드 없이 코드 변경을 즉시 반영합니다.
# 개발 환경 실행 (override 파일 추가)
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
# nodemon이 시작되는 로그 확인
docker compose logs app -f
# 예상 출력:
# [nodemon] 3.0.2
# [nodemon] watching path(s): *.*
# [nodemon] watching extensions: js,mjs,cjs,json
# [nodemon] starting `node src/index.js`
# 포트 3000 실행 중
# 이제 호스트에서 파일을 수정하면 즉시 반영됨
echo "// 주석 추가" >> app/src/index.js
# 로그에서 nodemon이 자동 재시작하는 것을 확인
# [nodemon] restarting due to changes...
# [nodemon] starting `node src/index.js`
# 포트 3000 실행 중
개발 중에는 이 방식으로, 배포할 때는 --build로 이미지를 새로 만들어 배포하는 패턴을 구분해서 사용합니다.
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d배포 버전을 이미지 태그로 관리하면 이전 버전으로 빠르게 롤백할 수 있습니다.
# v1.0 이미지 보존용 태그
docker compose build app
docker tag web-was-lab-app:latest web-was-lab-app:v1.0
# 코드 수정 후 v1.1 빌드
# ... (코드 수정)
docker compose build app
docker tag web-was-lab-app:latest web-was-lab-app:v1.1
# v1.1 배포
docker compose up -d app
# 문제 발생 시 v1.0으로 롤백
# docker-compose.yml의 app.image를 web-was-lab-app:v1.0으로 변경 후
docker compose up -d app
# 이미지 목록 확인
docker images | grep web-was-lab-app
docker compose build --build-arg APP_VERSION=1.1.0 app- Nginx에서 app 서비스로 proxy_pass가 정확히 연결되어 502 없이 응답하는가?
- 개발 모드(bind mount + hot reload)와 배포 모드(rebuild + replace)의 차이를 실제로 확인했는가?
- 버전 태그 기반으로 이전 이미지로 되돌리는 롤백 절차를 재현했는가?
트러블슈팅
Nginx가 502 오류를 반환하면 WAS 컨테이너와의 연결에 문제가 있는 것입니다.
# 1. WAS 컨테이너 상태 확인
docker compose ps
docker compose logs app --tail=30
# 2. Nginx 오류 로그 확인
docker compose logs nginx --tail=20
# upstream connect error / connection refused 메시지 확인
# 3. 네트워크 연결 직접 테스트
docker compose exec nginx wget -qO- http://app:3000/api/health
# 이 명령이 실패하면 서비스 이름 불일치 또는 WAS가 아직 시작 중
# 4. WAS가 시작되기 전에 Nginx가 연결 시도하는 경우
# → docker-compose.yml에 depends_on + healthcheck 추가
# 해결: nginx가 app의 헬스체크 통과 후 시작하도록
nginx:
depends_on:
app:
condition: service_healthy
가장 흔한 원인은 세 가지입니다:
- nginx.conf의
proxy_passURL이 docker-compose.yml 서비스 이름과 다름 (appvswas) - WAS가 아직 시작 중인데 Nginx가 먼저 트래픽을 받는 경우 →
healthcheck+depends_on으로 해결 - WAS가 크래시된 경우 →
docker compose logs app으로 오류 확인
docker compose up -d를 재실행했는데 이전 코드가 그대로 동작하는 경우입니다.
# 문제: --build 없이 실행하면 기존 이미지 재사용
docker compose up -d # 이미지 재빌드 안 함!
# 해결 1: --build 플래그 명시
docker compose up --build -d app
# 해결 2: 이미지 강제 삭제 후 재빌드
docker compose down
docker image rm web-was-lab-app
docker compose up --build -d
# 현재 실행 중인 컨테이너가 어느 이미지를 사용하는지 확인
docker inspect web-was-lab-app-1 --format '{{.Image}}'
# sha256:... 로 확인 후 docker images와 비교
개발 환경에서 bind mount를 사용하는 경우에는 이 문제가 발생하지 않습니다. 프로덕션 배포 워크플로우에서는 항상 --build를 명시하는 습관을 들이세요.
개발 환경에서 ./app:/app으로 전체 디렉토리를 mount하면 컨테이너 내부의 node_modules가 호스트 디렉토리로 덮어쓰여 오류가 발생합니다.
# 증상: nodemon 시작 후 모듈을 찾을 수 없다는 오류
# Error: Cannot find module 'express'
# 원인: 전체 /app을 mount하면 컨테이너에서 RUN npm ci로 설치한
# node_modules가 호스트의 빈 디렉토리로 덮어쓰임
# 해결: 소스 코드만 mount하고 node_modules는 컨테이너 내부 유지
app:
volumes:
- ./app/src:/app/src # 소스만 mount
# node_modules는 mount 안 함 → 컨테이너 내부 것 사용
# 또는 named volume으로 node_modules 보호
app:
volumes:
- ./app:/app # 전체 mount
- /app/node_modules # node_modules는 anonymous volume으로 보호
두 번째 방식에서 /app/node_modules 줄은 익명 볼륨으로 컨테이너 내부의 node_modules를 호스트 mount로부터 보호합니다.
실무 맥락
팀에 새로운 백엔드 개발자가 합류했습니다. 기존에는 각자 PC에 Node.js를 직접 설치하고, nginx는 EC2 서버에만 있어서 "내 로컬에서는 되는데 서버에서는 안 돼요" 문제가 반복됐습니다.
Docker Compose로 Nginx + WAS 구성을 코드로 관리하면서 달라진 점:
# 새 팀원 온보딩: 이 세 줄로 끝
git clone https://github.com/company/backend.git
cd backend
docker compose up --build -d
# 개발 사이클:
# 1. 코드 수정 (VSCode에서 일반적으로 편집)
# 2. 로컬 확인: curl http://localhost/api/xxx
# 3. 커밋 & 푸시
# 4. 서버에서: git pull && docker compose up --build -d
환경 변수로 개발/스테이징/프로덕션 구분: 같은 이미지를 쓰되 환경별 변수만 바꾸는 패턴입니다.
# 개발
APP_VERSION=dev NODE_ENV=development docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
# 스테이징
APP_VERSION=1.2.0-rc1 NODE_ENV=staging docker compose up --build -d
# 프로덕션
APP_VERSION=1.2.0 NODE_ENV=production docker compose up --build -d
이 패턴이 익숙해지면, 나중에 CI/CD 파이프라인에서 docker compose up --build -d를 자동 실행하도록 확장하기 쉽습니다. 빌드와 배포가 표준화된 명령어 하나로 수렴되기 때문입니다.
기존 PHP/Apache 모놀리식 서비스를 Node.js API + Nginx로 전환하는 마이그레이션 프로젝트에서, 전환 기간 동안 구버전과 신버전을 동시에 운영해야 했습니다.
# 마이그레이션 기간 compose 구성
services:
nginx:
volumes:
- ./nginx/migration.conf:/etc/nginx/conf.d/default.conf:ro
new-api:
build: ./new-api
# 새 API 서버
legacy-php:
image: php:8.2-apache
volumes:
- ./legacy:/var/www/html
# 기존 PHP 서비스
# nginx/migration.conf — 경로별로 트래픽 분리
server {
listen 80;
# 새로 개발된 엔드포인트는 새 API로
location /api/v2/ {
proxy_pass http://new-api:3000;
}
# 아직 마이그레이션 안 된 엔드포인트는 레거시로
location / {
proxy_pass http://legacy-php:80;
}
}
Nginx의 location 블록으로 URL 경로 기준으로 트래픽을 분리했습니다. 새 엔드포인트가 준비될 때마다 nginx.conf만 수정하고 docker compose restart nginx로 반영해 점진적으로 마이그레이션을 진행했습니다.
핵심 요약
| 상황 | 명령어 | 설명 |
|---|---|---|
| 최초 빌드 및 실행 | docker compose up --build -d | Dockerfile 빌드 후 전체 스택 실행 |
| 코드 수정 후 재배포 | docker compose up --build -d app | app 서비스만 재빌드 |
| 개발 환경 실행 | docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d | hot reload 포함 |
| 로그 확인 | docker compose logs -f app | WAS 실시간 로그 |
| 전체 종료 | docker compose down | 컨테이너+네트워크 삭제 |
| 완전 초기화 | docker compose down -v --rmi local | 볼륨+이미지까지 삭제 |
| 서비스 상태 확인 | docker compose ps | 헬스체크 포함 상태 |
| WAS에 직접 접속 | docker compose exec app sh | 내부 디버깅 |