도커 실전 강좌 #2 Django + PostgreSQL compose — 두 컨테이너 한 묶음

9 분 소요

#1에서 컨테이너 한 개를 다듬었다면, 이번 글은 두 개를 한 묶음으로 띄우는 단계입니다. 가장 흔한 조합 — 웹 앱과 DB.

도커 실전 강좌에서 이번 글의 위치:

  • #1 FastAPI 컨테이너화
  • #2 Django + PostgreSQL compose — 두 컨테이너 한 묶음 ← 이번 글
  • #3 React/Next.js 빌드 컨테이너
  • #4 CI에서 이미지 빌드 — GitHub Actions
  • #5 레지스트리 푸시와 태그 전략
  • #6 클라우드 배포 — Fly.io / Railway / ECS

이번 글의 도메인은 장고 기초 시리즈에서 다루는 표준 Django 프로젝트입니다. 핵심은 Django보다 compose 자체라서, 모델/뷰는 최소만 다룹니다.

왜 compose 인가 #

docker run만으로도 두 컨테이너를 띄울 순 있습니다. 그러나 다음을 한 줄씩 손으로 해야 합니다.

compose 없이 — 손으로
docker network create app-net
docker run -d --name db --network app-net \
    -e POSTGRES_PASSWORD=secret \
    -v pg-data:/var/lib/postgresql/data \
    postgres:17
docker run -d --name web --network app-net \
    -e DATABASE_URL=postgres://postgres:secret@db:5432/postgres \
    -p 8000:8000 \
    myapp

여기에 healthcheck, 시작 순서, 마이그레이션 자동 실행, 환경변수 정리까지 얹다 보면 셸 스크립트가 됩니다. compose가 그 작업을 대신합니다.

compose의 그림 — 한 파일로
services:
  db:    # postgres 컨테이너
  web:   # django 컨테이너
volumes:
  pg-data:    # db 데이터 영속

이번 글은 이 그림에 살을 붙입니다.

출발점 — Django 프로젝트 #

새 프로젝트를 만들고 PostgreSQL 드라이버까지 깝니다.

프로젝트 셋업
uv init blog-docker
cd blog-docker
uv add django 'psycopg[binary]' gunicorn
uv run django-admin startproject blog .
uv run python manage.py startapp posts

blog/settings.py의 DB 설정만 환경변수로 빼둡니다.

blog/settings.py 일부
import os
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent

DEBUG = os.getenv("DJANGO_DEBUG", "0") == "1"
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "dev-only")
ALLOWED_HOSTS = os.getenv("DJANGO_ALLOWED_HOSTS", "*").split(",")

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": os.getenv("POSTGRES_DB", "blog"),
        "USER": os.getenv("POSTGRES_USER", "blog"),
        "PASSWORD": os.getenv("POSTGRES_PASSWORD", ""),
        "HOST": os.getenv("POSTGRES_HOST", "db"),
        "PORT": os.getenv("POSTGRES_PORT", "5432"),
    }
}

STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles"

