3) 프론트엔드 & 사용자 워크플로우
💡 쉽게 말하면: 사용자가 검색창에 문장을 넣으면 국악 3곡이 카드로 뜨고, 카드마다 파형을 눌러 바로 들어볼 수 있습니다. “이걸로 만들래”를 누르면 필터가 채워진 샘플 페이지로 넘어갑니다. 화면은 랜딩·DISCOVER·CREATE 세 개뿐입니다.
9-1. 기술 스택
| 항목 | 값 |
|---|---|
| 프레임워크 | Next.js 16 (App Router) · React 19 |
| 언어 | TypeScript 5 |
| 스타일 | Tailwind CSS 4 (@tailwindcss/postcss) |
| 오디오 | WaveSurfer.js 7.12 |
| 애니메이션 | framer-motion 12 |
| 아이콘 | lucide-react |
| 유틸 | clsx · tailwind-merge |
| 폰트 | @fontsource/inter |
9-2. 라우트 구조
src/app/
├── layout.tsx Navbar · Footer · PlayerBar · BottomTabBar · Provider
├── error.tsx / not-found.tsx
├── (public)/
│ ├── page.tsx 랜딩 — 히어로 검색 + 인기 트랙 (SSR, revalidate 3600)
│ ├── discover/page.tsx DISCOVER — 검색 결과 (CSR)
│ └── create/page.tsx CREATE — 필터 + 샘플 목록 (CSR)
├── privacy/page.tsx
└── terms/page.tsx
랜딩만 서버 컴포넌트입니다. 인기 트랙을 서버에서 미리 받아 1시간 캐시하기 때문에 첫 화면이 즉시 렌더됩니다. DISCOVER·CREATE는 사용자 입력에 반응해야 하므로 클라이언트 컴포넌트입니다.
9-3. 사용자 워크플로우
flowchart LR
L["랜딩 /<br/>검색창 + 제안 칩"] -->|문장 입력| D["DISCOVER /discover?q=…"]
L -->|사운드 둘러보기| D
D -->|"AI가 감성을 분석 중…"| DR["ResultCard × 3<br/>MatchBadge · WhyBox · EmotionTagChips"]
DR -->|파형 클릭| P["PlayerBar 재생"]
DR -->|CreateBridgeButton| C["CREATE /create?instrument=…&emotion=…"]
C --> PB["PresetBanner<br/>'DISCOVER에서 넘어온 필터'"]
C --> F["FilterPanel<br/>악기·장단·감성·BPM·루프·라이선스"]
F --> SP["SamplePanel<br/>SampleRow + MiniWaveform + LoopBadge + LicenseBadge"]
9-4. 컴포넌트 지도
DISCOVER
| 컴포넌트 | 역할 |
|---|---|
SearchBar | 자연어 입력 → /discover?q=…로 라우팅 |
SuggestionChips | 무엇을 적어야 할지 모르는 사용자를 위한 예시 문장 칩 |
ResultCard | 매칭 트랙 한 건 카드 |
MatchBadge | 유사도 점수 배지 |
WhyBox | AI가 생성한 매칭 근거 문장(KO/EN) 표시 |
EmotionTagChips | 감성 태그 칩 |
CreateBridgeButton | preset_url이 있을 때만 렌더 → CREATE로 이동 |
CREATE
| 컴포넌트 | 역할 |
|---|---|
PresetBanner | 필터가 어디서 왔는지 알림 |
FilterPanel | 악기·장단·감성·BPM·라이선스 필터 |
LoopUnitFilter | 루프 단위(12박·6박·4박·전체) — 장단 선택 시 자동 활성화 |
SamplePanel / SampleRow | 필터링된 샘플 목록 |
MiniWaveform | 행 안의 소형 파형 미리듣기 |
LoopBadge | 루프 단위 배지 |
LicenseBadge | KOGL 1/2 → 상업 가능 여부 표시 |
CueMarker | CUE A·B·C 마커 (v1.1 자동 추출 대응) |
공통 · 레이아웃
| 컴포넌트 | 역할 |
|---|---|
Waveform | WaveSurfer.js 래퍼 |
PlayerBar | 화면 하단 고정 플레이어 (PlayerContext 전역 상태) |
Navbar | 로고 · Discover/Create 탭 · KO|EN 토글 · 로그인 진입 |
BottomTabBar | 모바일 하단 탭 |
SkeletonCard | 로딩 자리표시자 |
EmptyState | 결과 0건 |
Toast / ToastProvider | 알림 |
9-5. 오디오 플레이어
PlayerContext가 “지금 무엇이 재생 중인가”를 앱 전역에서 하나로 관리합니다.
| 문제 | 해결 |
|---|---|
| 여러 카드의 파형을 동시에 눌러 소리가 겹침 | 전역 컨텍스트가 현재 트랙 1개만 유지 |
| 페이지 이동 시 재생이 끊김 | PlayerBar가 layout.tsx에 있어 라우팅과 무관하게 유지 |
| 목록에서 큰 파형을 다 그리면 무거움 | 목록은 MiniWaveform, 상세·플레이어만 전체 파형 |
본문 영역에는 pb-[72px] md:pb-[56px] 패딩이 들어가 있어, 고정 플레이어가 콘텐츠를 가리지 않습니다.
9-6. CUE 마커
트랙 안에서 분위기가 바뀌는 지점을 A·B·C로 표시합니다. 색이 각각 다릅니다.
export const CUE_COLORS = {
A: { line: '#E8593C', bg: '#FDEAE6', text: '#E8593C' }, // 주황
B: { line: '#3B8BD4', bg: '#E6EFF8', text: '#3B8BD4' }, // 파랑
C: { line: '#4A7C59', bg: '#EAF2EE', text: '#4A7C59' }, // 초록
} as const;
크리에이터가 “3분짜리 국악에서 쓸 만한 8초”를 찾는 시간을 줄이는 장치입니다. MVP에서는 cue_points가 빈 배열이고, v1.1에서 오디오 특징 기반 자동 추출(POST /cue/extract/{track_id})이 붙습니다.
9-7. 프론트/백 계약 상수
lib/constants.ts가 백엔드와 맞춰야 하는 값을 모아 둔 파일입니다.
// [v4.0 추가] 장단 → 루프 단위(박) 매핑
// 백엔드 jangdan_vo.py 의 JANGDAN_LOOP_UNITS 와 동일한 값을 유지해야 한다
export const JANGDAN_LOOP_MAP: Record<string, number> = {
'자진모리': 12, '중모리': 12, '굿거리': 12,
'휘모리': 4, '세마치': 6, '엇모리': 10,
} as const;
export const INSTRUMENTS = ['장구','가야금','대금','해금','거문고','피리','아쟁','소금'] as const;
export const JANGDANS = ['자진모리','중모리','굿거리','휘모리','세마치','엇모리'] as const;
export const EMOTIONS = ['신남','서정','웅장','슬픔','신비','차분'] as const;
export const PRESET_PARAMS = {
instrument: 'instrument', emotion: 'emotion',
bpmMin: 'bpm_min', bpmMax: 'bpm_max',
} as const;
이 값들이 백엔드 VO와 어긋나면 필터가 조용히 0건을 반환합니다. 에러가 나지 않고 “결과가 없네”로 보이기 때문에 발견이 늦습니다. 계약 테스트가 필요한 이유이고, 프리셋 브릿지 설계 §6-6에 잔여 과제로 적어 두었습니다.
9-8. API 클라이언트
const BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
const API_PREFIX = '/api/soundbridge';
ApiTrack(snake_case 응답) → GugakTrack(camelCase 도메인 타입) 변환을 mapTrack()이 담당합니다. 백엔드 스키마가 프론트 타입에 그대로 새어 들어가지 않도록 경계에 매퍼를 둔 것으로, 백엔드의 orm_mapper와 같은 발상입니다.
NEXT_PUBLIC_API_URL은 브라우저에서 접근하는 주소라 빌드 시점에 번들됩니다. docker-compose.yaml에서 build arg로 넘기는 이유입니다.
9-9. 로딩·빈 상태 처리
응답이 여러 번의 LLM 호출을 거치므로 대기 시간이 짧지 않습니다. 그 시간을 설명으로 채웁니다.
<SkeletonCard /> × 3
<span>AI가 감성을 분석하고 있어요...</span>
무엇을 기다리는지 알려 주면 같은 시간도 짧게 느껴집니다. 결과가 0건이면 EmptyState가 뜨고, DISCOVER는 검색어 없이 들어왔을 때 인기 트랙을 대신 보여 줍니다.
9-10. 반응형·접근성
| 항목 | 처리 |
|---|---|
| 모바일 내비 | BottomTabBar (데스크톱은 Navbar 중앙 탭) |
| 카드 그리드 | grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 |
| 최대 폭 | max-w-[1080px] 중앙 정렬 |
| 다국어 | <html lang="ko">, KO|EN 토글 (MVP는 UI만, 응답은 lang 파라미터로 전환) |
| 확대 제한 | maximum-scale=1 — 오디오 조작 중 의도치 않은 핀치 줌 방지 |
maximum-scale=1은 사용자 확대를 막아 접근성을 해치는 설정입니다. 오디오 파형 드래그와의 충돌을 피하려는 선택이지만, 저시력 사용자를 배제할 수 있어 재검토 대상으로 남겨 둡니다.