Django에서 대용량 파일 업로드가 502를 내뿜는 이유, 그리고 tusd로 해결한 이야기
TL;DR
- Django가 파일 업로드를 직접 처리하면 gunicorn worker를 장시간 점유한다
- 프록시 체인에서 가장 짧은 타임아웃이 전체 시스템의 상한을 결정한다
- 파일 수신을 전용 서버(tusd)에 위임하면 Django는 비즈니스 로직에만 집중할 수 있다
- 결과: 삭제한 코드가 추가한 코드보다 많았다 (
-1411줄, +1136줄)
배경: 동영상을 올리면 502
videokeeper는 동영상을 아카이브하는 개인 프로젝트입니다. Django + Celery + yt-dlp 조합으로 동작하죠. URL을 입력하면 영상 다운로드, 자막 추출, AI 요약까지 자동 처리합니다.
여기에 파일 업로드 기능을 추가했는데, 문제가 시작됐습니다.
로컬에서는 수 GB 파일도 잘 올라갑니다. 하지만 프로덕션(K3s + Cloudflare Tunnel)에 배포하면? 업로드 도중 502 Bad Gateway. 매번. 100% 재현.
원인 분석
처음 의심한 것: 스토리지
GCS(Google Cloud Storage)로 바꾸면 나아지지 않을까?
잠깐 생각해보면, 파일이 어디에 저장되든 업로드 요청을 받는 주체는 달라지지 않습니다. Django가 HTTP 요청을 받아서 파일을 쓰는 동안 worker를 점유하는 건 마찬가지거든요.
진짜 원인: 프록시 타임아웃 체인
프로덕션 환경의 요청 경로를 따라가 봅시다.
클라이언트 → Cloudflare Tunnel → nginx → gunicorn → Django
각 레이어에 타임아웃이 있습니다.
| 레이어 | 타임아웃 | 변경 가능 |
|---|---|---|
| Cloudflare Tunnel | 30초 | 불가 (하드 리밋) |
| nginx | 60초 | 가능 |
| gunicorn | 120초 | 가능 |
가장 짧은 타임아웃이 전체 체인을 지배합니다. Cloudflare Tunnel의 30초 리밋은 올릴 수 없죠.
tus 프로토콜을 사용하고 있었기 때문에 파일은 5MB 청크로 분할됩니다. 각 PATCH 요청은 30초 안에 충분히 끝나니까 괜찮다고 생각했습니다.
하지만 Django가 tus 프로토콜을 직접 구현하고 있었고, 각 PATCH 요청마다 gunicorn worker 하나를 점유했습니다. gunicorn worker가 2개뿐인 상황에서, 수백 개 청크를 처리하다 보면:
[PATCH chunk 1] ─── worker 1 점유 ──┐
[PATCH chunk 2] ─── worker 2 점유 ──┤
[PATCH chunk 3] ─── 대기... 30초 ──→ 502 Bad Gateway
단일 요청의 시간이 아니라 worker 고갈이 문제였습니다.
검토한 대안들
1. 타임아웃 늘리기
nginx와 gunicorn의 타임아웃을 늘리면 될까요? Cloudflare Tunnel의 30초는 못 늘립니다. 그리고 타임아웃을 늘리는 것은 증상을 가리는 것이지, Django worker가 파일 I/O에 묶이는 구조적 문제를 해결하지 못합니다.
2. Celery로 업로드 이관
파일 수신을 Celery worker에서 처리하면? 불가능합니다. HTTP 청크 수신은 클라이언트와의 TCP 커넥션에 의존하는데, Celery task는 HTTP 커넥션을 소유하지 않거든요.
3. 전용 업로드 서버 (tusd) ← 최종 선택
파일 업로드를 아예 Django가 처리하지 않으면 됩니다.
tus 프로토콜의 공식 서버 구현체인 tusd는 Go로 작성된 경량 바이너리입니다. nginx가 업로드 경로를 tusd로 직접 라우팅하면, Django worker는 업로드에 전혀 관여하지 않죠.
업로드가 끝나면 tusd가 webhook으로 Django에 알려주고, Django는 파일 후처리만 담당합니다.
구현
Before / After 아키텍처
Before — Django가 모든 것을 처리:
graph LR
Client[클라이언트] -->|PATCH| CF[Cloudflare]
CF --> nginx
nginx --> gunicorn
gunicorn --> Django
Django -->|파일 I/O + DB| Storage[(스토리지)]
style Django fill:#fee,stroke:#c33,color:#333
style gunicorn fill:#fee,stroke:#c33,color:#333
Django가 파일을 직접 수신하므로 gunicorn worker가 장시간 점유됩니다.
After — 업로드는 tusd, 후처리는 Django:
graph LR
Client[클라이언트] -->|PATCH| CF[Cloudflare]
CF --> nginx
nginx -->|/videos/tus/| tusd
nginx -->|그 외| gunicorn
tusd -->|파일 수신| Storage[(스토리지)]
tusd -->|webhook| Django
gunicorn --> Django
Django -->|후처리| Pipeline[파이프라인 시작]
style tusd fill:#efe,stroke:#3a3,color:#333
style Django fill:#efe,stroke:#3a3,color:#333
nginx가 경로 기반으로 라우팅합니다. 업로드는 tusd가, 비즈니스 로직은 Django가 담당합니다.
핵심은 관심사의 분리입니다:
- tusd — tus 프로토콜 처리, 파일 수신, 청크 관리, 이어받기
- nginx — 경로 기반 라우팅 (업로드 → tusd, 나머지 → Django)
- Django — 인증/인가, 비즈니스 로직, 파이프라인 오케스트레이션
Webhook Handler 설계
tusd는 업로드 생명주기에서 두 가지 이벤트를 Django에 알립니다.
pre-create — 업로드 시작 전 검증:
def _handle_pre_create(request, payload):
if not request.user.is_authenticated:
return HttpResponse(status=403)
# 파일명 확장자 검증 (.mp4, .mkv, .webm 등)
# 파일 크기 검증 (10GB 이하)
return HttpResponse(status=200) # 업로드 허용
200을 반환하면 tusd가 업로드를 진행합니다. Django는 이 한 번의 HTTP 요청만 처리하면 되죠. 실제 파일 전송에는 관여하지 않습니다.
post-finish — 업로드 완료 후 처리:
- magic bytes 검증 — 확장자 위장 방어
- Video 레코드 생성 — DB에 메타데이터 저장
- 파일 이동 — tusd 임시 디렉토리 → 최종 저장 경로
- 파이프라인 시작 — 음성인식 + AI 요약 자동 실행
이 모든 과정에서 실패하면 롤백합니다. Video 레코드 삭제 + 파일 정리까지.
로컬 개발 환경
프로덕션과 동일한 구조를 로컬에서도 재현했습니다.
# docker-compose.yml
tusd:
image: docker.io/tusproject/tusd:v2
command:
- -base-path=/videos/tus/
- -upload-dir=/data
- -hooks-http=http://host.docker.internal:8000/videos/tus-hook/
- -hooks-http-forward-headers=Cookie
- -hooks-enabled-events=pre-create,post-finish
# 터미널 1: Django
uv run python manage.py runserver 8000
# 터미널 2: tusd + nginx
docker compose up tusd nginx-dev
로컬에서도 프로덕션과 동일한 코드 경로를 타므로 “로컬에서는 되는데 프로덕션에서 안 돼요”가 발생하지 않습니다.
결과
20 files changed, 1136 insertions(+), 1411 deletions(-)
삭제한 줄이 더 많습니다.
Django에서 tus 프로토콜을 직접 구현한 349줄의 코드와 테스트가 사라졌습니다. 대신 204줄의 webhook handler가 들어왔죠.
복잡한 프로토콜 구현을 전용 서버에 위임하면, 애플리케이션 코드는 비즈니스 로직에 집중할 수 있습니다.
배운 것들
- 타임아웃은 체인이다 — 프록시를 여러 단 거치면 가장 짧은 타임아웃이 전체 시스템의 상한이 된다. 변경 불가능한 타임아웃이 있으면 아키텍처 자체를 바꿔야 한다.
- 웹 프레임워크가 파일 I/O를 처리하면 안 된다 — Django든 Rails든, 동기 웹 프레임워크가 대용량 파일을 직접 수신하면 worker를 장시간 점유한다. 파일 업로드도 파일 서빙(
X-Accel-Redirect)처럼 전용 서비스에 위임하는 것이 올바른 패턴이다. - tus 프로토콜을 직접 구현하지 마라 — core protocol은 단순해 보이지만, 이어받기·동시성·에러 복구까지 고려하면 복잡도가 급격히 올라간다. tusd는 이 모든 것을 처리하는 Go 바이너리 하나다.
- 로컬과 프로덕션의 코드 경로를 일치시켜라 — 코드 경로가 다르면 프로덕션에서만 발생하는 버그를 로컬에서 재현할 수 없다.
마치며
502 Bad Gateway 한 줄이 보여주는 건 “서버가 응답하지 않았다”는 사실뿐입니다. 하지만 그 뒤에는 프록시 타임아웃 체인, worker 점유, 프로토콜 구현의 책임 소재라는 아키텍처 문제가 숨어 있었죠.
해결책은 의외로 단순했습니다. 파일 업로드를 애플리케이션이 아닌 전용 서버에 위임하는 것. Django는 인증과 비즈니스 로직에 집중하고, tusd는 파일 수신에 집중합니다.
각자 잘하는 일을 하면 됩니다.