호스트가 기본값 db 인 점이 중요합니다. compose에서 우리 서비스 이름이 db 면, 같은 네트워크의 다른 컨테이너에서 db 라는 이름으로 접근 가능합니다. (기초 #4 네트워크 참고.)

Django 용 Dockerfile #

#1에서 만든 FastAPI Dockerfile의 구조를 거의 그대로 가져옵니다. 다른 점은 gunicorn으로 띄우고, 마이그레이션과 collectstatic을 entrypoint에서 처리하는 것.

Dockerfile
FROM python:3.14-slim AS builder

COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/

WORKDIR /app

ENV UV_LINK_MODE=copy \
    UV_COMPILE_BYTECODE=1

COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev --no-install-project

COPY . .
RUN uv sync --frozen --no-dev


FROM python:3.14-slim AS runtime

RUN groupadd --system app && useradd --system --gid app --no-create-home app

WORKDIR /app
COPY --from=builder --chown=app:app /app /app
COPY --chown=app:app docker-entrypoint.sh /usr/local/bin/

ENV PATH="/app/.venv/bin:$PATH" \
    PYTHONUNBUFFERED=1 \
    DJANGO_SETTINGS_MODULE=blog.settings

USER app
EXPOSE 8000

ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["gunicorn", "blog.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3"]

핵심은 ENTRYPOINT + CMD의 분업입니다.

  • ENTRYPOINT — 컨테이너가 무엇을 하든 항상 먼저 도는 명령. 마이그레이션,collectstatic 같은 부트 작업을 여기 넣습니다.
  • CMD — entrypoint가 마지막에 exec로 넘겨받는 본 명령. 운영 시엔 gunicorn, 개발 시엔 runserver 식으로 바꿀 수 있습니다.
docker-entrypoint.sh
#!/bin/sh
set -e

echo ">>> applying migrations"
python manage.py migrate --noinput

if [ "${DJANGO_COLLECTSTATIC:-1}" = "1" ]; then
    echo ">>> collecting static files"
    python manage.py collectstatic --noinput
fi

echo ">>> starting: $*"
exec "$@"

exec "$@"가 핵심입니다. exec 없이 그냥 실행하면 entrypoint 셸이 PID 1을 잡고 있고, 본 프로세스는 그 자식이 됩니다. SIGTERM 같은 신호가 본 프로세스로 전달이 안 됩니다. (고급 #6 PID 1의 주제.) exec는 셸 자신을 본 프로세스로 치환 해서 PID 1을 넘겨줍니다.

스크립트는 실행권한이 있어야 합니다.

권한 부여
chmod +x docker-entrypoint.sh

빌드 컨텍스트에서 정리해 둘 항목.

.dockerignore
.git
.venv
__pycache__/
*.pyc
.env
.env.*
db.sqlite3
staticfiles
node_modules

compose 파일 — 첫 골격 #

이제 compose.yaml. 파일명은 신규 프로젝트에서는 compose.yaml이 권장 이름입니다 (docker-compose.yml도 호환됩니다).

compose.yaml — 첫 버전
services:
  db:
    image: postgres:17
    environment:
      POSTGRES_DB: blog
      POSTGRES_USER: blog
      POSTGRES_PASSWORD: secret
    volumes:
      - pg-data:/var/lib/postgresql/data
    ports:
      - "5432:5432"

  web:
    build: .
    environment:
      DJANGO_DEBUG: "1"
      DJANGO_SECRET_KEY: dev-only
      POSTGRES_DB: blog
      POSTGRES_USER: blog
      POSTGRES_PASSWORD: secret
      POSTGRES_HOST: db
    ports:
      - "8000:8000"
    depends_on:
      - db

volumes:
  pg-data:

띄워봅니다.

compose 기동
docker compose up --build

처음 실행하면:

  1. db이미지를 받고 컨테이너를 띄움 — 빈 데이터베이스 초기화에 몇 초.
  2. web이미지를 빌드 (Dockerfile 사용).
  3. web 컨테이너 entrypoint가 마이그레이션을 시도.
  4. 그런데 십중팔구 — DB가 아직 연결을 못 받아 마이그레이션이 깨집니다.

여기가 다음 절에서 다룰 문제입니다.

depends_on만으로는 부족 — healthcheck가 필요 #

depends_on: [db]는 단지 컨테이너 시작 순서만 보장합니다. db 컨테이너가 떴다는 것과, db가 연결을 받을 준비가 됐다는 건 별개입니다. Postgres는 pg_ctl start 후에도 몇 초 동안 초기화가 진행됩니다.

해결 방법은 dbhealthcheck를 붙이고, webdepends_on을 그 healthcheck에 묶는 것입니다. (중급 #4 compose 심화의 주제.)

compose.yaml — healthcheck 묶기
services:
  db:
    image: postgres:17
    environment:
      POSTGRES_DB: blog
      POSTGRES_USER: blog
      POSTGRES_PASSWORD: secret
    volumes:
      - pg-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U blog -d blog"]
      interval: 5s
      timeout: 3s
      retries: 10
      start_period: 5s

  web:
    build: .
    env_file: .env
    ports:
      - "8000:8000"
    depends_on:
      db:
        condition: service_healthy

이제 webdb의 health가 healthy가 되기 전엔 시작도 하지 않습니다. 첫 부팅 때 마이그레이션 충돌이 사라집니다.

pg_isready는 Postgres 이미지에 기본 포함된 명령으로, “연결 받을 준비됐냐” 를 묻는 가벼운 체크입니다.

.env로 시크릿 분리 #

위 compose에는 비밀번호가 평문으로 적혀 있습니다. compose는 env_file 또는 변수 치환으로 외부 파일을 읽을 수 있습니다.

.env (gitignore 대상)
DJANGO_DEBUG=1
DJANGO_SECRET_KEY=dev-only-change-in-prod
POSTGRES_DB=blog
POSTGRES_USER=blog
POSTGRES_PASSWORD=secret
POSTGRES_HOST=db
compose.yaml — env_file 사용
services:
  db:
    image: postgres:17
    env_file: .env
    volumes:
      - pg-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
      interval: 5s
      timeout: 3s
      retries: 10

  web:
    build: .
    env_file: .env
    ports:
      - "8000:8000"
    depends_on:
      db:
        condition: service_healthy

volumes:
  pg-data:

$$POSTGRES_USER$$는 compose의 변수 치환을 회피하고 셸 변수로 그대로 두기 위함입니다. compose가 $VAR를 자체적으로 치환하기 때문에, 컨테이너 내부 셸에 넘기려면 한 번 이스케이프 합니다.

.env.gitignore에 꼭 넣고, 저장소에는 .env.example만 들어가게.

.env.example
DJANGO_DEBUG=0
DJANGO_SECRET_KEY=
POSTGRES_DB=blog
POSTGRES_USER=blog
POSTGRES_PASSWORD=
POSTGRES_HOST=db

이 부분은 중급 #5 환경변수와 secrets에서 깊이 다룬 패턴 — 실전에서도 정확히 같은 구도.

데이터 영속화 — named volume #

db 서비스의 volumes: - pg-data:/var/lib/postgresql/data가 핵심입니다. 빠뜨리면 컨테이너를 내릴 때마다 DB가 초기화됩니다.

pg-data는 named volume으로 도커가 관리하는 영역에 저장됩니다. docker volume ls로 확인할 수 있습니다.

볼륨 확인
docker volume ls
# DRIVER    VOLUME NAME
# local     blog-docker_pg-data

docker volume inspect blog-docker_pg-data
# Mountpoint, CreatedAt 등

호스트 디렉터리에 직접 마운트(./data:/var/lib/postgresql/data)도 되긴 하지만, 권장하지 않습니다. macOS/Windows에서는 호스트 파일시스템과 컨테이너 사이의 IO가 느리고, 권한 충돌이 생기기 쉽습니다. 데이터는 named volume, 코드는 bind mount — 가 자연스러운 분업입니다.

개발 모드 — 코드 변경을 즉시 반영 #

위 compose는 web이미지를 빌드해서 띄웁니다. 코드를 고치려면 매번 docker compose up --build를 다시 해야 합니다. 개발 흐름엔 어울리지 않습니다.

override 파일을 두면 개발 시에만 다른 동작을 적용할 수 있습니다.

compose.override.yaml — 개발 전용
services:
  web:
    volumes:
      - .:/app             # 호스트 코드를 컨테이너에 마운트
      - /app/.venv         # 그러나 venv는 컨테이너 것 그대로
    command:
      ["python", "manage.py", "runserver", "0.0.0.0:8000"]

docker compose up만 하면 compose.yaml + compose.override.yaml이 자동 병합됩니다. CI/배포에서는 override가 없으니 운영 모드(gunicorn)가 그대로 동작합니다.

/app/.venv를 빈 볼륨으로 둔 트릭은 호스트의 .venv (또는 venv 미존재)가 컨테이너의 venv를 덮지 않게 막아 줍니다.

profiles로 관리 도구 분리 #

운영 명령(makemigrations, shell, dbshell)은 평소엔 띄우지 않는 게 좋습니다. profiles를 쓰면 명시적으로 부를 때만 동작합니다.

compose.yaml — profiles
services:
  # ... db, web ...

  manage:
    build: .
    env_file: .env
    depends_on:
      db:
        condition: service_healthy
    profiles: ["tools"]
    entrypoint: ["python", "manage.py"]
관리 명령 부르기
docker compose run --rm manage migrate
docker compose run --rm manage createsuperuser
docker compose run --rm manage shell

--rm은 명령 종료 후 컨테이너를 자동 정리합니다. profiles에 든 서비스는 docker compose up으로 자동 시작되지 않습니다 — --profile tools를 명시해야 뜹니다. 평소엔 일회성으로만 부르는 흐름이라 굳이 띄울 필요가 없습니다.

collectstatic과 정적 파일 #

docker-entrypoint.sh에서 collectstatic을 매 부팅마다 도리는 게 깔끔하긴 하지만, 컨테이너가 뜰 때마다 수천 개 파일을 옮기는 게 부담스러울 수 있습니다. 크게 두 가지 방향으로 나뉩니다.

  • 부팅에서 수행 — 파일은 이미지 안에 들어가지 않고, 컨테이너가 뜨면서 만들어짐. 수평 확장 시 각 컨테이너가 같은 작업을 반복.
  • 빌드에서 수행 — Dockerfile의 RUN python manage.py collectstatic으로 이미지에 박음. 그러면 부팅이 빨라지고, 모든 컨테이너가 같은 파일을 가지게 됨.

운영에서는 후자가 보통 더 깔끔합니다. 단, 빌드 시점에 SECRET_KEY 같은 게 필요 없도록 collectstatic만 위해 settings를 분리해야 할 수도 있습니다. 이번 글의 단순 설정에선 entrypoint에서 도는 걸로 충분합니다.

실행과 흔한 함정 #

docker compose up 한 번이면 끝.

기동/정지
docker compose up -d        # 백그라운드
docker compose logs -f web  # 로그 따라가기
docker compose ps           # 상태 확인
docker compose down         # 정지 + 컨테이너/네트워크 삭제 (볼륨은 남음)
docker compose down -v      # 볼륨까지 삭제 (주의)

자주 마주치는 문제:

connection refused — db 호스트web의 환경변수에서 POSTGRES_HOST=localhost로 잘못 설정됐거나 POSTGRES_HOST=db로 안 잡힘. 컨테이너 안에서 localhost는 컨테이너 자기 자신입니다.

psycopg / OperationalError가 첫 부팅에 발생 — healthcheck가 빠졌거나 너무 짧음. start_period를 5~10초 정도 두세요.

마이그레이션이 두 번 도는 것 같다web 컨테이너를 여러 개로 수평 확장하면 entrypoint가 각자 도립니다. 마이그레이션은 멱등이라 동시 실행에서 큰 문제는 없지만, 운영에선 별도의 one-shot job으로 빼는 게 정석.

compose.override.yaml의 코드가 반영되지 않음 — Docker Desktop의 파일 공유 설정에서 해당 디렉터리가 빠져 있을 수 있음. Settings → Resources → File Sharing 확인.

docker compose down으로 데이터 사라짐down만으로는 named volume이 사라지지 않습니다. 사라졌다면 down -v를 쓴 것. 운영에서는 절대 -v 금지.

정리 #

  • compose는 여러 컨테이너의 관계를 한 파일로 선언. services, volumes, networks가 골격.
  • DB 같은 외부 의존은 **healthcheck + condition: service_healthy**로 묶어야 첫 부팅이 안정적.
  • 시크릿은 .env로 빼고 저장소에는 .env.example만. compose가 env_file로 읽음.
  • 데이터는 named volume으로 영속. host bind mount는 코드용으로만.
  • ENTRYPOINT에 마이그레이션 / collectstatic 같은 부트 작업을 넣되, **exec "$@"**로 PID 1을 본 프로세스에 넘기기.
  • 개발 모드는 compose.override.yaml로 분리. 코드는 bind mount, venv는 컨테이너 것 유지.
  • 관리 명령(makemigrations, shell)은 profiles + docker compose run --rm으로 일회성 호출.

다음 글(#3 React/Next.js 빌드 컨테이너)에서는 백엔드를 떠나 프런트엔드로 갑니다. Next.js의 standalone 출력, deps → build → runner 세 단계 구성, NEXT_PUBLIC 환경변수의 빌드/런타임 차이, 정적 호스팅 옵션까지 정리합니다.

X