Mova — AI 영화 추천 플랫폼
직접 파인튜닝한 EXAONE 3.5 2.4B가 채팅의 판단(도구 선택)과 추천을 맡고, 사실은 데이터에서 가져와 답하는 영화 플랫폼. 카탈로그 3,919편, 박스오피스 랭킹, 에디터 리뷰, 예매 시간표, 봤어요·별점 기록.
주요 기능
| 기능 | 설명 |
|---|---|
| 채팅 에이전트 | 판단 모델이 도구 10개 중 다음 행동을 고른다. 출연진·시간표·상영작·OTT는 코드 템플릿이 답하고, LLM 문장은 추천 이유·리뷰 요약에만 쓴다. |
| AI 맞춤 추천 | EXAONE 3.5 2.4B LoRA(GGUF)가 후보 중 3편을 고르고 이유를 쓴다. GPU 서버 실패 시 Gemini 자동 폴백. “여자친구랑 볼 영화” 같은 동행 조건도 장르 선호로 읽는다. |
| 봤어요 · 별점 | “인셉션 봤어 4점” 한 문장으로 기록. 제목 일부만 말해도, 후보 중에서 “2026년 거”라고 골라도 이어진다. |
| 취향 추천 · 요약 | 기록을 바탕으로 한 추천(recommend_for_me), 비슷한 영화(similar_to), 취향 요약(taste_profile). |
| 에디터 리뷰 | 뉴스 요약으로 리뷰 자동 생성, 감정분석으로 별점 부여, 근거가 된 기사 링크 표시. |
| 박스오피스 랭킹 | KOFIC API로 일별·주간·월간 집계. mova 랭킹 — 최근 7일 영화 상세 열람 수(비로그인 포함, 사람·영화·하루 1회). |
| 예매 시간표 | 지역·날짜로 상영관과 회차 조회(카카오 로컬 · 롯데시네마). |
| 개봉 예정작 | TMDB API로 한국 개봉 예정 영화 자동 갱신, 월별 그룹핑. |
| 컬렉션·찜·마이페이지 | 컬렉션, 찜 목록, 추천 기록, 내 리뷰, 프로필. |
기능 요구 사항
| ID | 기능 | 설명 | 상태 |
|---|---|---|---|
| M-001 | AI 추천 | LoRA/Gemini 듀얼 백엔드 개인화 추천, 자동 폴백 | ✅ |
| M-002 | AI 리뷰 | 뉴스 요약 → 에디터 리뷰 + 감정분석 별점 + 출처 링크 | ✅ |
| M-003 | 랭킹 | KOFIC 박스오피스 일별/주간/월간 집계·시각화 | ✅ |
| M-004 | 채팅 | 도구 호출 에이전트(판단 모델 v9), 대화 저장·목록 | ✅ |
| M-005 | 개봉 예정작 | TMDB API 자동 갱신, 월별 그룹핑 | ✅ |
| M-006 | 컬렉션·찜 | 개인 영화 목록 생성·관리, 찜 토글 | ✅ |
| M-007 | 마이페이지 | 추천 기록, 찜, 리뷰 목록, 프로필 관리 | ✅ |
| M-008 | 소셜 로그인 | Google·Kakao·Naver OAuth 2.0 + httpOnly 쿠키 세션 | ✅ |
| M-009 | 봤어요·별점 | 채팅과 상세 화면에서 기록, 상태 조회 | ✅ |
| M-010 | 예매 시간표 | 지역·날짜별 상영관·회차 | ✅ |
AI 파이프라인
채팅 판단 에이전트 (v9 모델 + 도구 10개, 2026-09-30)
발화 + 최근 대화
│
▼ (직전 응답이 후보 되묻기였다면 → 고른 작품으로 하던 일을 그대로 잇는다)
MovaChatAgent ── 판단 모델(EXAONE 3.5 2.4B LoRA, GGUF, Ollama) ──→ 다음 행동 하나
│ <tool_call>{"name":"get_movie_details","arguments":{"title":"…"}}</tool_call> | FINAL
├─ 데이터 도구: search_movie · get_movie_details · now_showing → 결과를 붙여 재판단(≤3단계)
└─ 터미널 도구: recommend_movies · showtimes · where_to_watch
mark_watched · recommend_for_me · similar_to · taste_profile
│
▼
답변: 사실(출연진·시간표·상영작·OTT·기록 확인)은 코드 템플릿 · 문장은 추천 이유·리뷰 요약만 LLM
- 왜 바꿨나: 6칸 슬롯(intent·title·region·time·chain·followup)에 자리가 없는 질문(출연진, 직전 카드 전체, 현재 상영작)이 전부 오답이었고, 발화에 없는 제목을 지어내는 일이 있었다.
- 학습 데이터는 템플릿 합성(교사 비용 0원). 평가셋 86(실사용 대화 포함)에서 v9 77. 뒤이은 v10·v11은 새로 학습할 때마다 68~72로 오히려 내려가 채택하지 않았다 — 2.4B의 천장으로 보고, 새 도구 4개는 재학습 없이 프롬프트 예시만으로 v9에 얹었다.
- 가드는 학습이 아니라 코드: 제목·지역 근거(대화에 없는 값 차단), 반복 호출 차단, 호출 예산.
- 기록은 더 엄격하다: 별점은 사용자가 이번에 말한 숫자일 때만 남긴다. 안내 문구의 예시 숫자를 모델이 베껴 말하지 않은 별점이 기록된 적이 있어서다(2026-09-30).
- 운영 회귀 하네스: 단일턴 28질의 28/28, 멀티턴 17장면 16/17(날짜에 의존하는 검사 1건).
추천 엔진 — LoRA + Gemini 폴백
추천 요청 (에이전트 recommend_movies)
│
▼
RAG 후보 (pgvector HNSW, hub_knowledge) + 태그 실매칭 → 품질 하한·시리즈당 1편
│
├─ lora-server :8200 (EXAONE 3.5 2.4B LoRA, llama.cpp GGUF Q5_K_M, 집 서버 GTX 1650)
└─ 실패·서킷 오픈 시 Gemini API 폴백 (자동, 60초 쿨다운 후 복귀)
- 학습은 Colab(L4)에서 어댑터 병합 → GGUF 양자화까지 끝낸다(노트북 GPU는 운영 전용). 내보내기 판단은 노트북에서 GGUF로 다시 채점한 점수와 운영 회귀 하네스 기준.
- Gemini 블라인드 비교(2026-09-29): 추천 픽 27건 중 Gemini 12·EXAONE 3·동률 12 — 패인은 “3편을 채우려 주제 밖 작품을 끼움”. 다음 학습 회차의 목표다.
에디터 리뷰 파이프라인
박스오피스·카탈로그 → 리뷰 없는 작품 선정(30일 재시도 간격)
│
▼
Google News RSS (제목 + 요약만, 원문 미수집) ── 기사 출처 목록 보관
│
▼
Gemini ──→ "Mova 에디터" 리뷰 (영화당 1건, 재실행 안전)
│
▼
EXAONE 감정분석 LoRA(NSMC) ──→ 긍정/부정 + 확신도 → 0.5~5.0 자동 별점
│
▼
reviews 저장 (감정 라벨 · 뉴스 소스 수 · 출처 링크) → 상세 화면에 신뢰도 배지와 기사 링크
- 별점 변환: 긍정
2.5 + score × 2.5, 부정2.5 − score × 2.0, 0.5 단위 반올림. - 평점순 정렬은 베이지안 가중 평점
(v·R + m·C)/(v+m)(m=50, C=3.0) — 투표 1건짜리 5.0이 명작을 밀어내지 못한다.
기술 스택
| 구분 | 기술 |
|---|---|
| 백엔드 | Python 3.12 · FastAPI · PostgreSQL 16 + pgvector · Redis · SQLAlchemy 2.0 Async |
| 프론트엔드 | Next.js 16 · TypeScript 5.7 · React 19 · Tailwind CSS 4 · shadcn/ui |
| 모바일 | Flutter · Dart |
| AI/ML | EXAONE 3.5 2.4B LoRA(추천·판단 에이전트) · EXAONE 3.5 7.8B(이해·잡담) · Google Gemini(폴백·리뷰 요약) · pgvector RAG |
| 데이터 | TMDB API · KOFIC/KOBIS API · 카카오 로컬(영화관) · 롯데시네마 시간표 · Google News · 위키피디아 |
| 인프라 | 집 서버·노트북 k3s · Docker(DB·Redis) · llama.cpp · Ollama · Tailscale · Cloudflare Tunnel · AWS S3 |
트러블슈팅
LoRA 서버 ↔ Gemini 자동 폴백
- 상황: GPU 서버(lora-server)가 내려가면 추천 API가 그대로 실패했다
- 원인: 처음엔 환경 변수를 손으로 바꾸고 재기동해야 했고(왕복 8초), 자동 폴백 어댑터를 만든 뒤에도 DI에 연결되지 않아 동작하지 않았다
- 해결:
FallbackRecommendationAdapter(primary=LoRA, fallback=Gemini)를 DI에 연결, 연속 2회 실패 시 60초 우회하는 서킷 브레이커 - 결과: GPU 서버가 죽어도 추천이 끊기지 않고, 살아나면 자동 복귀
채팅에서 “봤어”가 9턴 내내 기록되지 않음 (2026-09-30)
- 상황: “스파이더맨 최신꺼 봤어” → 후보 제시 → “2026작품이겠지?” → “찾지 못했어요” → “브랜드 뉴 데이 봤어” → “못 찾았어요”
- 원인: 모델이 아니라 코드 네 곳 — 후보 문구 형식이 두 가지라 파서가 세 편을 한 제목으로 읽음, 선택 이어받기가 에이전트 뒤에 있어 실행되지 않음, 제목 검증이 정확 일치뿐, 선택 뒤 이을 일이 평가·예매뿐
- 해결: 문구 형식 하나로 통일, 이어받기를 에이전트 앞으로, 봤어요 전용 제목 해석(연도 꼬리 제거 → 정확 → 부분 한 편 → 연도 → 직전 응답의 작품 → 되묻기)
- 결과: 풀네임 없이 기록된다. 회귀 하네스에 없던 장면이라 실사용으로만 드러났다 — 하네스에 기록 후속 장면을 추가하는 것이 다음 과제
“좀비 영화” 추천 0건 — 세 겹의 원인
- 상황: 카탈로그에 좀비 영화가 있는데 추천이 0건
- 원인: ① RAG 히트가 있으면 태그 검색을 건너뜀 ② 2.4B는 후보 목록 앞쪽에 끌림 ③ 문두 담화어 정규식(
좀\s*)이 “좀비”의 “좀”을 떼어 “비 영화”로 만듦 - 해결: RAG·태그 합집합, 태그 실매칭 우선 정렬, 담화어는 공백 필수(
\s+) - 결과: 좀비딸 · 좀비스쿨 · #살아있다
IDOR 취약점 5건 전수 수정
- 상황: user_id를 경로·바디 파라미터로 받아 다른 사용자의 리소스에 접근 가능
- 원인: 초기 개발 시 user_id를 클라이언트 입력으로 신뢰, 토큰 기반 신원 확인 누락
- 해결: 전수 조사로 5건(mypage·profile·watchlist·picks·chat) 식별 후 전부 JWT 토큰 기반으로 전환
- 결과: 모든 리소스 접근에 소유권 검증 적용, IDOR 취약점 0건
스크린샷