유튜브 영상에 나온 여행지를 뽑아내려고 자막까지 읽었다
설명란이 비면 자막을 읽고, AI와 규칙의 역할을 나눈 이야기
#백엔드 #Node #AI #OpenAI #유튜브 #트러블슈팅
유튜브 영상에 나온 여행지를 뽑아내려고 자막까지 읽었다 요즘 강원도 여행 플래너를 하나 만들고 있다. 기능은 한 줄로 설명된다. 유튜브나 블로그 링크를 붙여넣으면 그 안에 나온 장소를 찾아서 일정까지 짜주는 것이다. 말로 하면 간단한데, 실제로는 첫 단계인 "링크에서 장소 이름 알아내기"가 제일 오래 걸렸다. 이 글에서는 그 과정에서 겪은 두 가지를 정리해본다. 하나는 설명란이 비어 있는 영상을 어떻게 처리했는가고, 다른 하나는 AI를 붙였는데 오히려 결과가 나빠졌던 이야기다. 링크 하나 받는 게 전부인 화면이다. 문제는 이 뒤에 있었다. 설명란이 비어 있는 영상이 너무 많았다 처음엔 제목과 설명란만 읽었다. 여행 유튜버들이 설명란에 "0:00 오프닝 / 1:20 백촌막국수" 같은 챕터를 정리해두는 경우가 많아서, 그것만 긁어도 꽤 잘 됐다. 그런데 브이로그는 사정이 달랐다. 설명란에 인사말이랑 협찬 문구만 있고 장소 이름은 한 줄도 없는 영상이 흔하다. 화면에는 분명히 가게 간판이 나오고 말로도 이름을 부르는데, 텍스트로는 아무것도 안 남아 있는 것이다. 그럼 남은 건 하나뿐이었다. 자막을 읽는 것 이다. 유튜브 API로는 자동 생성 자막을 못 가져온다 당연히 유튜브 공식 API에 자막 받는 기능이 있을 줄 알았다. 있긴 있는데, 자막 본문을 내려받는 건 그 영상의 소유자만 할 수 있다. 남의 영상 자막은 목록만 보이고 내용은 안 준다. 그래서 결국 yt-dlp 실행 파일을 서버에 두고 그걸 직접 돌리는 쪽으로 갔다. 교과서적으로는 안 예쁜 선택이다. Node 프로세스가 외부 바이너리를 실행하는 구조라 의존성도 늘고, 그 바이너리가 없거나 망가지면 어떻게 할지도 정해야 한다. 그래도 다른 방법이 없었고, 대신 자막을 못 받아도 서버는 멀쩡히 돌아가게 만드는 걸 조건으로 걸었다. 코드는 이렇게 생겼다. const YTDLP = process.env.YTDLP_BIN || path.join(os.homedir(), '.local/bin/yt-dlp'); const TRANSCRIPT_MAX = 12000; async function fetchYoutubeTranscript(videoId) { if (!fs.existsSync(YTDLP)) return ''; // 없으면 자막 없이 진행 const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'yt-cap-')); const out = path.join(dir, 'cap'); try { await new Promise((resolve) = { execFile(YTDLP, [ '--skip-download', '--write-auto-sub', '--write-sub', '--sub-lang', 'ko,ko-KR', '--sub-format', 'json3', '--no-warnings', '--no-playlist', '-o', out, `https://www.youtube.com/watch?v=${videoId}`, ], { timeout: 45000, maxBuffer: 4 * 1024 * 1024, env: { ...process.env, PATH: `${process.env.PATH || ''}:/usr/bin:/usr/local/bin:/bin` }, }, (err, _stdout, stderr) = { // 일부 언어가 실패해도 받아진 파일은 쓴다 if (err) console.warn('[sns] 자막 일부 실패:', String(stderr || err.message).trim().split('\n').pop()); resolve(); }); }); ... } finally { fs.rmSync(dir, { recursive: true, force: true }); } } 여기서 신경 쓴 게 세 가지다. 먼저 PM2로 띄운 프로세스는 내가 터미널에서 쓰던 PATH를 그대로 물려받지 않는다. 그래서 PATH를 직접 채워서 넘겼다 . 이걸 안 해서 한참 헤맸는데, 터미널에서 손으로 실행하면 잘 되고 서버에서만 안 되는 상황이라 원인을 찾기가 은근 까다로웠다. 그다음은 임시 폴더다. yt-dlp는 자막을 파일로 떨어뜨리기 때문에 mkdtemp로 매번 새 폴더를 만들고, finally에서 통째로 지운다. 안 그러면 /tmp에 쓰레기가 쌓인다. 마지막이 제일 중요한데, 에러가 나도 reject하지 않고 resolve한다 . 이유는 다음 문단에 있다. 실패를 실패로 취급하면 안 됐다 운영하면서 로그에 실제로 이런 게 찍힌다. [sns] 자막 일부 실패: ERROR: Unable to download video subtitles for 'ko': HTTP Error 429: Too Many Requests [sns] 자막 일부 실패: ERROR: [youtube] zzzzzzzzzzz: Video unavailable 처음엔 이걸 에러로 처리해서 분석 전체를 중단시켰다. 그랬더니 자막을 못 받은 영상은 제목이랑 설명란까지 멀쩡히 있는데도 결과가 통째로 안 나왔다. 생각해보면 자막은 있으면 좋은 재료지 필수 재료가 아니다 . 그래서 자막 수집은 성공이든 실패든 항상 resolve하고, 실패하면 빈 문자열을 돌려주는 걸로 바꿨다. 429가 떠도, 영상이 내려가 있어도, yt-dlp 자체가 없어도 분석은 제목과 설명란만으로 계속 간다. 덤으로 자막 수집은 메타 정보 수집이랑 동시에 시작한다. 어차피 서로 기다릴 이유가 없다. const transcriptPromise = fetchYoutubeTranscript(videoId); // 메타랑 같이 시작 // ... 메타 수집 ... return { transcript: await transcriptPromise, ... }; 막상 받아본 자막은 꽤 지저분했다 자막만 붙이면 다 될 줄 알았는데, 자동 생성 자막의 품질이 생각보다 나빴다. 실제로 영상 하나를 재봤더니 자막은 1,067자가 나왔는데 앞부분이 이랬다. เฮ [음악] [음악] [음악] [음악] [음악] [음악] เฮ [음악] [음악] [음악] [음악] [음악] [음악] [음악] [음악] 그런 포차기 때문에 수 있고 그리고 당차원에서 대응 못하는 수도 있고 아니면 후보의 차일 수도 있는 거죠. 여러 가지 문제 브이로그라 배경음악 구간이 길다 보니 절반이 [음악]으로 채워지고, 중간엔 엉뚱한 언어 조각이랑 아예 다른 내용까지 섞여 들어온다. 음성 인식이 만들어낸 텍스트라 어쩔 수 없는 부분이다. 그래서 자막은 후보를 넓히는 용도로만 쓰고, 자막에서 나온 이름을 그대로 믿지는 않는 쪽 으로 방향을 잡았다. 이게 다음 이야기로 이어진다. AI를 붙였더니 결과가 오히려 나빠졌다 장소 이름을 찾는 방법은 두 가지를 같이 쓰고 있었다. 하나는 규칙이다. 챕터, 해시태그, 소제목 같은 데서 패턴으로 이름을 긁고, 관광공사 공공데이터 카탈로그에 있는 이름이 본문에 나오는지 훑는다. 다른 하나는 AI다. 텍스트를 통째로 넘기고 장소 목록을 JSON으로 받는다. 처음엔 둘을 그냥 합쳤다. 후보가 많을수록 좋다고 생각했으니까. 그런데 합치고 나서 결과를 보니 이상한 게 자꾸 섞였다. 원인은 단순했다. AI는 어차피 본문이랑 소제목을 다 읽는다. 거기다 규칙까지 본문을 훑으면 같은 텍스트를 두 번 긁는 셈 이 되는데, 규칙 쪽은 문맥을 모르니까 스쳐 지나간 이름까지 후보로 올린다. 그리고 최종 결과는 상위 20곳만 남기기 때문에, 그 잡음이 진짜 다녀온 곳을 밀어냈다. 그래서 역할을 나눴다. AI가 켜져 있으면 규칙은 확실한 출처만 보탠다. const strongOnly = (c) = c.card || c.chapter || c.title; // 지도 카드, 챕터, 제목 const baseCands = rules.filter((c) = { if (useAi) return strongOnly(c); // AI가 본문·소제목을 읽으므로 규칙은 확실한 것만 return !longText || strongOnly(c) || c.heading || c.source === '해시태그' || c.count = 2; }); // 카탈로그 스캔도 AI 모드에선 기준을 올린다 // 본문에만 나오는 이름은 3회 이상(긴 이름은 2회 이상)일 때만 후보로 if (useAi !inHead (count 3 !(e.key.length = 6 count = 2))) continue; 그리고 AI 호출이 실패하면 소제목이랑 본문 규칙 후보를 다시 꺼내서 보완한다. AI가 죽었다고 결과가 통째로 비면 안 되니까 남겨둔 안전장치다. 그래서 얼마나 달라졌나 말로만 하면 못 믿을 것 같아서, 같은 영상 하나를 두고 세 가지 방식으로 직접 돌려봤다. 브이로그 하나에 챕터가 잘 정리된 영상이다. ===== 규칙만 : 1,522ms, 17곳 육구방앗간, 속초옥수수소금빵, 코끼리분식, 조롱박, 조개줍깅, 카레노카레, 삼포해수욕장, 만동제과, 강릉길감자, 정커피, 칠사당, 새바람이 오는 그늘, 강릉닭강정, 사근진해변, 강릉김밥, 속초 아이파크스위트 호텔앤리조트, 주문진해변 ===== AI 켬 : 4,647ms, 20곳 (위 17곳) + 키사, 노렌, 리토 규칙만으로도 17곳이 나온다. 챕터가 잘 정리된 영상이라 그렇다. 여기에 AI를 켜면 3초를 더 쓰고 3곳을 더 찾는다. 추가된 키사, 노렌, 리토는 전부 카페인데 챕터에 안 적혀 있고 설명란 문장 속에만 있던 이름들이다. 그다음은 아까 말한 잡음 문제다. 역할을 나누기 전 코드와 나눈 뒤 코드를 같은 영상에 돌려서 비교했다. ===== 수정 전 (규칙 전부 + AI 합치기) : 20곳 ... 속초해수욕장 ... ===== 수정 후 (AI 켜지면 규칙은 확실한 출처만) : 20곳 ... 주문진해변 ... 수정 전에만 있던 것: 속초해수욕장 수정 후에만 있던 것: 주문진해변 개수는 똑같이 20곳인데 내용이 하나 바뀌었다. 수정 전에는 본문에 한 번 스쳐 지나간 속초해수욕장 이 자리를 차지했고, 실제로 영상에서 간 주문진해변 이 20위 밖으로 밀려 있었다. 후보를 무작정 늘리면 좋아질 거라는 생각이 틀렸다는 걸 여기서 확인했다. 그리고 이런 것들도 하나씩 쌓였다. 일반 명사가 장소로 잡히는 걸 막는 목록인데, 전부 실제로 걸렸던 단어들이다. const GENERIC_CORES = new Set([ '유명식당', '맛집', '식당', '카페', '해변', '해수욕장', '시장', '호텔', '펜션', '리조트', '전망대', '박물관', '공원', '항구', '역', '터미널', '휴게소', '주차장', '관광지', '여행지', '명소', '숙소', '게스트하우스', '여행', '바다여행', '국내여행', '총정리', '오션뷰', '오션뷰호텔', '코스', '투어', '여행코스', '브이로그', '봄여름가을겨울', '사계절', '하루', '이틀', '주말', ].map(normalize)); "봄여름가을겨울"이나 "주말" 같은 게 왜 여기 있냐면, 영상 제목에 그 단어가 들어 있고 마침 같은 이름의 가게가 공공데이터에 있었기 때문이다. 그래도 오탐은 남는다 여기까지 하고도 완벽하진 않다. 실제 결과 화면을 보면 이런 게 섞여 있다. 세 번째 항목이 관광지로 잡혀 있다. 하수구랑 변기 수리 업체다. 공공데이터에서 못 찾은 이름은 카카오 로컬 검색으로 한 번 더 찾아보는데, 그 과정에서 속초고성양양하수구변기누수탐지설비달인 이라는 업체가 관광지로 딸려 들어왔다. 이름에 지역명이 세 개나 들어 있어서 검색에 걸린 것 같다. 이런 걸 코드로 전부 막는 건 사실상 불가능하다고 봤다. 그래서 방향을 바꿔서, 걸러내는 대신 얼마나 믿을 만한지를 화면에 그대로 보여주기로 했다. 공공데이터에서 정확히 찾은 건 확정, 비슷한 게 있으면 유력, 카카오 검색으로만 나온 건 확인필요로 나눈다. 위 화면에서도 10곳 중 확정은 2곳뿐이고 나머지 8곳엔 "공공데이터에서 못 찾았어요"가 붙어 있다. 숨기는 것보다 이게 낫다고 판단했다. 어차피 자동으로 뽑은 결과를 100% 맞출 수는 없는데, 맞는 척하다가 엉뚱한 데로 안내하는 것보다는 솔직하게 표시하고 사용자가 고르게 하는 편이 덜 위험하다. 정리하면 이번 작업에서 배운 건 두 가지다. 첫째, 외부 도구에 기대야 할 때는 그게 없어도 돌아가는 경로 를 같이 만들어두면 마음이 편하다. yt-dlp가 없어도, 429가 떠도, 영상이 내려가 있어도 분석은 계속된다. 이걸 안 해두면 남의 서비스 상태에 내 서비스가 끌려다닌다. 둘째, AI를 붙일 때 기존 로직을 그대로 두고 결과만 합치면 오히려 나빠질 수 있다. AI가 잘하는 영역과 규칙이 잘하는 영역이 겹치면 겹치는 만큼 잡음이 늘어난다. 합치기 전에 누가 무엇을 담당할지 먼저 나누는 게 순서였다. 다음 글에서는 이 분석이 17초씩 걸리는 바람에 요청 응답 방식을 통째로 갈아엎은 이야기를 써보려고 한다. 비슷하게 오래 걸리는 작업을 API로 만들고 있다면 한 번쯤 같이 고민해볼 만한 내용이다.