모던 파이썬 실전 #6 테스트와 배포 — pytest, Docker, Railway/Fly

7 분 소요

실전 시리즈의 마지막 — 테스트와 배포입니다. 만든 API가 정말 동작하는지 자동으로 검증하고, 한 명령으로 컨테이너 빌드해 클라우드에 올리는 흐름까지 한곳에 정리합니다.

pytest + httpx — FastAPI 테스트의 표준 #

FastAPI는 TestClient (httpx 기반)라는 in-process 테스트 클라이언트를 제공합니다. 실제 HTTP 서버를 띄우지 않고도 라우트를 호출할 수 있습니다.

설치 (이미 #1에서 추가됨)
uv add --dev pytest pytest-asyncio httpx

첫 테스트 #

tests/test_root.py
from fastapi.testclient import TestClient
from app.main import app

client = TestClient(app)

def test_health():
    response = client.get("/health")
    assert response.status_code == 200
    assert response.json() == {"status": "healthy"}
실행
uv run pytest -v

TestClient는 동기 인터페이스를 줍니다. 라우트가 async def 여도 그냥 호출.

비동기 테스트 — httpx.AsyncClient #

비동기 픽스처/라이브러리를 직접 쓰려면 AsyncClient.

tests/conftest.py
import pytest
import pytest_asyncio
from httpx import AsyncClient, ASGITransport
from app.main import app

@pytest_asyncio.fixture
async def client() -> AsyncClient:
    async with AsyncClient(
        transport=ASGITransport(app=app),
        base_url="http://test",
    ) as c:
        yield c
tests/test_root_async.py
import pytest

@pytest.mark.asyncio
async def test_health(client):
    response = await client.get("/health")
    assert response.status_code == 200

pytest.ini 또는 pyproject.toml에 비동기 모드 설정.

pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]

asyncio_mode = "auto"로 하면 모든 async def test_xxx가 자동으로 비동기로 실행됩니다 — @pytest.mark.asyncio 데코레이터 생략 가능.

의존성 오버라이드 — 외부 의존성 격리 #

테스트 때는 진짜 DB / 외부 API를 쓰면 느리고 변동성이 있습니다. 의존성 오버라이드가 표준 답입니다.

tests/conftest.py 확장
import pytest_asyncio
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from app.api.deps import get_session
from app.models.base import Base
from app.main import app

TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"

@pytest_asyncio.fixture
async def db_engine():
    engine = create_async_engine(TEST_DATABASE_URL)
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    yield engine
    await engine.dispose()

@pytest_asyncio.fixture
async def db_session(db_engine) -> AsyncSession:
    async_session = async_sessionmaker(db_engine, expire_on_commit=False)
    async with async_session() as session:
        yield session

@pytest_asyncio.fixture
async def client(db_session):
    async def override_get_session():
        yield db_session

    app.dependency_overrides[get_session] = override_get_session

    async with AsyncClient(
        transport=ASGITransport(app=app),
        base_url="http://test",
    ) as c:
        yield c

    app.dependency_overrides.clear()

핵심: app.dependency_overrides[원본] = 대체 한 줄이 #3에서 본 get_session의존성을 테스트 버전으로 바꿉니다. 라우트 코드는 한 줄도 바뀌지 않습니다. 이게 의존성 주입의 가장 큰 효용입니다.

인증 우회 #

#4get_current_user도 같은 방식으로:

auth 우회
from app.api.deps import get_current_user
from app.models.user import User

@pytest_asyncio.fixture
async def authed_client(client, db_session):
    test_user = User(id=1, email="test@example.com", hashed_password="x")
    db_session.add(test_user)
    await db_session.commit()

    async def override_user():
        return test_user

    app.dependency_overrides[get_current_user] = override_user
    yield client

테스트가 매번 토큰을 만들 필요 없이 인증된 상태로 시작.

라우트 테스트 패턴 #

tests/test_todos.py
import pytest

@pytest.mark.asyncio
async def test_create_todo(authed_client):
    response = await authed_client.post(
        "/todos/",
        json={"title": "Pytest 학습"},
    )
    assert response.status_code == 201
    data = response.json()
    assert data["title"] == "Pytest 학습"
    assert data["done"] is False
    assert "id" in data
    assert "created_at" in data

