[도커 30강] 14강. compose로 Python + Postgres 실습 환경 만들기

[도커 30강] 14강. compose로 Python + Postgres 실습 환경 만들기

12강, 13강에서 compose의 기본 구조와 Python + Redis 조합을 다뤘다면, 이제는 실무에서 훨씬 더 자주 쓰는 Python + Postgres 조합으로 넘어갈 타이밍입니다. 캐시는 유실되어도 복구 가능한 경우가 많지만, 데이터베이스는 서비스의 핵심 상태를 담고 있어서 설계와 운영 습관이 훨씬 중요합니다. 오늘 목표는 단순히 “컨테이너 두 개 띄우기”가 아니라, 팀원 누구나 같은 명령으로 같은 DB 환경을 재현하고, 장애 시 어디부터 확인해야 하는지까지 한 번에 익히는 것입니다.

이번 실습은 Flask + psycopg 기반의 아주 작은 API를 예제로 진행합니다. API는 Postgres에 테이블을 만들고, 요청이 들어올 때마다 레코드를 추가한 뒤 현재 카운트를 반환합니다. 이 흐름을 통해 서비스명 DNS, 초기화 SQL, 볼륨 영속성, 접속 실패 진단까지 자연스럽게 연습할 수 있습니다.


핵심 개념

  • compose에서 db 서비스를 Postgres로 정의하면, 앱 컨테이너는 localhost가 아니라 서비스명(db)으로 데이터베이스에 접속해야 합니다.
  • Postgres의 데이터는 컨테이너 파일시스템이 아니라 named volume에 저장해야 컨테이너 재생성 이후에도 유지됩니다.
  • DB 컨테이너가 “시작됨”과 “접속 가능함(ready)”은 다릅니다. 앱에는 짧은 재시도 로직 또는 healthcheck 기반 기동 전략이 필요합니다.
  • init.sql 같은 초기화 스크립트는 docker-entrypoint-initdb.d에 넣어 개발 환경을 표준화할 수 있습니다(단, 최초 볼륨 생성 시점에만 자동 실행됨).

기본 사용

예제 1) Python + Postgres compose 프로젝트 뼈대 만들기

아래 명령으로 실습 디렉터리, 앱 코드, 의존성 파일, Dockerfile, compose 파일, 초기화 SQL을 한 번에 구성합니다.

mkdir -p ~/docker100/lesson14/app ~/docker100/lesson14/db-init
cd ~/docker100/lesson14

cat > app/app.py <<'PY'
import os
import time
from flask import Flask, jsonify
import psycopg

app = Flask(__name__)

DB_HOST = os.getenv("DB_HOST", "db")
DB_PORT = int(os.getenv("DB_PORT", "5432"))
DB_NAME = os.getenv("DB_NAME", "appdb")
DB_USER = os.getenv("DB_USER", "appuser")
DB_PASSWORD = os.getenv("DB_PASSWORD", "apppass")

DSN = f"host={DB_HOST} port={DB_PORT} dbname={DB_NAME} user={DB_USER} password={DB_PASSWORD}"


def get_conn_with_retry(max_retry=20, delay=0.5):
    last_error = None
    for _ in range(max_retry):
        try:
            return psycopg.connect(DSN)
        except Exception as e:
            last_error = e
            time.sleep(delay)
    raise last_error


@app.get("/")
def index():
    with get_conn_with_retry() as conn:
        with conn.cursor() as cur:
            cur.execute("CREATE TABLE IF NOT EXISTS access_log (id SERIAL PRIMARY KEY, created_at TIMESTAMPTZ DEFAULT NOW())")
            cur.execute("INSERT INTO access_log DEFAULT VALUES")
            cur.execute("SELECT COUNT(*) FROM access_log")
            total = cur.fetchone()[0]
        conn.commit()

    return jsonify({
        "message": "hello from python + postgres",
        "db": f"{DB_HOST}:{DB_PORT}/{DB_NAME}",
        "total_access": total
    })
PY

cat > app/requirements.txt <<'REQ'
flask==3.0.3
psycopg[binary]==3.2.1
REQ

cat > app/Dockerfile <<'DOCKER'
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
CMD ["flask", "--app", "app", "run", "--host=0.0.0.0", "--port=8000"]
DOCKER

