Python 웹 앱 배포: Heroku, AWS, Docker

이 글의 핵심

로컬에서 잘 돌던 앱도 DEBUG를 끄고 외부에 노출하는 순간 비밀 키 관리, 정적 파일, 개발 서버 사용 같은 문제가 드러납니다. 개발 서버 대신 Gunicorn을 쓰는 이유와 설정 파일 작성법, Docker 이미지로 실행 환경을 고정하는 방법, Heroku와 EC2+Nginx 중 무엇을 고를지, systemd 유닛으로 프로세스를 관리하는 최소 패턴까지 정리했습니다.

들어가며

배포는 애플리케이션을 실제 사용자에게 제공하는 과정입니다.

로컬에서 flask run이나 python manage.py runserver로 잘 돌던 앱을 그대로 서버에 올리면 대부분 문제가 생깁니다. 이 명령들이 띄우는 개발 서버는 요청을 동시에 많이 처리하도록 만들어지지 않았고, 코드가 바뀌면 자동으로 재시작하는 기능과 에러 페이지의 디버거처럼 개발 편의를 위한 기능이 켜져 있습니다. 특히 Flask의 디버그 모드가 켜진 상태로 외부에 노출되면 에러 페이지의 대화형 디버거를 통해 서버에서 임의의 파이썬 코드를 실행할 수 있어, 단순한 정보 노출이 아니라 서버 장악으로 이어집니다. 이 글은 이런 개발용 구성을 운영용 구성(환경 변수로 분리한 설정, Gunicorn 같은 WSGI 서버, Nginx 리버스 프록시, 프로세스 관리자)으로 바꾸는 과정을 순서대로 따라갑니다.


배포 준비

requirements.txt

서버에 올릴 때는 개발 PC에 깔린 패키지 목록을 영수증처럼 고정해 두는 것이 안전합니다. pip freeze로 버전까지 적어 두면, 나중에 같은 조합을 다시 설치하기 쉽습니다.

# 패키지 목록 생성
pip freeze > requirements.txt
Flask==2.3.0
gunicorn==20.1.0
python-dotenv==1.0.0

pip freeze는 지금 활성화된 파이썬 환경에 설치된 모든 패키지를 출력합니다. 가상환경 없이 시스템 파이썬에서 실행하면 그 컴퓨터에 설치해 둔 온갖 패키지가 목록에 들어가고, 운영 서버에서 설치하다가 Windows 전용 패키지(pywin32 등) 때문에 실패하는 일이 생깁니다. 프로젝트마다 python -m venv venv로 가상환경을 만들고 그 안에서 pip freeze를 해야 목록이 깨끗합니다.

pip freeze는 직접 설치한 패키지와 그 의존성을 구분하지 않고 모두 평평하게 나열한다는 한계도 있습니다. 나중에 Flask를 올리려 할 때 어떤 줄이 Flask 때문에 들어온 것인지 알 수 없게 됩니다. 규모가 커지면 직접 쓰는 패키지만 requirements.in(또는 pyproject.toml)에 적고 pip-tools, Poetry, uv 같은 도구로 고정 버전 목록(lock 파일)을 생성하는 방식이 관리하기 쉽습니다. 어느 방식이든 핵심은 “운영 서버가 설치하는 버전이 내가 테스트한 버전과 정확히 같다”는 것을 보장하는 것입니다.

환경 변수 (.env)

# .env
SECRET_KEY=your-secret-key
DATABASE_URL=postgresql://user:pass@localhost/db
DEBUG=False
# app.py
from dotenv import load_dotenv
import os
load_dotenv()
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY')
app.config['DEBUG'] = os.getenv('DEBUG', 'False') == 'True'

설정을 코드가 아니라 환경 변수로 분리하는 이유는 같은 코드를 여러 환경에서 다른 설정으로 실행하기 위해서입니다. 개발 PC, 스테이징, 운영 서버가 각자 다른 DB와 비밀 키를 쓰더라도 코드는 한 벌이면 됩니다. .env 파일은 로컬 개발을 편하게 하려는 도구일 뿐이므로 반드시 .gitignore에 넣어야 하고, 운영 서버에서는 .env 파일 대신 systemd의 EnvironmentFile, 컨테이너의 환경 변수, 클라우드의 비밀 저장소(AWS Secrets Manager 등)로 주입하는 것이 일반적입니다. 실수로 커밋한 비밀 키는 커밋을 지워도 Git 이력과 포크에 남으므로, 발견하는 즉시 키 자체를 교체해야 합니다.