@pytest.mark.asyncio
async def test_create_todo_validates_title(authed_client):
    response = await authed_client.post(
        "/todos/",
        json={"title": ""},   # min_length=1
    )
    assert response.status_code == 422

@pytest.mark.asyncio
async def test_get_todo_not_found(authed_client):
    response = await authed_client.get("/todos/99999")
    assert response.status_code == 404

테스트 형태 — **AAA (Arrange / Act / Assert)**가 자연스러운 패턴:

  • Arrange — 픽스처가 미리 준비
  • Act — client.post(...)
  • Assert — 응답 검증

흐름 테스트 #

여러 요청이 엮인 시나리오:

흐름 테스트
@pytest.mark.asyncio
async def test_full_todo_lifecycle(authed_client):
    # Create
    create_resp = await authed_client.post("/todos/", json={"title": "test"})
    todo_id = create_resp.json()["id"]

    # Get
    get_resp = await authed_client.get(f"/todos/{todo_id}")
    assert get_resp.json()["title"] == "test"

    # Update
    update_resp = await authed_client.patch(
        f"/todos/{todo_id}",
        json={"done": True},
    )
    assert update_resp.json()["done"] is True

    # Delete
    delete_resp = await authed_client.delete(f"/todos/{todo_id}")
    assert delete_resp.status_code == 204

    # Verify deleted
    final_resp = await authed_client.get(f"/todos/{todo_id}")
    assert final_resp.status_code == 404

외부 호출 모킹 #

#5에서 본 httpx 외부 호출은 테스트에서 모킹하는 게 좋습니다.

설치
uv add --dev pytest-httpx
외부 API 모킹
import pytest

@pytest.mark.asyncio
async def test_external_call(httpx_mock):
    httpx_mock.add_response(
        url="https://api.external.com/data",
        json={"result": "ok"},
    )

    response = await my_function_that_calls_external()
    assert response == "ok"

pytest-httpx가 모든 httpx 요청을 가로채 미리 정한 응답을 줍니다. 외부 의존성이 없어 빠르고 안정적.

커버리지 #

커버리지 측정
uv add --dev coverage pytest-cov
uv run pytest --cov=app --cov-report=term-missing
출력 예
Name                         Stmts   Miss  Cover   Missing
----------------------------------------------------------
app/main.py                     12      0   100%
app/api/todos.py                45      3    93%   78-80
app/services/user.py            22      1    95%   45
----------------------------------------------------------
TOTAL                           79      4    95%

100% 가 목표가 아니라 중요한 경로가 검증되는지가 목표입니다. UI 테스트와 다르게 API는 커버리지가 비교적 잘 올라갑니다 — 80~90% 가 무난한 수준.

통합 테스트 vs 단위 테스트 #

종류대상
단위 테스트순수 함수, 로직 — test_validate_email(), test_calculate_total()
통합 테스트라우트 + DB + 의존성 — test_create_todo()
E2E 테스트실제 서버 + 실제 DB — 별도 환경

API 프로젝트는 통합 테스트가 주력입니다. 단위는 복잡한 비즈니스 로직에만 추가, E2E는 배포 전 smoke test.

Docker — 일관된 환경 #

배포할 때는 컨테이너로 묶어 어떤 환경에서도 같게 동작하게 합니다.

Dockerfile — 멀티스테이지 빌드 #

Dockerfile
# === 1) 빌드 스테이지 — uv로 의존성 설치 ===
FROM python:3.14-slim AS builder

# uv 설치
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv

WORKDIR /app

# 의존성만 먼저 복사 (캐시 효율)
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev

# 코드 복사
COPY app/ ./app/

# === 2) 런타임 스테이지 — 작은 이미지 ===
FROM python:3.14-slim AS runtime

# 비루트 사용자
RUN useradd --create-home --uid 1000 appuser

WORKDIR /app

# 빌더에서 venv와 코드 복사
COPY --from=builder /app/.venv /app/.venv
COPY --from=builder /app/app /app/app

