파이썬 웹 앱 프로덕션 배포: gunicorn, uvicorn, systemd, Docker
python manage.py runserver나 uvicorn main:app --reload로 개발을 끝내고 나면, 이걸 그대로 서버에 올리고 싶은 유혹이 옵니다. 안 됩니다. 개발 서버는 단일 프로세스에 디버그 편의 위주로 만들어져 있어서, 동시 접속을 감당하지 못하고 죽어도 되살아나지 않습니다. 이 글은 파이썬 웹 앱을 운영 환경에 올리는 표준 구성을 처음부터 끝까지 정리합니다.
전체 그림: 4개 층 #
프로덕션 구성은 역할이 나뉜 4개 층으로 이해하면 명확합니다.
클라이언트
→ nginx (리버스 프록시: TLS, 정적 파일, 버퍼링)
→ gunicorn (프로세스 매니저: 워커 여러 개 관리)
→ 워커 프로세스 (uvicorn worker 또는 sync worker: 앱 실행)
→ 애플리케이션 (Django, FastAPI, Flask)각 층이 왜 필요한지가 이 글의 뼈대입니다. 하나씩 내려갑니다.
WSGI와 ASGI: 앱과 서버 사이의 규약 #
파이썬 웹 서버와 프레임워크 사이에는 표준 인터페이스가 있습니다.
- WSGI: 동기 규약입니다. Django(전통 구성), Flask가 여기에 속하고, 서버는 gunicorn의 sync 워커가 표준입니다.
- ASGI: 비동기 규약입니다. FastAPI, Starlette, 비동기 Django가 여기에 속하고, 서버는 uvicorn이 대표입니다.
내 앱이 어느 쪽인지에 따라 서버 선택이 갈립니다. 헷갈리면 프레임워크 문서의 배포 섹션에 적힌 쪽이 정답입니다.
gunicorn + uvicorn 워커: 사실상 표준 조합 #
uvicorn 단독으로도 서비스는 가능하지만, 프로세스 관리(죽은 워커 재기동, 워커 수 관리, graceful 재시작)는 gunicorn이 성숙합니다. 그래서 ASGI 앱의 표준 구성은 gunicorn을 프로세스 매니저로, uvicorn을 워커 클래스로 쓰는 것입니다.
# FastAPI (ASGI)
gunicorn main:app \
--worker-class uvicorn.workers.UvicornWorker \
--workers 4 \
--bind 127.0.0.1:8000 \
--timeout 60 \
--graceful-timeout 30
# Django/Flask (WSGI)라면 워커 클래스 없이
gunicorn myproject.wsgi:application --workers 4 --bind 127.0.0.1:8000워커 수는 오래된 경험식 CPU 코어 수 × 2 + 1에서 시작합니다. 다만 이것은 출발점이지 정답이 아닙니다. CPU 위주 작업이면 코어 수 근처로 줄이고, I/O 대기가 긴 앱이면 조금 늘리되, 워커마다 메모리를 그만큼 먹는다는 것과 DB 커넥션 풀이 워커 수만큼 늘어난다는 것(SQLAlchemy 2.0 #2에서 다룬 계산)을 함께 봐야 합니다. 부하 테스트로 확정하는 것이 원칙입니다.
systemd: 프로세스를 OS에 맡깁니다 #
터미널에서 gunicorn을 띄우고 SSH를 끊으면 프로세스도 함께 사라집니다. nohup과 screen은 임시방편입니다. 리눅스 서버에서 프로세스 생명 주기는 systemd에 맡기는 것이 표준입니다.
# /etc/systemd/system/myapp.service
[Unit]
Description=myapp gunicorn service
After=network.target
[Service]
User=myapp
WorkingDirectory=/srv/myapp
Environment="DATABASE_URL=postgresql://..."
ExecStart=/srv/myapp/.venv/bin/gunicorn main:app \
--worker-class uvicorn.workers.UvicornWorker --workers 4 \
--bind 127.0.0.1:8000
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.targetsudo systemctl enable --now myapp # 부팅 시 자동 시작 + 즉시 시작
sudo systemctl status myapp # 상태 확인
sudo systemctl restart myapp # 배포 후 재시작
journalctl -u myapp -f # 로그 확인Restart=always 한 줄이 “프로세스가 죽으면 자동으로 되살린다"를 해결합니다. 가상 환경 안의 gunicorn 절대 경로를 쓰는 것과, 시크릿을 유닛 파일 대신 EnvironmentFile=로 분리하는 것이 실무 요령입니다.
nginx: 앱 서버 앞에 서는 이유 #
gunicorn을 80 포트에 직접 노출하지 않고 nginx를 앞에 두는 데는 구체적 이유가 있습니다.
- TLS 종료: 인증서 관리와 HTTPS 처리를 nginx가 맡습니다.
- 정적 파일: CSS, JS, 이미지를 파이썬 워커가 서빙하는 것은 낭비입니다. nginx가 직접 서빙합니다.
- 버퍼링: 느린 클라이언트가 응답을 다 받을 때까지 파이썬 워커를 붙잡고 있지 않도록, nginx가 응답을 받아서 대신 전송합니다. 워커 점유 시간이 크게 줄어듭니다.
- 요청 크기 제한, 타임아웃, 접근 로그 같은 방어선 역할도 겸합니다.
server {
listen 443 ssl;
server_name example.com;
location /static/ {
alias /srv/myapp/static/;
}
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}Docker로 배포한다면 #
컨테이너 환경에서는 systemd 자리를 컨테이너 오케스트레이터(Docker Compose, Kubernetes 등)가 대신하고, 이미지 안에서는 gunicorn이 포그라운드로 뜹니다.
FROM python:3.13-slim
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN pip install uv && uv sync --frozen --no-dev
COPY . .
# 컨테이너에서는 표준 출력으로 로그, 포그라운드 실행
CMD ["uv", "run", "gunicorn", "main:app", \
"--worker-class", "uvicorn.workers.UvicornWorker", \
"--workers", "2", "--bind", "0.0.0.0:8000", \
"--access-logfile", "-", "--error-logfile", "-"]- 의존성 설치 층과 코드 복사 층을 분리하면 코드만 바뀌었을 때 빌드 캐시가 살아 빌드가 빨라집니다. lock 파일 기반 재현성은 파이썬 패키징 #5에서 다뤘습니다.
- 컨테이너에서는 워커 수를 낮게 잡고(컨테이너당 1〜2개), 스케일은 컨테이너 수로 조절하는 편이 오케스트레이터와 궁합이 좋습니다.
마지막 체크리스트: 로그, 헬스체크, 종료 #
- 로그는 stdout으로: 파일 로그 대신 표준 출력으로 내보내고, 수집은 journald나 컨테이너 런타임에 맡깁니다. 로그가 어디 있는지 찾아다니는 상황 자체를 없애는 설계입니다.
- 헬스체크 엔드포인트:
/healthz같은 경로를 만들어 로드밸런서와 오케스트레이터가 살아 있는 프로세스에만 트래픽을 주게 합니다. DB까지 확인하는 readiness와 프로세스 생존만 보는 liveness를 구분하면 더 좋습니다. - graceful shutdown: 배포 재시작 시 처리 중인 요청을 끊지 않으려면, 새 요청 수신을 멈추고 진행 중인 요청을 마친 뒤 종료해야 합니다. gunicorn의
--graceful-timeout이 이 역할이고, 컨테이너라면 SIGTERM을 gunicorn이 직접 받도록 CMD를 exec 형태(JSON 배열)로 쓰는 것이 전제 조건입니다. - 환경 변수로 설정 분리: DB 주소, 시크릿 키는 코드와 이미지에 넣지 않고 환경에서 주입합니다.
정리 #
- 프로덕션 구성은 nginx(프록시), gunicorn(프로세스 관리), 워커(앱 실행)의 층 구조입니다. 개발 서버는 운영에 쓰지 않습니다.
- WSGI 앱은 gunicorn sync 워커, ASGI 앱은 gunicorn + uvicorn 워커 조합이 표준입니다. 워커 수는
코어 × 2 + 1에서 시작해 부하 테스트로 확정합니다. - 프로세스 생존은 systemd(또는 컨테이너 오케스트레이터)에 맡기고, 로그는 stdout, 설정은 환경 변수로 뺍니다.
- nginx는 TLS, 정적 파일, 버퍼링으로 파이썬 워커의 부담을 덜어 주는 앞단입니다.
- 헬스체크와 graceful shutdown까지 갖춰야 무중단 배포가 성립합니다.