cat > db-init/001-create-note.sql <<'SQL'
CREATE TABLE IF NOT EXISTS boot_note (
  id SERIAL PRIMARY KEY,
  note TEXT NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

INSERT INTO boot_note(note)
VALUES ('lesson14 init complete')
ON CONFLICT DO NOTHING;
SQL

cat > compose.yaml <<'YAML'
services:
  app:
    build: ./app
    container_name: lesson14_app
    ports:
      - "18014:8000"
    environment:
      DB_HOST: db
      DB_PORT: "5432"
      DB_NAME: appdb
      DB_USER: appuser
      DB_PASSWORD: apppass
    volumes:
      - ./app:/app
    depends_on:
      - db

  db:
    image: postgres:16-alpine
    container_name: lesson14_db
    environment:
      POSTGRES_DB: appdb
      POSTGRES_USER: appuser
      POSTGRES_PASSWORD: apppass
    volumes:
      - pg_data:/var/lib/postgresql/data
      - ./db-init:/docker-entrypoint-initdb.d:ro

volumes:
  pg_data:
YAML

설명:

  • 앱 코드는 간단하지만 접속 재시도 로직을 포함해 “DB가 조금 늦게 뜨는 상황”을 흡수합니다.
  • Postgres 비밀번호를 예제에서는 평문으로 넣었지만, 실제 운영에서는 시크릿 매니저/런타임 주입으로 분리해야 합니다.
  • db-init 스크립트는 팀 공통 초기 상태를 빠르게 맞추는 데 유용합니다.

예제 2) 기동 후 API/DB 상태 확인하기

구성이 끝났다면 설정 확인 → 기동 → API 호출 → DB 내부 확인 순서로 점검합니다.

cd ~/docker100/lesson14

docker compose config
docker compose up -d --build
docker compose ps

curl -s http://localhost:18014 | jq .
curl -s http://localhost:18014 | jq .

# DB 내부에서 행 개수 확인
docker compose exec db psql -U appuser -d appdb -c "SELECT COUNT(*) AS total FROM access_log;"

docker compose logs --tail=120 app db

설명:

  • curl 두 번 호출 후 total_access가 증가하면 API ↔ Postgres 쓰기 경로가 정상입니다.
  • docker compose exec db psql ...로 실제 DB 내부 상태를 보며 앱 로그와 교차 확인하면, 문제 원인을 빠르게 좁힐 수 있습니다.
  • 로그는 앱만 보지 말고 DB까지 같이 봐야 “앱 버그인지 DB 기동/인증 문제인지”를 즉시 분리할 수 있습니다.

예제 3) 영속성/재생성/초기화 스크립트 동작 검증

실무에서 특히 중요한 부분입니다. 컨테이너가 바뀌어도 데이터가 유지되는지 반드시 확인하세요.

cd ~/docker100/lesson14

# 현재 누적 확인
docker compose exec db psql -U appuser -d appdb -c "SELECT COUNT(*) FROM access_log;"

# 앱만 재생성해도 데이터는 유지돼야 함
docker compose up -d --force-recreate app
curl -s http://localhost:18014 | jq .

# down/up 후에도 named volume 덕분에 유지되는지 확인
docker compose down
docker compose up -d
sleep 2
docker compose exec db psql -U appuser -d appdb -c "SELECT COUNT(*) FROM access_log;"

# 볼륨까지 지우면 초기화됨
docker compose down -v
docker compose up -d
sleep 2
docker compose exec db psql -U appuser -d appdb -c "SELECT COUNT(*) FROM access_log;"

설명:

  • down만 하면 pg_data 볼륨이 살아 있으므로 기존 데이터가 남습니다.
  • down -v는 볼륨까지 제거하므로 DB가 초기 상태로 돌아갑니다.
  • 이 차이를 팀에서 명확히 합의하지 않으면, 개발/테스트 데이터 유실 사고가 자주 발생합니다.

자주 하는 실수

실수 1) 앱 DB 호스트를 localhost로 지정

  • 원인: 컨테이너 네트워크 개념을 로컬 프로세스 개념과 혼동.
  • 해결: compose 내부 통신은 서비스명(db) 사용. localhost는 각 컨테이너 자신을 가리킵니다.

실수 2) depends_on만으로 DB 준비 완료를 보장된다고 오해

  • 원인: 컨테이너 시작 순서와 애플리케이션 준비 상태를 같은 단계로 간주.
  • 해결: 앱에 재시도 로직을 넣고, 이후 단계에서 healthcheck와 함께 의존 조건을 정교하게 설계합니다.

실수 3) DB 데이터를 bind mount로 관리

  • 원인: 코드 마운트 습관을 데이터까지 그대로 적용.
  • 해결: DB 데이터는 named volume(pg_data)에 저장하고, 백업/복구는 별도 스크립트로 다룹니다.

실수 4) 초기화 SQL이 매번 실행된다고 생각

  • 원인: docker-entrypoint-initdb.d 동작 시점을 모르고 사용.
  • 해결: 해당 스크립트는 “데이터 디렉터리가 비어 있는 첫 기동”에만 자동 실행됩니다. 반복 적용은 마이그레이션 도구(Alembic 등)로 처리해야 합니다.

실무 패턴

Python + Postgres 환경을 안정적으로 운영하려면, compose 파일 자체보다 “운영 습관”이 더 중요합니다.