os.getenv('DEBUG', 'False') == 'True'처럼 문자열로 비교하는 부분도 흔한 함정입니다. 환경 변수는 항상 문자열이라 bool(os.getenv('DEBUG'))로 쓰면 "False"라는 문자열도 비어 있지 않아서 True가 됩니다. 운영 서버에서 DEBUG=False로 설정했는데 디버그 모드가 켜져 있다면 이 실수를 의심해 보세요. 반대로 SECRET_KEY가 설정되지 않으면 os.getenv가 None을 반환해 앱이 조용히 약한 설정으로 실행될 수 있으므로, 필수 설정은 os.environ['SECRET_KEY']로 읽어 없을 때 시작 단계에서 바로 KeyError로 실패하게 만드는 편이 안전합니다.


Gunicorn 설정

Gunicorn으로 실행

# 설치
pip install gunicorn
# 실행
gunicorn app:app
# 워커 설정
gunicorn -w 4 -b 0.0.0.0:8000 app:app

gunicorn.conf.py

bind = "0.0.0.0:8000"
workers = 4
worker_class = "sync"
timeout = 30
keepalive = 2

Gunicorn은 마스터 프로세스 하나가 여러 워커 프로세스를 관리하는 구조입니다. workers = 4면 앱이 네 번 따로 로드되어 동시에 네 요청을 처리하고, 워커 하나가 죽으면 마스터가 새로 띄웁니다. 워커 수의 흔한 출발점은 공식 문서가 제안하는 (2 × CPU 코어 수) + 1이지만, 워커마다 앱 전체가 메모리에 올라가므로 메모리가 작은 서버에서는 이 숫자를 그대로 쓰면 메모리가 부족해질 수 있습니다. 실제로는 이 값에서 시작해 메모리 사용량과 응답 시간을 보며 조정합니다.

worker_class = "sync"는 워커 하나가 한 번에 한 요청만 처리한다는 뜻입니다. 외부 API를 기다리는 요청이 많다면 워커가 대부분 대기에 묶이므로 gthread(워커당 스레드 여러 개, threads = 4 등)나 비동기 워커를 고려합니다. timeout = 30은 워커가 30초 동안 응답하지 않으면 마스터가 그 워커를 강제로 종료하고 다시 띄우는 설정입니다. 보고서 생성처럼 오래 걸리는 요청이 있으면 로그에 [CRITICAL] WORKER TIMEOUT (pid:1234)가 찍히고 클라이언트는 502를 받는데, 이때 timeout을 무작정 늘리기보다는 오래 걸리는 작업을 Celery 같은 백그라운드 작업 큐로 빼는 것이 근본적인 해결책입니다. 참고로 Gunicorn은 Unix 전용이라 Windows에서 실행하면 ModuleNotFoundError: No module named 'fcntl'이 납니다. Windows에서 로컬 테스트를 하려면 WSL이나 Docker를 쓰거나 Waitress 같은 다른 WSGI 서버를 사용합니다. FastAPI 같은 ASGI 앱이라면 Gunicorn에 uvicorn.workers.UvicornWorker를 워커 클래스로 지정하거나 Uvicorn을 직접 실행합니다.


Docker 배포

Dockerfile

FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:8000", "app:app"]

requirements.txt만 먼저 복사해 pip install을 하고, 그다음에 나머지 코드를 복사하는 순서에는 이유가 있습니다. Docker는 각 명령의 결과를 레이어로 캐시하고, 입력 파일이 바뀌지 않은 단계는 다시 실행하지 않습니다. 코드만 수정했다면 requirements.txt가 그대로이므로 몇 분씩 걸리는 패키지 설치 단계를 캐시에서 재사용하고 COPY . .부터만 다시 빌드합니다. 순서를 바꿔 COPY . .를 먼저 하면 코드 한 줄을 고칠 때마다 모든 패키지를 다시 설치하게 됩니다.

COPY . .는 현재 디렉터리의 모든 것을 이미지에 넣으므로 .dockerignore가 꼭 필요합니다. .env, .git, venv/, __pycache__/를 제외하지 않으면 비밀 값이 이미지에 들어가 레지스트리에 올라가고, 로컬 가상환경의 바이너리가 컨테이너의 파이썬과 섞여 이상한 import 에러가 나기도 합니다. 또 이 Dockerfile은 root 사용자로 앱을 실행하므로, 운영용이라면 RUN useradd -m app과 USER app을 추가해 일반 사용자로 실행하는 것이 좋습니다. ENV PYTHONUNBUFFERED=1을 넣어 두면 print와 로그 출력이 버퍼에 쌓이지 않고 바로 docker logs에 나타나서, “컨테이너 로그가 아무것도 안 찍힌다”는 혼란을 줄일 수 있습니다.

docker-compose.yml

version: '3.8'
services:
  web:
    build: .
    ports:
      - "8000:8000"
    environment:
      - DATABASE_URL=postgresql://user:pass@db:5432/mydb
    depends_on:
      - db
  
  db:
    image: postgres:15
    environment:
      - POSTGRES_USER=user
      - POSTGRES_PASSWORD=pass
      - POSTGRES_DB=mydb
    volumes:
      - postgres_data:/var/lib/postgresql/data
volumes:
  postgres_data:
# 실행
docker-compose up -d

compose 파일 안에서 web 서비스가 db 호스트 이름으로 DB에 접속할 수 있는 것은, compose가 서비스들을 같은 가상 네트워크에 두고 서비스 이름을 DNS 이름으로 등록해 주기 때문입니다. 그래서 DATABASE_URL의 호스트가 localhost가 아니라 db입니다. 컨테이너 안에서 localhost는 그 컨테이너 자신을 가리키므로, 로컬 설정을 그대로 복사하면 Connection refused가 납니다.

처음 compose로 앱과 DB를 함께 띄울 때 거의 모두 한 번은 겪는 문제가 있습니다. depends_on은 DB 컨테이너가 시작되는 순서만 보장할 뿐, PostgreSQL이 접속을 받을 준비가 끝날 때까지 기다려 주지는 않습니다. 그래서 첫 실행에서 앱이 DB보다 먼저 접속을 시도해 could not connect to server: Connection refused로 죽는 일이 흔합니다. db 서비스에 healthcheck(예: pg_isready -U user)를 정의하고 web의 depends_on에 condition: service_healthy를 지정하거나, 앱이 시작할 때 몇 번 재시도하도록 만들어야 합니다. postgres_data 볼륨은 컨테이너를 지워도 데이터를 남기기 위한 것인데, 반대로 docker compose down -v처럼 -v를 붙이면 볼륨까지 삭제되어 데이터가 사라진다는 점도 알아 두세요. 최신 Docker Compose에서는 파일 맨 위의 version: 키가 더 이상 필요 없고 무시됩니다.


Heroku 배포

Procfile

web: gunicorn app:app

runtime.txt

python-3.11.0

Procfile의 web: 줄은 Heroku가 HTTP 요청을 받을 프로세스를 어떻게 실행할지 알려 줍니다. 여기서 주의할 점은 Heroku가 앱이 들을 포트를 PORT 환경 변수로 매번 정해서 넘겨준다는 것입니다. Gunicorn은 PORT 환경 변수가 있으면 자동으로 0.0.0.0:$PORT에 바인드하므로 위 Procfile은 그대로 동작하지만, 코드에서 app.run(port=5000)처럼 포트를 고정하면 Heroku가 60초 안에 포트가 열리지 않았다며 Error R10 (Boot timeout)으로 프로세스를 죽입니다. 파이썬 버전은 오래전부터 runtime.txt로 지정해 왔지만, 최근 Heroku의 파이썬 빌드팩은 .python-version 파일(예: 3.11)을 권장하는 방향으로 바뀌었으므로 새 프로젝트라면 공식 문서의 현재 방식을 확인하는 것이 좋습니다.

배포 명령어

# Heroku CLI 설치 후
heroku login
heroku create myapp
# 환경 변수 설정
heroku config:set SECRET_KEY=your-secret-key
# 배포
git push heroku main
# 로그 확인
heroku logs --tail

