Skip to content

리뉴얼: Streamlit+RunPod를 로그인 전용 서버리스 웹앱(Vercel·Modal·Supabase)으로 - #1

Merged
seungwook-kim merged 7 commits into
mainfrom
renewal
Oct 20, 2026
Merged

seungwook-kim merged 7 commits into
mainfrom
renewal

Conversation

@seungwook-kim

@seungwook-kim seungwook-kim commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

요약

Streamlit + RunPod 상시 GPU 구조를 로그인 전용 서버리스 웹앱(웹 Vercel + API·STT·TTS Modal + DB·파일·로그인 Supabase)으로 다시 구성합니다. 파인튜닝한 STT·TTS 모델, 500개 레시피 데이터, 대화 흐름(상태 기계)은 그대로 두고 서빙과 연결 방식만 바꿨습니다. 안 쓸 때는 API·GPU가 0대(마지막 요청 5분 뒤)가 됩니다.

배포본: https://chefear.vercel.app (로그인 필요). 구조·결정·인수 기준은 docs/specs/renewal_serverless.md, 배포 절차는 renewal/DEPLOY.md.

왜

  • 사용자가 거의 없는 기간에도 RunPod A40(48GB)이 상시 과금됩니다.
  • Streamlit 재실행 + WebRTC 구조에서 화면 잔상, Queue overflow, 동시 1~2명 한계가 반복됐고, 서버는 개인 PC나 단일 Pod에 묶여 있었습니다.
  • 공개 서비스에서 로그인 없이 GPU를 호출할 수 있으면 남이 비용을 만듭니다.

변경 내용 (커밋 4개로 나눔)

커밋 내용
refactor 제거 Streamlit 앱(src/app.py, src/ui/, ui/), RunPod·Docker·GHCR 배포, 소개 페이지(landing/), GPU 워커 풀, 관리자 화자검증, 로컬 계정 모듈과 이들만 검사하던 테스트
feat(server) server/chefear_api(FastAPI, 상태 기계·로그인 확인·마이레시피), inference/modal_app.py(Stt·Tts는 L4, 등록 음성은 동시 1대), db/migrate_renewal.sql, 공용 음성 업로드 스크립트, 입력 방어 guard.py, 테스트 79개
feat(web) Vite + React 웹: 로그인 화면, 브라우저 VAD, 원본 디자인·반응형, 등록 음성 준비 안내, 데모 모드(VITE_MOCK=1)
docs README 재작성(평가·팀 기록은 유지), 스펙·결정기록·AGENTS.md·배포 문서·API 계약, .env.example·requirements 정리, CI, keepalive

핵심 동작:

  • 서비스 전체 로그인 전용: 비로그인은 health·warmup 외 모든 요청이 401 login_required. warmup은 비로그인이면 GPU를 깨우지 않습니다.
  • 대화 상태는 Supabase sessions: API가 꺼졌다 켜져도 이어집니다.
  • 음성: 응답 문장의 지문으로 미리 만든 mp3를 먼저 재생하고, 없을 때만 실시간 TTS를 씁니다. 등록 레시피 음성은 저장 후 별도 GPU 함수가 만들어 본인만 듣는 비공개 저장소에 둡니다.
  • STT는 GPU(L4): Modal 실측(10문장) CPU 2vCPU 11.27초, 4vCPU 8.12초, L4 0.20초.
  • 입력 방어: 채팅·음성 인식 결과·등록 폼·마이레시피 수정·TTS 입력을 정리(유니코드 정규화, 제어문자·보이지 않는 문자 제거)·길이 제한·공격 문구 차단. DB 조회는 원래 파라미터 방식이라 주입이 구조적으로 막혀 있고, 이 층은 그 위에 얹었습니다.
  • 마이크: 끄기·로그아웃·레시피 등록 화면에서는 연결 자체를 닫습니다(브라우저 표시등 꺼짐).
  • 원본과 맞춘 동작: 대화 카드의 "ChefEar:" 줄, 말 처리가 0.4초 넘게 걸릴 때 오버레이와 알림음, 우상단 아이디 표시(누르면 마이레시피, 로그아웃은 그 안).
  • 비용 안전장치: Modal 월 spend limit $0(무료 크레딧 소진 시 카드 청구 대신 중단), DB 공개(anon) 키 접근 정책 제거와 RLS.