첫째, 환경변수 계약을 고정하세요. DB_HOST, DB_PORT, DB_NAME, DB_USER처럼 필수 키를 README/템플릿/CI에서 동일하게 유지하면, 사람마다 다른 로컬 설정 때문에 생기는 장애를 크게 줄일 수 있습니다.

둘째, 상태 데이터와 앱 코드를 분리하세요. 앱 코드는 자주 바뀌고, DB 데이터는 보호 대상입니다. 이 둘을 같은 생명주기로 다루면 문제 분석이 어려워지고 복구 절차도 복잡해집니다. 코드 변경은 재빌드/재배포로, 데이터 변경은 마이그레이션/백업 정책으로 분리해야 합니다.

셋째, 진단 순서를 팀 표준으로 문서화하세요. 예를 들어 docker compose ps로 상태 확인 → docker compose logs app db로 오류 분리 → docker compose exec db psql로 실제 쿼리 확인 같은 루틴을 고정하면 신규 인원도 빠르게 대응할 수 있습니다. 장애 대응 속도는 개인 실력보다 표준화된 절차에서 나옵니다.

넷째, 초기화 스크립트와 마이그레이션을 구분하세요. 초기화 SQL은 로컬 부트스트랩에 유용하지만, 운영 변경 이력은 버전 관리되는 마이그레이션 도구로 관리해야 추적성과 롤백 가능성을 확보할 수 있습니다.

다섯째, 개발용 compose와 운영용 compose를 분리할 준비를 하세요. 오늘 예제의 bind mount는 개발 효율에 좋지만 운영에는 적합하지 않습니다. 운영에서는 immutable 이미지, 명시적 리소스 제한, 시크릿 분리, 백업 자동화가 기본입니다.

오늘의 결론

한 줄 요약: compose로 Python + Postgres를 묶을 때 핵심은 서비스명 기반 접속, DB 볼륨 영속성, 준비 상태를 고려한 기동 전략이며, 이 세 가지를 표준화하면 팀 생산성과 안정성이 함께 올라간다.

연습문제

  1. 오늘 예제를 실행한 뒤 API를 5회 호출하고 access_log 행 수가 정확히 5 증가하는지 확인하세요. 만약 증가하지 않는다면 어떤 순서로 점검할지 작성해보세요.
  2. DB_HOST를 일부러 localhost로 바꿔 접속 실패를 재현하고, 앱 로그와 DB 로그를 근거로 원인을 설명해보세요.
  3. docker compose downdocker compose down -v를 각각 실행한 뒤 access_log 데이터가 어떻게 달라지는지 비교하고, 실무에서 어떤 명령을 기본으로 써야 안전한지 정리해보세요.

이전 강의 정답

13강 연습문제 해설:

  • curl을 여러 번 호출했을 때 hits가 계속 증가하면 앱이 Redis에 정상적으로 연결되어 카운터를 저장하고 있다는 뜻입니다. 증가가 멈췄다면 보통 ① 컨테이너 상태(docker compose ps) ② 앱/Redis 로그(docker compose logs) ③ 내부 접속 테스트(docker compose exec app ...) 순서로 확인하면 빠르게 원인을 찾을 수 있습니다.
  • REDIS_HOST=localhost는 컨테이너 환경에서 거의 항상 오설정입니다. 앱 컨테이너의 localhost는 Redis가 아니라 앱 자신이므로 연결 실패가 발생합니다. compose 내부에서는 서비스명(redis)을 사용해야 정상 통신됩니다.
  • docker compose down -v를 실행하면 named volume까지 제거되어 Redis 데이터(hits)가 초기화됩니다. 즉, 단순 컨테이너 정리(down)와 데이터 제거(down -v)는 영향 범위가 다르며, 후자는 복구 불가한 데이터 손실로 이어질 수 있어 의도적으로만 사용해야 합니다.

실습 환경/재현 정보

  • OS: macOS 15+ (Apple Silicon) 또는 Ubuntu 22.04+
  • Docker 버전: Docker Engine/Desktop 25.x 이상
  • Compose 버전: v2.x (docker compose version)
  • Python 이미지: python:3.12-slim
  • Postgres 이미지: postgres:16-alpine
  • 실행 순서:
    1. ~/docker100/lesson14 디렉터리 및 파일 생성
    2. docker compose config로 설정 검증
    3. docker compose up -d --build로 기동
    4. curlpsql로 API/DB 동작 검증
    5. docker compose downdown -v 차이 확인
  • 재현 체크:
    • API 응답의 total_access가 호출할 때마다 증가하는가?
    • docker compose logs app db에 인증/연결 오류가 없는가?
    • 앱 컨테이너 재생성 후에도 DB 데이터가 유지되는가?
    • down -v 실행 시 데이터가 초기화되는가?
    • 팀원이 같은 compose 파일로 동일 결과를 재현하는가?