Privia Trip (AI 트래블)
Architecture
Stack
- Next.js 16
- React 19
- Vercel AI SDK 6
- Zustand
- React Query
External integrations
- Google Gemini 2.5 Flash
- OpenAI gpt-5.2-mini
- Hotel POI Search API
- Vercel
Highlights
- Gemini → OpenAI rate-limit fallback
- RSC cacheTag pipeline
- Bayesian hotel ranking
문제정의
privia-front-common의 POI 검색은 키워드와 지도까지 호텔 발견 경험을 끌어올렸지만, 한 단계 더 가야 했다. 사용자는 여전히 도시·지역·등급 같은 명시적 조건을 직접 골라야 했고, "부모님 모시고 갈 한적한 온천 호텔, 객실에서 산이 보이면 좋겠어" 같은 모호하고 복합적인 의도는 검색창이 받아주지 못했다.
서비스개발실 호텔매핑 과제의 후속으로 새 시도가 필요했다 — 자연어 그대로 던지면 AI가 의도를 풀고, 풀어낸 의도로 호텔을 큐레이션하는 경험. 단순한 RAG 검색이 아니라 사용자 모호성과 호텔 데이터(태그·리뷰·평점·가격) 사이의 간극을 LLM이 메우는 구조다.
| 한계 | 영향 |
|---|---|
| 명시적 카테고리만 받음 | "조용한", "가성비", "뷰가 좋은" 같은 정성 의도 표현 불가 |
| 리뷰 데이터 미활용 | agoda/expedia 다국어 리뷰는 있지만 검색·정렬에 반영 안 됨 |
| 평점만으로 정렬 | 리뷰 1건짜리 5점이 100건짜리 4.5점보다 위로 — 신뢰도 왜곡 |
| 검색 결과가 정적 | 사용자가 호텔별 세부 질문을 이어가지 못함 |
목표는 세 가지로 좁혔다.
- 자연어 의도를 구조화된 검색 파라미터로 — 모호한 한 줄을 Intent 스키마(budget, mustHave, avoid, priorities, 5축 루브릭)로 변환.
- 신뢰도 가중 랭킹 — Bayesian Average로 리뷰 수 적은 호텔의 평점 왜곡을 보정.
- LLM 장애에도 죽지 않는 파이프라인 — Gemini 레이트리밋·구조화 출력 버그·토큰 한도 같은 상시 사고를 첫 출시 시점에 사전 차단.
구현
Day 1 prototype → Week 2 RSC 전환
2026-02-04 단일 세션에서 Landing · Analysis · Results · Compare · ChatPanel + Zustand store 까지 CSR 풀 프로토타입을 푸시. 의도 분석 → 큐레이션 API → 스트리밍 채팅을 손에 잡히는 형태로 먼저 검증했다. 일주일 뒤 (02-10~11) 같은 흐름을 RSC 스트리밍 + 카드별 Suspense 아키텍처로 옮겼다. AnalysisView를 통째로 제거하고 getIntent/getHotels/getCuration을 서버 컴포넌트 안으로 끌어들였다.
전환 효과는 명확했다 — 즉시 네비게이션(getIntent를 Suspense 안으로) + 호텔 카드 점진 렌더(curateSingleHotel per-card) + 레거시 API 라우트 전면 제거. 02-11 시점엔 /api/intent-analysis, /api/reviews, /api/curate 가 모두 삭제됐고 같은 동작을 RSC가 서빙했다.
Intent → Bayesian → Curation 파이프라인
세 단계는 입력·출력 계약과 캐시 키가 명확히 갈린다. 단계마다 다른 모델·다른 캐시 전략을 쓴다.
| 단계 | 모델/알고리즘 | 캐시 키 | 책임 |
|---|---|---|---|
getIntent | Gemini 2.5 Flash-Lite | canonicalized query | 자연어 → {budget, mustHave, avoid, priorities, 5축 rubric} |
getHotels | Bayesian Average + Time-Weighted | search signature | 평점 + 리뷰 수 + 최신성을 합친 신뢰도 정렬 |
getCuration | Gemini → OpenAI fallback | hotelId + intent + prompt_version | 호텔별 매칭 점수(matchScore) + 매칭 이유 + 대표 리뷰 |
랭킹은 단순 평점이 아니라 Bayesian Average다. 리뷰 1건 5점이 100건 4.5점 위로 올라가는 왜곡을 보정하고, Time-Weighted 변형으로 최신 리뷰에 가중치를 더했다. 큐레이션은 LLM이 호텔×의도 쌍마다 5축 루브릭으로 매칭 점수를 매기고, 그 점수로 <FitBadge> 5단계(perfect/great/good/fair/poor)를 표시한다.
LLM 장애 표면 차단 — modelWithFallback + 6종 가드
프로덕션에서 LLM은 자주 깨진다. 단일 모델 의존은 출시 첫 주에 사고로 돌아온다는 가정 아래 6종을 박았다.
| 가드 | 트리거 | 대응 |
|---|---|---|
modelWithFallback | Gemini 429 / RateLimitError | OpenAI gpt-5.2-mini로 자동 폴백 |
isRateLimitError 재귀 깊이 제한 | 중첩 에러 객체 | 무한 재귀 방지 |
structuredOutputs: false | gemini-2.5-flash-lite 구조화 출력 버그 | Zod 검증으로 우회 |
maxOutputTokens 안전망 | 토큰 한도 초과 | generateObject에 상한 설정 |
| 프로그래밍적 truncation | 리뷰 prompt 글자수 폭주 | LLM 호출 전 절단 |
CurationLLMSchema 분리 | LLM 출력과 도메인 모델 불일치 | structured output 전용 스키마 |
핵심은 modelWithFallback 헬퍼다. Gemini가 레이트리밋을 뱉으면 그 자리에서 OpenAI로 같은 프롬프트·같은 Zod 스키마로 재시도하므로 호출부 코드는 폴백 존재를 모른다. 챗 스트리밍(ChatPanel)은 처음부터 OpenAI를 쓰고, 큐레이션 경로만 폴백 모델을 거친다.
캐시 전략의 두 번의 전환
캐시는 한 번에 자리잡지 않았다. 세 라운드의 시행착오가 있었다.
| 라운드 | 시기 | 결정 | 동기 |
|---|---|---|---|
| R1 | 02-11 | Supabase L2 캐시 (curation 결과 영속화) | RSC use cache만으로는 LLM 호출 비용을 못 가린다고 판단 |
| R2 | 02-09 → 03-05 | L2 캐시 전면 제거 + 파일 레벨 use cache 도입 | 캐시 키·prompt_version·RLS·unique constraint 사고가 누적 |
| R3 | 03-05 | 파일 레벨 use cache → 함수 레벨 use cache 전환 | curateSingleHotel만 캐싱, 입력 경량화(reviews → prompt string) |
최종 형태는 RSC cacheTag로 의도/호텔 캐시를 묶고, LLM이 호출되는 curateSingleHotel 한 곳에만 함수 레벨 use cache를 박은 단순한 구조다. L2를 끝까지 안 들고 간 게 핵심 결정 — 캐시 정합성 사고를 운영에서 흡수하기보다 RSC 캐싱에 신뢰를 몰았다.
운영 정확도를 끌어올린 작은 결정들
- 품질 게이트: 평점 3.5 미만 · 리뷰 10건 이하 호텔을 큐레이션에서 제외. 신뢰 못 할 데이터는 LLM에 넣지 않는다.
- 다국어 리뷰 통합: agoda + expedia를 병렬 호출하고 전 언어 리뷰를 큐레이션 프롬프트에 합쳐 모델이 직접 번역·요약하게 했다.
- prompt_version 캐시 키: 프롬프트가 바뀌면 캐시도 분리. 프롬프트 회귀가 캐시 히트로 가려지는 사고 방지.
- Request Coalescing: 동시 동일 요청을 in-flight Promise로 합쳐 LLM 호출 폭주 차단.
- representativeReview 스키마 maxLength: LLM이 대표 리뷰 필드에 원본 전체를 토해내는 패턴 차단.
출시
5주에 걸친 단계적 푸시 → 리브랜딩으로 마무리.
| 시기 | 주요 변경 |
|---|---|
| Week 1 (02-04 ~ 02-06) | CSR 프로토타입 · 기본 큐레이션 API · gemini-2.5-flash 적용 · 토큰 한도 사고 → 병렬 배치 처리 · Gemini → OpenAI 자동 폴백 도입 |
| Week 2 (02-09 ~ 02-12) | RSC 스트리밍 전환 · 카드별 Suspense · Bayesian + Time-Weighted 랭킹 · 5축 루브릭 Intent 스키마 · L2 Supabase 캐시 도입 |
| Week 3 (02-23 ~ 02-27) | Privia-pink 디자인 시스템 · metadata/JSON-LD/sitemap/robots · structured output 버그 우회 · 슬롯머신 가격 롤링 |
| Week 4 (03-04 ~ 03-06) | FitBadge 5단계 · Bayesian 리뷰 수 가중 · revalidateTag 내부 엔드포인트 · PRIVIA Trip 리브랜딩 · trip.priviatravel.com 커스텀 도메인 |
| Week 5 (03-09 ~ 03-11) | sitemap 40개 확장 · L2 캐시 전면 제거 · 함수 레벨 use cache 전환 · 레거시 API 라우트 삭제 · Suspense 리팩토링 |
| 정리 (04-21 ~ 04-22) | stay-choice-ai → privia-trip 프로젝트명 변경 · Vercel 배포 워크플로우 정리 |
출시 후 가장 큰 사고 두 건은 gemini-2.5-flash-lite의 structured output 버그 (02-27)와 토큰 한도 초과 (02-05) 였다. 둘 다 모델 자체 결함이 코드로 새는 사례여서, structuredOutputs: false 우회와 병렬 배치 처리 + maxOutputTokens 안전망으로 막은 뒤 같은 카테고리 사고가 안 들어왔다.
결과학습
| 항목 | 결과 |
|---|---|
| 개발 기간 | 핵심 5주 (Week 1 프로토타입 → Week 5 캐시 단순화) |
| AI 파이프라인 | Intent → Bayesian → Curation 3단계, 단계별 캐시 키 분리 |
| 모델 회복탄력성 | Gemini 레이트리밋 시 OpenAI 자동 폴백 (modelWithFallback) |
| 아키텍처 전환 | CSR 프로토타입 → RSC 스트리밍 + 카드별 Suspense |
| 캐시 전략 | L2 Supabase → 제거 → 함수 레벨 use cache 단순화 |
| SEO | metadata · JSON-LD · sitemap 40개 검색어 · 네이버 verification |
| 누적 변경 | 252 커밋 (프로토타입 → 리브랜딩 정리까지) |
기술적·운영적으로 가져간 학습은 네 가지다.
- 프로토타입은 빨리, 아키텍처 전환은 망설이지 않는다. Day 1 CSR 풀 프로토타입으로 의도-큐레이션-채팅 흐름을 검증한 뒤 Week 2에 RSC로 통째 전환. 프로토타입을 버릴 줄 알아야 즉시 네비게이션·점진 렌더 같은 RSC 고유 강점을 얻는다.
- LLM 장애는 출시 첫 주에 온다 —
modelWithFallback은 옵션이 아니다. Gemini 레이트리밋·structured output 버그·토큰 한도가 모두 출시 한 달 내에 발생. 단일 모델 의존은 운영 사고로 직결되고, 같은 Zod 스키마로 폴백되는 헬퍼가 가장 ROI 높은 방어선이다. - 캐시는 단순할수록 옳다. L2 Supabase 캐시는 캐시 키·prompt_version·RLS·unique constraint 사고를 모두 끌어왔고, 결국 전면 제거 후 RSC
cacheTag+ 함수 레벨use cache조합이 더 안정적이었다. 캐시는 정합성 사고가 누적되면 들어내는 게 답이다. - Bayesian Average는 평점 정렬의 기본값이어야 한다. 리뷰 1건 5점이 100건 4.5점 위로 가는 왜곡은 사용자 신뢰를 직접 해친다. Time-Weighted 변형까지 더하면 최신성도 자연스럽게 반영된다.
Next
- 폴백 효과 정량화 — Gemini 레이트리밋 발생 빈도와 OpenAI 폴백 호출 비율을 Langfuse/Vercel Analytics로 산출. 멀티 프로바이더가 실제로 얼마나 가용성을 끌어올렸는지 수치화한다.
- 임프레션 기반 매칭 점수 보정 — privia-front-common의
useItemVisibility패턴을 도입해 노출·클릭 로그를 모으고, matchScore에 사용자 반응 신호를 후행 가중치로 합쳐 정렬을 강화한다. - 호텔 매핑 도메인 코어 추출 —
privia-front-common+privia-trip+privia-select가 공유하는 호텔 메타데이터·POI 좌표·리뷰 통합을hotel-core패키지로 빼면 다음 표면 온보딩이 훨씬 빨라진다.