하루 만에 MVP를 배포하고, 13일간 꾸준히 키운 이야기 — videokeeper 개발기

TL;DR

  • Claude Code와 함께 하루 만에 MVP(다운로드 + 자막 + 요약)를 만들어 배포했다
  • 이후 13일간 107 커밋, 80 PR, 747개 테스트로 기능을 꾸준히 확장했다
  • 3개 AI 에이전트 교차 리뷰로 1인 개발의 “리뷰어 부재” 문제를 보완했다
  • Django에서 tus를 직접 구현하다 502 지옥을 경험하고, tusd로 분리해서 해결했다
  • homelab K3s에 배포하여 추가 인프라 비용 0원으로 운영 중

들어가며

작년에 구독하던 YouTube 채널이 하나 사라졌다. 영상 30개 정도를 즐겨찾기 해두고 나중에 정리하려 했는데, 어느 날 전부 비공개로 전환됐다. 채널 주인이 마음을 바꾼 거다. 복구할 방법은 없었다.

유료 강의도 마찬가지다. 수강 기간이 끝나면 다시 볼 수 없고, 강의 녹화를 저장해둬도 2시간짜리를 다시 처음부터 재생할 엄두가 안 난다. 핵심 내용이 어디쯤에 있었는지 기억이 안 나니까.

그래서 만들기 시작했다. 가치 있는 영상을 미리 아카이브하고, 자막을 추출하고, AI로 핵심 내용을 요약해두는 도구.

Claude Code와 함께 첫날에 기획부터 핵심 기능(YouTube 다운로드 + 자막 추출 + AI 요약)까지 구현하고, 이틀째에 Docker + K3s 배포를 완료했다. 그리고 나서 “강의 녹화도 올리고 싶다”, “로컬에 있는 영상 파일도 함께 관리하고 싶다”는 욕구가 자연스럽게 생기면서, 13일에 걸쳐 파일 업로드, 음성인식 fallback, Google OAuth, 웹 UI를 하나씩 붙여나갔다.

이 글은 videokeeper를 하루 만에 MVP로 배포하고, 13일간 꾸준히 확장해간 과정을 다룬다.


프로젝트 한눈에 보기

항목수치
MVP 완성1일 (2026-05-09)
프로덕션 배포2일차 (2026-05-10~11)
전체 개발 기간13일 (2026-05-09 ~ 05-21, 기능 확장 포함)
커밋 수107개 (머지 포함 187개)
PR 수80개
테스트747개
코드 규모~13,000 라인 (앱 + 템플릿 + 테스트)
기술 스택Django 5.1 · Celery 5.6 · yt-dlp · PostgreSQL · Redis · K3s

핵심 기능

1. URL 하나로 영상 아카이브

YouTube URL을 입력하면 yt-dlp가 영상을 다운로드하고, 자막을 추출하고, Claude API가 핵심 내용을 요약한다. 모든 과정은 Celery 비동기 파이프라인으로 처리되어 다운로드 → 스크립트 추출 → 요약의 각 단계가 독립적으로 실패하고 재시도할 수 있다.

2. 파일 업로드 — 강의 녹화, 로컬 영상도 함께 관리

YouTube 다운로드만 있으면 절반짜리다. 강의 녹화, 세미나 영상, 로컬에 흩어진 파일들도 한 곳에서 관리하고 싶었다. 그래서 파일 업로드를 추가했는데, 이게 예상보다 긴 여정이 됐다 (아래 “tus에서 tusd까지” 섹션에서 자세히 다룬다).

3. AssemblyAI 음성인식 fallback

업로드한 파일에는 자막이 없다. YouTube 영상 중에도 자막이 없는 것들이 있다. 이런 영상은 AssemblyAI Universal-2로 음성인식을 돌린 후 동일한 요약 파이프라인을 태운다. URL 다운로드든 파일 업로드든 최종적으로 “요약 + 핵심 포인트”라는 같은 결과물이 나온다.

4. Google 로그인 + 접근 제어

django-allauth로 Google OAuth2 로그인을 연동했다. 개인 도구이므로 일반 가입은 차단하고, 소셜 로그인만 허용하되 관리자 승인 후 접근 가능하도록 커스텀 어댑터를 구현했다.

5. 웹 UI

Django 템플릿 기반의 웹 UI로, URL 입력/파일 업로드 탭 전환, 영상 목록(상태별 필터링), 상세 페이지(영상 재생 + 요약 + 태그 + 스크립트 + 인라인 메모 편집)를 제공한다.


개발 프로세스 — 11단계 구조화 + 3에이전트 교차 리뷰

이 프로젝트에서 가장 특이한 점은 1인 개발이면서도 체계적인 리뷰 프로세스를 적용했다는 것이다.

단계별 흐름

[1] 제품 기획서 → [2] 기술 기획서 → [3] ADR → [5] SP 분리안
→ [6] SP별 PRD → [7] TDD 테스트 → [8] 구현