검증한 것

  • pytest tests 167개 통과(기존 87 + 신규 80). 깨끗한 가상환경(CPU torch + requirements-server.txt)에서 CI와 같은 명령으로 166개 통과(그 뒤 ChefEar 줄 테스트 1개 추가).
  • 웹 npm run build(타입 검사 포함) 통과.
  • 배포본(브라우저 자동화): 글자 입력으로 "된장찌개" 검색 → 재료 11개 → 조리 단계 이동(Modal API + Supabase 실제 호출).
  • 비로그인 호출 56건(7개 엔드포인트 × 8회) 전부 401, 개발용 우회 헤더·가짜 토큰도 401.
  • 가짜 마이크로 마이크 트랙 확인: 시작 시 연결 → 끄기·로그아웃·등록 화면에서 닫힘 → 다시 켜기·조리 화면 복귀에서 연결.
  • 화면 크기 4가지(폰·태블릿·노트북·2560px)에서 레이아웃과 가로 넘침 없음 확인.
  • Modal 이번 달 청구 $0.00(크레딧 상쇄), spend limit $0 설정 확인.

검증하지 못한 것 / 위험

  • 리뉴얼 후 실제 사람이 마이크로 끝까지 돌려 본 기록이 없습니다. 실시간 TTS(GPU)와 등록 음성 생성(RecipeAudio)은 실제 Modal에서 한 번도 실행하지 않았습니다.
  • 처리 중 오버레이·알림음은 실제 말소리가 있어야 동작해서 화면 모양만 확인했고, 실제 발화로 뜨는 것은 확인하지 못했습니다.
  • 구글·이메일 로그인 전체 완료는 확인하지 못했습니다(계정 입력이 필요). 구글 동의 화면이 "테스트" 상태면 등록한 사용자만 로그인됩니다. Supabase 기본 메일은 발송 한도가 작다는 자료가 있어 이메일 가입은 한도를 확인해야 합니다.
  • 종단 응답 시간(p95)과 동시 사용자 부하는 측정하지 않았습니다. API 콜드 스타트 약 20초만 확인했고, 등록 음성 생성 시간(화면 안내 "보통 1~3분")은 추정입니다.
  • 요리명 추정 LLM(EXAONE)을 서비스 경로에서 뺀 영향은 같은 발화 세트로 재지 않았습니다.
  • 공용 음성 2,724개는 직접 생성해 올리는 중이라 올리기 전에는 많은 문장이 실시간 TTS를 탑니다.
  • Modal·Vercel 배포는 개인 PC에서 올렸고 CI로 배포하지 않습니다. 이 PR을 머지해도 배포가 자동으로 바뀌지 않습니다.

리뷰에서 봐 주실 결정

  1. EXAONE 제거: 서비스에서 LLM 호출을 없앴습니다(코드와 테스트는 src/llm에 남겨 A/B용으로 둠). 정확도 비교가 필요하면 별도 작업입니다.
  2. 계정 방식 변경: 로컬 가입(아이디·비밀번호)에서 Supabase Auth(구글·이메일)로 바뀌어 로그인 이름이 이메일입니다. 기존 users 테이블 10명은 새 로그인과 연결되지 않고 데이터만 남습니다.
  3. 관리자 페이지 제거: 레거시 approved='N' 행을 처리하는 용도였는데, 그런 행이 DB에 남아 있는지는 확인하지 않았습니다.
  4. 서비스 전체 로그인 전용(비용 보호): 로그인만 하면 누구나 GPU를 쓸 수 있고 분당 요청 제한만 있습니다.
  5. 기존 DB에 db/migrate_renewal.sql을 이미 적용했습니다(새 테이블 3개, 버킷 2개, 공개 키 접근 정책 제거, RLS). 서버 전용 키를 쓰는 기존 앱에는 영향이 없지만, 공개 키로 접속하던 코드가 있다면 막힙니다.

머지 후 할 일

  • GitHub 저장소 비밀값 SUPABASE_URL, SUPABASE_KEY 등록(keepalive.yml이 3일마다 접속해 Supabase 무료 프로젝트 정지를 막음, 기본 브랜치에 있어야 동작).
  • 공용 음성 mp3 업로드(python scripts/upload_tts_audio.py).
  • 이전 RunPod 배포(chefear.store)는 이 PR로 바뀌지 않으므로 필요하면 따로 정리.

되돌리기

PR을 되돌려도 DB 마이그레이션(공개 키 접근 정책 삭제, RLS)은 되돌려지지 않습니다. 이전 Streamlit 앱을 다시 쓰려면 서버 전용 키를 써야 합니다.


