infra
Platform

모듈 맵

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

0 / 27 완료

펼치기
0 / 27 완료0%

도커 & 컨테이너 · 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를 앞에 두는 걸까요?

Web-WAS-DB 3-tier 배포 — 인터넷→Nginx(:80/:443, 정적·SSL·로드밸런싱)→proxy_pass→WAS(:3000, API·비즈니스 로직)→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 배포 프로젝트 구조 — Nginx(정적·리버스 프록시)와 WAS(동적 앱) 컨테이너를 docker-compose로 묶고, 각 서비스의 Dockerfile·설정(nginx.conf)·환경파일(.env)·볼륨을 디렉터리로 구조화. 정적/동적 분리와 설정 외부화로 환경별 재현성과 유지보수성을 확보확대

디렉토리 레이아웃

실습에서 사용할 프로젝트 구조입니다.

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 프로덕션 환경 구성 — 개발은 소스 bind mount(실시간 반영)·디버그 포트·상세 로그, 프로덕션은 빌드된 이미지·포트 최소 개방·리소스 제한·헬스체크. 공통 compose.yml에 override 파일(compose.override.yml/compose.prod.yml)로 환경 차이만 분리해 같은 정의로 안전하게 전환확대

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

💡개념

docker compose up 하면 2계층이 어떤 순서로 뜨나 — 빌드부터 첫 요청까지

docker compose up --build를 치면 잠시 뒤 브라우저에서 페이지가 뜨지만, 그 사이 Web(Nginx)과 WAS는 정해진 순서로 기동됩니다. 이 순서를 모르면 "배포 직후 502"가 났을 때 Nginx 문제인지, WAS가 아직 안 뜬 건지, 이름을 못 찾는 건지 구분하지 못합니다. Web-WAS 2계층 배포는 이미지 빌드 → 컨테이너 기동 → WAS 리슨 → 헬스체크 통과 → Nginx 프록시 연결 → 첫 요청 라우팅의 결과입니다.

TEXT
[명령]  docker compose up --build
   │
   ① 이미지 빌드 — WAS Dockerfile로 앱 이미지 생성        (Nginx는 보통 공식 이미지 그대로)
   │
   ② 네트워크·컨테이너 기동 — 사용자 정의 네트워크 생성 후 컨테이너 start
   │
   ③ WAS 리슨 — 앱이 부팅되어 3000 포트를 LISTEN           (기동에 수 초~수십 초)
   │
   ④ 헬스체크 통과 — WAS가 healthy가 될 때까지 대기          (depends_on: service_healthy)
   │     → Nginx는 이 신호를 기다렸다 뜬다
   │
   ⑤ 프록시 연결 — Nginx가 proxy_pass의 app:3000을 DNS로 풀어 연결
   │
   ⑥ 첫 요청 라우팅 — location /는 정적 파일, location /api는 WAS로 분기
   ▼
[결과]  브라우저 200 — 정적은 Nginx가, 동적은 WAS가 응답

각 단계에서 무슨 일이 일어나고, 막히면 어떤 증상인가:

단계하는 일여기서 막히면
① 빌드WAS Dockerfile로 이미지 생성. --build 없이 올리면 옛 이미지 재사용코드 고쳤는데 반영 안 됨 → --build 빠뜨린 것 / 빌드 실패면 컨테이너가 아예 안 뜸
② 기동Compose가 프로젝트 네트워크를 만들고 두 컨테이너를 그 위에 올린다같은 사용자 정의 네트워크가 아니면 서비스 이름 해석 불가(host not found in upstream)
③ WAS 리슨앱이 부팅되어 3000을 LISTEN. PORT·바인드 주소가 맞아야 함앱이 127.0.0.1에만 바인드하면 다른 컨테이너에서 못 붙음 → 0.0.0.0 바인드 필요
④ 헬스체크healthcheck가 통과해야 Nginx가 service_healthy로 기동WAS가 아직 준비 전인데 Nginx가 먼저 요청 받으면 502 Bad Gateway
⑤ 프록시 연결Nginx가 proxy_pass의 서비스 이름을 임베디드 DNS로 IP 해석해 연결proxy_pass 이름이 compose 서비스 이름과 다르면 502, localhost로 쓰면 자기 자신을 찾다 실패
⑥ 라우팅location /는 정적, location /api는 WAS로 분기WAS는 살아있는데 응답이 느리면 504 Gateway Timeout(502와 원인 다름)