각 단계에서 산출물이 나오면, 3개의 AI 에이전트가 병렬로 교차 리뷰를 수행한다:

  • Claude Opus — 정밀 분석 (아키텍처, 엣지 케이스, 보안)
  • Claude Codex — 코드 검증 (구현 정합성, 누락 체크)
  • Claude Sonnet — 균형 검토 (가독성, 일관성)

2회 연속 ALL CLEAN(P0/P1 이슈 0건)이 달성되어야 다음 단계로 넘어간다. 리뷰에서 발견된 이슈는 심각도에 따라 P0(블로커)~P3(제안)으로 분류하고, P0/P1은 반드시 수정한다.

리뷰가 잡아낸 실제 버그들

이 프로세스는 “형식적 리뷰”가 아니었다. 실제로 프로덕션에 나갈 뻔한 버그들을 잡아냈다:

  • Path Traversal 공격: capture API에서 source/source_id 파라미터를 파일 경로에 직접 사용 → ../../etc/passwd 식의 경로 조작 가능. sanitize 함수 추가로 해결
  • JavaScript falsy 버그: getAttribute('data-raw') || display.textContent에서 빈 문자열("")이 falsy로 평가되어 이전 텍스트로 폴백. !== null 명시적 체크로 수정
  • non-dict JSON body 500 에러: json.loads("5")는 int를 반환하는데, "notes" not in 5가 TypeError를 발생시킴. isinstance(data, dict) 체크 추가
  • urllib/anthropic 예외 계층 누락: urllib.error.HTTPErroranthropic.APIConnectionError가 Python 빌트인 ConnectionError를 상속하지 않아, autoretry에서 누락됨. 명시적 예외 타입 지정으로 해결

숫자로 보는 리뷰

전체 프로젝트에서 수십 라운드의 리뷰가 진행되었다. 대부분 R2~R5 안에 ALL CLEAN을 달성했고, 가장 오래 걸린 SP는 R8(SP-16 TDD)이었다. 리뷰 과정에서 Codex 리뷰어의 반복적인 False Positive(특히 linkification XSS 관련)도 경험했는데, 이를 “FP 인정” 처리하는 판단 기준도 함께 다듬어갔다.


아키텍처 결정들

3-모델 분리 (Video / Transcript / Summary)

단일 모델에 모든 필드를 넣는 대신, 파이프라인 단계별로 모델을 분리했다. “다운로드는 성공했지만 요약은 실패”한 상태가 레코드 존재 여부로 자연스럽게 표현된다. 각 단계를 독립적으로 재시도할 수 있고, 향후 STT 등 소스 타입 확장에도 유연하다.

Celery immutable signature 체인

파이프라인은 download → extract_transcript → summarize의 Celery 체인으로 구성된다. immutable signature를 사용하여 각 태스크가 이전 태스크의 결과에 의존하지 않고, DB를 통해 상태를 공유한다. 덕분에 중간 단계부터 재시도하는 resume_video가 가능해졌다.


tus에서 tusd까지 — 파일 업로드의 여정

파일 업로드 기능은 이 프로젝트에서 가장 많은 시행착오를 겪은 부분이다.

1단계: Django에서 tus 직접 구현

수 GB짜리 영상 파일을 한 번에 올리는 건 무리다. 네트워크가 끊기면 처음부터 다시 올려야 한다. 그래서 tus 프로토콜을 선택했다. 파일을 5MB 청크로 나눠 보내고, 중간에 끊겨도 이어서 올릴 수 있다.

처음에는 Django 뷰에서 tus 프로토콜을 직접 구현했다. POST로 업로드를 생성하고, PATCH로 청크를 받고, HEAD로 현재 오프셋을 반환하는 식이다. 로컬에서는 완벽하게 동작했다. 268MB .mov 파일도 잘 올라갔다.

2단계: 프로덕션에서 502

프로덕션에 배포하니 매번 502 Bad Gateway가 뜬다. 100% 재현.

원인은 프록시 타임아웃 체인이었다:

클라이언트 → Cloudflare Tunnel(30초) → nginx(60초) → gunicorn(120초) → Django

가장 짧은 Cloudflare Tunnel의 30초가 전체 체인의 상한이 되는데, 이건 변경이 불가능한 하드 리밋이다. 각 PATCH 요청 자체는 5MB라 수초면 끝나지만, Django가 tus 프로토콜을 직접 처리하면서 gunicorn worker(2개)를 점유했다. 수백 개 청크가 worker를 돌아가며 차지하다 보면 worker가 모두 점유되고, 새 요청은 대기 → 타임아웃 → 502.

3단계: tusd로 분리

해결책은 의외로 단순했다. 파일 업로드를 Django가 처리하지 않으면 된다.

