!신청 사랑하긴 그 긴거 : 개떡같이 말해도 찰떡같이 알아듣는 LLM 기반 신청곡 매칭 로직
CEO & Fullstack Engineer
1. 문제는 매칭이 아니라 그 앞에 있습니다
라이브 방송에서 시청자가 채팅으로 노래를 신청합니다. 이 메시지를 스트리머의 노래책에 있는 곡으로 연결해야 합니다.
교과서적으로 보면 문자열 매칭 문제입니다. 하지만 실제 채팅을 열어보면 입력이 이렇게 들어옵니다.
!신청곡 아이유 조은날
!sr IU/좋은날
신청 밤편지요
잠깐 오는 비 신청할게요
버즈 그 신나는 노래 아무거나
여러분의 등록금이 펑펑 이거 신청해요오타가 있고 제목 일부만 있습니다. 아티스트가 빠져 있고, 곡의 내용을 풀어서 말하고, 가사 한 줄만 던지고, 아예 선택을 스트리머에게 위임합니다.
그런데 더 어려운 건 그 앞입니다. 채팅에는 신청곡이 아닌 메시지가 압도적으로 많습니다.
신청이 완료되었습니다 <- 봇 응답
수강신청 망했어 <- 잡담
!출첵 <- 다른 명령어
갱신 신청했는데 자꾸 해외나가고 <- 문의
신청자가 170명이 넘은건에 대하여 <- 잡담전부 “신청”이라는 글자를 포함합니다. 매칭 알고리즘을 아무리 잘 만들어도 신청이 아닌 메시지를 신청으로 받으면 방송에 엉뚱한 곡이 쌓입니다. 그래서 파이프라인의 첫 단계는 매칭이 아니라 “이 메시지가 신청인가”를 판정하는 일입니다.
이 글은 그 판정부터 마지막 LLM 호출까지 멜로밍이 실제로 돌린 코드를 그대로 열어 정리한 기록입니다.
2. 게이트는 두 겹입니다
프로덕션 경로는 Go로 만든 채팅 디스패처와 NestJS 백엔드 두 프로세스에 걸쳐 있습니다.
Go 쪽 게이트는 의도적으로 느슨합니다. ParseRequest가 nil을 돌려주면 그 메시지는 백엔드로 넘기지 않고 버리고, 그 외에는 전부 넘깁니다. 디스패처는 명백한 비신청만 쳐내고 정밀 판정은 백엔드의 티어 분류기로 미룹니다. artist/title 분리도 best effort입니다. 어차피 백엔드가 raw_message로 다시 매칭하기 때문에 잘못 갈라도 무해합니다.
디스패처가 쓰는 표준 접두사와 구분자는 백엔드와 같은 목록입니다.
var standardRequestPrefixes = []string{
"!노래신청",
"!신청곡",
"!곡신청",
"!신청",
"!sr",
}
separators: []string{"-", "/", "\u2013", "\u2014"},구분자 네 개는 하이픈, 슬래시, en dash, em dash입니다. 사람이 한글 자판에서 하이픈 대신 긴 붙임표를 치는 경우가 실제로 있습니다.
접두사 배열은 sort.SliceStable로 길이 내림차순 정렬한 뒤 매치합니다. 이 정렬이 없으면 !신청이 !신청곡보다 먼저 걸려서 payload가 곡 아이유 좋은날로 잘립니다. 백엔드의 티어 분류기에도 같은 정렬이 같은 이유로 들어 있습니다.
같은 규칙이 두 언어, 두 저장소에 중복으로 존재한다는 뜻입니다. 한쪽만 고치면 프로덕션에서는 아무 변화가 없습니다. ! 뒤에 구분자가 오는 자유 형식을 허용할 때도 백엔드만 고쳐서는 효과가 없었고 디스패처의 파서를 함께 고쳐야 했습니다.
3. 티어 분류기의 실제 상수들
백엔드의 TierClassifierService는 메시지를 tier1 / tier2a / tier2b / tier3 / ignored 다섯 갈래로 나눕니다. 234줄짜리 작은 서비스인데 이 파이프라인에서 정확도 기여가 가장 큰 부분입니다.
분류 기준의 출처는 실제 로그입니다. ClickHouse에 쌓인 채팅 4억 9천만 건을 훑어서 세 가지를 확인했습니다.
!로 시작하는 160만 건은 대부분 신청과 무관한 명령이었습니다(!출첵,!멤버,!투표). 그래서 Tier 2b는 화이트리스트로 좁혔습니다.신청으로 시작하는 9만 6천 건은 대부분 봇 응답(신청이 완료되었습니다)과 잡담이었습니다. 그래서 Tier 2a와 Tier 3에 노이즈 패턴 필터를 걸었습니다.신청을 포함한 28만 3천 건 중 진짜 신청은 동사 형태(신청합니다,신청이요)로 등장했습니다. 그래서 Tier 3는 동사 화이트리스트와 결합해야 의미가 있습니다.
이 세 문장이 그대로 세 개의 배열이 됩니다.
3.1 Tier 1 접두사와 Tier 2b 화이트리스트
private static readonly TIER1_PREFIXES = [
'!노래신청', // 5
'!신청곡', // 4
'!곡신청', // 4
'!신청', // 3
];
private static readonly TIER2B_WHITELIST = ['!sr'];채널이 설정한 requestCommand(기본값 !신청)는 이 배열과 합쳐진 뒤 길이 내림차순으로 정렬됩니다. 채널 명령어로 잡히면 reason이 request_command_prefix, 표준 변형으로 잡히면 exclam_request_variant입니다. 두 경로 모두 결과는 tier1이지만 사유 코드가 갈려서 나중에 통계에서 구분됩니다.
3.2 Tier 2a 노이즈 접두사 열일곱 개
! 없이 신청으로 시작하는 메시지는 대부분 신청이 아닙니다. 그래서 통과시키기 전에 이 목록을 먼저 봅니다.
private static readonly TIER2A_NOISE_PREFIXES = [
'신청이 완료', // 봇 응답
'신청 실패', // 봇 응답
'신청자', // 잡담
'신청창', // 잡담
'신청은', // 잡담
'신청을', // 잡담
'신청에', // 잡담
'신청도', // 잡담
'신청이용', // 잡담
'신청이야', // 잡담
'신청한사람', // 잡담
'신청해야', // 잡담
'신청햇', // 잡담
'신청할꺼', // 잡담
'신청할까', // 잡담
'신청해서', // 잡담
'신청하고', // 잡담 (애매: "신청하고 싶다" 등은 신청 의도지만 prefix로는 무시)
];목록의 절반 이상이 조사입니다. 신청은, 신청을, 신청에, 신청도. 한국어에서 명사 뒤에 조사가 붙으면 그 명사는 주제이지 명령이 아닙니다. 신청 밤편지는 신청이고 신청은 어떻게 하나요는 질문입니다. 이 구분을 형태소 분석기 없이 조사 목록 열일곱 개로 처리했습니다.
마지막 항목은 애매한 채로 두었습니다. 신청하고 싶다는 분명 신청 의도인데 접두사 목록에 있으니 무시됩니다. 재현율을 조금 버리고 정밀도를 사는 선택입니다.
노이즈에 걸리면 ignored로 떨어지는데 사유가 두 갈래로 나뉩니다.
reason: noise.startsWith('신청이 완료') || noise === '신청 실패'
? 'bot_message'
: 'noise_word',봇 응답이면 bot_message, 잡담이면 noise_word입니다. 둘 다 결과는 버림인데 사유를 나눈 이유는 관측입니다. bot_message가 갑자기 늘면 어떤 채널의 봇 설정이 바뀐 것이고, noise_word가 늘면 필터 목록을 늘려야 한다는 신호입니다.
노이즈를 통과한 뒤에도 조건이 하나 더 있습니다.
const after = message.substring('신청'.length);
if (after === '' || /^[\s,!?.]/u.test(after)) {신청 바로 뒤가 공백이거나 문장부호여야 통과합니다. 신청곡 해도될까요처럼 합성어로 이어지면 여기서 통과하지 않고 아래 Tier 3 검사로 흘러갑니다. 그리고 payload가 비면 다시 no_signal로 버려집니다. 신청 두 글자만 친 메시지에는 매칭할 대상이 없기 때문입니다.
3.3 Tier 2b 구분자 규칙
!로 시작하는 메시지는 화이트리스트(!sr)에 걸리거나, 구분자 규칙을 통과해야 합니다.
// separator(하이픈 / 슬래시 / en dash / em dash) 포함 시 신청 의도 강함 → tier2b 진입.
// `!출첵 / !멤버 / !공지`처럼 단어 단독 명령어는 separator 없으니 통과 안 함.
// `!아이유 - 밤편지`, `!IU/좋은날`, `!10cm-봄이 좋냐` 모두 cover.
// separator 양쪽에 한 글자 이상 있어야 함 (`!--` 같은 노이즈 제외).
const after = message.substring(1).trim();
const hasSeparator = /\S[-/\u2013\u2014]\S|\S [-/\u2013\u2014] \S/.test(after);
if (hasSeparator && after.length >= 3) {정규식이 두 갈래인 이유는 붙여 쓴 형태(!10cm-봄이 좋냐)와 띄어 쓴 형태(!아이유 - 밤편지)를 모두 잡기 위해서입니다. \S로 양쪽을 요구하기 때문에 !-- 같은 노이즈는 통과하지 못하고 길이 3 이상 조건이 하나 더 걸립니다.
이 규칙 하나로 다음이 갈립니다.
!출첵 -> ignored (구분자 없는 단어 하나짜리 명령)
!멤버 -> ignored
!공지 -> ignored
!투표 1 -> ignored
!프로필 -> ignored
!sql injection -> ignored (!sr 아닌 다른 명령)
!아이유 - 밤편지 -> tier2b
!BTS / Dynamite -> tier2b
!10cm-봄이 좋냐 -> tier2b위 아홉 줄은 전부 tier-classifier.service.spec.ts의 실제 테스트 케이스입니다. !sql injection이 목록에 있는 이유는 화이트리스트를 안 쓰고 “! 뒤에 공백이 있으면 신청”으로 처리했을 때 통과해버리는 사례이기 때문입니다. 규칙을 느슨하게 잡으면 무엇이 뚫리는지 테스트로 못 박아뒀습니다.
3.4 Tier 3 동사 화이트리스트와 노이즈 부분 문자열
메시지 중간에 신청이 등장하는 경우는 283K건이고 그중 진짜 신청은 동사 형태로 나타납니다.
private static readonly TIER3_REQUEST_VERBS = [
'신청합니다',
'신청이요',
'신청해주세요',
'신청 부탁',
'신청부탁',
'신청드려요',
'신청드립니다',
'신청해도',
'신청가능',
'신청할게요',
'신청할께요',
'신청해요',
'신청곡 해도',
'신청곡 가능',
'신청곡 부탁',
'신청곡 받',
];신청할게요와 신청할께요가 나란히 있는 것이 이 목록의 성격을 보여줍니다. 맞춤법상 옳은 표기 하나만 넣으면 실제 채팅의 절반을 놓칩니다. 신청곡 받은 어미를 잘라둔 항목입니다. 신청곡 받나요, 신청곡 받아요, 신청곡 받으시나요를 한 줄로 덮습니다.
동사 검사보다 먼저 도는 것이 노이즈 부분 문자열입니다.
private static readonly TIER3_NOISE_SUBSTRINGS = [
'<음표 그림문자> 신청',
'신청이 완료',
'신청 실패',
'신청곡 리스트에 추가',
'신청곡 양식',
'수강신청',
'가입신청',
'교환신청',
'산재신청',
'현피신청',
'재입고 알림신청',
'재입고알림신청',
'갱신 신청',
'면허신청',
'청구신청',
'환불신청',
];첫 항목은 음표 그림문자로 시작합니다. 다른 신청곡 봇들이 응답 앞에 이 문자를 붙이는 관습이 있어서 그림문자 하나가 봇 응답 전체를 잡아내는 가장 싼 지문이 됩니다.
수강신청, 가입신청, 교환신청, 산재신청, 현피신청, 면허신청, 청구신청, 환불신청. 이 여덟 개는 책상에서 지어낼 수 있는 목록이 아닙니다. 산재신청과 현피신청이 같은 배열에 들어 있는 것 자체가 이 목록이 실제 로그에서 나왔다는 증거입니다.
재입고 알림신청과 재입고알림신청이 둘 다 있는 것도 마찬가지입니다. 이 검사 단계에서는 띄어쓰기 정규화를 하지 않기 때문에, 두 표기를 모두 적어야 둘 다 걸립니다.
3.5 사유 코드 여덟 개
분류 결과는 티어와 함께 사유 코드를 돌려줍니다.
export type TierClassificationReason =
| 'request_command_prefix' // 채널의 requestCommand(`!신청` 등)
| 'exclam_request_variant' // `!신청곡 / !노래신청 / !곡신청 / !sr`
| 'request_word_prefix' // `신청 ...`
| 'exclam_whitelisted_command' // `!sr` 같은 신청 alias 명령
| 'request_word_contained' // 메시지 중간에 `신청` + 신청 동사
| 'bot_message' // 봇 응답
| 'noise_word' // `수강신청`, `갱신 신청` 등
| 'no_signal'; // 매칭 신호 없음티어는 다섯 개인데 사유는 여덟 개입니다. 같은 ignored라도 왜 버려졌는지가 셋으로 갈리고, 같은 tier1이라도 어느 접두사로 들어왔는지가 둘로 갈립니다. 나중에 룰을 고칠 때 실제로 들여다본 쪽은 티어보다 사유 코드였습니다.
4. 티어가 결정하는 것은 순서가 아니라 예산입니다
티어가 정해지면 매칭에 들어갑니다. 여기서 갈리는 지점은 단계의 순서가 아니라 각 단계가 어느 티어까지 열려 있는가입니다.
코드의 게이트를 그대로 옮기면 이렇습니다.
// Step 1: YouTube URL (Tier 1만)
if (tier === 'tier1') { ... }
// Step 4: GlobalSong cross-streamer alias (Tier 1/2만)
if (tier !== 'tier3') { ... }
// Step 5: 자모 단위 fuzzy (Tier 1/2만), 결과는 LLM 컨텍스트에도 사용
if (tier !== 'tier3') { ... }
// Step 6: LLM context-rich matcher (Tier 1만)
if (tier === 'tier1' && this.llmMatcher.isConfigured()) { ... }Tier 3은 v1 캐스케이드만 돌고 끝납니다. Tier 3은 “혹시 신청일 수도 있는” 메시지이므로 여기에 LLM을 붙이면 비용이 채팅량에 비례해서 폭발하고 오탐도 늘어납니다. 반대로 Tier 1은 사용자가 명시적으로 신청 명령어를 친 것이므로 여기서 매칭에 실패하면 그건 확실한 실패입니다. 비싼 수단을 쓸 가치가 있습니다.
자동 수락 임계값도 티어별로 다릅니다.
export const TIER_THRESHOLDS: Record<Exclude<MatchTier, 'ignored'>, number> = {
tier1: 0.5,
tier2a: 0.7,
tier2b: 0.7,
tier3: 0.95,
};근거는 각 티어가 동원하는 수단의 차이입니다. Tier 1은 LLM과 전체 fuzzy까지 다 쓰므로 낮게 잡아 관대하게 열어두고, Tier 2는 기본 알고리즘 결과만 보므로 중간에 두고, Tier 3은 정확한 형식만 통과시켜야 하므로 높게 올립니다.
여기에 대응하는 기본 confidence도 티어별로 다릅니다.
function tierBase(tier: MatchTier): number {
const base: Record<MatchTier, number> = {
tier1: 0.85,
tier2a: 0.85,
tier2b: 0.85,
tier3: 0.95,
ignored: 0,
};
return base[tier];
}Tier 3은 기본 confidence 0.95에 임계값도 0.95입니다. v1이 정확히 매칭한 경우에만 딱 통과하고 그보다 조금이라도 약한 신호는 전부 탈락합니다. 두 숫자가 “Tier 3에서는 정확 매칭만 자동 수락한다”를 그대로 말하고 있습니다.
Tier 1은 반대입니다. 기본 0.85에 임계값 0.5이므로 LLM이 0.55만 줘도 통과합니다. 대신 뒤에서 보듯 LLM 경로에는 별도의 하한과 상한이 걸려 있습니다.
5. v1 결정론적 캐스케이드 10단계
LLM 이전 단계는 전부 규칙 기반입니다. SongMatcherService(510줄)가 담당하며 순서대로 시도하고 걸리면 거기서 끝냅니다.
모든 단계가 공유하는 것은 정규화 함수입니다.
private normalizeText(text: string): string {
if (!text) return '';
return text
.normalize('NFKC')
.toLowerCase()
.replace(/[\s\-_]/g, '')
.replace(
/[^\w가-힣ᄀ-ᇿㄱ-ㅣぁ-んァ-ヺー〜一-鿿㐀-䶿]/g,
'',
);
}NFKC로 전각을 반각으로 접고 소문자로 내린 뒤, 공백과 하이픈과 언더스코어를 지우고 허용 문자만 남깁니다. 허용 범위는 영숫자, 한글 음절, 한글 자모, 히라가나, 가타카나(장음 부호와 물결표 포함), CJK 한자 두 블록입니다.
이 정규식은 원래 [^\w가-힣]이었습니다. v2 개편을 시작하면서 v1의 결함을 심각도 순으로 정리했더니 목록이 이렇게 나왔습니다.
| 심각도 | 이슈 |
|---|---|
| Critical | normalizeText가 일본어/한자를 완전히 삭제 |
| Critical | matchByKeyword DB 쿼리는 raw값, 스코어링은 normalized 값 사용 |
| High | GlobalSongMatcher의 alias/fuzzy가 채팅 매칭에서 미활용 |
| High | 가사 검색 없음 |
| Medium | 카테고리 매칭 없음 |
| Medium | 랜덤 신청 지원 없음 |
夜に駆ける가 정규화를 통과하면 빈 문자열이 되어 어떤 곡과도 매칭되지 않았습니다. 일본 곡을 노래책에 올린 스트리머 입장에서는 신청 기능 자체가 없는 것과 같았습니다.
두 번째 Critical도 흥미롭습니다. DB에는 raw 값으로 LIKE 쿼리를 날리고, 점수는 normalized 값으로 계산했습니다. 서로 다른 문자열 공간을 오가는 구조라 후보 선정과 순위 계산이 어긋났습니다. 그래서 채널 곡 전체를 한 번 로드해 인메모리로 처리하도록 바꿨습니다.
private indexSongs(songs: ...) {
return songs.map((song) => ({
song,
normTitle: this.normalizeText(song.title),
normArtist: this.normalizeText(song.artist.name),
}));
}채널당 50곡에서 500곡 규모라 전체 로드가 부담이 아니었고 로드 한 번으로 raw/normalized 불일치가 아예 사라졌습니다.
5.1 단계별 실제 조건
Step 0. 랜덤 키워드
private isRandomKeyword(normalized: string): boolean {
return normalized === '랜덤' || normalized === 'random';
}정확히 두 단어만 인정합니다. 부분 일치를 허용하면 랜덤플레이댄스라는 곡이 노래책에 있을 때 충돌합니다. 랜덤이 잡히면 캐시를 우회하고 실시간 곡 목록에서 하나를 뽑습니다.
Step 1. GlobalSongAlias 완전일치
globalSongId가 연결된 곡만 대상으로 별칭을 봅니다. 여기에는 제약이 하나 박혀 있습니다.
const titleAliases = song.globalSong.aliases?.map((a) => a.normAliasTitle) ?? [];
if (!titleAliases.includes(normTitle)) continue;
if (normArtist) {
const artistAliases = song.globalSong.globalArtist?.aliases?.map((a) => a.normAlias) ?? [];
if (!artistAliases.includes(normArtist)) continue;
}제목 별칭 일치는 필수이고 아티스트 별칭은 보조 판별로만 씁니다. 아티스트 별칭만으로 매칭하면 아이유가 아이유의 아무 곡이나 잡습니다. 별칭 매칭에서 제목이 주(主), 아티스트가 부(副)라는 비대칭을 코드로 강제했습니다.
Step 2. 아티스트 + 제목 완전일치
Step 2b. 제목만 완전일치
Step 3. 아티스트와 제목을 뒤바꿔 완전일치
const swapExact = indexed.find(
(item) =>
item.normTitle === normalizedArtist &&
item.normArtist === normalizedTitle,
);사람이 밤편지 아이유라고도 치고 아이유 밤편지라고도 치기 때문입니다. 입력 형식을 강제하는 대신 양쪽을 다 시도합니다.
Step 4. 부분일치 (원래 순서)
const partialScored = indexed
.map((item) => ({
item,
score:
this.calculateSimilarity(item.normArtist, normalizedArtist) * 0.4 +
this.calculateSimilarity(item.normTitle, normalizedTitle) * 0.6,
}))
.filter((x) => x.score > 0.3)
.sort((a, b) => b.score - a.score)
.slice(0, 5);
if (partialScored.length === 1) {
// 자동 매칭
} else if (
partialScored.length > 1 &&
partialScored[0].score - partialScored[1].score >= 0.3
) {
// 자동 매칭
} else if (partialScored.length > 1) {
// candidates만 반환, matched: false
}가중치는 제목 0.6, 아티스트 0.4입니다. 제목이 아티스트보다 식별력이 높기 때문입니다. 컷오프는 0.3이고 상위 5개만 남깁니다.
핵심은 자동 확정 규칙입니다. 후보가 하나면 확정하고, 여럿이면 1등과 2등의 격차가 0.3 이상일 때만 확정합니다. 점수의 절대값이 아니라 격차를 봅니다.
절대 점수가 0.9여도 2등이 0.88이면 그건 애매한 상황입니다. 반대로 절대 점수가 0.45여도 2등이 0.1이면 압도적 1등이므로 확실한 상황입니다. 자동 확정을 가를 때는 절대값보다 격차가 더 나은 신호였습니다.
Step 5. 부분일치 (뒤바꾼 순서)
같은 스코어링을 인자를 교차해서 한 번 더 돕니다.
score:
this.calculateSimilarity(item.normArtist, normalizedTitle) * 0.4 +
this.calculateSimilarity(item.normTitle, normalizedArtist) * 0.6,가중치는 그대로 두고 비교 대상만 바꿉니다. 완전일치에서 스왑을 시도했듯 부분일치에서도 시도합니다.
Step 6. 키워드 매칭
구분자 없이 한 덩어리로 들어온 입력은 matchByKeyword로 갑니다. 여기에는 별도의 스코어 사다리가 있습니다.
let score = 0;
if (normTitle === normalized) score = 1.0;
else if (normTitle.includes(normalized))
score = normalized.length / normTitle.length;
else if (normArtist === normalized) score = 0.5;
else if (normArtist.includes(normalized))
score = 0.3 * (normalized.length / normArtist.length);제목 완전일치 1.0, 제목 포함은 길이 비율, 아티스트 완전일치 0.5, 아티스트 포함은 길이 비율의 0.3배입니다. 제목 신호와 아티스트 신호에 명시적인 서열을 매긴 형태입니다. 컷오프는 0.2, 상위 5개, 격차 규칙은 여기서도 0.3입니다.
normalized.length / normTitle.length라는 식은 짧은 제목에 유리합니다. 밤편지에 밤편이 포함되면 2/3이지만, 사랑하게될거야에 사랑이 포함되면 2/7입니다. 긴 제목의 일부만 친 경우 점수가 낮아지므로 짧은 키워드로 긴 제목을 확정하는 사고가 자연히 억제됩니다.
Step 7. 가사 부분일치
if (normalized.length >= 4) {
const lyricsResult = await this.matchByLyrics(channelId, normalized);
...
}
private async matchByLyrics(channelId: number, normalized: string) {
const hits = await this.prisma.song.findMany({
where: { channelId, lyricsText: { contains: normalized } },
select: { id: true },
take: 11,
});
if (hits.length === 0 || hits.length > 10) return { matched: false };
if (hits.length === 1) { /* 자동 매칭 */ }
// 2-10 hits: return candidates without auto-matching
}정규화 후 4글자 이상일 때만 돕니다. 짧은 키워드는 아무 가사에나 걸립니다.
take: 11이 규칙을 그대로 드러냅니다. 히트가 하나면 확정하고, 2개에서 10개면 후보만 돌려주고 자동 매칭하지 않고, 11개 이상이면 아예 버립니다. 열한 번째가 있는지 보려고 11개를 가져옵니다. 히트가 10개를 넘는다는 것은 그 문자열이 흔한 표현이라는 뜻이므로 매칭 신호로 쓸 가치가 없습니다.
Step 8. 카테고리 매칭
const matchedCat = allSongs
.flatMap((s) => s.songCategories)
.find((sc) => sc.category.name === keyword);카테고리는 완전일치만 인정합니다. 부분 일치를 허용하면 발라드가 뉴발라드를 잡습니다. 그리고 카테고리를 마지막 단계에 둔 이유는 곡 제목과 카테고리 이름이 충돌할 수 있기 때문입니다. 곡 매칭을 먼저 전부 시도하고 모두 실패했을 때만 카테고리로 내려갑니다.
!신청 발라드를 쳤을 때 노래책에 발라드라는 곡이 있으면 그 곡이 이깁니다. 없을 때만 카테고리로 해석해서 그 카테고리의 곡 하나를 뽑습니다.
5.2 여기서 발견한 실제 결함
matchByCategory는 keyword를 정규화하지 않고 원문 그대로 category.name과 비교합니다. 반면 다른 모든 단계는 정규화된 값끼리 비교합니다. 카테고리 이름에 앞뒤 공백이나 전각 문자가 섞이면 이 단계만 조용히 실패합니다. 매칭 실패는 예외를 던지지 않으므로 로그에도 남지 않습니다.
matchByLyrics도 비슷한 성격의 비대칭을 안고 있습니다. 검색어는 공백을 제거한 정규화 문자열인데, Song.lyricsText 컬럼은 사람이 입력한 원문입니다. 공백이 살아 있는 저장 텍스트에 공백을 지운 질의를 contains로 던지면, 여러 어절짜리 가사 조각은 구조적으로 매칭될 수 없습니다.
그리고 더 근본적인 문제가 있었습니다. Song.lyricsText는 가사 컬럼이 아니었습니다. 스트리머가 자기만 보려고 적어두는 비공개 메모이고, 오버레이 응답에서도 보안 정책으로 제외되는 필드입니다. v1의 가사 검색은 처음부터 가사가 아닌 것을 뒤지고 있었습니다. 이 사실은 v2 작업 중에 드러났고, 가사 히트의 출처를 GlobalSongLyrics.body로 옮기는 것으로 정리했습니다.
6. 유사도 함수: 포함관계가 편집거리보다 먼저입니다
v1 캐스케이드의 Step 4와 5가 쓰는 유사도 함수는 두 갈래로 갈립니다.
private calculateSimilarity(str1: string, str2: string): number {
if (str1 === str2) return 1;
if (!str1 || !str2) return 0;
// 포함 관계 확인
if (str1.includes(str2) || str2.includes(str1)) {
const shorter = str1.length < str2.length ? str1 : str2;
const longer = str1.length < str2.length ? str2 : str1;
return shorter.length / longer.length;
}
// Levenshtein 거리 기반 유사도
const distance = this.levenshteinDistance(str1, str2);
const maxLength = Math.max(str1.length, str2.length);
return 1 - distance / maxLength;
}포함관계를 편집거리보다 먼저 처리하는 것이 핵심입니다. 두 방식은 순수 삽입만 있는 경우에는 같은 값을 내지만 삽입과 치환이 섞이면 갈라집니다.
포함관계 분기는 “짧은 쪽이 긴 쪽 안에 통째로 들어 있다”는 강한 사실을 먼저 확인한 뒤 길이 비율만 봅니다. 편집거리는 그 사실을 모른 채 문자 단위로 셉니다. 예를 들어 밤편지와 아이유밤편지가사는 포함관계로 3/8 = 0.375인데, 만약 사용자가 밤펀지처럼 한 글자를 틀리면 포함관계가 깨져서 편집거리 분기로 떨어지고 점수 성격이 완전히 바뀝니다. 부분 입력이 흔한 신청곡 도메인에서는 포함관계를 먼저 보는 쪽이 더 정확했습니다.
Levenshtein 구현 자체는 교과서 그대로의 전체 DP 테이블입니다.
private levenshteinDistance(str1: string, str2: string): number {
const m = str1.length;
const n = str2.length;
if (m === 0) return n;
if (n === 0) return m;
const dp: number[][] = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0));
for (let i = 0; i <= m; i++) dp[i][0] = i;
for (let j = 0; j <= n; j++) dp[0][j] = j;
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
const cost = str1[i - 1] === str2[j - 1] ? 0 : 1;
dp[i][j] = Math.min(
dp[i - 1][j] + 1,
dp[i][j - 1] + 1,
dp[i - 1][j - 1] + cost,
);
}
}
return dp[m][n];
}두 행만 유지하는 최적화도, 조기 종료도 없습니다. 채널당 곡 수가 수백 규모이고 문자열이 짧아서 그대로 둬도 문제가 없었습니다. 다만 이 O(m×n)이 나중에 자모 단계에서 문제가 되고 거기서 상한이 하나 생깁니다.
7. 자모 단위 fuzzy
한글에서는 글자 단위 편집 거리가 체감과 어긋납니다. 박편지와 밤편지는 글자로 보면 세 글자 중 한 글자가 다르니 1/3 = 33% 차이입니다. 하지만 사람이 느끼기에 이건 받침 하나 잘못 친 것입니다.
자모로 풀면 그 감각과 맞습니다. 밤편지를 ㅂㅏㅁㅍㅕㄴㅈㅣ 여덟 자모로 분해한 뒤 Levenshtein을 재면 박편지와의 차이는 1/8, 12.5%입니다. 한글 한 글자 오타에 강해집니다.
분해는 유니코드 한글 음절 블록의 산술로 합니다.
const HANGUL_BASE = 0xac00; // '가'
const HANGUL_END = 0xd7a3; // '힣'
const CHO = ['ㄱ','ㄲ','ㄴ','ㄷ','ㄸ','ㄹ','ㅁ','ㅂ','ㅃ','ㅅ','ㅆ','ㅇ','ㅈ','ㅉ','ㅊ','ㅋ','ㅌ','ㅍ','ㅎ'];
const JUNG = ['ㅏ','ㅐ','ㅑ','ㅒ','ㅓ','ㅔ','ㅕ','ㅖ','ㅗ','ㅘ','ㅙ','ㅚ','ㅛ','ㅜ','ㅝ','ㅞ','ㅟ','ㅠ','ㅡ','ㅢ','ㅣ'];
const JONG = ['','ㄱ','ㄲ','ㄳ','ㄴ','ㄵ','ㄶ','ㄷ','ㄹ','ㄺ','ㄻ','ㄼ','ㄽ','ㄾ','ㄿ','ㅀ','ㅁ','ㅂ','ㅄ','ㅅ','ㅆ','ㅇ','ㅈ','ㅊ','ㅋ','ㅌ','ㅍ','ㅎ'];
function decomposeChar(ch: string): string {
const code = ch.charCodeAt(0);
if (code < HANGUL_BASE || code > HANGUL_END) return ch;
const idx = code - HANGUL_BASE;
const cho = Math.floor(idx / (21 * 28));
const jung = Math.floor((idx % (21 * 28)) / 28);
const jong = idx % 28;
return CHO[cho] + JUNG[jung] + JONG[jong];
}초성 19개, 중성 21개, 종성 28개(무종성 포함)로 이루어진 11,172자 배열이라 나눗셈과 나머지로 바로 풀립니다. 종성 배열의 첫 항목이 빈 문자열인 것이 요령입니다. 받침 없는 글자는 자연스럽게 두 자모만 내놓습니다.
expect(decomposeJamo('밤편지')).toBe('ㅂㅏㅁㅍㅕㄴㅈㅣ');
expect(decomposeJamo('가')).toBe('ㄱㅏ'); // 종성 없음
expect(decomposeJamo('값')).toBe('ㄱㅏㅄ'); // 겹받침은 한 글자로
expect(decomposeJamo('IU 좋은날')).toBe('IU ㅈㅗㅎㅇㅡㄴㄴㅏㄹ');한글이 아닌 문자는 그대로 통과시킵니다. 그래서 IU 좋은날처럼 한영이 섞인 입력도 한 번에 처리됩니다.
유사도는 분해 후 Levenshtein을 정규화한 값입니다.
export function jamoSimilarity(a: string, b: string): number {
const ja = decomposeJamo(a);
const jb = decomposeJamo(b);
if (!ja || !jb) return 0;
const dist = levenshtein(ja, jb);
const maxLen = Math.max(ja.length, jb.length);
return 1 - dist / maxLen;
}테스트가 이 함수의 목표를 그대로 표현합니다.
it('자모 단위 1글자 오타에 강함', () => {
const sim = jamoSimilarity('박편지', '밤편지');
expect(sim).toBeGreaterThan(0.85);
});
it('완전 다른 문자열 → 낮은 점수', () => {
expect(jamoSimilarity('아이유', 'BTS')).toBeLessThan(0.3);
});초성 검색도 있습니다.
export function chosungMatch(query: string, target: string): boolean {
if (!/^[ㄱ-ㅎ]+$/u.test(query)) return false;
const targetChosung = extractChosung(target).replace(/[^ㄱ-ㅎ]/g, '');
return targetChosung === query;
}질의가 초성으로만 이루어졌을 때만 발동하고 대상의 초성과 전부 일치해야 참입니다. ㅂㅍㅈ가 밤편지를 잡고, 밤편지를 그대로 넣으면 초성 전용이 아니므로 거짓입니다.
영한 매핑은 사전 스물세 개짜리 간이 테이블입니다.
const EN_TO_KO_MAP: ReadonlyMap<string, string> = new Map([
['bts', '방탄소년단'], ['iu', '아이유'], ['exo', '엑소'],
['twice', '트와이스'], ['blackpink', '블랙핑크'], ['nct', '엔시티'],
['ive', '아이브'], ['lesserafim', '르세라핌'], ['newjeans', '뉴진스'],
['aespa', '에스파'], ['gidle', '여자아이들'], ['itzy', '있지'],
['stray kids', '스트레이키즈'], ['straykids', '스트레이키즈'], ['skz', '스트레이키즈'],
['day6', '데이식스'], ['exid', '이엑스아이디'], ['shinee', '샤이니'],
['snsd', '소녀시대'], ['mamamoo', '마마무'], ['fromis_9', '프로미스나인'],
['oasis', '오아시스'], ['radiohead', '라디오헤드'], ['queen', '퀸'],
]);stray kids, straykids, skz 세 키가 같은 값을 가리킵니다. 정밀한 로마자 변환은 아예 시도하지 않았습니다. 일반화 가능한 규칙이 없는 영역은 사전 스물세 줄로 막고 나머지는 뒤에 오는 LLM에 넘깁니다.
7.1 자모 단계의 임계값과 상한
v2 파이프라인의 Step 5는 이 유틸을 채널 노래책 전체에 돌립니다.
/** Jamo fuzzy DoS 방어, Levenshtein O(m×n)이라 입력 길이 cap. */
private static readonly JAMO_INPUT_MAX_CHARS = 80;이 상한은 뒤늦게 붙인 것입니다. Levenshtein이 전체 DP이므로 시청자가 아주 긴 문자열을 붙여 넣으면 노래책 곡 수만큼 곱해져서 CPU가 갈립니다. 입력과 노래책 제목 양쪽을 80자로 자릅니다. 노래책 제목에 같은 상한을 걸어도 안전한 이유는 ClickHouse 분석에서 가장 긴 곡 제목이 53자 정도였기 때문입니다.
스코어링과 채택 규칙은 이렇습니다.
const titleScore = jamoSimilarity(cleanTitle, sTitle);
const artistScore = cleanArtist ? jamoSimilarity(cleanArtist, sArtist) : 0;
const score = cleanArtist ? titleScore * 0.7 + artistScore * 0.3 : titleScore;.filter((x) => x.score > 0.5) // LLM 컨텍스트로 더 많이 보내기 위해 임계값 낮춤
.sort((a, b) => b.score - a.score)
.slice(0, 10);
const top = scored[0];
const next = scored[1];
const accept = top.score >= 0.85 || (next && top.score - next.score >= 0.05);가중치가 v1과 다릅니다. v1의 부분일치는 0.6/0.4였는데 자모 단계는 0.7/0.3입니다. 자모로 풀면 제목의 정보량이 더 커지므로 제목 비중을 올렸습니다.
채택 조건도 다릅니다. 절대 점수 0.85 이상이거나, 1등과 2등의 격차가 0.05 이상이면 채택합니다. v1의 격차 규칙이 0.3이었던 것에 비하면 훨씬 헐겁습니다. 자모 공간에서는 점수가 촘촘하게 붙기 때문입니다. 여덟 자모짜리 문자열에서 자모 하나 차이는 0.125이므로 0.3 격차를 요구하면 거의 아무것도 통과하지 못합니다. 이름만 같은 규칙이지 점수 공간이 바뀌면 상수도 따라 바뀝니다.
필터의 임계값 0.5가 이 단계의 성격을 결정합니다. 0.5는 채택하기에는 낮은 값입니다. 이 단계가 스스로 확정하기 위한 값이 아니라 다음 단계인 LLM에게 넘길 후보를 넉넉히 모으기 위한 값입니다.
실제로 코드가 그렇게 생겼습니다.
const jamoMatch = await this.tryJamoFuzzy(...);
if (jamoMatch) {
jamoTop = jamoMatch.allCandidates; // accept와 무관하게 항상 보관
if (jamoMatch.accept) {
return this.finalize(...);
}
}accept가 거짓이어도 allCandidates는 살려둡니다. 앞 단계가 확정에 실패했다고 결과를 버리는 것이 아니라 “이 후보들이 가까웠다”는 정보로 재활용합니다. 확정에 실패한 단계가 남긴 후보 목록이 그대로 다음 단계의 입력이 됩니다.
8. 다른 스트리머의 표기를 빌려옵니다
같은 곡이라도 스트리머마다 노래책에 다르게 적습니다. 어떤 채널은 사랑하게 될 거야, 어떤 채널은 사랑하게될거야, 어떤 채널은 한로로 - 사랑하게 될 거야입니다.
멜로밍에는 채널 곡을 정규 곡(GlobalSong)에 연결하는 구조가 있고 표기 변형이 GlobalSongAlias.normAliasTitle에 쌓입니다. GlobalSongCrossAliasService는 그 축적을 신청 매칭에 끌어옵니다. 별칭 후보는 글로벌 풀 전체에서 찾되, 매칭 대상은 언제나 현재 채널에 등록된 곡으로 제한합니다. 시청자가 신청한 채널의 노래책에 그 곡이 없으면 매칭할 의미가 없기 때문입니다.
탐색은 전역, 결과는 채널 한정입니다. 이 서비스가 하는 일은 사실상 그 비대칭 하나입니다.
두 경로가 있고 점수가 다릅니다.
// 1) GlobalSongAlias.normAliasTitle == normTitle
return { ..., via: 'global_song_alias', score: 0.92 };
// 2) GlobalSong.normTitle == normTitle (alias는 없지만 정규 제목 일치)
return { ..., via: 'global_song_title', score: 0.88 };별칭 일치가 0.92, 정규 제목 일치가 0.88입니다. 별칭이 더 높은 이유는 별칭이 사람의 행동에서 나온 증거이기 때문입니다. 누군가가 실제로 그렇게 적었기 때문에 별칭이 존재합니다. 정규 제목은 시스템이 정한 표기라 사용자의 입력 습관과 덜 맞습니다.
후보가 여럿이면 아티스트 별칭으로 좁히고 그래도 안 좁혀지면 첫 항목을 택합니다.
// 매치 안 되면 첫 항목 (호출자가 confidence 낮춰 후처리)
return { songId: songs[0].id, globalSongId: songs[0].globalSongId! };9. LLM은 마지막 판정자입니다
여기까지 와도 못 잡는 것들이 남습니다.
잠깐 오는 비 -> 소나기
무슨 한강다리 해도 -> 양화대교
배털기웃으 -> 베텔기우스
여러분의 등록금이 펑펑 -> 불꽃놀이 (하현상)
버즈 그 신나는 노래 -> 버즈의 업템포 곡 중 아무거나문자열 유사도로는 도달할 수 없습니다. 잠깐 오는 비와 소나기는 공통 글자가 하나도 없습니다. 오히려 단어가 겹치는 비가 오는 날엔이 문자열상으로는 훨씬 가깝습니다. 정답은 의미 쪽에 있습니다.
9.1 왜 agentic이 아닌가
도구를 여러 번 왕복하는 tool-loop 구조로는 채팅 반응 1초 목표를 맞출 수 없었습니다. P95가 4초를 넘어갑니다. 반면 어떤 데이터를 넣어야 하는지는 코드가 미리 결정할 수 있으므로, 근거를 사전에 전부 채워 한 번만 호출하는 쪽이 지연과 비용과 정확도에서 모두 앞섰습니다.
모델이 도구를 골라 여러 번 왕복하는 대신 코드가 필요한 근거를 전부 모아 한 번에 넣습니다. 여기서 결정적인 조건은 “무엇을 가져올지 코드가 이미 안다”는 사실입니다. 자모 상위 후보, 교차 별칭, 가사 히트는 전부 결정론적으로 계산할 수 있습니다. 모델에게 검색을 시킬 이유가 없습니다.
9.2 시스템 프롬프트 전문
프롬프트는 문자열을 이어 붙여 만들었고 규칙이 일곱 개입니다.
You match Korean live-stream chat messages to a song in the streamer's songbook (channel_songs).
Reply ONLY with compact JSON: {"matched_song_id":number|null,"confidence":0.0-1.0,"reasoning":"<short>"}.
MATCH AGGRESSIVELY in these cases: pick a song from channel_songs whenever a plausible candidate exists:
1. Typos / fuzzy spelling: 조은날→좋은날, 박편지→밤편지, 배털기웃으→베텔기우스, 라이오테→라이언테.
2. Abbreviated or fragment title (chorus only, first phrase only, partial line).
3. Artist or title omitted / partial.
4. Thematic / topical / paraphrase references: match by what the song is about or a paraphrased
equivalent. Prefer semantic match over single-word overlap:
- "무슨 한강다리 해도" → 양화대교 (Han River bridge)
- "잠깐 오는 비" → 소나기 (NOT "비가 오는 날엔": single-word "비" overlap;
"잠깐 오는 비" is the literal definition of 소나기)
- "쏟아지는 비" → 폭우 / 소나기 (paraphrase, not "빗속에서")
- "첫눈 오는 날" → 첫눈 (direct, not "눈이 오던 날")
- "부산 노래" → 부산갈매기
- "벚꽃 노래" → 봄날, 벚꽃엔딩 등 (pick any matching, see #5)
IMPORTANT: When two candidates exist, one with shared keyword, one with semantic/paraphrase
match, choose the semantic match unless the keyword candidate is overwhelmingly closer in form.
5. Vague mood/style/artist descriptions: pick ANY song that matches the description
(random tie-break OK):
- "버즈 그 신나는 노래" → pick any uptempo 버즈 song from songbook
- "잔잔한 발라드" → pick any ballad
- "신해철꺼 아무거나" → pick any 신해철 song
This is intentional: viewers often delegate the choice to the streamer.
6. Pronunciation-based matches (Korean phonetic transliteration of foreign titles/artists):
- "배털기웃으" → 베텔기우스 (Betelgeuse, similar pronunciation)
- "예스터데이" / "예스떼이" → Yesterday
7. Lyrics-fragment requests: viewers quote a remembered lyric line:
- "여러분의 등록금이 펑펑" → 불꽃놀이 (하현상) if the lyrics contain that line.
- Use lyrics_hits as primary evidence here.
CONFIDENCE GUIDE:
- Exact / near-exact title match (typos, abbreviation, fragment): 0.85-1.0
- Clear thematic / lyrics reference with one strong candidate: 0.7-0.9
- Vague description with multiple plausible candidates → pick one: 0.55-0.7
- Genuinely ambiguous between unrelated songs (or songbook has nothing close): null
Use cross_streamer_aliases and lyrics_hits as supporting evidence, but matched_song_id MUST be
a song from channel_songs (the streamer's own songbook).
NO markdown fences, NO prose outside the JSON.이 프롬프트가 처음부터 이랬던 것은 아닙니다. 초기 버전은 정확 매칭이 아니면 null을 내라고 지시하는 보수적인 프롬프트였습니다. 한강다리에서 양화대교로 가는 추론도, 가사 조각도, 발음 변이도 전부 거부했습니다.
정확 매칭이 아니면 null을 내라고 지시하면 모델은 성실하게 그렇게 합니다. 그런데 이 도메인에서 null은 “시청자의 신청이 그냥 사라짐”을 의미합니다. 정밀도를 위해 재현율을 버린 결과가 사용자에게는 기능 고장으로 보입니다. 그래서 프롬프트를 뒤집어 “그럴듯한 후보가 있으면 적극적으로 고르라”로 바꿨습니다.
5번 규칙에 붙은 This is intentional이라는 문장이 이 반전의 요약입니다. 버즈 그 신나는 노래에 조심스럽게 null을 내는 것은 신중한 것이 아니라 요청을 무시하는 것입니다. 시청자는 선택을 스트리머에게 위임한 것이므로 조건에 맞는 곡 아무거나 고르는 편이 요청에 부합합니다. 무작위 동점 처리를 명시적으로 허용한 이유도 그것입니다.
4번 규칙의 IMPORTANT 문장은 실제 오답에서 나왔습니다. 잠깐 오는 비가 단어 비의 겹침에 끌려 비가 오는 날엔으로 매칭됐고, 정답인 소나기를 잡지 못했습니다. 잠깐 오는 비는 소나기의 정의 그 자체인데도 그랬습니다. 그래서 thematic 섹션에 패러프레이즈 사례와 semantic match > single-word overlap 지시를 명시하고, 같은 모델로 다시 돌려 확인했습니다.
'잠깐 오는 비' -> 소나기 (0.95)
'쏟아지는 비' -> 소나기 (0.92)
'새벽 안개' -> null (오탐 없음)명시하지 않으면 모델도 문자열 유사도 쪽으로 끌립니다. 그리고 검증에서 세 번째 줄이 중요합니다. 새벽 안개에 null이 나오는지를 함께 확인했습니다. 적극적으로 고르라고 지시했을 때 가장 무서운 것은 없는 것도 만들어내는 일입니다. 그래서 재현율을 올린 변경에 정밀도 확인을 같이 붙였습니다.
9.3 호출 파라미터
this.http = axios.create({
baseURL: 'https://openrouter.ai/api/v1',
timeout: 8_000,
headers: {
'HTTP-Referer': configService.get('OPENROUTER_SITE_URL') ?? 'https://meloming.com',
'X-Title': 'Meloming Song Matcher',
},
});
this.model =
configService.get('FUZZY_CHAT_DETECTION_LLM_MODEL') ??
'anthropic/claude-haiku-4.5';const requestBody = {
model: this.model,
user: 'backend:song-request-v2',
messages: [ /* ... */ ],
temperature: 0.1,
max_tokens: 250,
};temperature: 0.1은 판정 작업이라 흔들림을 최소화한 값입니다. max_tokens: 250은 응답이 {"matched_song_id":123,"confidence":0.92,"reasoning":"..."} 한 줄이면 충분하기 때문입니다. 상한을 낮게 잡으면 모델이 장황한 해설을 붙일 여지가 줄어듭니다.
timeout: 8_000은 8초입니다. 채팅 반응 목표가 1초인데 타임아웃이 8초인 것은 모순처럼 보이지만 이 경로는 v1이 실패한 뒤에만 도달하는 폴백이라 그렇습니다. 정상 경로의 지연은 v1의 약 50ms이고 LLM까지 가는 경우는 애초에 느린 것이 확정된 케이스입니다.
user 필드는 OpenRouter 사용량 귀속용 라벨입니다. 신청곡 매칭과 노래책 추가에 서로 다른 라벨을 붙여서 비용을 분리 집계합니다.
10. 컨텍스트를 짓는 코드
프롬프트만큼이나 손이 많이 간 쪽이 “무엇을 넣을지” 고르는 코드입니다. buildLlmContext가 네 종류의 근거를 모읍니다.
1) 채널 노래책
private static readonly LLM_CHANNEL_SONGS_MAX = 500;500곡을 넘는 채널은 사전 필터를 겁니다.
if (allSongs.length > SongMatcherV2Service.LLM_CHANNEL_SONGS_MAX) {
const keepIds = new Set<number>();
jamoTop.forEach((j) => keepIds.add(j.songId));
v1Candidates?.forEach((c) => keepIds.add(c.id));
if (crossHit) keepIds.add(crossHit.songId);
// 같은 artist의 곡 모두 포함
if (artistQuery) {
const artistMatches = allSongs.filter((s) =>
s.artist.name.toLowerCase().includes(artistQuery.toLowerCase()),
);
artistMatches.forEach((s) => keepIds.add(s.id));
}
scoped = allSongs.filter((s) => keepIds.has(s.id));
// 빈 경우 fallback: 무조건 cap 만큼 자름 (id 오름차순)
if (scoped.length === 0) {
scoped = allSongs.slice(0, SongMatcherV2Service.LLM_CHANNEL_SONGS_MAX);
}
}앞 단계가 만든 후보를 전부 합집합으로 모으고 아티스트가 지목됐으면 그 아티스트의 곡을 통째로 넣습니다. 마지막 fallback을 눈여겨볼 만합니다. 합집합이 비면 아무것도 안 보내는 대신 앞에서 500곡을 자릅니다. 정확도가 떨어지더라도 “컨텍스트 없음”으로 무조건 실패하는 것보다는 낫다는 판단입니다.
여기서 아티스트 절이 특히 5번 규칙과 맞물립니다. 신해철꺼 아무거나가 들어오면 자모 점수는 낮게 나오지만 아티스트 필터가 신해철의 곡을 전부 넣어주므로 모델이 그 안에서 하나를 고를 수 있습니다.
2) 자모 힌트
fuzzyHints: jamoTop.length
? jamoTop.map((j) => ({
songId: j.songId,
score: Number(j.score.toFixed(3)),
via: 'jamo',
}))
: undefined,소수 셋째 자리로 반올림합니다. 부동소수 꼬리를 그대로 실어 보내면 토큰만 먹고 모델에게 주는 정보는 같습니다.
3) 가사 히트
const trimmed = payload.trim();
const candidates: string[] = [];
if (trimmed.length >= 6 && trimmed.length <= 60) {
candidates.push(trimmed);
}
if (trimmed.length > 60) {
candidates.push(trimmed.slice(0, 30));
}
const tokens = trimmed.split(/\s+/).filter((t) => t.length >= 4);
for (const t of tokens.slice(0, 3)) {
candidates.push(t);
}전체 문장(6자에서 60자), 너무 길면 앞 30자, 4자 이상인 어절 최대 3개까지 검색어를 만듭니다. 가사 인용은 시청자가 기억을 더듬어 치는 것이라 정확하지 않으므로 통문장과 어절을 함께 던져 하나라도 걸리기를 노립니다.
검색 대상은 반드시 GlobalSongLyrics.body, 즉 Musixmatch에서 받아온 가사입니다. 앞에서 본 대로 Song.lyricsText는 스트리머의 비공개 메모라 매칭 신호로 쓰면 안 됩니다.
히트하면 주변을 잘라 스니펫을 만듭니다.
const idx = matched ? body.indexOf(matched) : 0;
const start = Math.max(0, idx - 20);
const snippet = body.slice(start, start + 80);매치 지점 앞 20자부터 80자를 떼어냅니다. 가사 전문을 보내면 토큰이 폭발하고, 매치된 조각만 보내면 앞뒤 맥락이 없습니다. 80자짜리 창이 그 사이의 타협점입니다.
4) 교차 별칭
const norm = normalizeText(titleQuery);
if (!norm || norm.length < 2) return [];
const aliases = await this.prisma.globalSongAlias.findMany({
where: { normAliasTitle: { contains: norm.slice(0, 20) } },
select: { /* ... */ },
take: 10,
});2자 미만이면 아예 조회하지 않고 검색어는 20자로 자릅니다. 결과는 10개까지, 곡당 별칭은 8개까지 붙입니다. 곡별로 중복을 제거해서 같은 곡의 별칭이 목록을 채우는 것을 막습니다.
11. 프롬프트 캐싱: 노래책은 안 바뀝니다
이 구조에는 명백한 비용 문제가 있습니다. 요청마다 채널 노래책 전체를 프롬프트에 실어 보냅니다. 500곡이면 매 요청이 큽니다.
그런데 노래책은 채팅 한 줄마다 바뀌지 않습니다. 같은 방송에서 연속으로 들어오는 신청은 완전히 동일한 노래책을 봅니다. 그래서 시스템 프롬프트와 노래책 블록을 ephemeral 프롬프트 캐시에 박았습니다. 한 방송은 곧 같은 channelId이므로 캐시 키가 같고 5분 TTL 안에서 재사용됩니다. 메시지마다 달라지는 컨텍스트만 매번 새로 만듭니다.
시스템 메시지를 두 블록으로 쪼개고 두 번째 블록에만 캐시 표시를 답니다.
messages: [
{
role: 'system',
content: [
{ type: 'text', text: systemPrompt },
{
type: 'text',
text: `\n\n[CHANNEL ${ctx.channelId} SONGBOOK]\n${channelSongsBlob}`,
cache_control: { type: 'ephemeral' },
},
],
},
{
role: 'user',
content: perMessageBlob,
},
],메시지마다 달라지는 부분(입력, 자모 힌트, 교차 별칭, 가사 히트)은 전부 user 메시지로 분리했습니다. 그래서 같은 채널의 두 번째 요청부터는 노래책 블록이 캐시에 걸립니다.
캐싱을 쓰려면 프롬프트를 “변하는 것”과 “변하지 않는 것”으로 나눠야 합니다. 이건 프롬프트 문구를 다듬는 일이 아니라 구조를 설계하는 일에 가깝습니다. 노래책 블록을 user 메시지에 두거나 시스템 프롬프트와 한 문자열로 이어 붙였다면 캐시 경계 자체가 생기지 않았을 것입니다.
캐시가 실제로 걸리는지도 코드가 셉니다.
const cached = usage.prompt_tokens_details?.cached_tokens ?? 0;
const promptUncached = (usage.prompt_tokens ?? 0) - cached;
if (promptUncached > 0) {
this.metrics.songMatcherV2LlmTokensTotal.inc({ type: 'prompt' }, promptUncached);
}
if (cached > 0) {
this.metrics.songMatcherV2LlmTokensTotal.inc({ type: 'cached_prompt' }, cached);
}
if ((usage.completion_tokens ?? 0) > 0) {
this.metrics.songMatcherV2LlmTokensTotal.inc({ type: 'completion' }, usage.completion_tokens ?? 0);
}prompt와 cached_prompt를 나눠서 셉니다. 캐시된 토큰을 프롬프트 카운터에서 빼기 때문에 두 값을 더하면 전체가 되고 비율을 보면 캐시 적중률이 바로 나옵니다. 캐싱은 켜두는 것으로 끝나지 않습니다. 실제로 걸리고 있는지를 세지 않으면 어느 날 프롬프트 순서를 바꾸다 경계가 깨져도 아무도 모릅니다.
12. 환각과 파싱은 코드로 막습니다
프롬프트에 “반드시 노래책 안에서 고르라”고 써두어도 그것만으로는 보장이 안 됩니다. 그래서 응답을 파싱한 뒤 검증합니다.
// 환각 방지: LLM이 채널 노래책에 없는 song_id를 반환하면 reject
if (matchedSongId !== null) {
const known = ctx.channelSongs.some((s) => s.songId === matchedSongId);
if (!known) {
this.logger.warn(
`LLM hallucinated song_id=${matchedSongId} not in channel ${ctx.channelId} songbook, rejecting`,
);
return {
matchedSongId: null,
confidence: 0,
reasoning: `LLM returned id ${matchedSongId} not in songbook`,
raw,
};
}
}거절할 때 confidence: 0으로 내리고 사유를 사람이 읽을 수 있게 남기며, 원문(raw)도 보존합니다. 환각을 조용히 삼키지 않고 관측 가능한 사건으로 만듭니다.
같은 패턴이 노래책 추가 경로에도 똑같이 있습니다. 거기서는 후보 풀(ctx.candidates)에 없는 globalSongId를 거절합니다. 두 흐름 모두 “모델의 출력이 유효한 값의 집합 안에 있는가”를 코드가 다시 확인합니다.
프롬프트는 요청이고 검증은 보장입니다. 지시만 믿고 통과시키는 자리는 이 파이프라인에 없습니다.
응답 파싱도 방어적입니다. JSON만 달라고 해도 코드 펜스가 붙어 오거나 앞뒤에 설명이 붙어 옵니다.
let cleaned = raw.trim();
cleaned = cleaned.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/i, '');
let parsed: unknown;
try {
parsed = JSON.parse(cleaned);
} catch {
const start = cleaned.indexOf('{');
const end = cleaned.lastIndexOf('}');
if (start === -1 || end === -1 || end <= start) {
return { matchedSongId: null, confidence: 0, raw };
}
try {
parsed = JSON.parse(cleaned.slice(start, end + 1));
} catch {
return { matchedSongId: null, confidence: 0, raw };
}
}1단계는 코드 펜스 제거, 2단계는 첫 {와 마지막 } 사이를 잘라 재시도, 3단계는 포기하고 매칭 실패 처리입니다. lastIndexOf('}')를 쓰는 것이 요령입니다. 모델이 JSON 뒤에 설명 문장을 붙여도 중괄호 바깥은 통째로 잘려 나갑니다.
세 단계 어디에서도 예외를 던지지 않습니다. 형식이 어긋났다고 신청 흐름 전체를 깨뜨리지 않고 조용히 매칭 실패로 떨어뜨립니다. 그리고 raw를 계속 물고 다니므로 실패했을 때 무엇이 왔는지 추적할 수 있습니다.
파싱된 값에도 범위 강제가 들어갑니다.
const matchedSongId =
typeof idVal === 'number' && Number.isInteger(idVal) ? idVal : null;
const confidence =
typeof o.confidence === 'number' ? Math.max(0, Math.min(1, o.confidence)) : 0;정수가 아니면 null, confidence는 0에서 1로 클램프합니다. 모델이 0.0-1.0을 지시받고도 95를 줄 가능성을 코드가 흡수합니다.
마지막으로 로그에서 API 키를 지웁니다.
private redact(s: string): string {
if (!this.apiKey) return s;
return s.split(this.apiKey).join('***');
}HTTP 클라이언트 에러 메시지에 요청 헤더가 섞여 나오는 경우가 있어서 경고 로그를 남기기 전에 한 번 걸러냅니다.
12.1 LLM 결과에 걸린 하한과 상한
파싱과 검증을 통과해도 바로 확정되지 않습니다.
if (llmResult.matchedSongId !== null && llmResult.confidence >= 0.5) {
const song = await this.v1.getSongById(input.channelId, llmResult.matchedSongId);
if (song) {
const result = this.finalize(
tier,
classification.reason,
threshold,
song,
Math.min(0.95, llmResult.confidence),
trace,
v1Result.candidates,
);하한이 0.5입니다. 프롬프트의 confidence 가이드에서 가장 낮은 구간이 “모호한 서술에서 하나 고름: 0.55에서 0.7”이므로 0.5 하한은 그 구간을 살리면서 그 아래는 자릅니다.
상한이 0.95입니다. Math.min(0.95, ...)가 있기 때문에 LLM은 아무리 확신해도 1.0을 받을 수 없습니다. 결정론적 경로만이 1.0에 도달할 수 있고 모델의 자기 확신은 설계상 그보다 낮게 취급됩니다.
그리고 채택 직전에 getSongById로 DB를 한 번 더 조회합니다. 채널 스코프까지 걸어서 다시 읽으므로 노래책 스냅숏을 만든 뒤 곡이 삭제됐더라도 존재하지 않는 곡으로 신청이 들어가지 않습니다.
매칭에 완전히 실패해도 결과에 정보가 남습니다.
return {
matched: false,
candidates: v1Result.candidates,
tier,
reason: classification.reason,
confidence:
v1Result.candidates && v1Result.candidates.length > 0 ? 0.4 : 0,
threshold,
autoAcceptable: false,
trace,
};후보가 있으면 confidence 0.4, 없으면 0입니다. 어느 쪽이든 autoAcceptable은 거짓이라 자동 신청은 안 되지만 0.4와 0의 차이가 “가까웠지만 못 정했다”와 “아예 없었다”를 구분합니다.
13. shadow를 버리고 하이브리드로 갔습니다
v2를 프로덕션에 붙이는 방법으로 처음 잡은 것은 shadow 모드였습니다. v1 결과를 그대로 쓰고 v2는 뒤에서 돌려 비교만 하는 방식입니다. 그런데 이 계획은 붙이기 전에 뒤집혔습니다.
shadow 모드는 안전해 보이지만 부작용이 있습니다. 모든 요청에서 두 경로를 다 돌려야 하므로 DB 부하가 두 배가 되고, 비동기로 뒤에서 돌리면 경합과 promise leak과 타이머 정리 문제가 따라옵니다. 결과를 쓰지 않으므로 티어 라벨도 실제 트래픽 분포와 어긋납니다.
그래서 v1을 먼저 태우고 실패한 것만 v2로 넘기는 하이브리드로 바꿨습니다. 이 구조가 위 문제들을 한꺼번에 지웁니다. v1이 성공하면 v2를 아예 부르지 않으니 정상 경로의 지연도 DB 부하도 그대로이고, 동기 흐름이라 leak도 경합도 없습니다. 그리고 LLM 비용에 자연 상한이 생깁니다. v1이 이미 잡은 요청은 LLM 근처에도 못 갑니다.
실제 코드는 이렇습니다.
// Step 1: v1 직접 호출 (기존 동작과 동일, DB only, ~50ms).
const v1MatchId = await this.tryV1Match(matchChannelId, dto.rawArtist, dto.rawTitle);
if (v1MatchId !== undefined) {
this.metrics.songMatcherV2OutcomesTotal.inc({ outcome: 'v1_match', tier: 'unknown' });
return v1MatchId;
}
// Step 2: v1 실패 → v2 시도 (Tier 1이면 LLM까지 + 5-8s 가능).
const v2Enabled =
(this.config.get<string>('SONG_MATCHER_V2_FALLBACK') ?? 'true') === 'true';
if (!v2Enabled) {
this.metrics.songMatcherV2OutcomesTotal.inc({
outcome: 'v1_no_match_v2_disabled',
tier: 'unknown',
});
return undefined;
}비용을 줄이는 조건이 하나 더 있습니다.
// 랜덤 신청은 백엔드가 노래책에서 곡을 추출하므로 매칭 자체가 불필요. LLM 비용 회피.
if (dto.requestType === 'RANDOM') return undefined;랜덤 신청은 매칭할 대상이 없습니다. 여기에 LLM을 태우면 순수 낭비입니다.
SONG_MATCHER_V2_FALLBACK kill switch는 기본값 true이고 문자열 'true'와 정확히 비교합니다. 환경변수를 false로 바꾸면 v2 경로 전체가 즉시 닫히고 v1만 남습니다. 되돌리는 방법이 환경변수 하나라는 점이 중요합니다. 배포도 롤백도 필요 없습니다.
14. 관측: trace와 메트릭
v2 결과에는 단계별 흔적이 전부 담깁니다.
export interface MatchStepTrace {
step: string;
algorithm:
| 'exact' | 'alias' | 'fuzzy_levenshtein' | 'fuzzy_jamo'
| 'fuzzy_chosung' | 'fuzzy_romanize' | 'lyrics_contains'
| 'lyrics_semantic' | 'global_song_cross_alias' | 'youtube_url'
| 'llm_extract' | 'category' | 'random';
matched: boolean;
songId?: number;
score?: number;
candidates?: Array<{ songId: number; score: number; title: string; artist: string }>;
note?: string;
}각 단계가 성공했든 실패했든 흔적을 남깁니다. 자모 단계는 실패해도 이렇게 적습니다.
note: `no candidate above 0.5 for title="${cleanTitle}"`성공해도 상위 두 점수와 채택 여부를 남깁니다.
note: `top=${top.score.toFixed(3)}${next ? ` next=${next.score.toFixed(3)}` : ''} accept=${!!accept}`LLM 단계는 모델의 이유와 컨텍스트 규모를 함께 적습니다.
note:
(llmResult.reasoning ? `reason="${llmResult.reasoning}" ` : '') +
`channel_songs=${llmCtx.channelSongs.length} ` +
`cross_aliases=${llmCtx.crossAliases?.length ?? 0} ` +
`lyrics_hits=${llmCtx.lyricsHits?.length ?? 0}`,이 세 숫자가 나중에 실제 버그를 잡아냅니다.
관리자 API 세 개가 이 trace를 그대로 노출합니다.
| 엔드포인트 | 용도 |
|---|---|
preview | 단건 dry run, 단계별 trace 반환, DB 변경 없음 |
classify | 매칭 비용 없이 티어 분류 결과만 확인 |
batch | 최대 500건 일괄, tierCounts / matched / autoAcceptable 요약 |
classify 엔드포인트가 따로 있는 것이 실용적입니다. 티어 룰만 고칠 때는 매칭까지 돌릴 이유가 없고 DB도 LLM도 건드리지 않으니 수백 건을 순식간에 돌려볼 수 있습니다.
프로메테우스 메트릭도 다섯 개 붙였습니다.
meloming_song_matcher_v2_outcomes_total{outcome,tier}
// v1_match / v2_rescue / both_no_match / error / v1_no_match_v2_disabled
meloming_song_matcher_v2_latency_seconds{tier,llm_called}
// buckets: 0.05, 0.1, 0.25, 0.5, 1, 2, 4, 8, 15
meloming_song_matcher_v2_llm_calls_total{matched}
meloming_song_matcher_v2_rescues_total{tier}
meloming_song_matcher_v2_llm_tokens_total{type}
// prompt / cached_prompt / completionrescues_total의 도움말 문구가 이 메트릭 세트의 목적을 요약합니다.
help: 'v1 missed but v2 caught, direct measure of v2 improvement',v1이 놓쳤는데 v2가 잡은 건수입니다. 새 시스템을 붙일 때 가장 답하기 어려운 질문이 “이게 실제로 도움이 됐나”인데, 그 답을 카운터 하나로 직접 셉니다. 티어 라벨이 붙어 있어서 어느 티어에서 얼마나 건졌는지까지 갈립니다.
지연 히스토그램 버킷도 0.05초에서 15초까지 넓게 잡혀 있습니다. v1만 도는 케이스는 앞쪽 버킷에, LLM까지 가는 케이스는 뒤쪽 버킷에 쌓입니다. llm_called 라벨이 붙어 있어서 두 분포를 섞지 않고 볼 수 있습니다.
15. 프로덕션에서 잡힌 버그
관측을 촘촘히 깔아둔 보람이 나온 사례가 있습니다.
!신청 내 모습이 보이지 않아처럼 가사를 인용한 신청에서 lyrics_hits=0이 찍혔습니다. 근거를 하나도 받지 못한 모델은 아이유 드라마를 confidence 0.75로 추측했습니다. 그 곡이 채널 노래책에 없으면 신청이 거절되고, 있으면 엉뚱한 곡이 큐에 들어갑니다.
증상이 나타난 자리와 원인이 있던 자리가 다릅니다. 겉으로 드러난 것은 “LLM이 엉뚱한 곡을 골랐다”였지만, 진짜 원인은 LLM에 도달하기 전 가사 검색이 접두사가 붙은 문자열로 조회한 것이었습니다. 가사 본문에 !신청이라는 글자가 있을 리 없으니 히트가 0이 나오고 근거가 사라진 모델은 추측으로 답합니다.
그리고 이 추측에는 confidence 0.75가 붙어 있었습니다. 하한 0.5를 넘으므로 그대로 채택됩니다. 근거가 부족하다고 해서 모델이 낮은 점수를 주지는 않습니다.
이 버그를 찾을 수 있었던 이유는 trace의 note에 lyrics_hits=0이 찍혀 있었기 때문입니다. 결과만 기록했다면 “LLM이 틀렸다”에서 멈췄을 것입니다. 넘어간 근거의 규모까지 적어둔 덕에 범인이 한 단계 앞에 있다는 사실이 드러났습니다.
수정은 한 줄입니다. rawMessage 대신 티어 분류기가 접두사를 떼어낸 payload를 넘깁니다.
같은 파이프라인에 rawMessage와 payload가 둘 다 흐르고 어느 단계는 앞의 것을, 어느 단계는 뒤의 것을 써야 합니다. 타입이 둘 다 string이라 컴파일러는 아무 말도 하지 않습니다. 이런 자리는 잘못 넘겨도 예외가 아니라 0건이라는 조용한 결과로 나타나므로, 넘기는 값이 어느 쪽인지를 호출하는 쪽에서 매번 확인하는 수밖에 없습니다.
16. 마무리
이 파이프라인에서 가장 앞에 놓인 것은 매칭이 아니라 분류입니다. 디스패처의 느슨한 게이트가 명백한 비신청을 쳐내고, 티어 분류기가 실제 로그에서 뽑은 상수 배열로 나머지를 다섯 갈래로 나눕니다. 티어가 정해진 뒤에 갈리는 것은 단계의 순서가 아니라 각 단계가 어느 티어까지 열려 있는가입니다. Tier 3은 v1 캐스케이드에서 끝나고 LLM은 Tier 1에만 열립니다.
확정에 실패한 단계도 결과를 버리지 않습니다. 자모 단계가 채택하지 못한 후보 목록은 교차 별칭, 가사 히트와 함께 그대로 LLM 컨텍스트로 들어갑니다. 각 단계가 판정자이면서 동시에 다음 단계의 증거 수집기입니다.
마지막 판정자에게는 코드로 된 관문을 따로 뒀습니다. 노래책에 없는 곡 ID는 거절되고 confidence는 0에서 1로 클램프되며, 하한 0.5와 상한 0.95를 지난 뒤 채택 직전에 DB를 한 번 더 읽습니다. 그리고 이 경로 전체는 환경변수 하나로 닫힙니다.
시리즈 · 제품에 LLM 넣기
1 / 4LLM을 실제 사용자 흐름에 넣으면서 붙인 판정 기준과 안전장치.
- 1.!신청 사랑하긴 그 긴거 : 개떡같이 말해도 찰떡같이 알아듣는 LLM 기반 신청곡 매칭 로직
- 2.노래 제목과 Musixmatch 트랙 매칭하기 - LLM Agent와 HITL(Human-in-the-Loop)
- 3.노래클립 자동생성 파이프라인 - 방송 녹화, STT, Agent 판정
- 4.Vibe Infra로 장애를 만들고, 장애에서 배우다
시리즈 · 노래책과 신청곡
1 / 5개떡같이 말해도 알아듣고, 키를 바꾸고, 클립으로 남기기까지 노래책을 이루는 조각들.
- 1.!신청 사랑하긴 그 긴거 : 개떡같이 말해도 찰떡같이 알아듣는 LLM 기반 신청곡 매칭 로직
- 2.노래 제목과 Musixmatch 트랙 매칭하기 - LLM Agent와 HITL(Human-in-the-Loop)
- 3.확장 프로그램 없이 브라우저에서 영상 키 조절하기
- 4.유튜브 임베드를 벗어나 직접 재생하기 : 광고 없이 video 태그로 유튜브 영상 서빙하기
- 5.노래클립 자동생성 파이프라인 - 방송 녹화, STT, Agent 판정