즉 **"배포했는데 502"는 대부분 ④(WAS가 아직 안 떴는데 Nginx가 먼저 받음) 또는 ⑤(이름 불일치·localhost 오사용)**에서 갈립니다 — 앱 코드 문제가 아닐 때가 많습니다. curl -I로 502(연결 실패)와 504(응답 지연)를 먼저 가르고, docker compose exec nginx wget -qO- http://app:3000/api/health로 Nginx→WAS 경로가 실제로 열려 있는지 확인하면 어느 단계에서 끊겼는지 좁혀집니다. depends_on: service_healthy가 ④를, 정확한 서비스 이름이 ⑤를 보장합니다.

기본 실습

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
🔍실행 후 확인할 것
  • 먼저 curl -I http://localhost 의 응답 코드를 확인한다 — 그 다음 docker logs nginx-container 에서 upstream connect error 메시지가 있는지 읽는다. 502 Bad Gateway는 Nginx가 WAS에 연결을 시도했지만 실패한 것이고, 504 Gateway Timeout은 WAS가 응답하지 않는 것이다 — 원인이 다르므로 두 코드를 구분해야 한다
  • nginx.conf의 proxy_pass에 서비스 이름이 docker-compose.yml의 서비스 이름과 정확히 일치하는지 확인한다 — 하나라도 다르면 DNS 해석 실패로 502가 난다. 컨테이너 이름(--name)이 아닌 서비스 이름(docker-compose.yml의 최상위 키)을 써야 한다
  • docker compose ps 에서 모든 서비스가 Up 상태이고 — docker stats 에서 WAS 컨테이너 CPU 사용률이 100% 근처이면 → WAS 과부하로 502가 날 수 있다. CPU가 정상인데도 502가 나면 네트워크 설정 또는 포트 불일치 문제다

트러블슈팅

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로부터 보호합니다.

심화 — 컨테이너 통신의 실제: NET 네임스페이스와 임베디드 DNS

💡개념

심화: 왜 서비스 이름은 되고 localhost는 안 되나 — 네트워크 네임스페이스

"nginx.conf에 proxy_pass만 맞게 쓰면 되겠지"에서 멈추면, localhost로 썼을 때 왜 502가 나는지·왜 서비스 이름은 되는지 설명하지 못합니다. 그 답은 컨테이너가 네트워크를 어떻게 갖는지에 있습니다.

  • 컨테이너마다 독립된 NET 네임스페이스: docker run은 컨테이너에 자기만의 네트워크 네임스페이스를 줍니다 — 자기 loopback(127.0.0.1)과 자기 인터페이스를 갖습니다. 그래서 nginx 컨테이너 안에서의 localhostnginx 자신이지 WAS가 아닙니다. proxy_pass http://localhost:3000은 nginx가 자기 자신의 3000 포트를 찾다 실패합니다.
  • veth로 브리지에 연결된다: 컨테이너의 인터페이스는 veth 페어로 호스트의 브리지에 연결됩니다. Compose는 프로젝트마다 사용자 정의 브리지 네트워크를 자동으로 만들어 모든 서비스를 그 위에 올립니다.
  • 임베디드 DNS가 이름을 IP로 푼다: 같은 사용자 정의 네트워크의 컨테이너끼리는 Docker의 임베디드 DNS(127.0.0.11) 가 서비스 이름을 현재 컨테이너 IP로 해석해 줍니다. 그래서 proxy_pass http://app:3000처럼 서비스 이름을 쓰면, 컨테이너가 재시작돼 IP가 바뀌어도 이름으로 계속 찾습니다.
  • 이름 해석은 '같은 네트워크'에서만: 이 DNS 해석은 두 컨테이너가 같은 사용자 정의 네트워크에 있을 때만 됩니다. 기본 bridge 네트워크는 이름 기반 DNS를 제공하지 않고, 서로 다른 네트워크에 있으면 이름을 풀 수 없습니다.

정리하면 컨테이너 간 통신은 'localhost 공유'가 아니라 '같은 네트워크 위에서 이름으로 서로를 찾는' 모델입니다. 이 그림이 있으면 502의 원인이 이름인지, 네트워크인지, 앱 준비 상태인지 나눠서 볼 수 있습니다.

상황: docker compose up으로는 잘 붙던 Nginx + WAS 구성을, 급히 nginx만 docker run으로 따로 띄웠습니다. 그랬더니 nginx가 시작조차 못 하고 nginx: [emerg] host not found in upstream "app"을 뱉거나, 접속 시 502가 납니다. 같은 nginx.conf인데 Compose에서만 되는 것이 이상합니다.