# venv의 python을 PATH 우선
ENV PATH="/app/.venv/bin:$PATH"

USER appuser

EXPOSE 8000

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

핵심 패턴:

  • 멀티스테이지 — 빌드 도구는 최종 이미지에서 빠짐, 이미지 크기 작아짐
  • 의존성 먼저, 코드 나중 — 코드만 바뀌면 의존성 레이어 캐시 재사용
  • 비루트 사용자 — 보안 (root 권한 컨테이너는 위험)
  • --frozenuv.lock 그대로, 버전 결정성 보장
  • --no-dev — 개발 의존성 제외

.dockerignore #

.dockerignore
.venv/
__pycache__/
*.pyc
.pytest_cache/
.git/
.github/
tests/
.env
*.md

이미지에 들어가지 않아도 되는 것들. 빌드 속도와 보안에 영향.

빌드와 실행 #

로컬 빌드
docker build -t todo-api:0.1 .
docker run --rm -p 8000:8000 \
  -e DATABASE_URL=postgresql+asyncpg://... \
  -e JWT_SECRET=... \
  todo-api:0.1

docker-compose — 로컬 개발 환경 #

API + DB + Redis같이 띄울 때.

docker-compose.yml
services:
  api:
    build: .
    ports:
      - "8000:8000"
    environment:
      DATABASE_URL: postgresql+asyncpg://user:pw@db:5432/todo
      JWT_SECRET: dev-secret
      REDIS_URL: redis://redis:6379
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: pw
      POSTGRES_DB: todo
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user"]
      interval: 5s
      timeout: 5s
      retries: 5
    volumes:
      - pg-data:/var/lib/postgresql/data

  redis:
    image: redis:7-alpine

volumes:
  pg-data:
실행
docker compose up -d

Postgres + Redis + API가 한 명령으로 뜨고, API는 DB가 ready가 될 때까지 기다립니다.

마이그레이션 — 배포 시점 자동화 #

#3의 Alembic 마이그레이션은 배포 시점에 자동으로 적용되어야 합니다.

옵션 1 — 컨테이너 시작 시 적용 #

entrypoint.sh
#!/usr/bin/env bash
set -e
alembic upgrade head
exec "$@"
Dockerfile에 추가
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