Heroku 같은 PaaS의 장점은 서버 OS 관리, 보안 패치, 로드 밸런서, TLS 인증서를 플랫폼이 대신 맡는다는 점입니다. 대신 파일 시스템이 일시적(ephemeral) 이라 사용자가 업로드한 파일을 로컬 디스크에 저장하면 dyno가 재시작될 때(최소 하루 한 번) 사라지므로, 업로드 파일은 S3 같은 외부 저장소에 두어야 합니다. SQLite를 DB로 쓰는 앱을 그대로 올리면 데이터가 주기적으로 초기화되는 것도 같은 이유입니다.


AWS 배포 (EC2)

EC2 설정

# 서버 접속
ssh -i key.pem ubuntu@ec2-instance
# Python 설치
sudo apt update
sudo apt install python3-pip python3-venv
# 프로젝트 클론
git clone https://github.com/user/myapp.git
cd myapp
# 가상환경
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
# Gunicorn 실행
gunicorn -w 4 -b 0.0.0.0:8000 app:app

이 절차는 동작 확인용입니다. SSH 세션에서 Gunicorn을 직접 실행하면 터미널을 닫는 순간 프로세스도 함께 종료되고, 서버가 재부팅되어도 자동으로 다시 뜨지 않습니다. 운영에서는 아래 “실전 심화 보강”의 systemd 유닛처럼 프로세스 관리자에게 맡겨야 합니다. 또 0.0.0.0:8000으로 바인드하면 EC2 보안 그룹에서 8000번 포트가 열려 있을 때 Nginx를 거치지 않고 Gunicorn에 직접 접속할 수 있으므로, Nginx 뒤에 둘 때는 127.0.0.1:8000으로 바인드해 외부에서 직접 접근하지 못하게 하는 편이 안전합니다. 접속이 안 된다면 대부분 EC2 보안 그룹의 인바운드 규칙(80/443 포트)을 열지 않은 경우입니다.

Nginx 설정

# /etc/nginx/sites-available/myapp
server {
    listen 80;
    server_name example.com;
    
    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}
# Nginx 활성화
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl restart nginx

Nginx를 앞에 두는 이유는 Gunicorn이 잘하지 못하는 일을 맡기기 위해서입니다. 느린 클라이언트의 요청과 응답을 Nginx가 버퍼링해 주므로 Gunicorn 워커가 느린 네트워크 때문에 묶이지 않고, 정적 파일(CSS, 이미지)을 파이썬을 거치지 않고 직접 빠르게 서빙하며, HTTPS 인증서 처리도 Nginx에서 끝낼 수 있습니다. sudo nginx -t로 설정 문법을 먼저 검사하는 습관은 오타 하나 때문에 Nginx가 다시 시작하지 못해 모든 사이트가 내려가는 사고를 막아 줍니다. restart 대신 reload를 쓰면 기존 연결을 끊지 않고 설정만 다시 읽습니다.

위 설정에 한 가지를 더 추가하는 것이 좋습니다. Nginx에서 HTTPS를 처리하면 Gunicorn이 받는 요청은 HTTP이므로, 앱은 원래 요청이 HTTPS였는지 알 수 없어 리다이렉트 URL을 http://로 만들거나 “HTTPS로 강제 이동” 설정이 무한 리다이렉트를 일으킵니다. proxy_set_header X-Forwarded-Proto $scheme;와 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;를 넘기고, Flask는 werkzeug.middleware.proxy_fix.ProxyFix로, Django는 SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') 설정으로 이 헤더를 신뢰하게 해야 합니다. 앱 앞에 Gunicorn이 없거나 멈춘 상태라면 Nginx는 502 Bad Gateway를 반환하므로, 502가 보이면 먼저 systemctl status myapp으로 Gunicorn이 살아 있는지 확인합니다.


프로덕션 배포 전에 확인할 항목

서버에 올리는 순간 앱은 디버그 모드가 꺼진 채 외부와 연결됩니다. 비밀 키·DB URL은 환경 변수로만 두며, Django라면 ALLOWED_HOSTS와 collectstatic·migrate까지 한 번에 점검하는 습관이 필요합니다. HTTPS는 인증서(예: Let’s Encrypt)나 프록시 뒤에서 종료하는 방식으로 맞춥니다.

