프론트엔드백엔드트러블슈팅

메인 BROWSE 섭션 (지역·11 / 시점·5 / 카테고리·7) + Long-tail SEO 랜딩 + 음악 재생 안정화 — POP-SPOT v2.21 S1~S9

김동현
··6분 읽기

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 칩 성수 12 / 한남 5 같은 카운트 시각화
  • 11 개 지역 분류 롤업과 priority 구조
  • 지도 필터와 BROWSE 칩을 useSearchParams 로 연동
  • 자동수집 신뢰도 0.8 이상만 공개 — 환경변수화
  • /popups/[slug] 동적 라우트 한 파일로 23 개 랜딩 페이지 자동 생성
  • 음악 재생 실패 자동 넘김 — 3 회 실패 트랙 자동 제외
  • v2.21 S8 — YouTube IFrame API 가 CSP 에 막혀 음악 재생 안 되던 진짜 원인
  • 모르는 단어 한 줄로

    용어한 줄 설명
    BROWSE 칩태그 형태의 필터 버튼. 지역·시점·카테고리별 결과 몇 개인지 숫자도 같이
    Long-tail SEO자주 찾는 단일 키워드 대신 조합 키워드로 경쟁이 적은 구간에서 노출
    useSearchParamsNext.js 의 React Hook. URL 의 ?key=value 쿼리 파라미터를 컴포넌트에서 읽고 쓸 수 있음
    SSGStatic Site Generation. 빌드 시점에 페이지 HTML 을 미리 만들어둡. 빠르고 SEO 친화적
    CSPContent Security Policy. 이 페이지에서 쓸 수 있는 외부 자원의 화이트리스트

    S1 — 메인 BROWSE 섭션

    지역 분류 롤업

    파일: popspot-frontend/src/lib/regions.ts (v2.21 S1 신규)

    typescript
    // 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;
    }

    해석 — 한 줄씩

  • priority 같으면 키워드 길이가 긴 쪽 먼저. 성수동 한남대로 같은 주소에서 "한남" 매칭 주의 → "성수동" 4 글자가 먼저 잡도록
  • for...of 순회 — 첫 매칭에서 종료. 이미 priority 순으로 정렬되어 있으니 더 좋은 후보를 찾는 일 없음
  • 조용한 자료구조이지만 의도가 명확함
  • BROWSE 칩

    파일: popspot-frontend/src/components/browse/BrowseSection.tsx (v2.21 S1 신규)

    typescript
    // 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>
      );
    }

    해석

  • useMemo — markers 배열이 바뀌지 않으면 재계산 안 함. 매 렌더마다 200개 팝업 필터링하는 낭비 피함
  • filter(r => r.count > 0) — 팝업이 없는 지역은 칩 표시 안 함. 빈 칩 클릭 시 빈 결과 이탈
  • href={/?region=${r.slug}} — 메인으로 이동하면서 region 쿼리 첨부. v2.15.2 의 deep link 화이트리스트 활용

  • S2 — 자동수집 신뢰도 0.8 필터

    파일: popspot-backend/src/main/java/com/popspot/service/PopupStoreService.java

    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();
    }

    해석

  • confidenceScore == null 통과 — 수동 등록이나 자동수집 이전의 레거시 row 는 검토하지 않음
  • >= 0.80 — v2.13 의 임계값을 그대로 계승. Algolia 인덱싱과 일치
  • filter(this::isPublic) — 메서드 레퍼런스. 다음에 임계값 변경 시 한 곳에서만 수정하면 됨

  • S3 — /popups/[slug] 랜딩 페이지

    파일: popspot-frontend/app/popups/[slug]/page.tsx (v2.21 S3 신규 동적 라우트)

    typescript
    // 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>
      );
    }

    해석

  • generateStaticParams — 빌드 시점에 모든 slug 를 열거. 지역 11 + 시점 5 + 카테고리 7 = 23 개. Next.js 가 빌드 시점에 23 개 HTML 자동 생성
  • generateMetadata — 페이지별 metadata 동적 생성. 제목에 키워드 자연스럽게 포함 → SEO
  • alternates.canonical — 이 경로를 대표 주소로 설정. 중복 콘텐츠로 인한 구글 감점 방지
  • sitemap 확장

    파일: popspot-frontend/app/sitemap.ts

    typescript
    // 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];
    }

    해석

  • 5 + 23 = 28 개 페이지. 네이버·구글 이 long-tail 키워드 진입
  • priority 값은 결과 페이지 순위에 직접 영향은 적지만 크롤러에게 우선순위 힌트를 줌

  • S6 — 음악 재생 실패 자동 skip

    파일: popspot-frontend/src/components/music/MusicPlayerProvider.tsx

    typescript
    // v2.21 S6 — MusicPlayerProvider.tsx. 재생 실패 시 자동 다음 곡
    onError={(event) => {
      toast.error('이 곡은 재생할 수 없어 다음 곡으로 넘길게요');
      reportPlaybackFailed(currentTrack.id);
      playNext();
    }}

    파일: popspot-backend/src/main/java/com/popspot/controller/MusicController.java

    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

    sql
    -- v2.21 S6 — V12__music_track_playback_failed.sql
    ALTER TABLE music_track ADD COLUMN playback_failed_count INT DEFAULT 0;

    해석 — 일련 흐름

  • YouTube IFrame 이 onError 발행 → 토스트 + 서버에 실패 알림
  • 백엔드 playback_failed_count 증가
  • 음악 추천 로직이 count >= 3 인 트랙 제외 (이후 추천에서 안 나옴)
  • 자동 처리. 운영자 개입 불필요

  • S8 — 음악 재생 안 되던 진짜 원인

    증상

    v2.21-S1~S7 출시 후 몇 명이 "음악 재생이 아예 안 됩니다" 피드백. 로컬에서는 되는데 운영에서는 멈춤.

    원인

    브라우저 콘솔에 CSP 위반 에러.

    파일: 코드 파일 아닌 브라우저 DevTools 콘솔 에 챍힌 에러 메시지

    javascript
    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 에 호스트 추가)

    typescript
    // 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",
    ];

    해석

  • www.youtube.com — IFrame API 출처
  • s.ytimg.com — YouTube static. SDK 가 내부적으로 이 도메인도 호출
  • frame-src — iframe 읽기 출처
  • 교훈: CSP 화이트리스트 수정 시 로컬 캐시 적중 주의. 시크릿 창·다른 기기로 검증 필수

  • S9 — 비공식 변형 차단

    파일: popspot-backend/src/main/java/com/popspot/service/music/MusicQueryNormalizationService.java

    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();
    }

    해석

  • v2.14 는 점수 감점. v2.21-S9 는 완전 제외 — 30 개 키워드 포함되면 후보에서 제외
  • findFirst() — 처음 한 곡만 채택. 모두 걸러지면 Optional.empty()
  • 매칭 후보 0 때 이전에는 아무나 넘겨서 옾매한 곡이 재생. 이제는 넘김 조용히 다음

  • 관련 글

  • 이전 — v2.20.3, SEO 봇 인덱싱
  • 다음 — v2.21 S10~S18, Spotify OAuth + 3-tier 재생 엔진
  • 공유

    댓글