원인: Docker의 서비스 이름 → IP 해석(임베디드 DNS)은 두 컨테이너가 같은 사용자 정의 네트워크에 있을 때만 동작합니다. Compose는 프로젝트마다 사용자 정의 브리지 네트워크를 자동으로 만들어 모든 서비스를 그 위에 올리기 때문에 이름으로 서로를 찾습니다. 그런데 nginx를 docker run으로 따로 띄우면 기본 bridge(또는 app과 다른 네트워크)에 붙는데, 기본 bridge는 이름 기반 DNS를 제공하지 않고 app과도 다른 네트워크라 app이라는 이름을 풀 수 없습니다.

진단: docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' <nginx><app>을 각각 실행해 두 컨테이너가 같은 네트워크에 있는지 확인합니다. docker network ls로 Compose가 만든 네트워크(보통 <프로젝트>_default)가 보이는지, nginx가 그 위에 있는지 봅니다.

해결: 두 컨테이너를 같은 사용자 정의 네트워크에 둡니다 — docker network create appnet 후 두 컨테이너 모두 --network appnet으로 실행하거나, 애초에 하나의 Compose로 함께 관리합니다. 임시로는 docker network connect <app의 네트워크> <nginx>로 nginx를 app의 네트워크에 붙일 수 있습니다. 핵심 원칙은 '서비스 이름 해석은 같은 사용자 정의 네트워크 안에서만 된다'는 것입니다.

실무 맥락

💼
실무 맥락스타트업 초기 배포 환경 — 로컬 개발과 서버 배포의 차이를 없애는 과정
현업 패턴

팀에 새로운 백엔드 개발자가 합류했습니다. 기존에는 각자 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내부 디버깅

명령어·단축키 빠른 참조

Nginx+WAS 배포 루프에서 실제로 쓴 Compose·디버깅·롤백 명령을 모았습니다.

명령어/단축키용도자주 쓰는 예
docker compose up --build재빌드 후 실행(전체/특정 서비스)docker compose up --build -d / ... -d app(app만)
docker compose up --no-deps의존 서비스 재시작 없이 한 서비스만 교체docker compose up --build -d --no-deps app
docker compose -f -fbase+override로 개발 환경 전환docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
docker compose build --build-arg빌드 인자로 버전 주입docker compose build --build-arg APP_VERSION=1.1.0 app
docker compose ps서비스 상태·헬스 확인docker compose ps --format "table {{.Service}}\t{{.Status}}"
docker compose logs서비스 로그 확인docker compose logs -f app / docker compose logs nginx --tail=20
docker compose exec컨테이너 내부 접속·연결 테스트docker compose exec nginx wget -qO- http://app:3000/api/health
curl -INginx 응답 코드로 502/504 구분curl -I http://localhost (502=연결실패, 504=타임아웃)
docker tag / images버전 태그로 롤백 대비docker tag web-was-lab-app:latest web-was-lab-app:v1.0
docker inspect실행 컨테이너가 쓰는 이미지 확인docker inspect web-was-lab-app-1 --format '{{.Image}}'
docker network create/connect별도 실행 컨테이너 이름 해석 연결docker network create appnetdocker run --network appnet ...
docker compose down스택 종료(볼륨·이미지까지 초기화)docker compose down / docker compose down -v --rmi local

관련 모듈로 더 깊이:

다음 모듈에서는 컨테이너의 상태를 자동으로 진단하고 장애 시 재시작하는 헬스체크와 Restart Policy 설정을 다룹니다.

지식 확인

퀴즈 — 8문제

Q1

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

Q2

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

Q3

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

Q4

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

Q5

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

Q6

Nginx를 WAS(Tomcat·Node 등) 앞단에 두고 정적 파일은 Nginx가 직접 서빙하고 동적 요청만 WAS로 보내는 구성이 주는 이점은?

Q7

[심화] Compose로 띄운 nginx 컨테이너의 nginx.conf에서 proxy_pass 대상으로 localhost:3000을 쓰면 WAS에 닿지 못한다. 서비스 이름(app:3000)을 써야 하는 이유는?

Q8

[심화] Compose에서는 잘 되던 구성인데, nginx만 docker run으로 따로 띄웠더니 host not found in upstream app 으로 시작조차 못 한다. 원인과 해결로 옳은 것은?

0 / 8 답변

🧪 실습으로 확인하기

Docker Compose 멀티 서비스 구성

초급

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

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

이것도 배워보세요