infra
Platform

모듈 맵

[Docker] 프로덕션 수준의 멀티 서버 아키텍처 배포 워크플로우

0 / 27 완료

펼치기
0 / 27 완료0%

Docker · 18 / 27

[Docker] 프로덕션 수준의 멀티 서버 아키텍처 배포 워크플로우

Nginx 리버스 프록시와 WAS(Node.js/Python)를 Docker Compose로 구성하고, 코드 수정 후 재빌드·반영하는 실전 워크플로우를 익힙니다

Nginx + WAS 풀스택 배포와 반복 배포 워크플로우

🚨INCIDENT ALERT
HIGH

개발 서버에서는 잘 동작하던 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 V2 확인
docker compose version
실습 디렉토리 생성
mkdir -p ~/web-was-lab && cd ~/web-was-lab
Node.js 이미지 미리 pull
docker pull node:20-alpine
Nginx 이미지 미리 pull
docker pull nginx:alpine
💡개념

Nginx + WAS 아키텍처 — 왜 두 개로 나누는가

Nginx + 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 네트워크 안에서 서비스 이름으로 서로를 찾습니다.

YAML
# docker-compose.yml
services:
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"          # 외부에서 80포트로 접근
    depends_on:
      - app

  app:
    build: ./app
    # ports를 외부에 노출하지 않음 — nginx를 통해서만 접근 가능
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)

JS
// 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}`)
})
JSON
// 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

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/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을 분리해 환경별로 다른 설정을 적용하는 방법을 다룹니다.

개발 환경 vs 프로덕션 환경 구성

기본 docker-compose.yml (프로덕션 기준)

YAML
# 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 파일

YAML
# 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이 파일 변경을 감지해 서버를 자동 재시작합니다.

기본 실습

1프로젝트 파일 생성

실습 디렉토리 구조를 만들고 위의 파일들을 각 경로에 생성합니다.

실습 전 디렉토리와 예제 파일을 먼저 준비합니다.

로컬 터미널
# 실습 디렉토리 준비
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}
2첫 번째 빌드와 실행

--build 플래그로 app 이미지를 빌드하고 전체 스택을 실행합니다.

Docker
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 -d
3코드 수정 후 재빌드·반영

app/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 app
4개발 환경으로 전환 — hot reload 설정

bind mount + nodemon으로 재빌드 없이 코드 변경을 즉시 반영합니다.

Docker
# 개발 환경 실행 (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
5버전 태그로 이미지 관리와 롤백

배포 버전을 이미지 태그로 관리하면 이전 버전으로 빠르게 롤백할 수 있습니다.

Docker
# 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 컨테이너와의 연결에 문제가 있는 것입니다.

Docker
# 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 추가
YAML
# 해결: nginx가 app의 헬스체크 통과 후 시작하도록
nginx:
  depends_on:
    app:
      condition: service_healthy

가장 흔한 원인은 세 가지입니다:

  1. nginx.conf의 proxy_pass URL이 docker-compose.yml 서비스 이름과 다름 (app vs was)
  2. WAS가 아직 시작 중인데 Nginx가 먼저 트래픽을 받는 경우 → healthcheck + depends_on으로 해결
  3. WAS가 크래시된 경우 → docker compose logs app으로 오류 확인

docker compose up -d를 재실행했는데 이전 코드가 그대로 동작하는 경우입니다.

Docker
# 문제: --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가 호스트의 빈 디렉토리로 덮어쓰임
YAML
# 해결: 소스 코드만 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를 자동 실행하도록 확장하기 쉽습니다. 빌드와 배포가 표준화된 명령어 하나로 수렴되기 때문입니다.

💼
실무 맥락레거시 서비스 Docker화 — PHP + Apache에서 Nginx + Node.js WAS로 전환
현업 패턴

기존 PHP/Apache 모놀리식 서비스를 Node.js API + Nginx로 전환하는 마이그레이션 프로젝트에서, 전환 기간 동안 구버전과 신버전을 동시에 운영해야 했습니다.

YAML
# 마이그레이션 기간 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
# 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 -dDockerfile 빌드 후 전체 스택 실행
코드 수정 후 재배포docker compose up --build -d appapp 서비스만 재빌드
개발 환경 실행docker compose -f docker-compose.yml -f docker-compose.dev.yml up -dhot reload 포함
로그 확인docker compose logs -f appWAS 실시간 로그
전체 종료docker compose down컨테이너+네트워크 삭제
완전 초기화docker compose down -v --rmi local볼륨+이미지까지 삭제
서비스 상태 확인docker compose ps헬스체크 포함 상태
WAS에 직접 접속docker compose exec app sh내부 디버깅

지식 확인

퀴즈 — 5문제

Q1

Nginx가 WAS 앞단에서 리버스 프록시로 동작할 때 얻을 수 있는 이점이 아닌 것은?

Q2

docker compose up --build 명령과 docker compose up의 차이는?

Q3

개발 환경에서 코드 변경 시 컨테이너를 재빌드 없이 즉시 반영하는 가장 일반적인 방법은?

Q4

docker compose up --build 실행 후 Nginx가 WAS를 서비스 이름으로 찾지 못하는 원인으로 가장 가능성이 높은 것은?

Q5

프로덕션 환경에서 코드 변경 후 무중단 재배포를 위해 Docker Compose 수준에서 사용할 수 있는 방법은?

0 / 5 답변

🧪 실습으로 확인하기

Docker Compose 멀티 서비스 구성

초급

docker-compose.yml로 nginx + 앱 컨테이너를 함께 정의하고, 서비스 간 통신과 볼륨 마운트를 구성한다.

35📋 4단계💻 직접 환경
실습 시작하기 →

이것도 배워보세요

docker중급 · 55
[Docker] 백엔드 서버 이슈를 쫓는 도커 셸 접속과 디버깅 기법
Docker 트랙 계속
networking입문 · 45
[Network] OSI 7계층과 TCP/IP 4계층 모델 실무적 관점 분석
Networking 트랙 시작점