tus 프로토콜의 공식 서버 구현체인 tusd는 Go 바이너리 하나다. nginx가 업로드 경로(/videos/tus/)를 tusd로 직접 라우팅하면, Django worker는 업로드에 전혀 관여하지 않는다.

Before: 클라이언트 → nginx → gunicorn → Django(tus.py) — worker 점유

After:  클라이언트 → nginx ─┬→ tusd(:8080)         — 파일 수신 (Django 무관)
                            │    └ 완료 시 webhook → Django — 후처리만
                            └→ gunicorn(:8000)      — 일반 요청

Django는 webhook 2개만 처리한다:

  • pre-create: 업로드 시작 전에 인증/검증 (쿠키 기반 세션 확인, 확장자/크기 체크)
  • post-finish: 업로드 완료 후 Video 레코드 생성 + 파이프라인 시작

결과적으로 Django에서 tus를 직접 구현한 349줄의 코드가 삭제되고, 204줄의 webhook handler로 대체됐다. 삭제한 코드가 추가한 코드보다 많은 변경이었다. 복잡한 프로토콜 구현을 전용 서버에 위임하면, 애플리케이션 코드는 비즈니스 로직에 집중할 수 있다.


인프라 — homelab K3s

프로덕션은 homelab의 K3s 클러스터에 배포한다:

  • 스토리지: NAS를 PersistentVolume으로 마운트 (추가 비용 0)
  • 접근: Cloudflare Tunnel + Access로 외부 접근 + 인증
  • CI/CD: GitHub Actions → GHCR 이미지 빌드(amd64+arm64) → self-hosted runner에서 kubectl rollout restart
  • CD 소요시간: Docker build ~36초 + deploy ~2분

self-hosted runner를 K3s master 노드에 설치했더니 kubeconfig도 Tailscale 터널도 필요 없었다. 가장 단순한 CD 구성이었다.


삽질 기록

OOMKilled 반복

K3s Pod가 반복적으로 OOMKilled 됐다. 원인은 Django admin에서 Video.objects.all()을 렌더링할 때 yt-dlp의 metadata JSON(영상당 수MB)을 모두 메모리에 올리는 것이었다. Admin에서 metadata 필드를 list_display에서 제거하고, lazy import + CONN_HEALTH_CHECKS + gunicorn --preload를 적용하여 해결했다.

Google OAuth 6연속 핫픽스

Google 로그인을 붙이는 건 간단할 줄 알았다. 실제로는 6개 PR에 걸쳐 핫픽스를 쏟아야 했다:

  1. 소셜 로그인 중간 확인 페이지가 뜸 → SOCIALACCOUNT_LOGIN_ON_GET = True
  2. OAuth callback이 http://로 전송됨 → SECURE_PROXY_SSL_HEADER 추가
  3. Traefik이 X-Forwarded-Protohttp로 덮어씀 → Cloudflare Cf-Visitor 헤더로 전환
  4. 일반 가입 폼이 노출됨 → 커스텀 어댑터로 차단 + 리다이렉트
  5. 소셜 가입 폼 자동 제출 안 됨 → JavaScript 자동 제출 + 승인 대기 페이지
  6. 기존 유저(createsuperuser)가 Google 로그인 실패 → EMAIL_AUTHENTICATION + AUTO_CONNECT 설정

K3s + Cloudflare Tunnel + Traefik + django-allauth 조합은 문서에 나오지 않는 엣지 케이스가 많았다.

yt-dlp YouTube 429

YouTube가 yt-dlp 요청에 429 rate limit을 걸기 시작했다. remote_components 옵션을 활성화하여 YouTube의 JavaScript 챌린지를 해결하도록 했고, Docker 이미지에 deno 런타임을 추가했다.


마치며

하루 만에 MVP를 배포하고, 13일간 기능을 확장하면서 실제로 매일 쓰는 도구로 키웠다. 솔직히 Claude Code 없이는 이 속도로 불가능했을 것이다. 특히 3에이전트 교차 리뷰는 1인 개발의 가장 큰 약점인 “리뷰어 부재”를 상당 부분 보완해줬다.

물론 AI 리뷰가 완벽하진 않다. Codex 리뷰어가 같은 False Positive를 4회 연속 지적하는 것처럼 한계도 있었다. 하지만 Path Traversal이나 예외 계층 누락 같은 실제 버그를 잡아준 순간들을 생각하면, 이 프로세스의 가치는 충분했다.

videokeeper는 지금 실제로 사용 중이다. YouTube 영상을 저장하고, 요약을 읽고, 메모를 남기고 있다. 다음 단계는 Telegram 봇 인터페이스와 영상 간 관계 관리(태그 기반 자동 연결)다.


  • GitHub: jaypy-h/videokeeper (private)
  • 기술 스택: Python 3.12 · Django 5.1 · Celery 5.6 · yt-dlp · AssemblyAI · Claude API · PostgreSQL · Redis · K3s · GitHub Actions
  • 배포: homelab K3s + Cloudflare Tunnel + Access