프로젝트 개요
프로젝트 정보
| 항목 | 내용 |
|---|---|
| 프로젝트명 | suvisdev — AI 영화 추천(Mova) · 반려견 산책 경로(Gildle) 통합 플랫폼 |
| 개발 기간 | 2026.07.08 ~ 2026.09.30 (약 12주) — v1 마감 2026-09-30 |
| 개발 인원 | 1명 (개인 프로젝트) |
| 역할 | 풀스택 개발 · ML 파이프라인 · 인프라 · 데이터 수집 |
| 레포지토리 | github.com/suvisdev/suvisdev.cloud |
| 데모 | suvisdev.cloud |
프로젝트 배경
영화 추천 — 수천 편 속에서 취향에 맞는 영화를 고르는 데 시간이 든다. 기존 협업 필터링은 콜드 스타트 문제가 있고, LLM 챗봇은 없는 극장과 상영 시간을 지어낸다. Mova는 직접 파인튜닝한 소형 모델이 도구를 골라 부르고, 사실은 데이터에서 가져와 답한다.
산책 경로 — 반려견과 산책할 때 그늘·경사·결빙·들를 곳을 고려한 경로를 찾기 어렵다. Gildle은 서울 전역 보행 그래프에 환경 점수와 건물 그림자를 입혀 계절·시간대별 경로 후보를 이유와 함께 보여 준다.
서브 프로젝트
Mova — AI 영화 추천
파인튜닝한 EXAONE 3.5 2.4B가 채팅의 판단(도구 선택)과 추천을 맡고, Gemini가 폴백과 리뷰 요약을 맡는 영화 플랫폼. 카탈로그 3,919편.
| 핵심 기능 | 구현 |
|---|---|
| 채팅 에이전트 | 판단 모델 v9가 도구 10개 중 다음 행동을 고름. 인자 근거·반복 호출·호출 예산은 코드 가드 |
| AI 추천 | EXAONE 3.5 2.4B LoRA(GGUF Q5_K_M) + Gemini 자동 폴백, 취향 벡터 코사인 재정렬 |
| 봤어요 · 별점 | 채팅 한 문장으로 기록, 후보 되묻기 뒤의 선택을 하던 일로 이어 받음 |
| 에디터 리뷰 | 뉴스 요약 → 리뷰 + 감정분석 별점 + 출처 링크 |
| 랭킹 · 시간표 | KOFIC 박스오피스, 카카오 로컬·롯데시네마 상영 시간표 |
| 대량 수집 | TMDB + KOFIC 배치 파이프라인, 영화당 upsert → credits 백필 → RAG 인제스트 |
Gildle — 반려견 산책 경로
서울 전역 보행 그래프에 나무·결빙·경사 점수와 건물 그림자를 입혀, 계절·시간대별 경로 후보를 계산하는 산책 경로 추천 서비스. 웹과 안드로이드 앱(길들)이 같은 기능을 제공한다.
| 핵심 기능 | 구현 |
|---|---|
| 보행 그래프 | 서울 전역 노드 164,740 · 간선 약 23만 (OSM / osmnx) |
| 건물 그림자 | 건물 약 80만 동, 월별 12벌 × 13시간대 사전 계산 |
| 경로 후보 | 빠른·그늘·푸른·편한·언덕길 + 고를 이유, 계절 모드 3종 |
| 문장 추천 | EXAONE 7.8B가 조건을 읽고 규칙이 검증, A*가 계산 |
| 산책 기록 | 위치 추적·저장·통계, 웹·앱 공통 |
| 지도 | 네이버 지도(웹 JS v3 · Flutter SDK) |
기술 스택
| 구분 | 기술 |
|---|---|
| 백엔드 | Python 3.12 · FastAPI · PostgreSQL 16 + pgvector · Redis · SQLAlchemy 2.0 Async · Alembic |
| 프론트엔드 | Next.js 16 · TypeScript 5.7 · React 19 · Tailwind CSS 4 · shadcn/ui · 네이버 지도 JS v3 |
| 모바일 | Flutter · Dart · 카카오 로그인 SDK · 네이버 지도 SDK |
| AI/ML | EXAONE 3.5 2.4B LoRA(추천 GGUF Q5_K_M · 판단 에이전트 v9) · EXAONE 3.5 7.8B(이해·홈 채팅) · Google Gemini(폴백·리뷰 요약) · pgvector HNSW RAG |
| 데이터 | TMDB API · KOFIC/KOBIS API · 카카오 로컬 API · 브이월드 건물 · OpenStreetMap / osmnx · SRTM 고도 |
| 인프라 | 노트북 온프레미스 k3s(backend·auth·cloudflared 파드) · Docker(db·redis) · llama.cpp lora-server · Ollama · Cloudflare Tunnel · Vercel · AWS S3 |
아키텍처
시스템 구성도
┌──────────────────────────────────────────────────────────┐
│ 클라이언트 │
│ │
│ Next.js 16 (Vercel) Flutter (Android/iOS) │
│ │ │ │
│ └─────────── HTTPS ────────────┘ │
│ │ │
├───────────────────────┼──────────────────────────────────┤
│ Cloudflare Tunnel (api. / auth.suvisdev.cloud) │
│ │ │
│ ─────────── 노트북 온프레미스 (k3s) ─────────── │
│ ┌───────────┼───────────┐ │
│ ▼ ▼ ▼ │
│ backend 파드 auth 파드 Traefik 인그레스 │
│ │ │
├───────────┼──────────────────────────────────────────────┤
│ FastAPI (모듈러 모놀리식 · Star Topology) │
│ │
│ mova gildle viewer analytics media │
│ \ | / / (Spoke) │
│ ★ ontology ★ (Hub) │
│ / | \ │
│ dispatch execsuite titanic contents │
│ │
│ ─────────── core.* (공유 인프라) ────────── │
│ security · matrix(DB·S3) · lol(OllamaClient·LoRA) │
├──────────────────────────────────────────────────────────┤
│ PostgreSQL 16 + pgvector · Redis (도커) S3 (AWS) │
│ lora-server :8200 (llama.cpp GGUF, GPU) Ollama :11434 │
└──────────────────────────────────────────────────────────┘
프로덕션은 집 서버와 GPU 노트북 두 대의 온프레미스다(2026-10-05~06). DB·Redis·추천 모델 서버(lora-server)는 상시 켜 둔 집 서버 한 곳에 두고, 두 기기 모두 k3s로 같은 백엔드를 띄운다. 노트북이 집 네트워크에 있으면 노트북이 서빙하고, 꺼지거나 밖으로 나가면 약 30초 안에 집 서버가 넘겨받는다(판단기 두 개가 Cloudflare Tunnel 연결을 바꾼다). 노트북이 밖에 있어도 Tailscale로 GPU(Ollama)만 집 서버에 빌려준다. 단 검색용 임베딩(bge-m3)은 집 서버 CPU에 고정했다 — 질문 한 줄 임베딩은 집 서버 직접 약 0.1초, 노트북 GPU를 거치면 네트워크 왕복 때문에 약 0.35초였다(2026-10-07 실측). 클라우드 분리안은 검토 뒤 기각했다 — 백엔드가 유휴에도 2GB RAM이라 무료 인스턴스에 들어가지 않고, 백엔드↔GPU 지연이 늘어난다(2026-09-29).
모듈러 모놀리식 · Star Topology
단일 배포 단위(FastAPI)에서 앱 간 경계를 명확히 분리하는 구조다.
- Hub (ontology) — 공통 크롤링·AI 파이프라인·이벤트 버스. Spoke를 import하지 않는다.
- Spoke (mova·gildle·viewer 등) — 각 도메인 로직. Hub만 import 가능, Spoke 간 직접 import 금지.
import-linter— 커밋 시 의존 방향을 자동 검사한다.
| 의존 방향 | 허용 |
|---|---|
| Spoke → Hub (ontology) | ✅ |
| Spoke → Spoke (직접) | ❌ 금지 |
| Hub → Spoke | ❌ 금지 |
| Spoke · Hub → core.* | ✅ |
두뇌 층 — 오케스트레이터 · 에이전트 · 도구 · 클라이언트 (2026-09-29)
LLM이 판단하는 자리에 이름을 네 층으로 고정했다. “오케스트레이터”는 전체 두뇌 하나에만 쓴다.
오케스트레이터 (Orchestrator) ← 전체 두뇌 · 허브(ontology)에 하나 (예정)
└ 에이전트 (Agent) ← 앱 두뇌 · 앱마다 하나 — MovaChatAgent(v9) · GildleWalkAgent(예정)
└ 도구 (Tool) ← 판단 없이 정해진 일 — search_movie · showtimes · plan_walk …
└ 클라이언트 (Client) ← 바깥 세계 연결 — OllamaClient · Kakao · KOFIC · DB 리포지토리
- 판단 루프(
AgentLoop)·행동 프로토콜·JudgePort는 허브의 공용 부품이다. 앱은 도구 목록과 시스템 프롬프트만 준다. 루프가 코드로 막는 것: 인자 근거(발화·대화·결과에 없는 제목·지역 차단), 같은 호출 반복, 호출 예산(3단계). - 허브는 스포크를 import하지 않으므로, 앱은 기동 때 허브 레지스트리에 자기 에이전트를 등록하고 오케스트레이터는 그 목록만 본다(예정).
- 구
SuvisdevOrchestrator는 실체가 Ollama HTTP 클라이언트라OllamaClient로 개명했다.
Clean Architecture + Hexagonal (Ports & Adapters)
의존 방향은 바깥 → 안쪽. 도메인 코어는 FastAPI·SQLAlchemy·HTTP에 의존하지 않는다.
adapter/inbound/api/ ← Router (Schema 수신, DI)
│
▼
app/ports/input/ ← Input Port (ABC)
│
▼
app/use_cases/ ← Interactor (비즈니스 로직)
│
▼
app/ports/output/ ← Output Port (ABC)
│
▼
adapter/outbound/pg/ ← PgRepository (ORM)
이 구조 덕분에 Gildle의 CSV → PostgreSQL 전환 시 도메인/애플리케이션 레이어 변경 0건으로 Adapter만 교체할 수 있었다.
배포 환경
운영 구성 (2026-09 현재)
| 환경 | 구성 | 특이사항 |
|---|---|---|
| 집 서버 (상시) | k3s 파드(backend·auth·cloudflared) + Docker(PostgreSQL·Redis, 유일) + lora-server(:8200) + Ollama(CPU — 임베딩 상시·생성 예비) | GTX 1650 SUPER 4GB. 이미지를 빌드하지 않는다(메모리 부족으로 DB가 죽은 적이 있다) |
| 노트북 (우선 서빙·GPU) | k3s 파드 + Ollama(GPU) + 배포 러너 | RTX 4060 8GB. 집이면 우선 서빙, 밖이면 GPU만 Tailscale로 제공 |
| Colab | LoRA 학습 → 병합 → GGUF 양자화 | 노트북 GPU는 운영 전용이라 학습은 코랩에서 끝낸다 |
| Vercel | Next.js 프론트엔드 | main 머지 시 자동 배포 |
| EC2 | 중지 보관 | 2026-09-03 노트북으로 이전. 30GB 디스크·GPU 부재가 이전 사유 |
배포는 GitHub Actions가 한다 — main에 백엔드가 머지되면 노트북의 셀프호스티드 러너가 이미지를 빌드하고, 바뀐 레이어만 집 서버의 로컬 레지스트리로 보내 두 기기를 같은 버전으로 맞춘다(노트북이 집 밖이면 노트북은 빌드만 하고 집 서버에 반영). 각 기기 안에서는 ./k8s/deploy.sh --external-db가 .env를 Secret으로 변환해 주입하고 롤아웃한다. .env가 없으면 스크립트가 즉시 실패하므로 compose 시절의 “빈 자격증명으로 DB 재생성” 사고는 구조적으로 재발하지 않는다.
추천 엔진 폴백
추천 요청
│
▼
lora-server :8200 (집 서버 GTX 1650, llama.cpp GGUF)
│
├─ 성공 → 응답
└─ 실패 → Gemini API로 자동 전환
연속 2회 실패하면 60초 동안 바로 Gemini로 우회(서킷 브레이커) → 이후 자동 복귀
- 초기엔 환경 변수(
RECOMMENDATION_BACKEND)를 손으로 바꾸고 재기동했다(왕복 8초). 2026-08-26부터는 코드가 자동으로 전환한다. - 환경 변수는 수동 고정이 필요할 때만 쓴다.
ERD · 데이터 모델
Mova의 영화 도메인 테이블과 Gildle의 보행 그래프 테이블이 users 테이블을 공유하되, ORM 메타데이터는 앱별로 분리한다.
┌─ Mova ────────────────────────────────────────────────┐
│ movies ──┬── characters ── actors │
│ ├── movie_directors │
│ ├── tags │
│ ├── reviews (AI 자동 + 사용자 작성) │
│ ├── rankings (KOFIC 일별/주간/월간) │
│ ├── picks (추천 이력) │
│ └── watchlist · user_actions │
│ │
│ chat_conversations ── chat_messages │
│ user_taste_vectors (리뷰 기반 취향 벡터, 768d) │
│ hub_knowledge (RAG 벡터, bge-m3 1024d, HNSW) │
├───────────────────────────────────────────────────────┤
│ users ──┬── user_identity (Google·Kakao·Naver OAuth) │
│ └── groups [공유] │
├─ Gildle ──────────────────────────────────────────────┤
│ walks (산책 기록·경로 좌표) · push_tokens │
│ route_nodes ── route_edges (초기 시범 데이터) │
│ * 서울 전역 그래프·그늘 표는 JSON 파일로 메모리 적재 │
├─ 관리 ────────────────────────────────────────────────┤
│ visitor_activity · alembic_version │
└───────────────────────────────────────────────────────┘
- 마이그레이션: Alembic (리비전 42개, 테이블 42개, head
20260930_0001) - Mova: movies 3,919편 · actors 21,551명 · tags 13,277개 · reviews 455건 · hub_knowledge 3,756건
- Gildle: 보행 그래프 노드 164,740 · 간선 약 23만(
scored_edges.json), 그늘 표 월별 12벌
인증 · 보안
인증 체계
| 항목 | 구현 |
|---|---|
| 인증 게이트웨이 | 별도 파드(auth.suvisdev.cloud) — 이메일 로그인·가입, 토큰 발급·갱신·폐기, RS256 JWT |
| 웹 OAuth | Google · Kakao · Naver OAuth 2.0 |
| 모바일 | 카카오 모바일 로그인(kapi 토큰 검증) + 이메일 가입, 회원 탈퇴 |
| 웹 세션 | httpOnly 쿠키(access 7일 · refresh 14일). 토큰을 브라우저 저장소에 두지 않는다(2026-09-30 전환) |
| 세션 저장 | Redis (auth:refresh:web:{jti} / auth:refresh:mobile:{userId} 네임스페이스 분리) |
| 권한 | admin / user 역할, require_admin · require_user 공통 가드 |
쿠키 세션 (BFF)
브라우저 ── 쿠키 자동 전송 ──→ Next.js 프록시(same-origin)
│ 쿠키의 access를 Bearer로 바꿔 전달
│ 401이면 refresh로 한 번 갱신 후 재시도
▼
FastAPI (require_user / require_admin)
└→ JWT 디코딩 → user_id → 소유권 검증
- 로그인은 Next.js 프록시가 게이트웨이를 대신 호출하고, 받은 토큰을 자기 도메인의 httpOnly 쿠키로 심는다. 게이트웨이는 다른 도메인이라 직접 심을 수 없다.
- 브라우저 자바스크립트는 토큰을 읽을 수 없다 — 스크립트 공격(XSS)으로 토큰이 빠져나가지 않는다.
- 화면의 “로그인됨” 표시는 브라우저 저장소의 표시용 정보다. 페이지를 열 때 쿠키가 유효한지 확인해 어긋나면 표시를 지운다.
보안 대응
| 위협 | 대응 |
|---|---|
| IDOR | 전수 조사 5건 식별 → 전부 JWT 토큰 기반으로 전환, 취약점 0건 |
| S3 미인증 접근 | 버킷 비공개 + presigned URL (1시간 만료)로만 접근 |
| 토큰 탈취(XSS) | 토큰을 localStorage에서 httpOnly 쿠키로 이전 |
| API 키 노출 | 환경변수 분리 (.env 단일 파일 → k8s Secret), RS256 개인키 격리 |
| Spoke 간 의존 | import-linter 자동 검사로 아키텍처 규칙 강제 |
테스트
| 영역 | 테스트 수 | 방법 |
|---|---|---|
| Mova | 484 | pytest, 유스케이스·리포지토리·API 레이어별 |
| Gildle | 256 | pytest, Clean Architecture 레이어별 단위·통합 |
| Ontology(허브) · 인증 등 | 330+ | 에이전트 루프, RAG, 인증 게이트웨이 |
| 전체 백엔드 | 1,077 passed | pytest -m "not gpu and not ollama" |
| 운영 회귀 하네스 | 단일턴 28 · 멀티턴 17 | 배포 전후 실제 채팅 API에 같은 질의를 보내 비교 |
| 프론트엔드 | - | pnpm type-check (tsc –noEmit) + pnpm lint |
| 길들 앱 | - | flutter analyze + flutter test |
# 일반 실행 (GPU·Ollama 불필요)
pytest -m "not gpu and not ollama"
# Gildle 단독
pytest apps/gildle/tests
# 프론트 타입 검사
pnpm type-check
트러블슈팅
Docker .env 미설정 → DB 전면 장애
- 증상: 백엔드 전 요청 502
- 원인:
docker compose up -d에서--env-file suvisdev/.env를 빠뜨려${POSTGRES_USER}등이 빈 문자열로 치환, DB 컨테이너가 빈 자격증명으로 재생성 - 해결: CLAUDE.md에 필수 규칙으로 등록, 이후 k3s로 옮기면서 배포 스크립트가
.env를 Secret으로 변환(없으면 즉시 실패) — 구조적으로 재발 불가 - 교훈: 환경변수 외부 주입에서 파일 누락은 무증상 실패를 부른다. 규칙으로 막는 것보다 스크립트가 실패하게 만드는 편이 확실하다
S3 Presigned URL 403
- 증상: EC2에서 발급한 presigned URL로 이미지 불러오기 실패
- 원인:
boto3.client("s3")기본 글로벌 엔드포인트 → 리전 엔드포인트로 307 리다이렉트 시 SigV4 서명의 Host 불일치 - 해결:
endpoint_url=f"https://s3.{region}.amazonaws.com"명시 - 교훈: SigV4 서명은 Host 헤더를 포함하므로 리다이렉트 시 무효화됨
PendingRollbackError 도미노
- 증상: 대량 수집 중 1건 실패 후 이후 418건 전부 연쇄 실패
- 원인: SQLAlchemy AsyncSession이 flush 실패 시 pending-rollback 상태를 유지,
session.rollback()호출 없이는 같은 세션의 이후 모든 쿼리 실패 - 해결: 각 except 블록에
await session.rollback()추가, 실패를 영화 한 편으로 격리 - 교훈: 배치 처리에서 세션 공유 시 실패 격리 패턴 필수
EC2 30GB 디스크 반복 부족
- 증상: backend·auth 컨테이너 빌드 중 디스크 풀
- 원인: 동일 Dockerfile(torch+CUDA)인데 이미지가 따로 태깅돼 중복 레이어 미공유
- 해결:
docker builder prune -a+ 순차 빌드로 임시 대응 → 2026-09-03 프로덕션을 노트북으로 옮기면서 문제 자체가 사라짐
쿠키 세션 전환 뒤의 회귀 3건 (2026-09-30)
- 증상: 화면은 로그인 상태인데 “인증이 필요합니다” / 마이페이지가 로그인 화면으로 되돌아감 / 본문 없는 응답(204)이 500
- 원인: ① 전환 전에 로그인한 브라우저엔 표시만 있고 쿠키가 없음 ② 일괄 치환이 변수명
request만 잡아req를 쓰는 프록시 9개를 놓침 ③ 프록시가 204 응답에 빈 본문을 실어 응답 생성이 실패 - 해결: 페이지를 열 때 쿠키 유효성 확인, 패턴이 아닌 전수 검색으로 잔여 0 확인, 204·205·304는 본문 없이 전달
- 교훈: 모든 요청이 지나가는 코드를 바꿨으면 200·401뿐 아니라 204·기존 로그인 사용자까지 확인한다