구현·검증은 Claude Code로 진행했고, 사용자의 요청에 따라 커밋·PR을 만들었습니다(AGENTS.md의 "에이전트는 커밋하지 않는다" 규칙의 예외).

서버리스 웹앱 리뉴얼(docs/specs/renewal_serverless.md)로 쓰이지 않게 된 것을 지운다.
- Streamlit 앱: src/app.py, src/ui/, ui/, .streamlit/, run_local.sh
- 배포: Dockerfile, docker/, .dockerignore, docker-build 워크플로, docs/runpod_deploy.md, landing/
- 오케스트레이션: gpu_worker_pool.py, speaker_verify.py, auth.py (계정은 Supabase Auth로 대체)
- 위 코드만 검사하던 테스트: test_ui, test_theme, test_my_recipes, test_speaker_verify, test_auth
남은 코드(orchestration 핵심, stt, tts, llm)와 테스트 87개는 그대로 통과한다.
- server/chefear_api: 기존 Streamlit 흐름을 옮긴 대화 상태 기계(상태는 Supabase sessions), Supabase 토큰 확인, 음성 주소 결정, 마이레시피 REST
- 서비스 전체 로그인 전용: 비로그인은 health·warmup 외 401 login_required, warmup은 비로그인이면 GPU를 깨우지 않음
- guard.py: 모든 사용자 글자를 정리·길이 제한·공격 문구 차단. 동작 이름·ID 형식·JSON 본문·요청 크기 검증, 보안 헤더
- inference/modal_app.py: api(CPU), Stt·Tts(L4, 안 쓰면 0대, 모델은 이미지에 포함), RecipeAudio(동시 1대)
- db/migrate_renewal.sql: sessions·tts_assets·tts_jobs, 버킷 2개, 공개(anon) 키 접근 정책 제거와 RLS
- scripts: 공용 음성 목록 추출·업로드, data/tts_manifest.csv(2,724문장)
- STT 후처리를 src/stt/postprocess.py로 분리(동작 변경 없음)
- tests/renewal: 79개(API 계약, 상태 기계, 입력 방어, 추론 로직, 음성 목록)
- 로그인 화면(구글 우선, 이메일은 시트), 로그인 후 시작하기
- 브라우저 VAD(silero)로 발화를 잘라 WAV로 보냄. 끄기·로그아웃·등록 화면에서는 마이크 연결을 닫음
- 원본 로고·배경·색·칩·점 진행·파형 플레이어·마이크 바·대화 카드, rem 기반 반응형
- 화면 단위로 DOM을 통째로 교체해 잔상 방지, 처리 중 흐림·클릭 차단
- 레시피 등록 완료 후 음성 준비 안내(보통 1~3분)와 완료 표시
- 서버 오류 코드를 한글 문구로, 입력칸 길이 제한
- VITE_MOCK=1 데모 모드(가짜 API)
- README를 새 구조로 다시 씀(평가·팀 기록은 유지), 한계와 미측정 항목 명시
- docs/specs/renewal_serverless.md 추가, 스펙 목록·결정기록·AGENTS.md·테스트 문서 갱신, 리뉴얼 전 문서에 안내 배너
- renewal/DEPLOY.md, API_CONTRACT.md(로그인 전용, 입력 검증, 오류 코드)
- .env.example, requirements 정리(Streamlit·관리자 의존성 제거), .gitignore(data/tts_audio 등)
- CI(.github/workflows/ci.yml: pytest + 웹 빌드), keepalive.yml(Supabase 정지 방지)
- 새 화면 캡처 docs/images/renewal
읽어 줄 문장을 정하는 _say()가 대화 기록에 ai 줄을 남기지 않아 카드에 나만 보이던 것을 원본처럼 고친다(끝의 문장부호는 제거). 테스트 1개 추가(167개).
- 말을 보낸 뒤 0.4초 넘게 걸리면 오버레이(말씀 잘 들었어요, 잠시만요...)와 880Hz 알림음 1회. 글자·버튼 요청은 기존처럼 화면만 흐림
- 우상단은 아이디(이메일 9자까지, 말줄임) 버튼 하나로 바꾸고, 누르면 마이레시피, 로그아웃은 마이레시피 안으로(원본과 같게)
- 마이레시피 제목에 아이디님이 등록한 레시피 형태로 표시
로그인부터 마이레시피 수정까지 데모 모드에서 새로 찍음(우상단 아이디 표시, 음성 준비 안내 포함).

@minhahamin minhahamin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

배포 전환 확인했습니다

@seungwook-kim
seungwook-kim merged commit d7edcca into main Oct 20, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants