Case study

Privia Trip (AI 트래블)

frontend·252 commits·2026

Architecture

pinch to zoom · drag to pan

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점보다 위로 — 신뢰도 왜곡
검색 결과가 정적사용자가 호텔별 세부 질문을 이어가지 못함
pinch to zoom · drag to pan

목표는 세 가지로 좁혔다.

  1. 자연어 의도를 구조화된 검색 파라미터로 — 모호한 한 줄을 Intent 스키마(budget, mustHave, avoid, priorities, 5축 루브릭)로 변환.
  2. 신뢰도 가중 랭킹 — Bayesian Average로 리뷰 수 적은 호텔의 평점 왜곡을 보정.
  3. 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을 서버 컴포넌트 안으로 끌어들였다.

pinch to zoom · drag to pan

전환 효과는 명확했다 — 즉시 네비게이션(getIntent를 Suspense 안으로) + 호텔 카드 점진 렌더(curateSingleHotel per-card) + 레거시 API 라우트 전면 제거. 02-11 시점엔 /api/intent-analysis, /api/reviews, /api/curate 가 모두 삭제됐고 같은 동작을 RSC가 서빙했다.

Intent → Bayesian → Curation 파이프라인

세 단계는 입력·출력 계약과 캐시 키가 명확히 갈린다. 단계마다 다른 모델·다른 캐시 전략을 쓴다.

단계모델/알고리즘캐시 키책임
getIntentGemini 2.5 Flash-Litecanonicalized query자연어 → {budget, mustHave, avoid, priorities, 5축 rubric}
getHotelsBayesian Average + Time-Weightedsearch signature평점 + 리뷰 수 + 최신성을 합친 신뢰도 정렬
getCurationGemini → OpenAI fallbackhotelId + intent + prompt_version호텔별 매칭 점수(matchScore) + 매칭 이유 + 대표 리뷰

랭킹은 단순 평점이 아니라 Bayesian Average다. 리뷰 1건 5점이 100건 4.5점 위로 올라가는 왜곡을 보정하고, Time-Weighted 변형으로 최신 리뷰에 가중치를 더했다. 큐레이션은 LLM이 호텔×의도 쌍마다 5축 루브릭으로 매칭 점수를 매기고, 그 점수로 <FitBadge> 5단계(perfect/great/good/fair/poor)를 표시한다.

pinch to zoom · drag to pan

LLM 장애 표면 차단 — modelWithFallback + 6종 가드

프로덕션에서 LLM은 자주 깨진다. 단일 모델 의존은 출시 첫 주에 사고로 돌아온다는 가정 아래 6종을 박았다.

가드트리거대응
modelWithFallbackGemini 429 / RateLimitErrorOpenAI gpt-5.2-mini로 자동 폴백
isRateLimitError 재귀 깊이 제한중첩 에러 객체무한 재귀 방지
structuredOutputs: falsegemini-2.5-flash-lite 구조화 출력 버그Zod 검증으로 우회
maxOutputTokens 안전망토큰 한도 초과generateObject에 상한 설정
프로그래밍적 truncation리뷰 prompt 글자수 폭주LLM 호출 전 절단
CurationLLMSchema 분리LLM 출력과 도메인 모델 불일치structured output 전용 스키마

핵심은 modelWithFallback 헬퍼다. Gemini가 레이트리밋을 뱉으면 그 자리에서 OpenAI로 같은 프롬프트·같은 Zod 스키마로 재시도하므로 호출부 코드는 폴백 존재를 모른다. 챗 스트리밍(ChatPanel)은 처음부터 OpenAI를 쓰고, 큐레이션 경로만 폴백 모델을 거친다.

캐시 전략의 두 번의 전환

캐시는 한 번에 자리잡지 않았다. 세 라운드의 시행착오가 있었다.

라운드시기결정동기
R102-11Supabase L2 캐시 (curation 결과 영속화)RSC use cache만으로는 LLM 호출 비용을 못 가린다고 판단
R202-09 → 03-05L2 캐시 전면 제거 + 파일 레벨 use cache 도입캐시 키·prompt_version·RLS·unique constraint 사고가 누적
R303-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-aiprivia-trip 프로젝트명 변경 · Vercel 배포 워크플로우 정리

출시 후 가장 큰 사고 두 건은 gemini-2.5-flash-lite의 structured output 버그 (02-27)와 토큰 한도 초과 (02-05) 였다. 둘 다 모델 자체 결함이 코드로 새는 사례여서, structuredOutputs: false 우회와 병렬 배치 처리 + maxOutputTokens 안전망으로 막은 뒤 같은 카테고리 사고가 안 들어왔다.

pinch to zoom · drag to pan

결과학습

항목결과
개발 기간핵심 5주 (Week 1 프로토타입 → Week 5 캐시 단순화)
AI 파이프라인Intent → Bayesian → Curation 3단계, 단계별 캐시 키 분리
모델 회복탄력성Gemini 레이트리밋 시 OpenAI 자동 폴백 (modelWithFallback)
아키텍처 전환CSR 프로토타입 → RSC 스트리밍 + 카드별 Suspense
캐시 전략L2 Supabase → 제거 → 함수 레벨 use cache 단순화
SEOmetadata · JSON-LD · sitemap 40개 검색어 · 네이버 verification
누적 변경252 커밋 (프로토타입 → 리브랜딩 정리까지)

기술적·운영적으로 가져간 학습은 네 가지다.

  1. 프로토타입은 빨리, 아키텍처 전환은 망설이지 않는다. Day 1 CSR 풀 프로토타입으로 의도-큐레이션-채팅 흐름을 검증한 뒤 Week 2에 RSC로 통째 전환. 프로토타입을 버릴 줄 알아야 즉시 네비게이션·점진 렌더 같은 RSC 고유 강점을 얻는다.
  2. LLM 장애는 출시 첫 주에 온다 — modelWithFallback은 옵션이 아니다. Gemini 레이트리밋·structured output 버그·토큰 한도가 모두 출시 한 달 내에 발생. 단일 모델 의존은 운영 사고로 직결되고, 같은 Zod 스키마로 폴백되는 헬퍼가 가장 ROI 높은 방어선이다.
  3. 캐시는 단순할수록 옳다. L2 Supabase 캐시는 캐시 키·prompt_version·RLS·unique constraint 사고를 모두 끌어왔고, 결국 전면 제거 후 RSC cacheTag + 함수 레벨 use cache 조합이 더 안정적이었다. 캐시는 정합성 사고가 누적되면 들어내는 게 답이다.
  4. Bayesian Average는 평점 정렬의 기본값이어야 한다. 리뷰 1건 5점이 100건 4.5점 위로 가는 왜곡은 사용자 신뢰를 직접 해친다. Time-Weighted 변형까지 더하면 최신성도 자연스럽게 반영된다.
pinch to zoom · drag to pan

Next

  • 폴백 효과 정량화 — Gemini 레이트리밋 발생 빈도와 OpenAI 폴백 호출 비율을 Langfuse/Vercel Analytics로 산출. 멀티 프로바이더가 실제로 얼마나 가용성을 끌어올렸는지 수치화한다.
  • 임프레션 기반 매칭 점수 보정 — privia-front-common의 useItemVisibility 패턴을 도입해 노출·클릭 로그를 모으고, matchScore에 사용자 반응 신호를 후행 가중치로 합쳐 정렬을 강화한다.
  • 호텔 매핑 도메인 코어 추출privia-front-common + privia-trip + privia-select가 공유하는 호텔 메타데이터·POI 좌표·리뷰 통합을 hotel-core 패키지로 빼면 다음 표면 온보딩이 훨씬 빨라진다.