백엔드프론트엔드갈아엎기 시리즈

결제 페이지 폐기하고 Spotify · YouTube · Groq 로 음악 ↔ 팝업 매칭 만들기 — POP-SPOT v1.3

김동현
··6분 읽기

결제 페이지 폐기. Spotify 5 단계 한국어 폴백 + YouTube IFrame 영구 캐시 + Groq 40 화이트리스트 무드 분석으로 음악 ↔ 팝업 매칭. 자동수집 V4 와 04:00 cron 까지.

POP-PASS 결제 페이지를 폐기하고, 음악 ↔ 팝업 매칭을 새로운 코어 가치로 갈아엎었음. 같은 버전에 자동수집 V4 (Naver/Kakao 검색 API + Groq) 와 등급 시스템도 같이 들어갔다.

이 글에서 다루는 것

  • 결제 페이지를 왜 통째로 폐기했는지
  • Spotify 검색이 한국어에 약해서 5 단계 폴백을 만든 과정
  • YouTube IFrame 으로 풀곡 재생하면서 일일 quota (10,000) 를 안 터뜨리는 영구 캐시 설계
  • Groq 가 40 개 화이트리스트 안에서만 무드 태그를 고르도록 강제한 이유 (= 외부 호출 0 으로 매칭)
  • 자동수집 V4 의 흐름: 매일 04:00 cron → 검색 API → Groq 정규화 → confidence ≥ 0.8 자동 게시
  • 등급 시스템 (BEGINNER 3 / HUNTER 6 / MASTER 12) 추가
  • 모르는 단어 한 줄로

    용어한 줄 설명
    YouTube IFrameYouTube 플레이어를 내 사이트 안에 박는 표준 방식. 광고/약관 준수까지 자동 처리됨
    quota (YouTube API)YouTube Data API 의 일일 호출 한도. 보통 일일 10,000 units
    polling / lazy fetch"필요할 때만 외부 API 호출" — 페이지 안 보면 호출 안 함
    cron리눅스의 정기 작업 스케줄러. "매일 04:00 KST 에 이거 실행" 같은 표현
    confidence여기서는 자동수집한 팝업 정보가 "진짜일 확률" 을 0~1 사이 숫자로 표시한 값
    Geocoding주소 문자열을 위경도 좌표 (lat, lng) 로 변환하는 일

    무엇이 바뀌었나

    영역v1.2v1.3
    메인 가치팝업 정보 + 결제 (POP-PASS · 확성기)팝업 정보 + 음악 ↔ 팝업 매칭
    음악 검색없음Spotify Web API + Groq 영문 변환 (5 단계 폴백)
    음악 재생없음YouTube IFrame · 영구 캐시 (lazy fetch)
    매칭 알고리즘없음Groq 40 화이트리스트 + 키워드 30 점 + 카테고리 보너스
    자동수집없음V4 — Naver/Kakao 검색 API + Groq, confidence ≥ 0.8 자동 게시
    지도주소만 표시Kakao Local API 로 lat/lng 자동 채움
    스케줄러없음04:00 자동수집 · 05:00 만료 처리 (KST)
    등급없음BEGINNER (스탬프 3) / HUNTER (6) / MASTER (12)

    왜 이렇게 했음

    결제 폐기 — POP-PASS 와 확성기는 운영 책임 (환불, CS) 만 늘리고 사용자에게 주는 가치는 약했다. 같은 시간을 음악 매칭에 쓰는 게 "검색하다가 자연스럽게 음악으로 들어간다" 는 행동 흐름과 잘 맞았음.

    Spotify 5 단계 폴백 — Spotify 의 한국 카탈로그는 좋지만 검색은 한글에 약하다. 예) "아이유 좋은날" 을 그대로 던지면 첫 결과가 cover 일 때가 많음. 그래서 (1) 원문 → (2) Groq 정규화 → (3) 영문 표기 → (4) YouTube Suggest 보강 → (5) 글로벌 검색, 이 순서로 폴백을 깐다.

    YouTube 영구 캐시 — Data API 의 일일 한도가 10,000 units 인데 검색 한 번이 100 unit 이다. 100 번 검색하면 끝. 그래서 한 번 찾은 (artist, title) → video_id 매핑은 DB 에 영구 보관해서 같은 곡을 다시 찾을 때 API 호출을 안 하게 한다.

    Groq 40 화이트리스트 — 음악 → 팝업 매칭에서 Groq 가 자유롭게 무드 태그를 만들면 "우울한, 슬픈, 멜랑콜리한" 같은 비슷한 단어가 무한히 나온다. DB 매칭이 불가능해짐. 그래서 미리 40 개 (예: dreamy, energetic, retro, melancholy …) 만 후보로 주고 "이 중에서만 골라" 라고 강제. 결과: 외부 호출 0 회로 매칭 가능 + 결과가 결정적.

    04:00 자동수집 — 검색 API 응답은 시간대마다 다를 수 있고, 새벽이 외부 API 응답이 가장 안정적이다. 같은 시각에 "오늘 만료된 팝업" 도 같이 정리하는 게 데이터 일관성에 좋음.


    코드로는 어떻게 (필요한 부분만)

    Spotify 5 단계 폴백 — 한 단계 실패하면 다음 단계.

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

    java
    // v1.3 — SpotifySearchService. 5단계 폴백 파이프라인
    public Optional<Track> search(String query) {
    //     ^^^^^^^^         ^^^^^ 사용자가 입력한 원문 질의 (예: "아이유 좋은날")
    //     +-- null 가능성 명시. 이 5단계 모두 부족하면 empty 리턴
        return tryRaw(query)
    //         ^^^^^^^^^^^^^ 1단계: 원문 그대로 Spotify 에 던짐
    //                       (영문 곡이면 이게 바로 맞음. 불필요한 외부호출 없애는 일차 시도)
                .or(() -> tryGroqNormalized(query))
    //          ^^                                  Optional.or — 앞이 empty 일 때만 다음 람다 실행
    //                   ^^^^^^^^^^^^^^^^^^^^^^^^^  2단계: Groq 에 "이 한글 곡명·가수명을 영문 표기로" 요청
    //                                              예: "아이유 좋은날" → "IU Good Day"
                .or(() -> tryEnglishOnly(query))
    //                   ^^^^^^^^^^^^^^^^^^^^^^   3단계: 한글 제거·영문 토큰만 남겼 는지 재시도
    //                                              (본명+영어 제목 혼합 케이스 대응)
                .or(() -> tryYoutubeSuggest(query))
    //                   ^^^^^^^^^^^^^^^^^^^^^^^^^  4단계: YouTube 검색 suggestion 이 더 잘 잡아주는 경우가 있음
    //                                              추천된 결과 제목을 다시 Spotify 에 넘김
                .or(() -> tryGlobal(query));
    //                   ^^^^^^^^^^^^^^^^^   5단계: market=KR 없이 글로벌 카탈로그 검색
    //                                        (일본·중화권 곡·기타 외국 곡 대응)
    }

    YouTube 영구 캐시 — DB 에 (artist, title, video_id) 를 적재.

    파일: popspot-backend/src/main/java/com/popspot/service/music/MusicTrackService.java (v1.2 양식)

    java
    // v1.2 까지 — 조회 매번 YouTube Data API 호출
    String videoId = youtubeApi.search(artist + " " + title);
    //                          ^^^^^^ search 한 회당 100 quota units 소모
    //                                  일일 한도 10,000 / 100 = 100회면 소진
    //                                  많은 사용자가 같은 곡을 찾아도 구분 없이 재호출

    파일: 같은 MusicTrackService.java (v1.3 교체 후)

    java
    // v1.3 — (artist, title, video_id) DB 영구 캠시. 없을 때만 API 추가
    String videoId = musicTrackRepository
    //                ^^^^^^^^^^^^^^^^^^^^ Spring Data JPA 레포지터·테이블명: music_track
            .findByArtistAndTitle(artist, title)
    //       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 메서드명 규칙으로 자동 생성된 쿼리:
    //                                            SELECT * FROM music_track WHERE artist=? AND title=?
            .map(MusicTrack::getYoutubeVideoId)
    //       ^^^ Optional<MusicTrack> 에서 MusicTrack 존재 시만 내부 값 추출
    //             결과: Optional<String> (레코드가 없으면 여전히 empty)
            .orElseGet(() -> {
    //       ^^^^^^^^^ 캐시 미적중 시에만 람다 호출 — 지연 평가
                String fetched = youtubeApi.search(artist + " " + title);
    //                                      ^^^^^^ 그제서야 YouTube Data API 호출 (100 units 소모)
                musicTrackRepository.save(
                    MusicTrack.of(artist, title, fetched));
    //              ^^^^^^^^^^^^^ 트랙 레코드 생성 후 적재 — 다음 동일 호출부터는 캐시 적중
                return fetched;
            });
    //        ^ 결과: 캐시 적중 시 호출 한 줄로 곳바로 video_id 반환 — 외부 API 호출 0

    Groq 화이트리스트 강제 — 프롬프트에 "이 중에서만 골라" 를 명시.

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

    java
    // v1.3 — Groq 에 넘길 프롬프트. 자유 생성이 아닌 "택1" 구조
    String prompt = """
    //              ^^^ Text Block (Java 13+). \\n 에스케이프 없이 여러 줄 문자열
        아래 40 개 무드 중에서만 골라서 3 개 반환. 다른 단어 절대 금지.
    //  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    //  Groq 에게 "창의적으로 만들어" 가 아닌 "이 목록에서만 고르라" 명시.
    //  이게 없으면 "우울한·쓸쓸한·멜랑콜리한" 등 비슷한 단어가 무한 생성돼 DB 매칭 실패
        [dreamy, energetic, retro, melancholy, ...] (40 개)
    //  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    //  이 40개는 popup 카테고리에도 동일하게 부여되어 있음
    //  → Groq 답변이 "dreamy, retro, energetic" 이면 popup.mood_tags 와 바로 교집합 계산 가능
        곡 제목: %s, 가수: %s
    //             ^^      ^^ 아래서 title, artist 으로 치환됨
        """.formatted(title, artist);
    //      ^^^^^^^^^^ Java 15+: String::formatted. printf 처럼 %s 치환

    자동수집 cron — Spring Scheduling.

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

    java
    // v1.3 — PopupCrawlOrchestrator. 매일 04:00 KST 자동 수집
    @Scheduled(cron = "0 0 4 * * *", zone = "Asia/Seoul")
    //         ^^^^   ^ ^ ^ ^ ^ ^   ^^^^^^^^^^^^^^^^^^^
    //         |      | | | | | |   |
    //         |      | | | | | |   +-- 종일제 적용 타임존. 안 적으면 서버·JVM 기본으로 실행돼
    //         |      | | | | | +------- 요일 (* = 전체)
    //         |      | | | | +--------- 월 (*)
    //         |      | | | +----------- 일 (*)
    //         |      | | +------------- 시 = 4 (04시)
    //         |      | +--------------- 분 = 0
    //         |      +----------------- 초 = 0  → 매일 04:00:00
    //         +-------------------------- Spring 의 cron 은 6개 필드 (초까지). 리눅스 cron(5자리)과 다름
    public void runOnce() {
        keywords.forEach(this::collectAndNormalize);
    //  ^^^^^^^^ List<String> — 최상위 키워드 (v2.13 에서 95개로 확장됨)
    //           ^^^^^^^ 경량 순차 처리 — 외부 API 속도제한도 있으니 병렬 안 함
    //                   ^^^^^^^^^^^^^^^^^^^^^^^ 메서드 레퍼런스. 각 키워드마다 6단계 실행:
    //                                            1) Naver 검색 API  2) Kakao 검색 API
    //                                            3) Groq 정규화   4) Kakao Geocoding
    //                                            5) confidence 계산  6) ≥0.8 이면 자동 게시
    }

    핵심 파일:

  • popspot-backend/src/main/java/.../service/crawler/PopupCrawlOrchestrator.java
  • popspot-backend/src/main/java/.../service/music/MusicMoodAnalysisService.java
  • popspot-frontend/src/components/music/useYouTubePlayer.ts
  • popspot-frontend/src/components/music/MusicPlayerProvider.tsx

  • 직접 보는 법

    popspot.co.kr 에서 메인 진입 → "음악" 탭. 곡 카드 클릭하면 위에 만든 5 단계 폴백 → 영구 캐시 → YouTube IFrame 재생이 모두 한 번에 일어난다. 같은 곡을 두 번째 누르면 외부 호출이 일어나지 않는다 (캐시 적중).


    비하인드 · 사고

    매칭 정확도를 올리려고 Spotify 검색에서 첫 결과를 그대로 믿었다가 cover/라이브 영상이 자주 걸렸음. 결국 제목 검증 5 단계 (원곡 키워드 포함 / cover/live/karaoke 미포함 등) 를 한 번 더 거는데, 이 부분이 v2.14 의 출발점이 된다.

    교훈 한 줄. 외부 API 의 첫 결과는 "가장 정확한 답" 이 아니라 "가장 관련성 높은 후보" 다. 검증 단계를 따로 둬야 한다.


    관련 글

  • 이전 — v1.2, GCP → 친구 NAS 이전
  • 다음 — v1.4, 백엔드 70 파일 클린코드
  • 공유

    댓글