1 배포 아키텍처
개발 환경과 프로덕션 환경의 차이를 정리한다.
개발 환경:
브라우저 → Vite(5173) ──proxy──→ uvicorn(8000) → FastAPI → Agent
단일 워커, --reload
프로덕션 환경:
브라우저 → Nginx/LB(443) → gunicorn(8000) → FastAPI → Agent
4 워커, SSL 종료
Docker 컨테이너 내부
| 구성 요소 | 개발 | 프로덕션 |
|---|---|---|
| 프론트엔드 | Vite dev server (HMR) | Nginx 또는 FastAPI 정적 서빙 |
| 백엔드 서버 | uvicorn –reload | gunicorn + uvicorn worker |
| LLM Provider | Ollama (로컬) | Azure OpenAI |
| 프록시 | Vite Proxy | Nginx Reverse Proxy |
| 환경 변수 | .env 파일 |
Azure Key Vault / 환경 변수 |
| 컨테이너 | 없음 | Docker |
2 Docker 멀티스테이지 빌드
React와 FastAPI를 하나의 Docker 이미지로 빌드한다. 멀티스테이지 빌드로 최종 이미지에 Node.js를 포함하지 않아 이미지 크기를 최소화한다.
# ============================================
# Stage 1: React 프론트엔드 빌드
# ============================================
FROM node:22-alpine AS frontend
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
# 결과: /app/frontend/dist/
# ============================================
# Stage 2: Python 런타임
# ============================================
FROM python:3.11-slim AS runtime
WORKDIR /app
# 시스템 패키지 (build-essential은 일부 wheel 빌드용, 빌드 후 제거 가능)
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
curl \
&& rm -rf /var/lib/apt/lists/*
# Python 의존성 — pip 직접 사용 (Poetry는 빌드 컨텍스트만 비대해짐)
# sg-data-standardization (사내 SSH)은 이미지에서 제외, 런타임에 editable install
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
# 소스 코드
COPY src/ ./src/
COPY data/configs/ ./data/configs/
# React 빌드 결과
COPY --from=frontend /app/frontend/dist ./static
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
ENV ENVIRONMENT=production \
PYTHONUNBUFFERED=1
EXPOSE 8000
# 현 운영: 단일 uvicorn (warmup 후 caching·thread-safe lock으로 동시성 처리)
CMD ["uvicorn", "services.api.main:app", "--host", "0.0.0.0", "--port", "8000"]플랫폼 개발에서는 Poetry로 의존성을 잠그지만, 컨테이너 빌드 시점에는 poetry export -f requirements.txt로 추출한 잠긴 버전을 pip에 직접 설치한다. Poetry 자체를 이미지에 포함하면 ~50MB 추가되고, 빌드 단계마다 venv 격리 비용이 든다. 잠긴 버전은 이미 보장된 상태이므로 컨테이너에서는 pip만으로 충분하다.
표준화 도메인 라이브러리(sg-data-standardization)는 사내 git SSH 경로에 있어 빌드 환경에 SSH 키가 필요하다. CI에서 키를 주입하는 대신, 이미지에는 제외하고 컨테이너 시작 시 pip install -e .로 사이드카 설치하거나 사내 PyPI 미러에서 설치하는 방식을 사용한다. 외부 빌드(예: GitHub Actions)에서 이미지를 만들 수 없는 이유다.
2.1 빌드와 실행
# 이미지 빌드
docker build -t minerva-api:latest .
# 로컬 테스트 — 데이터·모델은 모두 volume으로 주입
docker run -d \
-p 8000:8000 \
--name minerva \
--env-file .env.cloud \
-v $(pwd)/data/docs:/app/data/docs:ro \
-v $(pwd)/data/experiments:/app/data/experiments \
-v $(pwd)/data/runtime:/app/data/runtime \
-v $(pwd)/data/models:/app/data/models:ro \
-v $(pwd)/data/faiss_index:/app/data/faiss_index:ro \
minerva-api:latest
docker logs -f minerva
curl http://localhost:8000/health데이터 표준화 에이전트의 ALBERT 도메인 분류기 가중치는 ~200MB, FAISS 인덱스는 문서 양에 따라 수백 MB가 된다. 이를 이미지에 포함하면 빌드·배포 시간이 급증하므로 항상 volume 마운트한다 (data/models/albert/, data/faiss_index/). 모델 교체나 인덱스 재생성도 컨테이너 재시작 없이 처리 가능하다.
2.2 이미지 크기 최적화
| 최적화 | 효과 |
|---|---|
python:3.12-slim (alpine 대신) |
C 확장 호환성 유지하면서 경량화 |
| 멀티스테이지 (Node.js 제외) | ~500MB 절감 |
--no-dev 의존성 제외 |
~100MB 절감 |
rm -rf /var/lib/apt/lists/* |
~50MB 절감 |
.dockerignore |
빌드 컨텍스트 축소 |
3 정적 파일 서빙
프로덕션에서 React 빌드 결과를 서빙하는 방법은 두 가지이다.
3.1 방법 1: FastAPI에서 직접 서빙
별도 Nginx 없이 FastAPI가 정적 파일도 함께 서빙한다. 소규모 배포에 적합하다.
# main.py
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
# API 라우터를 먼저 등록
app.include_router(qna_chatbot.router)
app.include_router(monitoring.router)
# ...
# SPA fallback: API가 아닌 모든 경로를 index.html로
@app.get("/{full_path:path}")
async def serve_spa(full_path: str):
file_path = f"/app/static/{full_path}"
if os.path.isfile(file_path):
return FileResponse(file_path)
return FileResponse("/app/static/index.html")3.2 방법 2: Nginx Reverse Proxy
Nginx가 정적 파일을 서빙하고, API 요청만 FastAPI로 전달한다. 대규모 배포에 적합하다.
server {
listen 443 ssl;
server_name minerva.example.com;
ssl_certificate /etc/ssl/cert.pem;
ssl_certificate_key /etc/ssl/key.pem;
# 정적 파일
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
}
# API 프록시
location /agents/ {
proxy_pass http://api:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 120s;
}
location /monitoring/ {
proxy_pass http://api:8000;
proxy_set_header Host $host;
}
location /health {
proxy_pass http://api:8000;
}
}4 환경 변수 관리
4.1 Pydantic BaseSettings
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
# Azure OpenAI
azure_openai_endpoint: str
azure_openai_key: str
azure_openai_api_version: str = "2024-12-01-preview"
# LLM Provider
llm_provider: str = "azure"
# 앱 설정
environment: str = "production"
log_level: str = "INFO"
warmup_on_startup: bool = True
# CORS
cors_origins: list[str] = ["https://minerva.example.com"]
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}4.2 환경별 .env 파일
# .env.local (Ollama + FAISS, 로컬 개발)
LLM_PROVIDER=ollama
ENVIRONMENT=development
MINERVA_LOG_LEVEL=DEBUG
CORS_ORIGINS=["http://localhost:5173"]
WARMUP_ON_STARTUP=false
# .env.cloud (Azure OpenAI + AI Search)
LLM_PROVIDER=azure
AZURE_OPENAI_ENDPOINT=https://my-resource.openai.azure.com/
AZURE_OPENAI_KEY=sk-xxx
AZURE_OPENAI_API_VERSION=2024-12-01-preview
ENVIRONMENT=production
MINERVA_LOG_LEVEL=INFO
CORS_ORIGINS=["https://minerva.example.com"]
WARMUP_ON_STARTUP=true.env > .env.cloud > .env.local
services/api/main.py의 _load_env_files()는 repo root에서 위 순서로 첫 번째 존재 파일만 로드한다. override=False로 설정되어 있어 시스템 환경변수(Docker --env-file, K8s Secret 등)가 이미 있으면 덮어쓰지 않는다. 즉:
- 프로덕션 컨테이너는
--env-file또는 K8s Secret으로 주입 →.env*파일은 무시됨 - 로컬 개발은
.env.cloud또는.env.local을 두면 자동 로드 - 둘 다 있으면
.env.cloud가 우선 (PoC 시절 cloud 위주 운영의 흔적)
이 우선순위는 머신마다 .env* 여러 개를 두고도 cd switch-env.ps1 같은 PowerShell 스크립트로 빠르게 전환하는 워크플로를 가능하게 한다.
4.3 Docker Compose 환경 변수
5 헬스체크
5.1 엔드포인트
5.2 Docker HEALTHCHECK
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 130초마다 헬스체크를 실행하고, 3회 연속 실패하면 컨테이너를 unhealthy로 표시한다. Docker Compose의 restart: unless-stopped와 결합하면 자동 복구가 가능하다.
6 워커 구성 — 단일 uvicorn 시작, 멀티워커는 스케일아웃 시
6.1 현 운영: 단일 uvicorn
MINERVA의 현재 운영은 컨테이너 1개당 uvicorn 단일 프로세스로 시작한다. agent 인스턴스 캐시가 in-memory (experiment, arm) 딕셔너리에 들어 있고 threading.Lock으로 동시 요청을 처리하므로, 단일 프로세스에서도 thread-pool 기반 비동기 I/O로 충분한 동시성을 확보한다.
이 구성의 장점:
- 워커 간 캐시 공유 불필요 — 모든 요청이 같은 in-memory 캐시에 접근
- ALBERT weight·FAISS 인덱스를 한 번만 로드 → 메모리 절감
- A/B 실험 arm별 에이전트 인스턴스 중복 생성 회피
6.2 스케일아웃 시: gunicorn + uvicorn worker
QPS가 단일 프로세스 한계(LLM I/O 대기 + 전후처리 CPU)를 초과하면 다음 단계로 멀티워커로 전환한다.
gunicorn services.api.main:app \
--worker-class uvicorn.workers.UvicornWorker \
--workers 4 \
--bind 0.0.0.0:8000 \
--timeout 120 \
--graceful-timeout 30 \
--keep-alive 30 \
--access-logfile -| 옵션 | 값 | 근거 |
|---|---|---|
--workers |
4 | 4코어 머신 기준 (2*CPU+1은 LLM 대기 비중 높은 워크로드에 과다) |
--timeout |
120 | LLM 응답이 최대 60초 + 여유 |
--graceful-timeout |
30 | 배포 시 진행 중 요청 완료 대기 |
--keep-alive |
30 | 프론트엔드 연결 재사용 |
6.3 멀티워커 전환 시 주의사항
각 워커가 독립적으로 에이전트를 초기화한다. 워커가 4개이면:
- 벡터 인덱스: 4 × ~200MB = ~800MB (FAISS 로컬 사용 시. Azure AI Search이면 워커별 0)
- BM25 인덱스: 4 × ~50MB = ~200MB
- ALBERT 도메인 분류기: 4 × ~200MB = ~800MB (DS 에이전트 한정)
- Reranker 모델: 4 × ~100MB = ~400MB
총 ~2GB 추가 메모리가 필요하다. A/B 실험에서 arm별 에이전트가 캐시되면 워커마다 별도 캐시를 가지므로 메모리가 더 늘어난다 — sticky_hash가 워커 간 사용자 일관성을 보장하지 않으므로, 멀티워커에서는 sticky 키에 worker_id를 포함하지 않도록 hash 키 구성을 재검증해야 한다.
7 동시 접속자 한계 — 어디가 진짜 병목인가
운영 중 “동시 접속 N명에서 막힌다”는 현상이 보고되면 흔히 프론트엔드(Vite/Nginx)가 의심받는다. 그러나 두 차원을 분리해서 봐야 정확하다.
7.1 두 차원 분리 — 같은 사실을 다르게 본다
| 차원 | 무엇을 측정 | Vite dev vs Nginx 정적 차이 |
|---|---|---|
| (A) 동시 접속 한계 | N명 동시 접속이 가능한지 | 운영 정상화(build + Nginx) 후에는 백엔드 LLM이 결정, 프론트는 거의 영향 없음 |
| (B) 페이지 로드 속도·CPU 부하 | 한 명의 페이지 로드 시간 / 서버 CPU | 차이 실재 — Vite는 매 요청 TS→JS 변환, Nginx는 변환 0 |
(A)가 운영 정상화 전제가 핵심이다. Vite dev server를 운영에 그대로 쓰던 비정상 상태에서는 (A)·(B) 모두 한계의 원인이 되지만, build + Nginx로 정상화하면 (A)의 프론트 측면은 거의 사라지고 (B)만 실재한다.
7.2 계층별 동시 접속 한계 (정상 운영 기준)
| 계층 | 한계 추정 | 근거 |
|---|---|---|
| 정적 파일 서빙 (Nginx 또는 FastAPI StaticFiles) | 수천 명 이상 | OS의 sendfile로 정적 파일 전송. CPU·메모리 거의 무사용 |
Vite dev server (npm run dev) |
수십 명 | 매 요청마다 ESM 모듈 변환 — 운영용 아님 |
| FastAPI 라우터 (간단한 엔드포인트) | 수백~수천 | uvicorn thread pool로 비동기 I/O |
| LLM 호출 (Azure OpenAI 동기 대기) | 단일 워커 약 10명 | 호출당 1~10초, thread pool로 보통 10 thread 동시 |
| 멀티워커 (gunicorn 4 worker) | 약 40명 | 워커별 독립 thread pool |
| RAG 검색 (Azure AI Search) | 수백 (서비스 SLA 의존) | 외부 API의 RPS 제한 |
7.3 시나리오 A/B/C — “10명 → 50명” 변화의 진짜 원인
운영 중 “Vite를 Nginx로 바꿨더니 10명에서 50명으로 늘었다”는 보고는 다음 세 시나리오 중 어느 것에 해당하는지 분리해서 봐야 한다.
| 시나리오 | 프론트 | 백엔드 | 동시 접속 한계 추정 |
|---|---|---|---|
| A Vite dev 운영 사용 (비정상) | npm run dev 노출 |
단일 uvicorn (~10 thread) | 약 10명 — Vite 변환 부하 + LLM 대기 누적 |
| B Build + Nginx 정상화 | npm run build + Nginx 정적 서빙 |
단일 uvicorn (~10 thread) | 약 10명 — 프론트 한계는 사라지고 LLM이 단일 병목 |
| C Build + Nginx + 멀티워커 | 동일 | gunicorn --workers 4 |
약 40명 — “50명”이 여기서 나왔을 가능성이 가장 큼 |
A → B 전환만으로 한계가 10에서 50으로 뛰지는 않는다 (A의 Vite 부하가 사라져도 백엔드 LLM이 단일 병목으로 남기 때문). A → C 전환 (Vite 정상화 + 워커 확장)이 함께 일어났을 가능성이 크다. 동료가 본 50명의 진짜 원인은 백엔드 워커 구성 변화 쪽일 가능성이 높다.
7.4 Vite의 (B) 차원 효과는 실재한다
위 시나리오는 (A) 동시 접속 한계 차원이다. (B) 차원에서는 build + Nginx의 효과가 따로 있다:
- 페이지 로드 속도: Vite dev는 매 요청 TS→JS 변환으로 첫 로드가 느려질 수 있다. Build 결과물은 minify·번들 완료 상태라 더 빠르게 로드된다.
- 서버 CPU: Vite dev는 변환 CPU가 실시간 누적된다. Nginx 정적 서빙은 OS의
sendfile이 디스크→네트워크로 직접 전송해 CPU ~0.
(B)는 동시 접속과 무관하게 사용자 한 명의 체감 응답성과 운영 비용에 영향을 준다. DS 친화 비유로는 05편 Vite callout의 ML 컴파일 비유 참조.
7.5 실제 한계를 늘리는 방법
| 방법 | 효과 | 비용 |
|---|---|---|
| 프론트엔드 build → Nginx 서빙 | (B) 페이지 로드 속도 ↑, CPU 부하 해소 — (A) 동시 접속 자체에는 영향 작음 | 빌드 단계 추가 (이미 Dockerfile에 포함) |
gunicorn --workers N |
동시 LLM 호출 N배 — (A) 동시 접속 한계의 결정적 변수 | 워커당 메모리 ~2GB |
LLM 호출 비동기화 (async/await 전 경로) |
thread pool 제한 우회 | 코드 비동기화 작업 |
| Azure OpenAI 배포 단위 분산 | rate limit 우회 | 비용 N배 |
| 캐싱 (의미 동일 질문) | LLM 호출 자체 회피 | semantic cache 인프라 |
| 응답 스트리밍 (SSE) | TTFT 단축 → 체감 응답성 | 이미 적용 (04편) |
MINERVA의 현실적 시작점: 단일 uvicorn → 동시 10명 한계가 보이면 → gunicorn --workers 4로 확장 (40명) → 그 이상은 Azure OpenAI rate limit · 메모리 · 캐싱 전략으로 풀어야 한다. 프론트엔드 측면은 build + Nginx로 정상화하는 것이 (B) 차원의 사용자 체감과 운영 비용에는 중요하지만, (A) 동시 접속 한계의 결정적 변수는 백엔드 워커 구성이다.
8 Makefile — 명령 통합
머신·OS 차이를 흡수하기 위해 모든 dev/build/test 명령을 Makefile에 통합한다.
| 타겟 | 동작 |
|---|---|
make install |
backend(Poetry) + poc(Poetry) + frontend(npm) 의존성 일괄 설치 |
make dev-backend |
uvicorn :8000 reload 모드 |
make dev-frontend |
Vite :5173 |
make dev-poc |
Streamlit PoC (별도 venv) |
make smoke-test |
Azure 자격증명 없이 import·경로 상수 검증 |
make rebuild-index NAME=qna |
Azure Search 인덱스 재생성 |
make clean |
venv·node_modules·캐시 삭제 |
make help로 전체 타겟을 노출시켜 새 개발자가 첫날에 환경을 빠르게 잡을 수 있게 한다.
9 배포 체크리스트
| 단계 | 확인 항목 |
|---|---|
| 빌드 전 | .env.cloud 환경 변수 확인 (Azure endpoint·key·api_version) |
| 빌드 전 | data/docs/ 지식베이스 문서 최신화, data/configs/*.yaml 검증 |
| 빌드 전 | ALBERT weight (data/models/albert/)·FAISS 인덱스 준비 |
| 빌드 | docker build 성공 (sg-data-standardization 이미지 외 사이드카 설치 확인) |
| 테스트 | docker run 후 /health 응답 확인 |
| 테스트 | warmup 완료 로그 확인 (QnA + DS, ALBERT 로드 ~3-5초) |
| 테스트 | 채팅 요청 → 응답 정상 확인 |
| 테스트 | 스트리밍 요청 → 토큰 수신 확인 |
| 테스트 | /monitoring/metrics, /monitoring/ab/experiments 정상 응답 |
| 배포 | docker compose up -d |
| 배포 후 | 헬스체크 통과 확인 |
| 배포 후 | 기존 세션 정상 종료 확인 (graceful shutdown) |
| 배포 후 | data/runtime/runs.jsonl append 확인 (관측성 작동) |
10 정본 핸드오프와 운영 변화 추적
배포 파이프라인이나 인프라 결정이 바뀌면 코드 리포의 docs/platform-refactor-handoff.md(정본)에 Phase별로 기록한다. 본 블로그 포스트는 시점 기준 스냅샷이므로, 운영 도중 발생한 의사결정·롤백·우회 패치는 정본 문서가 더 빠르고 정확하다. 새 머신·새 클러스터에서 배포를 시작할 때 가장 먼저 정본을 확인하는 것이 권장된다.
11 관련 주제
선행 지식 (Phase A)
- Docker 기초 – Dockerfile, 멀티스테이지, Compose
- ASGI와 uvicorn – gunicorn + uvicorn 워커 구조
- CORS와 Proxy – 프로덕션 Nginx 설정
이 시리즈
- MINERVA 아키텍처 개요 – 전체 구조
- FastAPI 서빙 레이어 – 서빙 레이어 상세
- A/B 실험 프레임워크 – 실험 설정 배포