feat(server): 곡의 의미 — 외부 자료 수집 + 한국어 요약 - #31
Merged
Conversation
가사는 있는데 그 곡이 무슨 이야기인지는 어디에도 없다. 첫 단계로 사람이 직접 읽으러 갈 링크를 곡 상세(가사 위)에 건다. API도 키도 필요 없어 바로 배포된다. Musixmatch의 "Meaning"은 API로 가져올 수 없다 — 공개 API에 meaning 엔드포인트가 없고(그 섹션은 사용자 기여 웹 콘텐츠다) 크롤링은 약관 위반이라 링크가 유일한 길이다. Genius는 공식 API로 곡 설명을 받아올 수 있으므로, 뒤이어 수집이 붙으면 검색 링크 대신 정확한 곡 페이지로 승격하도록 MeaningLinks.Genius를 미리 열어 뒀다. 관리자 CSP(default-src 'none')는 링크 이동에 관여하지 않으므로 <a href>는 그대로 동작한다. 외부 API 호출만 서버 쪽이어야 한다. 테스트 6건(이스케이프·한글·꼬리표·정확 주소 승격). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
가사는 있는데 그 곡이 무슨 이야기인지는 어디에도 없었다. 관리자 곡 상세의 가사
위에 "이 곡의 의미" 카드를 띄우고, 앱이 나중에 그대로 쓸 GET /v1/meaning도 함께
연다. 배경과 기각한 대안은 docs/adr/0007-song-meaning.md.
소스 셋을 병렬로 겹친다: Genius(/songs/{id}의 description, 무료 토큰) +
Last.fm(track.getInfo의 wiki, 무료 키) + Wikipedia(키 불필요). 하나가 죽어도
나머지가 채우고 실패는 예외가 아니라 null이다. Musixmatch의 "Meaning"은 공개
API에 엔드포인트가 없고 크롤링은 약관 위반이라 링크로만 제공한다.
엔진은 갈아끼운다 — IMeaningWriter + MeaningWriterRegistry로 기존
ITranslator/TranslatorRegistry와 같은 모양. 기본은 Gemini Developer API 직결
(API 키 한 줄이라 GoogleTranslateTranslator와 패턴이 같고, 무료 티어로 보유 곡
전체를 0원에 채운다 — Vertex AI는 서비스 계정·IAM 배선이 개인 프로젝트엔 과하다),
비교·전환용으로 OpenRouter(OpenAI 호환, model 문자열만 바꾸면 Claude·GPT·Gemini).
둘 다 순수 HttpClient라 SDK 의존성이 늘지 않는다.
생성은 사람이 누를 때만 일어난다(곡 상세 버튼 + 대시보드 일괄). 자동 생성을 두지
않은 이유는 쿼타·비용이 예측 가능해야 하고 실패가 조용히 쌓이면 안 되기 때문이다.
실패·자료없음도 행으로 남겨 백필이 같은 곡을 무한 재시도하지 않는다.
곡 해설은 그럴듯한 창작이 특히 쉬운 영역이라 환각 방어를 둘 뒀다.
① 소스가 하나도 없으면 LLM을 아예 호출하지 않는다.
② Wikipedia 문서 선택에 제목 일치 + 아티스트 확인을 필수 조건으로 걸고, 못 채우면
포기한다. 실측으로 "(song)" 제목을 무조건 우선했더니 Kids/MGMT에서 정답인
Kids (MGMT song)을 제치고 Pursuit of Happiness (song)이 뽑혔다 — 엉뚱한 문서는
자료가 없는 것보다 나쁘다.
프롬프트도 "자료에 없는 내용은 지어내지 않는다"로 못을 박는다.
실측으로 잡은 함정 하나 더: Wikimedia는 User-Agent가 없으면 403을 준다. .NET
HttpClient는 기본 UA를 보내지 않으므로 그대로 두면 위키피디아 소스가 항상 조용히
빈다. 가사 제공자들의 검증된 동작을 건드리지 않도록 의미 전용 MeaningHttp에만 붙였다.
출처 표기는 의무다(Wikipedia CC BY-SA, Genius·Last.fm 링크) — 화면과 응답의
attribution에 함께 싣는다. 저장은 새 meanings 테이블(user_version=2)이고 조회
키는 가사와 같은 해석기를 쓴다(가사가 느슨한 키로 맞는 곡은 의미도 맞아야 한다).
키를 하나도 넣지 않으면 기능이 통째로 꺼지고 외부 링크만 남는다 — 가사 기능에는
영향이 없다. 로컬에서 위키피디아 실수집으로 Kids/MGMT·Wonderwall/Oasis는 정확한
문서를, 없는 곡은 포기를 확인했다. 테스트 205개 통과, 경고 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
429는 시간이 지나면 풀리는데 `failed` 행으로 굳혀 버렸다. 백필은 행이 있는 곡을 건너뛰므로, 한도가 회복된 뒤에도 그 곡은 영영 의미가 만들어지지 않는다. 쿼타는 돌아오는데 기록만 남는 셈이다. 실측 전에 잡았다 — 무료 티어가 15 RPM이라 백필을 돌리면 바로 밟았을 자리다. 엔진이 "영구 실패"와 "일시적 실패"를 갈라 돌려준다(MeaningWriteResult). 429·5xx· 타임아웃·네트워크는 Retryable이고, 401·400처럼 다시 불러도 답이 같은 것은 아니다. 일시적이면 status='retry'로 올라오고 서버는 **아무것도 저장하지 않는다** — 이 값은 DB에 들어가지 않는다. 백필은 retry를 만나면 그 자리에서 멈춘다. 계속 돌아 봐야 남은 곡도 같은 벽에 부딪힐 뿐이고, 멈춰도 망가지는 게 없다(저장을 안 했으니 다음에 그 곡부터 이어진다). 안내문도 "남은 곡은 손대지 않았으니 잠시 후 다시" 로 사실대로 쓴다. 호출 간격은 MUSEBASE_MEANING_BACKFILL_DELAY_MS로 열어 두되 기본 0이다 — 유료 티어는 분당 한도가 넉넉해 일부러 느리게 돌 이유가 없고, 429가 나도 위처럼 안전하게 멈추기 때문이다. 무료 티어(15 RPM)에서 한 번에 끝까지 돌리려면 4500을 준다. 문서의 틀린 안내도 함께 고쳤다: "기존 GCP 프로젝트에 결제를 연결하면 무료 크레딧이 적용된다"고 써 뒀는데 **$300 크레딧은 Gemini API에 쓸 수 없다**(공식 문서의 명시적 제외 항목). 무료로 가는 길은 별개 제도인 무료 티어뿐이고 그건 결제가 연결되지 않은 프로젝트에만 적용된다 — 결제를 붙이면 즉시 Tier 1(유료)이 되고 무료 티어는 사라진다. 가사 번역 프로젝트는 Cloud Translation 때문에 결제가 필수라 그대로 쓰면 유료다. 그대로 뒀으면 "무료인 줄 알고 청구되는" 안내가 될 뻔했다. 테스트 7건 추가(212개 통과), 경고 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
배포 후 실측하니 Shallow 같은 유명 곡까지 전부 no-source였다. 위키피디아·Genius 모두
서버에서 200을 주는데도 그랬다. 두 가지가 겹쳐 있었다.
**① 아티스트를 통째로 비교하고 있었다.** 정규화가 구두점을 지우므로
"Lady Gaga/Bradley Cooper"는 ladygagabradleycooper가 되는데, 문서 제목
"Shallow (Lady Gaga and Bradley Cooper song)"은 shallowladygaga**and**bradleycoopersong이다.
가운데 "and" 때문에 영영 일치하지 않는다. 재생 메타데이터가 아티스트에 앨범을 꼬리표로
붙여 오는 경우도 같은 방식으로 깨졌다("harry styles — harry's house").
ArtistNames로 꼬리표를 떼고 공동 아티스트를 나눠, **한 명만 확인돼도** 받아들인다 —
동명이곡을 거르는 목적은 그것으로 달성된다. 완화가 "아무나 통과"가 되지 않도록,
아무도 안 맞으면 여전히 버린다(테스트로 고정). 구분자는 앞뒤 공백이 있는 것만 보고,
쪼갠 조각이 너무 짧으면(AC/DC → ac, dc) 원본 전체로 되돌린다. 검색어에는 대표 이름만 넣는다.
**② Genius 타임아웃이 2단 호출에 비해 빠듯했다.** 2.5초 예산 안에서 검색과 상세를 연달아
부르는데, 실측에서 Lady Gaga/Shallow가 0.96초 + 2.0초 = 2.95초였다. 즉 **설명이 길고 좋은
곡일수록 먼저 잘려 나갔다** — 가장 필요한 곡부터 조용히 사라지는 실패였다. 가사 검색과
달리 여기서는 사람이 버튼을 누르고 기다리므로 지연보다 누락이 훨씬 나쁘다. Genius 8초,
위키피디아·Last.fm 6초로 올렸다(소스는 병렬이라 최악이 합이 아니라 최댓값이다).
확인: 실제 위키피디아로 Shallow/Lady Gaga/Bradley Cooper가 정답 문서를 찾는 것을 봤다.
oh baby/LCD Soundsystem과 little freak/Harry Styles가 여전히 null인 것은 버그가 아니라
**문서 자체가 없어서**다(앨범 문서만 있고, 필수 조건이 그걸 올바르게 걸러 냈다).
테스트 10건 추가(222개 통과), 경고 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
위키피디아 문서 선택에는 "확신이 없으면 포기한다"를 걸어 뒀는데 Genius에는 아무 확인도
없었다. 검색 첫 히트 중 type=song이면 그냥 받았다.
Genius 검색은 **무엇을 넣든 무언가를 돌려준다.** 실측으로 음악이 아닌 유튜브 제목
("해외에서 화제라는 한국의 지하철 문화" / "여기는한국")으로 검색했더니 전혀 무관한
119 REMIX(GRAY)가 첫 히트로 나왔다. 그대로 받았으면 그 곡의 해설이 이 트랙의 "의미"로
붙었을 것이다 — 그럴듯하고 완전히 틀린 글이라 자료가 없는 것보다 나쁘다. 라이브러리에
유튜브 트랙이 섞여 있으므로 가상의 위험이 아니었다.
이제 제목 일치를 필수로 두고, 아티스트를 아는 경우 확인까지 요구한다(위키피디아와 같은
원칙). 제목은 어느 쪽이 담아도 인정한다 — "(Remix)"·"(Live)" 꼬리표가 흔하다. 첫 히트만
보지 않고 조건을 만족하는 첫 결과까지 훑는다.
정규화는 MeaningText로 모아 두 소스가 같은 기준을 쓰게 했다.
테스트 스텁이 실제 응답에 있는 title·artist_names를 빠뜨리고 있어 함께 채웠다 — 스텁이
실물보다 헐거우면 이런 검사를 통과시켜 버린다.
테스트 4건 추가(226개 통과), 경고 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
OpenRouter로 바꾸자 402가 났다. 메시지가 친절해서 원인이 바로 보였다 — "You requested up to 65535 tokens, but can only afford 16000." max_tokens를 안 보내면 공급자가 **모델 최대치를 예약하려 든다.** 정작 우리가 쓰는 건 3~5문장, 몇백 토큰인데 65,535토큰치 잔액을 요구받고 거절당한 것이다. 상한을 명시하니 해결된다. 비용 폭주 방지이기도 해서 Gemini(generationConfig.maxOutputTokens)에도 같이 넣었다. MeaningPrompt.MaxOutputTokens(1200)로 두 엔진이 같은 값을 쓴다. 402도 일시적 실패로 옮겼다. 충전하면 풀리는 실패인데 영구 실패로 굳히면, 백필 도중 잔액이 떨어졌을 때 남은 곡이 전부 "의미 없음"으로 박제된다 — 429에서 고친 것과 같은 병이다. 테스트 2건 추가(228개 통과), 경고 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
실제로 눌러 보고 읽어 보니 세 가지가 나왔다. 셋은 서로 무관하다 —
Musixmatch 링크가 죽어도 의미 생성에는 아무 영향이 없다(자료원이 아니라 링크일 뿐이다).
**① Musixmatch 경로형 URL이 403이다.** 계획 단계에서는 Cloudflare 때문에 형식을 확인할 수
없어 /search/{검색어}로 넣어 뒀는데, 실측하니 그 형식만 403이고 ?query= 는 200이다.
바꿨다.
**② 위키피디아 스니펫의 HTML을 이해하지 못했다.** 스니펫은 <span class="searchmatch">와
& 를 그대로 담아 온다. 우리 정규화는 글자만 남기므로 "Belle & Sebastian"이
belleampsebastian이 됐다("amp"가 글자로 섞인다). 찾는 값은 belleandsebastian이라 영영
맞지 않았고, 그래서 The Boy with the Arab Strap이 자료가 있는데도 no-source였다.
태그를 지우고 엔티티를 디코드한 뒤, &와 and를 같은 말로 본다(표기가 흔히 갈린다).
**③ 인용문이 객관적 서술로 둔갑했다.** Even Flow의 Genius 설명은 맷 캐머런의 인용문인데,
요약이 "밴드에 들어온 이후 수천 번 연주했지만…"처럼 1인칭 진술을 그대로 옮겨 누구
이야기인지 알 수 없는 글이 됐다. 프롬프트에 인용·감상을 사실처럼 쓰지 말라고 못을 박았다.
참고로 Genius의 description이 "?"인 곡이 있다(About 없음의 자리표시자). 40자 미만이라
기존 검사가 이미 올바르게 걸러 낸다 — Arab Strap이 그 경우였다.
테스트 2건 추가(230개 통과), 경고 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
**대시보드가 가사 서버처럼 보이게** 순서를 바꿨다. 조회 통계가 위에 있고 정작 "무슨 가사가 들어와 있는가"는 한참 아래였다. 이제 최근 올라온 가사 → 최근 조회 → 미스 상위 순이다. 모든 목록은 10행만 그리고 나머지는 `전체 보기 →`로 넘긴다 — 섹션마다 라우트를 파지 않고 `/admin/list?view=` 하나로 받는다. "최근 올라온 가사"의 전체 보기는 새 페이지를 만들지 않고 기존 `/admin/search`로 보낸다(질의 없는 검색이 이미 최신순 목록이라 같은 화면이다). **미스 행에서도 곡으로 간다.** 그때는 서버에 없었어도 지금은 있을 수 있다. 표시 시점에 `Locate`로 다시 찾아 키를 채우되 `result`는 그대로 둔다 — 그때 미스였던 것은 사실이므로 기록을 바꾸면 안 된다. **가사 검색에 의미 필터**(전체·있음·아직 없음)와 의미 열. 목록 질의가 `meanings`를 LEFT JOIN해 상태를 함께 읽으므로 필터를 안 걸어도 어떤 곡에 의미가 있는지 보인다. **Musixmatch 링크가 그 곡의 페이지로 간다.** 공식 API(`track.search`)가 주는 `track_share_url`만 쓴다. 주소를 규칙으로 만들면 안 되는 이유가 실측으로 확인됐다 — `/lyrics/Pearl-Jam/Even-Flow`는 오류 없이 200을 주면서 조용히 `/lyrics/Pearl-Jam/Alive` (**다른 곡**)로 넘어간다. 사용자를 엉뚱한 곡으로 보내는 실패라 추측은 금지다. 검색 결과 페이지를 서버가 긁는 길도 익명 요청이 로그인 페이지로 리다이렉트되어 막혀 있다. 키가 없으면 검색 링크로 물러난다. 검색 API는 무엇을 넣든 뭔가를 돌려주므로 Genius와 같은 관련성 검사를 건다 (그 판정을 MeaningMatch로 모아 두 소스가 같은 기준을 쓰게 했다). **의미 자료원을 고를 수 있다** — MUSEBASE_MEANING_SOURCES(기본 genius,lastfm,wikipedia). Musixmatch의 "Meaning"도 자료로 쓸 수 있게 열었지만 **기본은 꺼져 있다.** 그 텍스트는 사람이 쓴 해설이 아니라 가사를 기계로 분석한 결과다 — 페이지의 `lens` 블록에 무드·테마·콘텐츠 등급과 함께 들어 있다. 자료로 넣으면 LLM이 쓴 글을 다시 LLM에 넣어 요약하는 셈이라 "근거에 묶어 둔다"는 전제가 약해지고 무엇에 근거했는지 추적할 수 없다. 그래서 소스 이름을 "Musixmatch (AI 분석)"으로 두어 출처 표기에 성격이 드러나게 하고, 프롬프트도 다른 자료를 우선하게 했다. 켜는 판단과 약관 위험은 운영자 몫이고 기본값이 꺼져 있어 배포본이 저절로 그 상태가 되지는 않는다. 지금 켜져 있는 자료원은 대시보드에 그대로 표시한다 — 설정에만 있으면 나중에 아무도 모른다. 곡 페이지 주소는 `meanings.musixmatch_url`에 남긴다(user_version=3). 이번에 못 찾았다고 지난번에 확인한 주소를 지우지 않는다. 링크는 의미 소스와 별개라 Musixmatch를 자료로 쓰지 않아도 정확하다. Next.js 데이터에서 의미를 꺼낼 때 경로를 고정하지 않고 재귀로 찾는다 — 페이지 구조는 우리 사정과 무관하게 바뀌고, 박아 두면 바뀌는 순간 예외도 없이 조용히 빈다. 테스트 26건 추가(256개 통과), 경고 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
의미 생성은 외부 API를 여러 번 부르느라 수 초가 걸리는데 눌러도 아무 반응이 없었다. 그러면 사람이 다시 누르고, 같은 곡을 두 번 만든다. 제출하는 순간 버튼을 잠그고 스피너를 돌린다(일괄 생성 버튼도 같다). 이게 관리자 화면의 **유일한 JS**다. 지금까지 "JS 한 줄도 없음"을 근거로 CSP를 잠가 뒀는데, 그 보장을 포기하지 않으려고 'unsafe-inline' 대신 **그 스크립트의 sha256만** 허용한다. 해시는 스크립트 문자열에서 실행 시점에 계산하므로 손으로 맞출 필요가 없고, 스크립트가 늘거나 바뀌면 테스트가 먼저 걸린다(스크립트는 정확히 하나여야 한다). 버튼 비활성화는 setTimeout으로 미룬다 — 제출 전에 끄면 폼이 전송되지 않는 브라우저가 있다. [의미 가져오기] 옆에 자료원 체크박스를 뒀다. 설정을 건드리지 않고 한 곡으로 조합을 바꿔 가며 시험해 볼 수 있다. **키가 있는 소스만** 보여 준다 — 쓸 수 없는 걸 체크박스로 두면 눌러도 아무 일이 안 일어나 사람만 헷갈린다. Musixmatch 무료 개발자 플랜은 사라진 것으로 보인다: developer.musixmatch.com/plans는 상업용 Pro 요금제로 리다이렉트되고 /signup은 403, 공식 문서의 "Get API Key"도 같은 곳으로 간다. 그래서 곡 페이지 링크는 검색 폴백으로 두고 자료원으로도 쓸 수 없다(정확한 주소를 아는 길이 API뿐이라 추측은 다른 곡으로 간다). 코드는 그대로 두어 키가 생기면 켜진다. 테스트 3건 추가(259개 통과), 경고 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
두 가지가 겹쳐 있었다. **① 캐시 지시가 아예 없었다.** 관리자 응답에 Cache-Control이 없어 브라우저가 뒤로 가기를 캐시(bfcache 포함)에서 그렸다. 그래서 방금 만든 의미가 사라진 화면이 나온다 — 사람은 작업이 실패한 줄 알고 다시 누른다. 관리자 화면은 전부 지금 상태를 봐야 하므로 no-store로 못 박았다. 의미뿐 아니라 가사 편집·삭제 뒤에도 같은 문제였다. **② 제출이 히스토리 칸을 하나 더 만들었다.** 평범한 폼 제출은 [검색 → 곡 → 곡(생성 후)]이 되어 뒤로 가기가 생성 전의 **같은 곡**으로 간다. 사람이 가고 싶은 곳은 그 곡에 들어오기 전 화면이다. HTTP에는 히스토리를 지우는 방법이 없어 서버로는 못 고친다 — 이미 있는 스피너 스크립트에서 fetch로 보내고(제출 자체가 칸을 만들지 않는다) 결과 주소로 location.replace 한다. [검색 → 곡(생성 후)]만 남아 한 번에 검색으로 돌아간다. fetch가 없으면 평소대로 제출하고, 리다이렉트가 아니면(CSRF 실패 등) 같은 자리를 다시 읽는다. CSP에 connect-src 'self'를 더했다. fetch는 connect-src가 지배하는데 기본값이 'none'이라 그대로 뒀으면 **조용히 막혀 버튼만 잠긴 채 아무 일도 일어나지 않았을 것이다.** 리다이렉트는 302 → 303으로 바꿨다. 302는 다음 요청의 메서드를 규정하지 않아 브라우저마다 다르고, 303은 반드시 GET이라 새로고침이 POST를 되풀이하지 않는다. 테스트 1건 추가(260개 통과), 경고 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
프롬프트가 근거 없는 창작을 막으려고 "부족하면 부족하다고 쓰라"고 시키므로 그런 답은 정상 동작이다. 그런데 **글자가 있다는 이유만으로 ok로 저장**하고 있었다. 통계가 부풀고, 무엇보다 앱에는 "제시된 자료만으로는 파악하기 어렵다"가 곡 해설이라며 뜬다. `insufficient` 상태를 새로 뒀다. 문단은 남긴다 — 사람이 보고 자료원을 바꿔 다시 시도할지 판단할 수 있어야 한다. 다만 의미로 세지 않고, `/v1/meaning`은 404를 준다(앱은 그 영역을 감춘다). 판정은 두 겹이다. ① 프롬프트가 이 경우 첫 줄에 [자료부족] 표식을 쓰게 한다(가장 확실하다). 표식은 저장 전에 지운다. ② 표식을 안 붙이는 모델도 있어 문구도 본다. 자료·정보를 주어로 삼는 표현과 "말할 수 없다"는 서술이 함께 있을 때만 걸리게 해, "답을 알 수 없는 질문을 반복한다" 같은 진짜 의미는 통과한다 (양쪽 다 테스트로 고정). 이미 쌓인 행도 다시 갈라 준다(user_version=4). 그대로 두면 예전 통계가 계속 부풀어 있고, Arab Strap처럼 실제로 그렇게 저장된 곡이 있다. 판정은 생성 때와 같은 함수를 쓴다. 화면은 곡 목록에 "자료 부족"으로, 상세에는 문단 위에 그렇게 밝히고 보여 준다. 대시보드 타일에도 칸을 하나 늘렸다. 백필 집계는 "자료 없음"과 같은 칸에 센다(둘 다 못 만든 것이다). 테스트 12건 추가(272개 통과), 경고 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
기기마다 긴 토큰을 주소창에 붙여 넣는 것이 이 화면의 가장 큰 불편이었다. MUSEBASE_ADMIN_USER(기본 admin) + MUSEBASE_ADMIN_PASSWORD로 로그인할 수 있게 했다. 저장은 PBKDF2-SHA256(210k회)이다. 비밀번호는 토큰과 달리 **다른 서비스와 돌려 쓰이기 쉬워**, 설정 파일이 한 번 새면 피해가 이 서버에서 끝나지 않는다. `--hash-password`로 해시를 만들어 넣게 하고, 평문도 받아 주되(개인 서버의 편의) 문서에서는 해시를 권한다. 설정이 비어 있으면 무엇을 넣어도 통과하지 못한다 — 비밀번호를 안 정했는데 빈 값으로 들어가지는 사고를 막는다. **토큰 로그인은 살려 둔다.** 비밀번호를 잊거나 해시를 잘못 넣으면 들어갈 길이 아예 없어진다. 로그인 화면에서 "토큰으로 들어가기"로 접어 두었다. 토큰은 어차피 앱이 API에 쓰는 값이라 새 비밀이 늘지도 않는다. 실패하면 0.7초 쉰다. 테일넷 안이라 온라인 추측 위험은 낮지만 값이 싸다. 테스트 7건 추가(279개 통과), 경고 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
서버가 미리 만들어 둔 문단을 **읽기만 한다.** 생성은 관리자 화면에서만 일어나므로(쿼타·비용을 사람이 통제) 앱에는 만드는 길을 두지 않았다. 계약은 이미 확정돼 있던 GET /v1/meaning 그대로다. - Core: IRemoteLyricsCache.GetMeaningAsync + SongMeaningView(본문·출처·언어). - Windows: 트레이 [이 곡의 의미…] → 작은 창. 출처는 눌러서 원문으로 갈 수 있는 링크로. - Android: 상단 아이콘 → 다이얼로그. **출처 표기는 본문과 함께 나간다.** Wikipedia 본문은 CC BY-SA이고 Genius·Last.fm도 링크 표기를 요구한다(contracts/lyrics-api.md) — 본문만 떼어 보여 주면 계약 위반이라, 뷰 모델이 CreditLine을 같이 들고 다니게 했다. 의미 조회 실패는 **서킷 브레이커에 세지 않는다.** 회로는 가사와 공유하는데, 부가 기능 하나 때문에 회로가 열려 가사 조회까지 60초 막히면 손해가 훨씬 크다(테스트로 고정). 서버가 없거나 그 곡에 의미가 없으면 안내 한 줄로 끝난다 — 대부분의 곡에는 아직 의미가 없고 그건 실패가 아니다. 번역 문자열은 ko·en에만 넣었다. 나머지 17개 언어는 기존 폴백(en)으로 자연스럽게 내려간다. 테스트 3건 추가(282개 통과), 경고 0. Windows·Android 모두 Release 빌드 확인. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
**① Shallow의 의미가 앱에서 안 보였다.** 서버에는 ok로 저장돼 있는데도 404였다. 실측하니 같은 폰(s26)이 같은 곡을 7/31에는 "Lady Gaga/Bradley Cooper", 8/3에는 "Lady Gaga, Bradley Cooper"로 보고해 **두 행으로 갈려** 있었고, 의미는 앞쪽에만 붙어 있었다. 느슨한 키가 흡수했어야 할 경우인데 구분자를 몰라 두 행 다 loose_key = key였다. 느슨한 키를 만들 때 **공동 아티스트를 대표 한 명으로 줄인다**(ArtistNames 재사용) — "A/B"·"A, B"·"A & B"·"A feat. B"가 같은 곡으로 모인다. 제목이 같고 대표 아티스트가 같으면 사실상 같은 곡이고, 정확 키가 먼저 시도되므로 어디까지나 폴백이다. 기존 행은 user_version=5 마이그레이션으로 loose_key만 다시 계산한다(행은 합치지 않는다 — 사용자 편집본이 있을 수 있다). 이미 갈려 버린 행은 그것만으로 부족해서, GetMeaning이 **같은 느슨한 키의 형제 행까지** 본다. "가사는 뜨는데 의미만 비는" 상태를 만들지 않겠다는 ADR-0007의 약속이 바로 이 경우다. 쓸 수 있는 의미(ok)를 자료 부족 행보다 먼저 고른다. **② 안드로이드에서 팝업이 두 번 떴다.** 불러오기용 다이얼로그를 닫고 결과용을 새로 띄우고 있었다. 창은 하나만 띄우고 내용만 바꾼다. 테스트 6건 추가(288개 통과), 경고 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
곡의 의미 기능(서버 + 앱 표시)이 들어간 첫 릴리스다. 둘 다 기능 추가라 마이너를 올린다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
가사는 있는데 그 곡이 무슨 이야기인지는 어디에도 없었다. 관리자 곡 상세의 가사 위에
"이 곡의 의미" 카드를 띄우고, 앱이 나중에 그대로 쓸
GET /v1/meaning도 함께 연다.배경과 기각한 대안은
docs/adr/0007-song-meaning.md.무엇을 하나
소스 셋을 병렬로 겹친다 — Genius(
description) + Last.fm(wiki) + Wikipedia(키 불필요).하나가 죽어도 나머지가 채우고, 실패는 예외가 아니라 null이다.
Musixmatch의 "Meaning"은 공개 API에 엔드포인트가 없고 크롤링은 약관 위반이라 링크로만 제공한다.
엔진은 갈아끼운다 —
IMeaningWriter+MeaningWriterRegistry로 기존ITranslator/TranslatorRegistry와 같은 모양. 기본은 Gemini 직결, 비교·전환용으로 OpenRouter(모델 문자열만 바꾸면 Claude·GPT·Gemini). 둘 다 순수 HttpClient라 SDK 의존성이 늘지 않는다.
생성은 사람이 누를 때만 일어난다(곡 상세 버튼 + 대시보드 일괄). 쿼타·비용이 예측 가능해야 하고
실패가 조용히 쌓이면 안 되기 때문이다.
환각 방어 둘
곡 해설은 그럴듯한 창작이 특히 쉬운 영역이라 방어를 두 겹 뒀다.
②는 실측으로 잡았다 — "(song)"이 붙은 제목을 무조건 우선했더니
Kids / MGMT에서 정답인Kids (MGMT song)을 제치고Pursuit of Happiness (song)이 뽑혔다. 엉뚱한 문서가 근거로 들어가면그럴듯하고 완전히 틀린 의미가 만들어진다 — 자료가 없는 것보다 나쁘다.
실측으로 잡은 함정 둘
HttpClient는 기본 UA를 안 보내므로그대로 뒀으면 위키피디아 소스가 항상 조용히 비었을 것이다. 가사 제공자들의 검증된 동작을
건드리지 않도록 의미 전용
MeaningHttp에만 붙였다.그 곡은 영영 의미가 만들어지지 않는다. 이제 엔진이 영구/일시적 실패를 구분해
(
MeaningWriteResult) 일시적이면 아무것도 저장하지 않고 백필이 그 자리에서 멈춘다.확인한 것
로컬에서 위키피디아 실수집으로
Kids/MGMT→Kids_(MGMT_song),Wonderwall/Oasis→Wonderwall,없는 곡 → 포기를 확인했다.
meanings를 참조하지 않는다)남은 것
배포 후 서로 다른 성격의 곡 5곡으로 문장 품질(환각 여부)을 확인한다.
Windows·Android의 "의미 보기"는 계약(
GET /v1/meaning)만 확정해 두고 별도 작업으로 진행한다.🤖 Generated with Claude Code