소규모 VPS에서 Django와 Flask 메모리 사용량 비교
1GB에서 2GB 용량의 VPS에서 Django와 Flask의 Gunicorn 워커당 실제 메모리 점유율을 분석합니다. 워커당 RSS 및 PSS 수치를 통해 소형 서버에서 안정적으로 운영 가능한 최대 워커 수를 계산하는 방법을 확인하십시오.
소규모 VPS에서 Django와 Flask의 비용
소규모 VPS에서 Django와 Flask를 비교할 때 가장 먼저 고려해야 할 것은 메모리입니다. Django는 워커 프로세스를 시작할 때마다 객체 관계 매퍼(ORM), 마이그레이션 도구, 그리고 활성화된 경우 관리자 사이트까지 모두 로드합니다. 반면 Flask는 라우터와 요청 객체만 로드합니다. 1 GB 메모리 환경에서 이러한 차이는 실행 가능한 워커의 수를 결정하며, 워커의 수는 동시에 처리할 수 있는 요청의 양을 결정합니다.
Django가 제공하는 기능을 직접 구현하지 않는다면, 이러한 메모리 비용은 감수할 가치가 있습니다. 사용자 계정, 세션, 관리자 패널이 필요한 애플리케이션이라면 Django가 적합합니다. 워커당 소비되는 RAM은 직접 작성하지 않아도 되는 코드에 대한 대가이기 때문입니다. 이미 운영 중인 데이터 저장소 앞단에 JSON API만 필요한 경우라면 Flask가 적합합니다. Django의 기본 기능(batteries)을 전혀 사용하지 않게 되기 때문입니다. 이는 적합성의 문제입니다. 아래 측정 결과는 귀하의 애플리케이션이 어느 쪽에 해당하는지 판단하는 기준이 될 것입니다.
Gunicorn 워커 하나당 메모리 사용량은 어느 정도입니까?
The data behind this chart
[
{
"label": "Bare Python 3.12 process",
"rss_mb": 14,
"pss_mb": 9
},
{
"label": "Flask, one route",
"rss_mb": 42,
"pss_mb": 26
},
{
"label": "Flask + SQLAlchemy",
"rss_mb": 58,
"pss_mb": 38
},
{
"label": "Django, admin disabled",
"rss_mb": 78,
"pss_mb": 47
},
{
"label": "Django, admin enabled",
"rss_mb": 96,
"pss_mb": 58
}
]위 수치는 Ubuntu 24.04와 Python 3.12 환경에서 preload를 활성화하고 Gunicorn 워커 3개를 실행한 'Hello World' 애플리케이션의 일반적인 측정값입니다. 이 값은 최소치로 간주해야 합니다. 실제 애플리케이션에서는 여기에 추가로 사용하는 라이브러리들이 메모리를 점유하기 때문입니다. 관리자 페이지가 활성화된 Django 워커의 경우, 상주 메모리(RSS)는 96 MB이며, 비례 할당 메모리(PSS)는 58 MB입니다. 이 두 수치의 차이에 대해서는 다음 섹션에서 자세히 다룹니다.
본인의 서버에서 직접 측정해 보십시오.
sudo apt update && sudo apt install -y python3-venv
python3 -m venv /srv/site1/.venv
/srv/site1/.venv/bin/pip install django gunicorn setproctitlesetproctitle를 설치하십시오. 이 도구가 있으면 Gunicorn이 프로세스 이름을 gunicorn: master [site1] 및 gunicorn: worker [site1]으로 변경합니다. 덕분에 다음 명령어에서 프로세스 ID를 추측할 필요 없이 이름으로 워커를 찾을 수 있습니다.
pgrep -af gunicorn
ps -o pid,rss,args -p $(pgrep -d, -f 'gunicorn: worker')rss 열은 킬로바이트 단위의 상주 메모리 크기(RSS)를 나타내며, 프로세스가 현재 RAM에 보유하고 있는 모든 메모리 페이지를 의미합니다. 워커 전체의 RSS를 합산하면 실제보다 훨씬 크게 측정되는데, 이는 fork된 워커가 부모 및 형제 프로세스와 메모리 페이지를 공유하기 때문에 동일한 페이지가 여러 번 중복 계산되기 때문입니다. 따라서 커널에 비례 할당 메모리(PSS)를 요청해야 합니다. PSS는 공유 페이지를 해당 페이지를 사용하는 프로세스 수만큼 나누어 계산합니다.
for pid in $(pgrep -f 'gunicorn: worker'); do awk -v p="$pid" '/^Pss:/ {printf "%s %d MB\n", p, $2/1024}' /proc/$pid/smaps_rollup; done워커를 소유한 사용자 권한으로 실행하거나 sudo를 사용하십시오. RSS는 합산 시 오류가 발생하므로, 메모리 예산을 책정할 때는 반드시 PSS 열을 기준으로 삼아야 합니다.
Django의 메모리 사용량이 더 큰 이유는 django.setup()의 동작 방식 때문입니다. Django는 INSTALLED_APPS에 정의된 모든 항목을 import하고, 애플리케이션 레지스트리를 구축하며, 모든 모델 클래스와 각 필드에 대응하는 Python 객체를 인스턴스화합니다. django.contrib.admin을 추가하면 관리자 페이지 자동 탐색이 실행되는데, 이 과정에서 각 앱의 admin 모듈이 import되고 폼 및 템플릿 계층까지 함께 로드됩니다. 반면 Flask 워커는 Werkzeug와 Jinja2만 import하고 종료됩니다.
한 가지 주의할 점은 프레임워크 자체가 차지하는 비중은 생각보다 작다는 것입니다. 클라우드 SDK나 수치 계산 라이브러리를 import하는 워커가 Django보다 더 많은 메모리를 사용할 수 있습니다. 프레임워크가 문제라고 단정 짓기 전에 실제 애플리케이션을 직접 측정해 보십시오.
Copy on write와 preload가 메모리 사용량에 미치는 영향
Gunicorn의 마스터 프로세스는 워커 프로세스를 fork합니다. fork() 직후에는 자식 프로세스가 부모 프로세스와 모든 메모리 페이지를 공유하며, 커널은 어느 한쪽이 데이터를 쓸 때만 해당 페이지를 복사합니다. 따라서 Django 모델 레지스트리가 서버에 하나만 존재하는지, 아니면 네 개가 존재하는지는 fork 시점의 어느 단계에서 빌드되었는지에 따라 결정됩니다.
preload_app 옵션이 꺼져 있으면 각 워커는 fork 이후에 애플리케이션을 import하므로, 각 워커는 자신만의 독립적인 복사본을 생성합니다. 이 옵션이 켜져 있으면 마스터 프로세스가 애플리케이션을 한 번만 import하고 워커들이 해당 메모리 페이지를 상속받습니다.
import gc
bind = "unix:/run/site1/gunicorn.sock"
umask = 0o007
workers = 3
timeout = 30
preload_app = True
max_requests = 500
max_requests_jitter = 50
def when_ready(server):
gc.freeze()CPython은 copy on write 방식과 상충하는 면이 있습니다. 모든 객체 헤더에는 참조 횟수(reference count)가 포함되어 있으며, 객체에 접근할 때마다 해당 헤더에 쓰기 작업이 발생합니다. 그 결과 가비지 컬렉터가 힙을 탐색할 때마다 공유 페이지가 하나씩 복사됩니다. gc.freeze()는 지금까지 할당된 모든 객체를 가비지 컬렉터가 더 이상 방문하지 않는 영구 세대(permanent generation)로 이동시켜, 공유되는 페이지를 더 많이 유지합니다. when_ready는 preload 이후이자 첫 번째 워커가 fork되기 전에 실행되므로 적절한 훅입니다. 애플리케이션의 import 시점 상태가 차지하는 비중에 따라 절감 효과가 달라지므로, 적용 전후의 PSS(Proportional Set Size)를 측정해 보십시오.
Preload 사용 시 배포 당일에 당혹스러운 문제가 하나 발생할 수 있습니다. systemctl reload은 HUP 신호를 보내며, HUP에 대한 Gunicorn의 문서화된 동작 방식은 설정을 다시 불러오고 새로운 워커를 시작하는 것입니다. 애플리케이션이 preload된 상태에서는 코드를 다시 import하지 않으므로, 워커 프로세스가 새로 생성되더라도 실제로는 새로운 릴리스 코드가 실행되지 않습니다. 코드 변경 후에는 systemctl restart를 사용하거나, 기존 워커가 작업을 마칠 때까지 기다려야 한다면 USR2 이후 WINCH을 순차적으로 실행하십시오.
1 GB VPS에서 실제로 운영할 수 있는 워커는 몇 개인가?
The data behind this chart
[
{
"label": "Ubuntu 24.04 base",
"ram_mb": 190
},
{
"label": "nginx",
"ram_mb": 12
},
{
"label": "PostgreSQL, default config",
"ram_mb": 120
},
{
"label": "Headroom you must leave",
"ram_mb": 150
},
{
"label": "Left for gunicorn workers",
"ram_mb": 550
}
]위 수치는 아무런 요청도 처리하지 않는 유휴 상태의 값입니다. 애플리케이션 워커가 사용할 수 있는 메모리는 약 550 MB이며, 이는 첫 번째 요청이 들어오기 전의 상태입니다.
이제 이 값을 기준으로 보수적으로 계산해야 합니다. 요청이 실행되는 동안에는 메모리가 소모됩니다. 수천 개의 행을 불러오는 쿼리셋이나 템플릿 렌더링 과정이 포함되기 때문입니다. 워커당 최대 메모리 사용량은 보통 유휴 상태의 두 배에 달하므로, 이를 기준으로 예산을 잡아야 합니다. Django 관리자 페이지를 기준으로 유휴 상태에서 58 MB를 사용하는 경우, 이 서버에서는 4개의 워커를 운영할 수 있습니다. SQLAlchemy를 사용하는 Flask의 경우 38 MB를 사용하므로 7개까지 가능합니다.
Gunicorn의 (2 x cores) + 1 제안은 CPU가 부족한 자원이고 RAM은 충분하다는 가정하에 작성되었습니다. 소규모 VPS에서는 이 가정이 정반대입니다. 공유 vCPU 하나는 호스트가 바쁠 때 실제 코어 하나보다 적은 성능을 제공합니다. 코드를 탓하기 전에 이 점을 이해해야 합니다. 이웃 서버의 영향으로 인한 CPU 스틸 타임은 top에서 st 수치로 나타납니다.
뷰가 주로 데이터베이스나 업스트림 API의 응답을 기다리는 형태라면, 프로세스보다 스레드가 유리합니다. --worker-class gthread --workers 2 --threads 4를 사용하면 워커 2개 분량의 메모리로 8개의 동시 요청을 처리할 수 있습니다. 스레드는 인터프리터와 프레임워크가 로드된 메모리 영역을 공유하기 때문입니다. 다만, CPU를 많이 사용하는 뷰에서는 Global Interpreter Lock(GIL) 때문에 스레드가 성능 향상에 도움이 되지 않습니다.
서버에 스왑을 설정하십시오. 스왑이 없는 1 GB VPS는 메모리 사용량이 급증하면 프로세스가 강제 종료되지만, 스왑 파일이 있으면 요청 처리가 느려질 뿐 서비스가 중단되지는 않습니다.
sudo fallocate -l 1G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
echo 'vm.swappiness = 10' | sudo tee /etc/sysctl.d/99-swappiness.conf
sudo sysctl --system애플리케이션 자체에 제한을 두십시오. Gunicorn 유닛에 MemoryMax=600M을 설정하면, 메모리 사용량이 급증할 때 커널이 서버 전체에서 희생양을 찾는 대신 해당 애플리케이션의 cgroup에서 메모리를 회수합니다. 따라서 특정 요청이 폭주하더라도 SSH 세션이 끊기는 상황을 방지할 수 있습니다.
콜드 스타트 및 재시작 동작
The data behind this chart
[
{
"label": "Flask, one route",
"cold_start_ms": 90
},
{
"label": "Flask + SQLAlchemy",
"cold_start_ms": 260
},
{
"label": "Django, admin disabled",
"cold_start_ms": 480
},
{
"label": "Django, admin enabled",
"cold_start_ms": 720
}
]부팅 비용은 배포 시마다, 그리고 충돌 후 자동 재시작 시마다 두 번 발생합니다. 최소 사양의 Flask 앱은 약 90 ms 만에 준비되며, 관리자 페이지가 활성화된 Django는 동일한 공유 vCPU 환경에서 약 720 ms가 소요됩니다. 두 수치 모두 일반적인 공개 지표입니다. 의존성 패키지가 성능에 가장 큰 영향을 미치므로 직접 측정하십시오.
cd /srv/site1
DJANGO_SETTINGS_MODULE=site1.settings /srv/site1/.venv/bin/python -X importtime -c "import django; django.setup()" 2>&1 | tail -20마지막 줄에는 누적 마이크로초 단위로 가장 느린 import 항목이 나열됩니다. Flask의 경우, 모듈에 대해 동일한 플래그를 사용하여 python -X importtime -c "import app"을 실행하십시오.
preload를 활성화하면 마스터 프로세스가 해당 비용을 한 번만 지불하고 모든 포크된 워커는 즉시 시작됩니다. preload를 비활성화하면 각 워커가 비용을 지불하게 되며, gunicorn의 timeout은 요청뿐만 아니라 부팅 시간까지 포함합니다. timeout 초 이내에 체크인하지 못한 워커는 종료 후 교체되므로, 느린 공유 vCPU에서 무거운 앱을 실행하면 아무런 요청도 처리하지 못한 채 재시작 루프에 빠질 수 있습니다. 로그에는 다음과 같이 기록됩니다.
[2026-08-09 09:14:02 +0000] [981] [CRITICAL] WORKER TIMEOUT (pid:1004)마이그레이션은 애플리케이션 시작 코드가 아닌 유닛 파일에서 수행해야 합니다. ExecStartPre은 워커가 생성되기 전에 단 한 번 실행됩니다. 애플리케이션 내부에 migrate을 넣으면 세 개의 워커가 동일한 스키마 잠금을 두고 서로 경쟁하게 됩니다.
배포 형태는 거의 동일합니다
프로세스 관리자
두 프레임워크 모두 gunicorn 위에서 실행되며, gunicorn은 systemd 위에서 실행됩니다.
[Unit]
Description=gunicorn for site1
After=network.target
[Service]
User=site1
Group=www-data
WorkingDirectory=/srv/site1
RuntimeDirectory=site1
Environment="PATH=/srv/site1/.venv/bin"
ExecStartPre=/srv/site1/.venv/bin/python manage.py migrate --noinput
ExecStart=/srv/site1/.venv/bin/gunicorn -c /srv/site1/gunicorn.conf.py site1.wsgi:application
Restart=on-failure
RestartSec=3
MemoryMax=600M
[Install]
WantedBy=multi-user.targetRuntimeDirectory=site1는 시작 시 /run/site1을 생성하고 종료 시 삭제하므로, 소켓 경로는 항상 올바른 소유자로 존재합니다. gunicorn 설정의 umask = 0o007 줄은 해당 소켓을 www-data 그룹이 쓸 수 있게 만들며, nginx는 이 방식을 통해 소켓에 접근합니다.
Flask 유닛 파일은 한 줄만 변경된 동일한 파일입니다. ExecStart=... gunicorn -c /srv/site1/gunicorn.conf.py app:app을 사용하며 ExecStartPre은 없습니다. app:app 인자는 모듈과 호출 가능 객체(callable)를 의미하므로, Failed to find attribute 'app' in 'app'. 오류가 발생한다면 해당 모듈에 그 이름의 변수가 정의되지 않은 것입니다. 예약 작업도 같은 패턴을 따르며, Django 관리 명령어를 위한 systemd 타이머를 사용하면 이 정도 규모의 서버에 별도의 작업 큐를 추가할 필요가 없습니다.
정적 파일
DEBUG = False을 사용하는 Django는 정적 파일을 전혀 서빙하지 않습니다. STATIC_ROOT을 설정하고 python manage.py collectstatic를 실행한 뒤, 웹 서버가 해당 출력 디렉터리를 가리키도록 하십시오. 이 단계를 건너뛰면 관리자 페이지는 스타일 없이 로드되고 로그는 Not Found: /static/admin/css/base.css으로 가득 찰 것입니다.
정적 파일을 서빙하는 합리적인 방법은 두 가지입니다. nginx alias 블록을 사용하는 것은 애플리케이션에 아무런 부하를 주지 않습니다. 미들웨어로 추가되는 WhiteNoise는 워커에서 파일을 직접 서빙하여 nginx 블록 설정을 생략하게 해주지만, 파일당 약간의 워커 자원을 소모합니다. Flask는 개발 환경에서 자체 static/ 폴더를 서빙하며, 운영 환경에서도 같은 이유로 프록시가 해당 폴더를 가리키도록 설정합니다.
리버스 프록시
server {
listen 80;
server_name example.com;
location /static/ {
alias /srv/site1/static/;
expires 30d;
}
location / {
proxy_pass http://unix:/run/site1/gunicorn.sock;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}프록시 뒤에 있을 때, Django는 원래 요청이 HTTPS였음을 인지해야 합니다. 그렇지 않으면 사이트 간 요청 위조(CSRF) 검사가 자체 폼 요청을 거부하게 됩니다.
ALLOWED_HOSTS = ["example.com"]
CSRF_TRUSTED_ORIGINS = ["https://example.com"]
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")이미 컨테이너를 운영 중인 서버라면, 여러 Docker Compose 앱 앞단의 Traefik을 사용하여 사이트별 파일 대신 컨테이너의 레이블만으로 동일한 작업을 수행할 수 있습니다.
데이터베이스 선택
SQLite는 초당 쓰기 횟수가 몇 번 정도인 단일 애플리케이션 서버에는 충분히 훌륭하며, 메모리 예산에서 별도의 데몬 하나를 완전히 제거할 수 있습니다. Write Ahead Logging(WAL)을 켜고 드라이버에 busy timeout을 설정하십시오.
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
"OPTIONS": {
"timeout": 20,
"init_command": "PRAGMA journal_mode=WAL;",
},
}
}이 두 가지 옵션이 없으면 두 워커가 동시에 쓰기를 시도할 때 django.db.utils.OperationalError: database is locked 오류를 마주하게 됩니다. 기본 저널 모드는 쓰기 중에 읽기를 차단하고, 기본 타임아웃은 즉시 포기하기 때문입니다. SQLite가 적절하지 않은 시점에 대한 더 자세한 논의는 VPS에서 SQLite 운영하기를 참조하십시오.
동일한 1 GB 서버에서 PostgreSQL을 사용하면 위 예산에서 120 MB를 소모하며, 지속적인 연결마다 백엔드 프로세스가 하나씩 추가됩니다. Django의 CONN_MAX_AGE은 워커당 연결을 하나씩 유지하므로, 워커가 4개라면 백엔드도 4개가 필요합니다. 이는 보통 감수할 만한 가치가 있는 거래입니다. 워커 수를 설정하기 전에 이 점을 고려하십시오. 데이터베이스를 앱 옆의 컨테이너로 유지하고 싶다면, VPS에서 Docker 운영하기를 참조하십시오. 이 경우 데몬과 각 컨테이너가 추가적인 오버헤드를 발생시키므로, 이 정도 규모에서는 더 높은 기준점이 적용됩니다.
배터리 포함 방식의 이점과 비용
Django가 추가로 사용하는 수 메가바이트의 메모리는 이미 존재하며 상호 운용성이 검증된 기능들의 목록입니다. 마이그레이션을 포함한 ORM, 세션 및 인증 시스템, 권한 모델, CSRF 보호 기능이 포함된 폼 레이어, 템플릿 엔진, 관리 명령어, 그리고 관리자 페이지가 이에 해당합니다. 관리자 페이지는 사람들이 과소평가하는 기능 중 하나입니다. 이는 INSTALLED_APPS에 단 한 줄을 추가하는 것만으로 검색과 필터링 기능을 갖춘 모델용 데이터베이스 편집기를 즉시 제공합니다.
Flask는 이와 정반대입니다. 라우팅, 요청 객체, Jinja2 템플릿, 설정 객체만 제공됩니다. 그 외 모든 것은 개발자가 직접 선택해야 합니다. 애플리케이션 규모가 작을 때는 ORM을 아예 임포트하지 않아도 되므로 이것이 실질적인 이점이 됩니다.
문제는 어중간한 상태에 빠지는 것입니다. 모델을 위해 SQLAlchemy를, 마이그레이션을 위해 Alembic을, 세션을 위해 Flask-Login을, 폼과 CSRF를 위해 Flask-WTF를, 그리고 관리자 페이지를 위해 별도의 확장 기능을 추가하다 보면, 결국 Django와 비슷한 메모리 사용량을 가지면서도 Django가 가진 일관성은 전혀 없는 결과물을 만들게 됩니다. 각 구성 요소는 저마다의 릴리스 주기와 애플리케이션 구성 방식에 대한 고유한 철학을 가지고 있습니다. 바로 이 지점이 RAM 사용량과 업그레이드에 소요되는 시간을 고려했을 때 Django가 더 경제적인 선택이 되는 이유입니다.
Django와 Flask: 선택 기준
애플리케이션에 사용자 계정, 수정 가능한 콘텐츠, 지속적으로 변경될 스키마, 그리고 실제로 누군가 사용할 관리자 페이지가 필요하다면 Django를 선택합니다. 반면, 이미 존재하는 데이터 저장소에 대한 JSON 인터페이스를 제공하거나 HTML을 포함하지 않는 웹훅 수신기를 구현한다면 Flask가 적합합니다.
결정을 내리기 어려운 경우, 필요한 기능을 구현하기 위해 Flask에 설치할 패키지 목록을 작성해 봅니다. 만약 그 목록에 ORM과 마이그레이션 도구가 포함되어 있다면, 이미 Django를 선택한 것이나 다름없으며 단지 더 느리게 도달하기 위해 추가 비용을 지불하고 있는 셈입니다.
소형 하드웨어 환경에서 Flask가 확실히 유리한 경우는 한 대의 서버에서 여러 개의 소규모 서비스를 운영할 때입니다. 각 Flask 서비스는 독립적인 유닛 아래에서 가벼운 프로세스로 실행됩니다. 1 GB RAM을 가진 VPS 한 대에서 3개의 Django 사이트를 운영하면 프레임워크 3개가 메모리에 동시에 상주하게 되어, 앞서 언급한 효율성 계산이 성립하지 않게 됩니다. 만약 자원이 계속 부족하다면, 작동 중인 애플리케이션을 다시 작성하는 것보다 VPS의 실제 월간 비용을 확인하고 상위 요금제로 변경하는 것이 더 합리적인 해결책입니다.
발생 가능한 오류 유형
워커가 사라졌다가 다시 나타납니다. Gunicorn이 [ERROR] Worker (pid:1234) was sent SIGKILL! Perhaps out of memory?를 출력합니다. 이 메시지는 워커가 타임아웃 하트비트를 놓쳐 종료될 때와 커널이 프로세스를 강제 종료했을 때 모두 나타납니다. dmesg -T | grep -i "killed process"을 확인하여 두 경우를 구분하십시오. 해당 파일에 관련 기록이 있다면 메모리 부족 문제이므로 워커 수를 줄이거나 스왑을 추가하십시오.
모든 페이지에서 400 오류가 발생하고 로그에 Invalid HTTP_HOST header이 기록됩니다. 전체 메시지는 Invalid HTTP_HOST header: 'example.com'. You may need to add 'example.com' to ALLOWED_HOSTS.입니다. ALLOWED_HOSTS이 비어 있거나 프록시가 Host 헤더로 전달한 도메인 이름이 포함되어 있지 않아 Django가 코드에 도달하기 전에 요청을 거부하는 것입니다.
폼 제출 시 Origin checking failed 오류가 발생합니다. 페이지에는 CSRF 검증 실패라고 표시됩니다. 이는 TLS 종료 프록시 뒤에서 운영할 때 발생합니다. 애플리케이션은 일반 HTTP로 요청을 받아 http:// 오리진을 생성하는데, 실제 요청은 https://을 통해 들어오기 때문에 비교가 실패합니다. SECURE_PROXY_SSL_HEADER 및 CSRF_TRUSTED_ORIGINS 설정을 적용하고, 프록시가 실제로 X-Forwarded-Proto 헤더를 전달하는지 확인하십시오.
nginx가 즉시 502 오류를 반환합니다. 오류 로그에 원인이 명시됩니다. connect() to unix:/run/site1/gunicorn.sock failed (2: No such file or directory)은 유닛이 실행 중이지 않음을 의미하며, (13: Permission denied)는 소켓은 존재하지만 nginx가 이를 열 수 없음을 의미합니다. 이는 umask 및 그룹 설정 문제입니다.
관리자 페이지의 스타일이 깨져 보입니다. collectstatic를 실행하지 않았거나, alias 경로가 STATIC_ROOT 설정과 일치하지 않는 경우입니다. 접근 로그를 보면 /static/admin/ 경로에서 404 오류가 발생하는 것을 확인할 수 있습니다.
부하가 적은데도 쓰기 작업이 실패합니다. SQLite에서 발생하는 database is locked 오류는 WAL 모드가 꺼져 있거나, 두 워커가 동시에 쓰기를 시도할 때 busy timeout 설정이 너무 짧기 때문에 발생합니다.
FAQ
1 GB VPS에서 Django는 너무 무겁습니까?
아닙니다. 적은 수의 워커, 앞단의 Nginx, 뒤단의 SQLite 조합이라면 1 GB 환경에서도 Django는 원활하게 동작합니다. 하지만 기본 설정의 PostgreSQL, 캐시, 백그라운드 워커, Docker를 같은 서버에 추가하면 메모리가 부족해집니다. 워커 하나의 PSS(Proportional Set Size)를 측정하고, 요청 급증에 대비해 그 두 배를 계산한 뒤, 운영체제와 데이터베이스가 사용하는 메모리를 제외한 나머지 용량과 비교하십시오.
1 vCPU당 gunicorn 워커는 몇 개가 적당합니까?
3개로 시작하여 측정하십시오. 소규모 VPS에서는 보통 메모리가 제약 사항이므로, 운영체제와 데이터베이스가 사용하는 메모리를 제외한 가용 RAM을 워커 하나당 PSS의 두 배 값으로 나누어 결정하십시오. 뷰가 주로 데이터베이스나 업스트림 API의 응답을 기다리는 형태라면, gthread 워커 클래스로 전환하여 적은 수의 워커와 여러 개의 스레드를 사용하는 것이 좋습니다. 스레드는 프레임워크의 로드된 복사본을 공유하므로 프로세스를 추가하는 것보다 메모리 소모가 훨씬 적습니다.
PostgreSQL이 꼭 필요합니까, 아니면 SQLite로 충분합니까?
쓰기 작업이 많지 않은 애플리케이션 서버 하나만 운영한다면 SQLite로 충분하며, 별도의 데몬을 띄우지 않아도 되므로 메모리 절약에도 도움이 됩니다. WAL(Write Ahead Logging)을 활성화하고 busy timeout을 설정하십시오. 그렇지 않으면 동시 쓰기 시 database is locked 오류가 발생합니다. 여러 대의 서버에서 동시에 쓰기를 수행해야 하거나, 동시 쓰기 부하가 크거나, 역할별 접근 제어와 같이 SQLite가 지원하지 않는 기능이 필요할 때 PostgreSQL로 이전하십시오.
gunicorn 대신 uvicorn을 사용해야 합니까?
비동기 뷰를 사용하고 실제로 대기해야 할 작업이 있을 때만 고려하십시오. Flask는 WSGI 애플리케이션이므로 비동기 뷰를 사용해도 워커 스레드 내부의 새로운 이벤트 루프에서 실행되고 다음 요청이 시작되기 전에 종료되어 동시성 이점을 얻을 수 없습니다. Django 비동기 뷰는 ASGI 서버가 있어야만 비동기 처리의 이점을 누릴 수 있습니다. 최근 uvicorn 릴리스에서는 gunicorn 워커 클래스가 별도의 패키지로 분리되었으므로, 오래된 튜토리얼의 워커 클래스 플래그를 복사하지 말고 현재의 uvicorn 문서를 확인하십시오.
워커가 트레이스백 없이 사라지는 이유는 무엇입니까?
커널의 OOM(Out of Memory) 킬러에 의해 프로세스가 종료되면 SIGKILL 신호를 받게 되며, 종료 과정에서 아무런 로그도 남길 수 없으므로 애플리케이션 로그가 단순히 중단됩니다. Gunicorn은 이 공백을 감지하고 Worker (pid:1234) was sent SIGKILL! Perhaps out of memory?을 출력합니다. dmesg -T | grep -i "killed process" 명령어로 이를 확인하십시오. 해결 방법은 워커 수를 줄이거나, 메모리 급증 시 프로세스가 죽는 대신 요청 처리가 느려지도록 스왑 파일을 설정하는 것입니다.