메인 BROWSE 섭션 (지역·11 / 시점·5 / 카테고리·7) + Long-tail SEO 랜딩 + 음악 재생 안정화 — POP-SPOT v2.21 S1~S9
v2.21 의 첫 절반. 메인 BROWSE 칩으로 수수 12·한남 5 같은 지역 카운트 노출, 자동수집 신뢰도 0.8 이상 강제, /popups/[slug] 동적 라우트 23 개 모여 long-tail SEO, 음악 재생 실패 자동 skip + CSP 에 YouTube 추가 (S8 의 진짜 원인 해결).
v2.21 은 S1부터 S18까지 갈래서 한 편으로 묶기엔 너무 크다. 이 글은 앞절반 (S1~S9) — BROWSE 섭션 + Long-tail SEO 랜딩 + 음악 재생 안정화. 다음 글(S10~S18)은 Spotify OAuth + 3-tier 재생 엔진.
이 글에서 다루는 것
모르는 단어 한 줄로
| 용어 | 한 줄 설명 |
|---|---|
| BROWSE 칩 | 태그 형태의 필터 버튼. 지역·시점·카테고리별 결과 몇 개인지 숫자도 같이 |
| Long-tail SEO | 자주 찾는 단일 키워드 대신 조합 키워드로 경쟁이 적은 구간에서 노출 |
| useSearchParams | Next.js 의 React Hook. URL 의 ?key=value 쿼리 파라미터를 컴포넌트에서 읽고 쓸 수 있음 |
| SSG | Static Site Generation. 빌드 시점에 페이지 HTML 을 미리 만들어둡. 빠르고 SEO 친화적 |
| CSP | Content Security Policy. 이 페이지에서 쓸 수 있는 외부 자원의 화이트리스트 |
S1 — 메인 BROWSE 섭션
지역 분류 롤업
파일: popspot-frontend/src/lib/regions.ts (v2.21 S1 신규)
// v2.21 S1 — src/lib/regions.ts (신규). 11개 지역 롤업 설정
export interface Region {
slug: string;
label: string;
keywords: string[];
priority: number;
}
export const REGIONS: Region[] = [
{ slug: 'seongsu', label: '성수',
keywords: ['성수', '성수동'], priority: 10 },
{ slug: 'hannam', label: '한남',
keywords: ['한남동', '한남'], priority: 9 },
{ slug: 'apgujeong', label: '압구정',
keywords: ['압구정'], priority: 9 },
// ... 총 11 개
];
export function classifyRegion(address: string): Region | null {
const sorted = [...REGIONS].sort((a, b) => {
if (b.priority !== a.priority) return b.priority - a.priority;
return Math.max(...b.keywords.map(k => k.length))
- Math.max(...a.keywords.map(k => k.length));
});
for (const r of sorted) {
if (r.keywords.some(k => address.includes(k))) return r;
}
return null;
}해석 — 한 줄씩
BROWSE 칩
파일: popspot-frontend/src/components/browse/BrowseSection.tsx (v2.21 S1 신규)
// v2.21 S1 — src/components/browse/BrowseSection.tsx (신규)
export function BrowseSection({ markers }: { markers: MapMarker[] }) {
const regions = useMemo(() => {
return REGIONS
.map(r => ({
...r,
count: markers.filter(m => classifyRegion(m.address)?.slug === r.slug).length,
}))
.filter(r => r.count > 0);
}, [markers]);
return (
<section className="px-4 py-6">
<h2 className="text-sm font-medium mb-3">지역별로 보기</h2>
<div className="flex flex-wrap gap-2">
{regions.map(r => (
<Link key={r.slug}
href={`/?region=${r.slug}`}
className="px-3 py-1.5 rounded-full bg-lime-50 hover:bg-lime-100">
{r.label} <span className="text-zinc-500">{r.count}</span>
</Link>
))}
</div>
</section>
);
}해석
S2 — 자동수집 신뢰도 0.8 필터
파일: popspot-backend/src/main/java/com/popspot/service/PopupStoreService.java
// v2.21 S2 — PopupStoreService.java. confidence ≥0.8 공개 강제
public boolean isPublic(PopupStore p) {
if (p.getConfidenceScore() == null) return true; // 레거시/수동은 통과
return p.getConfidenceScore() >= 0.80;
}
public List<MapMarkerResponse> findVisibleMapMarkers() {
return repository.findAll().stream()
.filter(this::isPublic)
.map(MapMarkerResponse::from)
.toList();
}해석
S3 — /popups/[slug] 랜딩 페이지
파일: popspot-frontend/app/popups/[slug]/page.tsx (v2.21 S3 신규 동적 라우트)
// v2.21 S3 — app/popups/[slug]/page.tsx (신규). 동적 라우트 + SSG
import { REGIONS } from '@/lib/regions';
import { PERIODS, CATEGORIES } from '@/lib/popupSlices';
export async function generateStaticParams() {
return [
...REGIONS.map(r => ({ slug: r.slug })),
...PERIODS.map(p => ({ slug: p.slug })),
...CATEGORIES.map(c => ({ slug: c.slug })),
];
}
export async function generateMetadata({ params }: { params: { slug: string } }) {
const slice = resolveSlice(params.slug);
return {
title: `${slice.label} 팝업스토어 추천 | 팝스팟`,
description: `${slice.label} 에서 열린 팝업스토어를 한 눈에 확인하고 일정·위치·카테고리를 탐색해보세요.`,
alternates: { canonical: `https://popspot.co.kr/popups/${params.slug}` },
};
}
export default async function PopupSlicePage({ params }: Props) {
const slice = resolveSlice(params.slug);
const popups = await fetchPopupsForSlice(slice);
return (
<main>
<h1>{slice.label} 팝업스토어</h1>
{popups.map(p => <PopupCard key={p.id} popup={p} />)}
</main>
);
}해석
sitemap 확장
파일: popspot-frontend/app/sitemap.ts
// v2.21 S3 — app/sitemap.ts. 정적 페이지 + 23개 래닝 페이지를 하나의 sitemap 에 담기
export default function sitemap(): MetadataRoute.Sitemap {
const base = 'https://popspot.co.kr';
const staticPages = [
{ url: base, priority: 1.0 },
{ url: `${base}/about`, priority: 0.8 },
{ url: `${base}/terms`, priority: 0.5 },
{ url: `${base}/privacy`, priority: 0.5 },
];
const slicePages = [...REGIONS, ...PERIODS, ...CATEGORIES].map(s => ({
url: `${base}/popups/${s.slug}`,
priority: 0.7,
}));
return [...staticPages, ...slicePages];
}해석
S6 — 음악 재생 실패 자동 skip
파일: popspot-frontend/src/components/music/MusicPlayerProvider.tsx
// v2.21 S6 — MusicPlayerProvider.tsx. 재생 실패 시 자동 다음 곡
onError={(event) => {
toast.error('이 곡은 재생할 수 없어 다음 곡으로 넘길게요');
reportPlaybackFailed(currentTrack.id);
playNext();
}}파일: popspot-backend/src/main/java/com/popspot/controller/MusicController.java
// v2.21 S6 — MusicController.java
@PostMapping("/{id}/playback-failed")
public void reportPlaybackFailed(@PathVariable Long id) {
musicTrackRepository.incrementFailureCount(id);
}파일: popspot-backend/src/main/resources/db/migration/V12__music_track_playback_failed.sql
-- v2.21 S6 — V12__music_track_playback_failed.sql
ALTER TABLE music_track ADD COLUMN playback_failed_count INT DEFAULT 0;해석 — 일련 흐름
S8 — 음악 재생 안 되던 진짜 원인
증상
v2.21-S1~S7 출시 후 몇 명이 "음악 재생이 아예 안 됩니다" 피드백. 로컬에서는 되는데 운영에서는 멈춤.
원인
브라우저 콘솔에 CSP 위반 에러.
파일: 코드 파일 아닌 브라우저 DevTools 콘솔 에 챍힌 에러 메시지
Refused to load the script 'https://www.youtube.com/iframe_api'
because it violates the following Content Security Policy directive:
"script-src 'self' 'unsafe-inline'".v2.17 에서 CSP 추가했을 때 YouTube 호스트가 빠졌음. v2.17~v2.20 공개 중에는 아무도 알아차리지 못했는데, YouTube SDK 가 로컬 캐시에 있을 때는 그냥 돌아갔다. 새 브라우저·시크릿수 사용자는 SDK 자체를 다운로드해야 해서 차단됨.
수정
파일: popspot-frontend/next.config.ts (v2.17 의 CSP 에 호스트 추가)
// v2.21 S8 — next.config.ts CSP 확장. YouTube 호스트 추가
const CSP = [
// ...
"script-src 'self' 'unsafe-inline' "
+ "www.youtube.com s.ytimg.com sdk.scdn.co "
+ "dapi.kakao.com",
"frame-src 'self' www.youtube.com sdk.scdn.co accounts.google.com",
];해석
S9 — 비공식 변형 차단
파일: popspot-backend/src/main/java/com/popspot/service/music/MusicQueryNormalizationService.java
// v2.21 S9 — MusicQueryNormalizationService.java. BAD_KEYWORDS 확장 + 완전 제외·행
private static final List<String> BAD_KEYWORDS = List.of(
// 기존
"cover", "live", "remix", "karaoke", "instrumental",
// v2.21-S9 추가 (하는 이유)
"piano", "orgel", "오르골", "피아노",
"nightcore", "sped up", "slowed", "자장가", "lullaby",
"acoustic", "unplugged", "어쿠스틱", "a cappella",
"reaction", "react", "리액션",
"trailer", "예고편", "티저",
"hardstyle", "techno mix",
"asmr", "소리",
"일본어·커버·번안",
"부르기 대회",
);
public Optional<Track> matchOrSkip(List<Track> hits, List<String> kw) {
return hits.stream()
.filter(t -> {
String name = t.getName().toLowerCase();
return BAD_KEYWORDS.stream().noneMatch(name::contains);
})
.findFirst();
}해석