# ✅ 환경 변수
# SECRET_KEY, DATABASE_URL 등
# ✅ DEBUG = False
app.config['DEBUG'] = False
# ✅ ALLOWED_HOSTS (Django)
ALLOWED_HOSTS = ['yourdomain.com']
# ✅ 정적 파일
python manage.py collectstatic
# ✅ 데이터베이스 마이그레이션
python manage.py migrate
# ✅ HTTPS 설정
# Let's Encrypt, Cloudflare

실전 심화 보강

실전 예제: Gunicorn + systemd 유닛 (프로덕션 최소 패턴)

애플리케이션은 myapp.wsgi:app이라고 가정합니다. 소켓 활성화 대신 여기서는 127.0.0.1:8000 바인드 예시를 둡니다. /etc/systemd/system/myapp.service:

[Unit]
Description=My Gunicorn App
After=network.target
[Service]
User=www-data
Group=www-data
WorkingDirectory=/var/www/myapp
Environment="PATH=/var/www/myapp/venv/bin"
EnvironmentFile=/etc/myapp.env
ExecStart=/var/www/myapp/venv/bin/gunicorn -w 4 -b 127.0.0.1:8000 myapp.wsgi:app
Restart=always
[Install]
WantedBy=multi-user.target

배포 후 sudo systemctl daemon-reload && sudo systemctl enable --now myapp으로 기동합니다.

Restart=always가 있으면 프로세스가 죽었을 때 systemd가 자동으로 다시 띄우고, enable로 등록하면 서버 재부팅 후에도 자동으로 시작합니다. EnvironmentFile=/etc/myapp.env는 .env 대신 비밀 값을 담는 파일로, 코드 디렉터리 밖에 두고 chmod 600으로 root만 읽을 수 있게 해 두는 것이 좋습니다. 이 파일의 형식은 셸 스크립트가 아니라 KEY=value 목록이라 export나 따옴표 처리가 셸과 다르다는 점도 알아 두세요. 서비스가 시작되지 않을 때는 journalctl -u myapp -n 50으로 최근 로그를 보면 대부분 원인이 나옵니다. 가장 흔한 것은 User=www-data가 프로젝트 디렉터리나 가상환경에 접근할 권한이 없어서 생기는 Permission denied이고, 그다음이 EnvironmentFile 경로 오타입니다. 코드를 새로 배포한 뒤에는 sudo systemctl restart myapp으로 재시작해야 새 코드가 반영되며, 무중단에 가깝게 하려면 Gunicorn 마스터에 HUP 신호를 보내 워커를 차례로 교체하는 systemctl reload(유닛에 ExecReload=/bin/kill -s HUP $MAINPID 추가)를 쓸 수 있습니다.

자주 하는 실수

  • DEBUG=True로 운영에 올려 시크릿·트레이스백이 노출되는 경우.
  • 가상환경 경로를 하드코딩해 배포 스크립트가 깨지는 경우.
  • 헬스체크 없이 로드밸런서만 믿고 프로세스가 죽은 줄 모르는 경우.

주의사항

  • 시크릿은 환경 변수·비밀 저장소에 두고 Git에 넣지 마세요.
  • ALLOWED_HOSTS·CORS·CSRF는 프로덕션 프로파일에서 재검증합니다.

실무에서는 이렇게

  • 리버스 프록시(Nginx/Caddy) 앞에 두고 TLS 종료를 맡깁니다.
  • 로그는 journald + 중앙 집중(CloudWatch, ELK)으로 보냅니다.
  • 컨테이너라면 읽기 전용 루트fs + non-root 유저를 기본으로 합니다.

비교 및 대안

방식장점
PaaS(Heroku 등)운영 단순
VM + systemd제어·비용 트레이드오프
Kubernetes대규모·멀티 서비스

추가 리소스


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. Django를 배포한 뒤 400 Bad Request가 나거나 CSS가 적용되지 않으면 무엇을 확인하나요?

A. DEBUG = False로 바꾸면 Django는 ALLOWED_HOSTS에 등록된 도메인으로 온 요청만 받기 때문에, 실제 도메인이 빠져 있으면 400 Bad Request가 납니다. 또 DEBUG가 꺼지면 개발 서버처럼 정적 파일을 대신 서빙해 주지 않으므로 python manage.py collectstatic으로 모은 파일을 웹 서버나 별도 방식으로 서빙해야 합니다. 배포 전에 migrate까지 함께 실행하는 습관을 들이면 스키마 불일치로 인한 오류도 막을 수 있습니다.