Privia Select
Architecture
Stack
- Next.js 16
- React 19
- TypeScript 5.8
- Tailwind CSS 4
- Zustand
- Supabase SSR
External integrations
- Privia SSO
- Supabase PostgreSQL
- SELECT Hotel API (Sabre)
- Vercel
Highlights
- Feature-Sliced Design
- 3 separate Zustand stores
- Batch price fetching with p-limit
문제정의
프리비아의 럭셔리 호텔 셀렉션은 PRIVIA SELECT 브랜드 단독 사이트로 분리될 필요가 있었다. 메인 포털과는 다른 톤·다른 정보 구조·다른 예약 동선을 가지면서도 Privia 회원/SSO를 그대로 재사용해야 했고, 호텔 메타데이터는 운영팀이 큐레이션한 컬렉션이 별도로 존재했다. 메인 포털의 코드를 잘라내 쓰기에는 도메인 관심사가 충돌했다.
또 하나의 제약은 가격이었다. 호텔 셀렉션 카드 그리드는 수십 장이 동시에 노출되는데, 카드마다 Sabre 실시간 요금을 띄워야 한다. 가격을 페이지 진입에서 일괄 조회하면 LCP가 무너지고, 카드별로 즉시 조회하면 동시성이 폭발한다.
| 한계 | 영향 |
|---|---|
| 메인 포털 코드 재사용 | 럭셔리 브랜드 톤과 충돌, 모듈 경계 흐림 |
| Supabase 직결 | 인증·캐싱 정책이 프론트에 분산 |
| 전체 카드 가격 일괄 조회 | LCP 악화, 동시성 제어 부재 |
| 비로그인 가격 노출 | 회원 전용 가격 정책 위배 |
목표는 세 가지로 좁혔다.
- FSD로 단단한 모듈 경계 — app/pages/widgets/features/entities/shared 6레이어를 처음부터 끝까지 일관 적용.
- SSO 재사용 + 회원 게이팅 — Privia 쿠키로 인증하되, 비로그인 사용자에게는 가격 영역을 게이팅.
- 카드별 배치 가격 패칭 — IntersectionObserver로 보이는 카드만 패칭 + p-limit으로 동시성 제한.
구현
FSD 6레이어와 widget→page 승격
처음 두 주는 entity·widget을 빠르게 쌓아 홈을 채우는 데 집중했다. brand, region, feature-slot, promotion, benefit, testimonial 엔티티가 모두 같은 패턴(model.ts + api.ts + ui/)으로 들어왔고, hero/hotel-grid/promotion-section/review-section/trending-destinations/testimonials-section 위젯이 홈을 구성했다.
호텔 상세·검색결과 페이지를 만들 때 처음에는 widgets에 페이지 컴포넌트를 두는 실수를 했고, 곧 pages 레이어로 승격하면서 FSD 원칙(상위 레이어가 하위만 import)을 회복했다. 이 정정이 빨랐던 덕에 이후 무한스크롤·브랜드별 페이지·프로모션 페이지를 추가할 때 마찰이 없었다.
3-store Zustand 분리 (User / Price / Preview)
거대한 단일 store가 아니라 라이프사이클이 다른 세 도메인을 분리했다. 같은 가격 데이터를 카드·디테일·모바일 드로어가 공유하지만 각 store는 자기 책임만 진다.
| Store | 책임 | 라이프사이클 |
|---|---|---|
PriviaUserStore | SSO 세션·SNS 구분·로그인 상태 | 세션 (쿠키 동기화) |
HotelPriceStore | 호텔별 가격 + 로딩/에러 상태 + 배치 캐시 | 페이지 |
HotelPreviewStore | 호텔 카드 클릭 → 상세 진입 시 즉시 노출용 프리뷰 | 단일 트랜지션 |
HotelPreviewStore는 Optimistic Navigation의 핵심이다. 카드를 클릭하는 순간 카드의 메타(이름·이미지·가격)를 store에 박아두고 상세 페이지가 그것을 즉시 렌더, 백엔드 응답을 기다리며 비어 보이는 시간을 0에 가깝게 만든다.
IntersectionObserver + p-limit 배치 가격 패칭
가격 패칭은 세 번의 진화를 거쳤다.
| 단계 | 방식 | 한계 |
|---|---|---|
| v1 (1월 17일) | 페이지 진입 시 전체 호텔 가격 병렬 조회 | LCP 악화, 응답 폭주 |
| v2 (1월 18일) | 카드별 IntersectionObserver 자체 패칭 | 동시성 폭발, 동일 호텔 중복 호출 |
| v3 (1월 18일) | IntersectionObserver + p-limit 배치 + Zustand 캐시 | 동시성 제한·중복 제거·재진입 캐시 |
1월 27일에 가격 훅들을 React Query로 마이그레이션해 캐시·재시도·로딩 상태 관리를 표준화했다. Zustand는 store-as-cache 역할을 React Query에 넘기고, 페이지간 공유 도메인 상태(선택된 객실·UI 동기화)에 집중하게 됐다.
Supabase → select-hotel API 마이그레이션
2월 24일부터 26일까지 단 사흘에 걸쳐 모든 데이터 fetch를 select-hotel 백엔드로 일괄 이관했다. 피처슬롯·혜택·후기·지역·브랜드·호텔 상세·미디어·검색·프리뷰까지 전부. 프론트가 인증·캐싱·에러 처리를 분산해 갖고 있던 책임을 백엔드가 가져갔고, Sabre circuit breaker·다층 TTL 캐시 같은 운영 정책이 프론트 코드에서 사라졌다.
4월 3일에는 셀렉트 Sabre 가격 어댑터도 백엔드로 옮겨, 프론트에서 직접 Sabre 스키마에 의존하던 부분을 제거했다. 가격 프록시 라우트의 계약을 백엔드와 맞추는 것이 이 단계의 작업.
성능과 SEO를 잡는 도구들
| 영역 | 적용 |
|---|---|
| LCP | 호텔 상세 페이지 최적화, 히어로 fetchPriority, Pretendard 변수 폰트 전환 |
| Streaming SSR | 객실 섹션을 Suspense로 분리해 초기 HTML 빠르게 (1월 13일) |
| 3-tier Suspense | 호텔 상세를 SEO-aware한 3단계 fallback으로 분리 (4월 20일) |
| ISR | unstable_cache + React cache()로 빌드 캐시와 request-level 중복 제거 결합 |
| 캐시 태그 | unstable_cache를 팩토리 패턴으로 변경해 호텔별 독립 캐시 태그 부여 |
| Soft navigation | <Link prefetch /> + 카드 클릭 즉시 스켈레톤 + Optimistic Preview |
| Reverse proxy | assetPrefix로 정적 자산을 CDN 직접 로드 (리버스 프록시 캐시 우회) |
| SEO | robots.txt/sitemap.xml 동적 생성, JSON-LD, OG 이미지, H1 최적화 |
비로그인 가격 게이팅
회원 전용 가격 정책을 지키기 위해 PriceGatedSection 컴포넌트를 두고, 비로그인 시 객실 섹션을 가린 채 StepGuide(다이나믹 아일랜드 형태)로 로그인 → 날짜 선택 → 객실 선택 → 예약 4단계를 안내했다.
출시
| 시기 | 주요 변경 |
|---|---|
| 2025-12-19 | 첫 푸시 — Supabase 클라이언트·6 엔티티·홈 위젯 일괄 구축 |
| 2025-12-22 | 호텔 검색결과·캘린더/인원 모달 widget 분리, 라우팅 연동 |
| 2025-12-30 | 라운드형 UI를 플랫 스퀘어 디자인으로 전환 (브랜드 톤 확정) |
| 2026-01-09 | README, KakaoTalk 채널 연동, GA Tag Manager — 운영 준비 완료 |
| 2026-01-13 | Streaming SSR, 리버스 프록시 deep dive, 럭셔리 404, LCP 최적화 |
| 2026-01-17 | 가격 API 연동 + 카드별 병렬 로딩 첫 시도 |
| 2026-01-18 | IntersectionObserver + p-limit 배치 가격 패칭으로 전환 |
| 2026-01-27 | React Query 도입, 가격 훅 일괄 마이그레이션 |
| 2026-02-24~26 | Supabase 직결 → select-hotel API 전면 이관 |
| 2026-04-03 | 가격 fetch도 백엔드 경유로 전환 (Sabre 어댑터 분리) |
| 2026-04-20 | 3-tier Suspense, 회복탄력성 인프라, 지역 허브 3단 구조 Phase A |
결과학습
| 항목 | 결과 |
|---|---|
| FSD 6레이어 | app · pages · widgets · features · entities · shared 일관 적용 |
| Zustand store | User · Price · Preview 3-store 분리 |
| 가격 패칭 | 전체 병렬 → IntersectionObserver + p-limit 배치 + React Query |
| 데이터 소스 | 정적·동적 모두 select-hotel API로 단일화 |
| Streaming SSR | 객실 섹션 Suspense + 3-tier SEO-aware fallback |
| 캐시 정책 | unstable_cache 팩토리 + React cache() + 호텔별 캐시 태그 |
| Optimistic UX | 카드 클릭 → Preview store → 즉시 상세 렌더 |
프론트 단독으로 가져간 학습은 네 가지다.
- FSD는 widget→page 승격 사고를 일찍 정리하면 비용이 0이다. 처음 widgets에 페이지를 둔 실수를 일주일 안에 잡았기에 이후 확장이 마찰 없이 흘렀다. FSD 원칙(상위 import 금지)을 사고 후 잡으려면 리팩토링 비용이 폭발한다.
- 가격 같은 외부 의존 fetch는 IntersectionObserver + 동시성 제한 + 캐시 3종이 한 묶음이다. 한 가지만 빠져도 동시성 폭발이나 중복 호출이 살아난다. p-limit·Observer·Store(또는 React Query) 셋이 함께 갈 때 안정화된다.
- 프론트의 인증·캐싱·에러 처리를 백엔드로 옮기면 코드가 가볍다. Supabase 직결을 select-hotel API로 이관한 사흘이 프론트 책임의 절반을 덜었다. 백엔드의 circuit breaker·TTL 캐시·Sabre 어댑터가 프론트로 새지 않아 코드가 도메인 UI에 집중할 수 있게 됐다.
- Optimistic Navigation은 store 한 개로 끝난다.
HotelPreviewStore가 카드의 메타를 클릭 순간 보관하기만 해도 상세 페이지의 체감 지연이 사라진다. 큰 SSR 최적화 없이도 작은 store 하나가 UX를 잡는다.
Next
- 카드 가시성 기반 우선순위 가중치 — IntersectionObserver의 스코어를 활용해 화면 중앙 카드의 가격을 먼저 패칭하는 우선순위 큐 도입.
- 3-tier Suspense의 fallback 표준화 — SEO-aware fallback 패턴을 다른 페이지(검색결과·브랜드 페이지)로 확장하고, 백엔드 응답 지연에 따른 단계 전이 규칙 문서화.
- 회원 게이팅 정책의 UI 단일화 —
PriceGatedSection+StepGuide를 entity 레이어 컴포넌트로 끌어올려 다른 화면(검색결과·브랜드 페이지)에서도 동일한 4단계 안내가 가능하도록 통합.