Gemini 웹과 API의 현실: 유튜브 분석 구현 Deep Dive
Criti.AI 프로젝트를 진행하며 겪었던 기술적 난관과 이를 해결한 과정입니다.
프로젝트의 핵심 요구사항 중 하나는 'Gemini의 최신 기능을 활용한 유튜브 영상 분석'이었습니다.
Gemini 웹 (https://gemini.google.com/ )에서 유튜브 링크를 입력하면 영상 내용을 요약하고 분석해주는 것을 보고, 이 기능을 저희 서비스에 핵심적으로 활용하고자 했습니다.

처음의 가정은 간단했습니다. "Gemini API도 동일한 기능을 제공할 것이다."
하지만 이 가정은 API의 현실과 문제를 겪게되었습니다.
이 글은 제미나이 웹 서비스와, 공개 API의 '현실' 사이의 간극을 메우고, 분석 시간을 단축시킨 엔지니어링의 과정을 다룹니다.
1. 기대와 첫 번째 벽 - API와 웹의 차이
가장 먼저 시도한 것은 공식 API가 Gemini 웹처럼 유튜브 URL을 직접 처리할 수 있는지 확인하는 것이었습니다.

Gemini API | Google AI for Developers
Gemini Developer API 문서 및 API 참조
- 시도:
generateContentAPI에 텍스트 프롬프트와 함께 유튜브 URL을 전달했습니다.
- 결과: 실패. API는 URL을 '링크'로 인식할 뿐, 해당 URL의 비디오 콘텐츠를 능동적으로 가져와 분석하는 기능은 없었습니다.
- → 제미나이 웹에서는 채팅 내용중에 그냥 링크를 넣으면 자동으로 분석해 주던 것과 다르게, Gemini-flash로 동일한 모델을 쓰더라도, 제공하는 결과가 다르단 것을 알게 되었습니다.
- → Gemini 웹의 유튜브 분석은 Google 내부의 별도 프로세스 및 최적화된 기능을 통해 작동하며, 이는 공개 API의 기본 기능이 아님을 알게 되었습니다
2. 작동은 하지만 실패했던 두 번째 시도 - 멀티모달
다음으로 시도한 방법은 Gemini의 핵심 기능인 '멀티모달'이었습니다. API가 직접 가져오지 못한다면, 영상 파일 자체를 API에 전달하자는 전략이었습니다.
가이드 문서중 동영상 이해 기능과 관련된

동영상 이해 | Gemini API | Google AI for Developers
Gemini API에서 Gemini의 멀티모달 기능을 사용한 빌드 시작하기
를 참고하여
fileData 속성을 활용했습니다.// 초기 실패 코드: GeminiService.ts async analyzeYoutubeVideo(url: string): Promise<YoutubeTrustAnalysis> { // ... const response = await this.ai.models.generateContent({ model: "gemini-1.5-flash", contents: [{ role: "user", parts: [ { text: /* ... (분석 프롬프트) ... */ }, { // "요구사항": 영상 자체(프레임, 오디오)를 분석하라! fileData: { fileUri: url, // 비디오 URL을 파일 URI로 전달 mimeType: "video/*", }, }, ], }], // ... }); // ... }
이 코드는 기술적으로 작동했지만 제품 관점에서는 실패였습니다.
- 치명적인 응답 속도: 20분짜리 영상 분석에 평균 5분이 소요되었습니다.
- 사용자 경험(UX) 붕괴: '분석' 버튼을 누르고 5분간 로딩 화면을 봐야 하는 서비스는 아무도 사용하지 않을 것이라고 생각하였습니다.
- 예측 불가능한 비용: 비디오 프레임 전체를 토큰으로 처리하는 비용은 텍스트 대비 수천 배에 달했습니다.
3. 문제의 재정의 - '픽셀'이 아닌 '의미'를 분석
이 문제를 맞이한 시점에서, 어떻게 문제를 해결할지에 대해 고민을 시작했습니다. 아무리 캐시 등 최적화를 진행하여도, 메인 분석 엔진인 제미나이 측에서의 응답이 오래 걸린다면 사용자 경험은 매우 나쁠것이라 생각하였고, 보다 본질적인 해결 방법을 고민하였습니다.
이때 요구사항을 구현하는 것에서 한발짝 물러서서, 문제를 해결하는 엔지니어로서 관점을 가지고 여러 방법을 생각해 보았고,
"사용자가 정말 원하는 것은 영상의 '모든 픽셀'에 대한 분석이 아니고, 유튜브 영상의 '논리적 오류, 편향, 광고성' 등 의미론적인 정보다."
라는 정리를 하게 되었습니다. 그리고 이 핵심 정보가 영상의 어디에 들어있을지를 구분해 보았습니다
- 발화 내용 (What is said?) → 자막 (Transcript)
- 맥락 정보 (What is the context?) → 메타데이터 (Title, Description 등)
이러한 과정을 통해, 무거운 비디오 문제를 가벼운 텍스트 문제로 치환하는 것이 이 문제의 본질적인 해결책이라는 가설을 세웠습니다.
4. 공식 API에 없는 자막 추출하기
가설은 세우고, 문제를 해결할 수 있을 것이라 생각했지만, '자막'에서 다시 한번 벽에 부딪혔습니다.
Google의 공식 API는 제목, 조회 수, 설명 등 유용한 메타데이터를 제공했지만, 정작 핵심인 자막 추출(Transcript API) 기능은 지원하지 않았습니다.
API Reference | YouTube Data API | Google for Developers
YouTube Data API를 사용하면 YouTube 웹사이트에서 일반적으로 실행되는 기능을 자체 웹사이트나 애플리케이션에 통합할 수 있습니다. 다음 섹션에서는 API를 사용하여 가져올 수 있는 다양한 유형의 리소스를 식별합니다. API는 이러한 리소스를 여러 개 삽입, 업데이트 또는 삭제하는 메서드도 지원합니다.
가이드의 자막에 해당되는 부분은, 다른 사람의 영상이 아닌, 오직 자신의 영상에 대한 API였습니다.

공식적으로 자막 정보를 제공하지 않는다는 새로운 문제에 대해 오픈소스로 눈을 돌렸고,
강력한 대안인 yt-dlp를 발견했습니다.
사실,
yt-dlp 외에도 유튜브 자막을 가져오는 여러 오픈소스 라이브러리들이 존재했습니다. 서버 환경과 동일하게
npm으로 설치해 사용할 수 있는 더 편리한 라이브러리도 있었습니다. 하지만 공식 기능이 아닌 우회로를 택하는 오픈소스 도입에서, 저는 '편의성'보다 '지속성'과 '신뢰성'을 핵심 가치로 삼았습니다. yt-dlp는 거대한 커뮤니티를 기반으로 활발히 유지보수되고 있었으며, 이것이 잦은 변경이 발생하는 유튜브 플랫폼에 대응하기 가장 적합하다고 판단했습니다.
yt-dlp를 express 기반 서버에서 작동시키기 위해,
Node.js의
child_process.exec를 통해 서버에서 yt-dlp 커맨드를 직접 실행하여 공식 API가 막은 길을 우회하는 방식으로 문제를 해결했습니다async extractTranscript(videoId: string): Promise<VideoTranscript> { // 1. 비디오 ID 검증 (Command Injection 방지) if (!this.validateVideoId(videoId)) { throw new Error("유효하지 않은 비디오 ID입니다."); } console.log(`📝 자막 추출 시작: ${videoId}`); // 임시 디렉토리 경로 const tmpDir = "/tmp"; const subtitlePath = `${tmpDir}/${videoId}`; try { // 2. yt-dlp 명령어 구성 (자막을 파일로 저장) const args = [ "--skip-download", "--write-auto-subs", "--write-subs", "--sub-lang", "ko", "--sub-format", "json3", "--output", subtitlePath, "--no-warnings", `https://www.youtube.com/watch?v=${videoId}`, ]; console.log("yt-dlp 실행 중..."); // 3. execFile 사용 try { await execFileAsync(this.ytDlpPath, args, { maxBuffer: 1024 * 2048, // 2MB timeout: 30000, // 30초 타임아웃 }); console.log("yt-dlp 실행 완료"); } catch (execError: any) { // 부분 실패(exit code 1)는 자막 파일이 하나라도 생성되었으면 OK console.warn( "yt-dlp 부분 실패 (일부 자막은 성공했을 수 있음):", execError.message ); } // 4. 생성된 자막 파일 찾기 const possibleFiles = [ `${subtitlePath}.ko.json3`, `${subtitlePath}.en.json3`, `${subtitlePath}.ko-KR.json3`, `${subtitlePath}.en-US.json3`, ]; let subtitleFile: string | null = null; const fs = await import("fs"); // fs는 async import 권장 for (const file of possibleFiles) { if (fs.existsSync(file)) { subtitleFile = file; console.log(`자막 파일 발견: ${file}`); break; } } if (!subtitleFile) { // ... (자막 파일 못찾을 시 에러 처리) throw new Error( "자막 파일을 찾을 수 없습니다. 이 비디오에는 자막이 없을 수 있습니다." ); } // 5. 자막 파일 읽기 및 파싱 const subtitleContent = fs.readFileSync(subtitleFile, "utf-8"); const transcriptData = JSON.parse(subtitleContent); // 6. 파일 정리 (생략) // ... // 7. 자막 세그먼트 변환 const segments: TranscriptSegment[] = []; let fullText = ""; if (transcriptData.events) { for (const event of transcriptData.events) { if (event.segs) { const text = event.segs.map((s: any) => s.utf8 || "").join(""); if (text.trim()) { segments.push({ text: text.trim(), start: event.tStartMs / 1000, // 밀리초 -> 초 duration: (event.dDurationMs || 0) / 1000, }); fullText += text.trim() + " "; } } } } // ... (에러 처리 및 반환) return { segments, fullText: fullText.trim(), language: subtitleFile.includes(".ko") ? "ko" : "en", }; } catch (error) { // ... (전체 에러 처리) throw error; } }
-skip-download: 영상/오디오 다운로드 없이 텍스트(자막)만 초고속으로 추출. (3분 -> 1~2초)
-sub-format json3: 타임스탬프가 포함된 구조적 JSON으로 데이터를 받음
-output: 결과를 임시 파일로 저장하여exec의 stdout 버퍼 제한을 피하고 안정적으로 처리
5. 출력 형식 오류 문제 해결
이제 자막과 메타데이터(YouTube Data API v3 활용)라는 텍스트 재료를 준비했고,
프롬프트와 재료들을 조합하여 Gemini API에 전달했습니다.
다음 문제는 '안정성'이었습니다. AI가 일관된 JSON 형식으로 응답하도록 프롬프트에
REQUIRED JSON OUTPUT FORMAT을 명시했습니다. 하지만 프롬프트 규칙을 명시했음에도 프롬프트와 자막의 길이가 길어서 그런지, AI는 종종 규격에 미세하게 어긋나는 텍스트(예: 후행 쉼표, 주석 포함)를 반환했습니다.
JSON.parse()는 이런 사소한 오류에도 즉시 예외를 발생시켰고, 이는 서비스 장애로 이어졌습니다.이 문제를 해결하기 위해, 먼저 Gemini API의 공식 '구조화된 출력(Structured Output)' 기능을 검토했습니다.

구조화된 출력 | Gemini API | Google AI for Developers
Gemini API를 사용하여 구조화된 출력을 생성하는 방법 알아보기
공식문서에 따르면
responseSchema를 구성하여 모델이 항상 JSON 스키마에 맞는 응답을 하도록 강제할 수 있었습니다.// Gemini의 구조화된 출력 시도 const response = await ai.models.generateContent({ model: "gemini-2.5-flash", contents: /* ... */, config: { responseMimeType: "application/json", responseSchema: { /* ... (복잡한 Criti.AI 스키마) ... */ }, }, });
하지만 이 기능을 테스트하는 과정에서 또 다른 현실의 문제에 부딪혔습니다. 해당 방식을 적용했음에도 계속 원인을 알 수 없이 오류 코드가 발생하였고,
원인을 찾던 중 공식 가이드 가장 하단에는 다음과 같은 내용을 찾을 수 있었습니다.

Criti.AI가 요청하는 JSON은 타임라인별 분석 하이라이트(
timelineHighlights) 배열을 포함하는 등, 중첩되고 복잡한 구조를 가지고 있었습니다. 이로 인해
responseSchema를 적용하려 할 때마다 400 오류가 발생하거나 응답 생성이 실패했습니다.결국, Criti.AI 서비스의 복잡한 스키마에는 이 기능을 적용할 수 없다고 판단했습니다.
이 불완전함을 API 레벨에서 해결할 수 없다면, 애플리케이션 레벨에서 방어해야 했습니다.
그리고 이 문제를 해결하기 위해 또 다른 오픈소스, jsonrepair를 도입했습니다.
해당 오픈소스 또한, json관련해서 가장 널리 사용되는 오픈소스를 선택하여 적용하였습니다
// GeminiService.ts - parseYoutubeAnalysisResult() import { jsonrepair } from 'jsonrepair'; // 핵심 라이브러리 async parseResult(rawResponse: string): Promise<YoutubeTrustAnalysis> { try { // AI가 만든 json... 마크다운 제거 const jsonSlice = this.extractJsonObject(rawResponse); // 깨진 JSON을 복구 // 예: { "key": "value", } (후행 쉼표) -> { "key": "value" } const repairedJson = jsonrepair(jsonSlice); // 안전하게 파싱 const parsed = JSON.parse(repairedJson); // 후처리 (빈 값 필터링 등) return this.postProcessHighlights(parsed); } catch (error) { // ... (에러 처리 및 로깅) } }
jsonrepair의 도입은 JSON.parse()가 실패할 수 있는 수많은 엣지 케이스를 AI의 출력을 교정하여 처리함으로써, 시스템의 안정성을 크게 끌어올릴 수 있었습니다.엔지니어링의 가치
'요구사항'을 넘어 '진짜 문제'를 정의하고 해결한 결과는 다음과 같습니다
항목 | ❌ 초기 멀티모달 방식 | ✅ 개선된 텍스트 파이프라인 |
속도 | 평균 5분 | 평균 1분 |
비용 | 예측 불가 (비디오 토큰) | 수십 분의 일로 절감 |
안정성 | AI 응답 변동성 큼 | jsonrepair를 통한 안정적인 JSON 응답 |
평균 1분이라는 분석 시간은 5분에 비해서는 80% 시간 단축이라는 성과이지만, 여전히 짧지는 않은 시간입니다. 허나 1분이라는 분석 시간동안, 짧은 미디어 리터러시 퀴즈를 진행할 수 있도록 구성하여, 사용자들이 분석 시간으로 인한 이탈율을 줄이도록 설계하였습니다.
이번 경험을 통해 기술을 '적용'하는 개발자를 넘어, 문제를 해결하는 엔지니어로서 다음과 같은 교훈을 얻었을 수 있었습니다.
- 현상을 넘어 본질을 묻습니다. "느리다"는 현상에 매몰되지 않고, "사용자가 진짜 원하는 것은 픽셀이 아니라 의미"라는 본질을 파고들어 문제를 정의하고 해결할 수 있었습니다.
- 막히면, 길을 찾습니다. 공식 API가 벽이라고 생각하고 멈추는 것이 아니라,
yt-dlp라는 또 다른 방법을 찾아내어 문제를 돌파하는 집요함이 필요합니다.
- Edge Case에서 완성도가 결정됩니다.
JSON.parse()에서 멈추지 않고,jsonrepair로 AI의 실패까지 방어하는 시스템을 구축했을 때 비로소 기능이 완성되었습니다.