ENTRYPOINT ["/entrypoint.sh"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

간단하지만 여러 인스턴스가 동시에 적용을 시도하면 충돌이 가능. 작은 트래픽에서만.

옵션 2 — CI/CD 단계에서 별도 #

배포 파이프라인에서 마이그레이션을 명시적인 단계로 분리. 한 번만 실행되고, 실패하면 배포 자체가 중단되도록.

GitHub Actions 단계 예
- name: Run migrations
  run: |
    uv run alembic upgrade head
  env:
    DATABASE_URL: ${{ secrets.DATABASE_URL }}

- name: Deploy
  run: |
    flyctl deploy --remote-only

큰 프로젝트는 옵션 2가 안전합니다.

클라우드 배포 — 가벼운 옵션 두 가지 #

Railway #

GitHub 저장소 연결만으로 자동 배포가 동작하는 가장 가벼운 옵션.

  1. railway.app가입
  2. “New Project” → “Deploy from GitHub”
  3. 저장소 선택, Postgres/Redis 추가
  4. 환경 변수 설정 (JWT_SECRET, DATABASE_URL 등)
  5. 자동 배포

railway.json으로 빌드/실행 명령 커스터마이즈 가능.

railway.json
{
  "build": {
    "builder": "DOCKERFILE"
  },
  "deploy": {
    "startCommand": "uvicorn app.main:app --host 0.0.0.0 --port $PORT",
    "healthcheckPath": "/health",
    "restartPolicyType": "ON_FAILURE"
  }
}

Fly.io #

여러 리전 배포가 쉬운 옵션. CLI 도구 (flyctl)가 있습니다.

Fly 배포
brew install flyctl                  # macOS
flyctl auth login
flyctl launch                        # 자동으로 fly.toml 생성
flyctl deploy
flyctl secrets set JWT_SECRET=...
fly.toml — 자동 생성된 후 편집
app = "todo-api"
primary_region = "nrt"

[http_service]
  internal_port = 8000
  force_https = true
  auto_stop_machines = true
  auto_start_machines = true

[checks]
  [checks.health]
    type = "http"
    path = "/health"
    interval = "30s"
    timeout = "5s"

비교:

RailwayFly.io
학습 곡선매우 평탄평탄
가격사용량 기반사용량 + 무료 티어
글로벌 분산단순강함 (여러 리전)
어울리는 경우빠른 프로토타입글로벌 서비스

프로덕션 체크리스트 #

배포 전 확인:

  • JWT_SECRET — 64바이트+ 랜덤
  • DEBUG=False 또는 --debug 미사용
  • HTTPS — 클라우드 제공자가 자동 처리하지만 강제 리다이렉트 확인
  • CORS — allow_origins=["*"]가 아닌 명시적 도메인
  • DB 비밀번호 — 추측 불가
  • 로그 레벨 — 프로덕션은 INFO 이상
  • 에러 추적 — Sentry 같은 도구 통합
  • 헬스 체크/health 엔드포인트
  • 마이그레이션 자동화 — 배포 파이프라인에 포함
  • 백업 — DB 자동 백업 활성
  • 모니터링 — 응답 시간, 에러율 대시보드
  • rate limiting — DDoS / abuse 방지

이 체크리스트는 시작점입니다. 트래픽과 요구사항에 따라 추가됩니다.

정리 + 시리즈 회고 #

이번 글에서 잡은 것:

  • pytest + httpxTestClient(동기), AsyncClient + ASGITransport(비동기)
  • asyncio_mode = "auto" — 모든 async 테스트 자동 인식
  • app.dependency_overrides — DB/인증/외부 호출 격리, 의존성 주입의 진가
  • 흐름 테스트 — Create → Get → Update → Delete
  • pytest-httpx로 외부 호출 모킹
  • 커버리지 — 100%가 아니라 중요 경로 보장
  • Docker 멀티스테이지 — uv로 의존성, 비루트, .dockerignore
  • docker-compose로 로컬 풀스택 (API + DB + Redis)
  • 마이그레이션은 배포 파이프라인 단계로 분리가 안전
  • Railway (가장 가벼움) vs Fly.io (글로벌)
  • 프로덕션 체크리스트 — 시크릿, HTTPS, CORS, 백업, 모니터링

시리즈 회고 #

6편을 거쳐 모던 파이썬 실전 — FastAPI의 흐름이 완성됐습니다.

#다룬 것
1FastAPI 셋업, OpenAPI 자동 문서
2APIRouter, Pydantic v2, 의존성 주입
3SQLAlchemy 2.x async, Alembic
4argon2 + JWT, OAuth2 패스워드 플로우
5async/await, BackgroundTasks, 외부 큐, lifespan
6pytest + httpx, Docker, Railway/Fly

모던 파이썬 트랙 회고 (4 시리즈 / 27편) #

시리즈편수핵심
모던 파이썬 기초7uv, 타입 힌트, match-case, pyproject
모던 파이썬 중급7dataclass, Protocol, with, generator, decorator, async
모던 파이썬 고급7dunder, descriptor, metaclass, asyncio 깊이, GIL, typing 고급, 성능
모던 파이썬 실전6FastAPI 풀스택

이 트랙으로 구 파이썬 강좌 (2017~2018)가 다루지 못한 3.14 + 모던 도구 체인 + 타입 힌트 우선 관점이 채워졌습니다. 새 트랙이 현행 학습 자료로 자리 잡았고, 구 트랙은 그대로 남아 두 시점의 파이썬을 같이 볼 수 있게 됩니다.

다음 학습 방향:

  • FastAPI 깊이 — WebSocket 본격, 마이크로서비스 패턴, 분산 추적
  • 데이터 — pandas, polars, 데이터 파이프라인
  • AI/LLM — Vercel AI SDK / Anthropic SDK로 RAG, 에이전트
  • 시스템 프로그래밍 — Cython, PyO3로 확장 모듈

각 방향은 별도 트랙으로 다룰 가치가 있는 영역들입니다.

X