튜토리얼
Twelve Labs API를 사용하여 적합한 인플루언서 파트너를 찾는 방법

김미란
이 튜토리얼에서는 Twelve Labs Search API를 사용해 비디오 제목이나 설명에 브랜드 검색어가 없더라도, 영상 속에서 관련 브랜드나 제품을 언급하는 유튜브 인플루언서를 찾아내고 그 결과를 채널별로 일목요연하게 정리해 주는 "Who Talked About Us" 애플리케이션의 구축 과정을 단계별로 알아봅니다.
이 튜토리얼에서는 Twelve Labs Search API를 사용해 비디오 제목이나 설명에 브랜드 검색어가 없더라도, 영상 속에서 관련 브랜드나 제품을 언급하는 유튜브 인플루언서를 찾아내고 그 결과를 채널별로 일목요연하게 정리해 주는 "Who Talked About Us" 애플리케이션의 구축 과정을 단계별로 알아봅니다.

목차
뉴스레터 구독하기
뉴스레터 구독하기
영상 이해 분야의 최신 기술 업데이트, 튜토리얼 및 인사이트를 받아보세요.
영상 이해 분야의 최신 기술 업데이트, 튜토리얼 및 인사이트를 받아보세요.
AI로 영상을 검색하고, 분석하고, 탐색하세요.
2024. 1. 16.
16분
링크 복사하기
소개
뷰티 산업의 마케팅 전문가로서 인플루언서 파트너십 분야에서 수년간 경험을 쌓으며, 브랜드에 가장 적합한 YouTube 또는 TikTok 인플루언서를 선정하는 데 있어 귀중한 교훈을 얻었습니다. (네, 저는 마케팅 전문가에서 소프트웨어 엔지니어로 전향했습니다 😉) 가장 성공적인 협업은 *이미* 귀사의 제품이나 브랜드에 진정 어린 관심을 가지고 있는 인플루언서들과 자연스럽게 일어나는 경향이 있습니다. 예를 들어, "A"라는 브랜드를 홍보하는 경우, 사전 접촉 없이도 "A"를 언급한 인플루언서들을 발견할 수 있습니다. 이들에게 연락을 취하면 대개 협업에 매우 긍정적인 반응을 보입니다.
하지만 이러한 인플루언서들을 찾아내는 것은 다소 까다로울 수 있습니다. 특히 동영상 제목이나 설명에 브랜드명이 명시적으로 언급되지 않았을 때는 더욱 그렇습니다. 이는 저 스스로도 겪었던 페인 포인트(pain point)이기도 합니다. 예를 들어, 한 유튜버가 브랜드명을 밝히지 않고 "겨울철 필수 아이템 탑 10"과 같은 동영상에서 여러분의 제품을 소개했다면, 일반적인 YouTube 키워드 검색으로는 이를 찾아낼 수 없습니다.
이러한 상황에서 Twelve Labs API를 활용한 'Who Talked About Us(누가 우리 이야기를 했을까)' 서비스는 게임 체인저가 될 수 있습니다. 일반적인 YouTube나 TikTok 검색과 달리, 이 API는 깊이 있는 맥락적 동영상 검색을 지원합니다. 단순히 제목이나 설명에 의존하지 않고, 동영상에서 움직임, 오브젝트, 인물, 소리, 화면 상의 텍스트, 음성 등 다양한 요소를 추출합니다. "MAC 골드 하이라이터 사용법"과 같이 키워드나 구체적인 묘사를 입력하는 것만으로, 여러분의 브랜드나 제품을 언급하는 동영상과 채널을 찾아내고 해당 언급이 등장하는 정확한 순간까지 포착할 수 있습니다.
이를 통해 연락을 취할 인플루언서 리스트는 물론, 이들이 언급한 제품 정보와 맥락에 대한 세부 정보까지 확보할 수 있습니다. 잠재적 인플루언서들에 대한 이러한 깊이 있는 인사이트는 이들과 더욱 효과적으로 소통하고 의미 있는 관계를 구축할 수 있는 강력한 무기가 됩니다.
자, 이제 Twelve Labs API를 활용하여 이러한 강력한 앱을 만드는 과정을 단계별로 함께 살펴보겠습니다!
사전 준비 단계
Twelve Labs API의 세계로 뛰어들기 전에, 먼저 회원 가입을 하고 API 키를 생성해야 합니다. Twelve Labs Playground를 방문하여 가입하고 API 키를 생성하세요. 가입 시 최대 10시간 분량의 동영상 콘텐츠를 인덱싱할 수 있는 무료 크레딧이 제공됩니다!
이 앱에 필요한 모든 파일이 담긴 저장소는 Github에서 확인하실 수 있습니다.
이 앱은 JavaScript, Node, React, React Query를 사용하여 제작되었습니다. 이러한 기술들에 익숙하시다면 도움이 되겠지만, 잘 모르시더라도 걱정하지 마세요. 이 글에서 가장 중요한 포인트는 Twelve Labs API가 무엇이고, 이 앱이 이를 어떻게 활용하는지 배우는 데 있으니까요.
1단계 - 컴포넌트 설계
이 앱은 React로 구축되었으며, React의 핵심은 요소들을 재사용 가능한 컴포넌트로 세분화하는 데 있습니다. 따라서 컴포넌트를 설계하는 것부터 시작했고, 물론 이 과정에서 여러 번의 수정을 거쳤습니다.

상위 수준에서 이 앱은 ExistingIndexForm, IndexForm, VideoIndex로 구성됩니다. IndexForm은 사용자가 새로운 인덱스를 생성할 수 있는 간단한 폼입니다. ExistingIndexForm은 사용자가 이 앱을 통해 이전에 이미 생성해 둔 인덱스의 ID를 제출할 수 있는 또 다른 간단한 폼입니다. VideoIndex는 인덱스의 상세 정보를 가져오는 호출이 이루어지고 VideoComponents가 위치하는 곳입니다.
VideoComponents는 동영상과 관련된 모든 컴포넌트로 구성됩니다. UploadYoutubeVideo 컴포넌트는 동영상 다운로드 및 인덱싱을 지원하며, 각 동영상 작업의 진행 상태를 보여줍니다. VideoList는 인덱스의 동영상들을 단순히 렌더링하며, PageNav를 사용해 페이지당 12개씩 가져와 보여줍니다. SearchForm은 사용자 입력(검색 쿼리)을 받아 검색 API 호출로 전달하는 동영상 검색 처리 컴포넌트입니다. SearchResults는 검색 결과와 동영상을 가져오는 API를 호출한 다음, 이를 인플루언서별로 정리합니다. 마지막으로 SearchResult는 각 검색 결과를 사용자가 보기 편한 방식으로 보여줍니다.
2단계 - 서버 및 API hook 구축
Server.js와 apiHooks.js는 Twelve Labs API 및 ytdl-core와 같은 다른 라이브러리로부터의 모든 API 호출을 관리하는 파일입니다. Server.js에는 Twelve Labs API 및 기타 API 호출을 처리하는 모든 엔드포인트가 위치합니다. apiHooks.js는 상태 관리, 캐시, 데이터 페칭(fetching)을 위한 커스텀 React Query hook 모음입니다. 서버, 아니 사실상 이 앱 전체의 핵심 기능은 Twelve Labs API를 활용한 동영상 검색이므로, 이를 어떻게 사용하는지 자세히 살펴보겠습니다.
Twelve Labs API 사용을 위한 4단계
첫 번째 단계는 동영상을 위한 인덱스를 생성하는 것입니다. 그 다음 이 인덱스에 동영상을 업로드합니다. 그런 다음 동영상 메타데이터를 업데이트하여 각 동영상에 YouTube 채널과 URL을 추가합니다 (이 단계는 본 앱에 특화된 단계이며 일반적으로는 선택 사항입니다). 마지막으로 동영상 검색을 시작할 준비가 끝납니다. 이 앱에서는 Twelve Labs 및 기타 API 호출과 동영상 업로드 관련 기능을 모두 server.js 파일에 정리해 두었습니다.
환경 설정
루트 디렉토리에 .env 파일을 생성하고 필요한 값을 업데이트합니다. 아래 내용을 그대로 복사하여 붙여넣고 값을 커스텀하시면 됩니다.
.env
REACT_APP_API_URL=https://api.twelvelabs.io/v1.1 REACT_APP_API_KEY=<YOUR API KEY> REACT_APP_SERVER_URL=<YOUR SERVER URL> REACT_APP_PORT_NUMBER=<YOUR PORT NUMBER>
REACT_APP_API_URL: 이 앱은 v1.1을 지원합니다.
REACT_APP_API_KEY: 이전 단계에서 생성한 API Key를 저장합니다.
REACT_APP_SERVER_URL: "http://localhost"와 같은 형식이 될 수 있습니다.
REACT_APP_PORT_NUMBER: 사용하고자 하는 포트 번호를 설정합니다 (예: 4001).
파일 내에서 환경 변수 값에 접근하려면 process.env를 활용할 수 있습니다. 예를 들어, server.js 파일에서는 process.env.REACT_APP_APP_URL을 사용하여 API_URL에 접근하고 이를 저장할 수 있습니다. 다음 예시는 이를 구현하는 방법을 보여줍니다.
/** 상수 정의 및 TL API 엔드포인트 구성 */ const TWELVE_LABS_API_KEY = process.env.REACT_APP_API_KEY; const API_BASE_URL = process.env.REACT_APP_API_URL; const TWELVE_LABS_API = axios.create({ baseURL: API_BASE_URL }); const PORT_NUMBER = process.env.REACT_APP_PORT_NUMBER;
1단계. 인덱스 생성
인덱스는 동영상을 업로드, 인덱싱 및 검색할 수 있는 일종의 동영상 라이브러리입니다. 날짜, 주제 또는 YouTube 채널별로 자신만의 인덱스를 생성할 수 있습니다. 인덱스를 생성하려면 메서드를 'POST'로, 엔드포인트를 'https://api.twelvelabs.io/v1.1/indexes'로 설정한 뒤 헤더를 추가하고 engine_id, index_options, index_name과 같이 필요한 데이터를 제공하면 됩니다. index_options의 경우 네 가지 옵션 중 일부를 선택할 수 있습니다. 이 앱에서는 네 가지 옵션이 모두 포함되었습니다.
💡인덱스 생성에 대한 자세한 내용은 API 레퍼런스를 확인하세요.
/** 인덱스를 생성합니다 */ app.post("/indexes", async (request, response, next) => { const headers = { "Content-Type": "application/json", "x-api-key": TWELVE_LABS_API_KEY, }; const data = { engine_id: "marengo2.5", index_options: ["visual", "conversation", "text_in_video", "logo"], index_name: request.body.indexName, }; try { const apiResponse = await TWELVE_LABS_API.post("/indexes", data, { headers, }); response.json(apiResponse.data); } catch (error) { console.error("서버 측 에러:", error); response.json({ error }); } });
2단계. YouTube URL을 통해 동영상 업로드하기
이 앱은 채널 ID, 재생목록 ID 또는 아래와 같은 형식의 URL 객체 배열이 포함된 JSON 파일을 통해 YouTube 동영상을 대량으로 업로드하는 기능을 지원합니다.
example.json
[ { "url": "VIDEO URL" }, { "url": "VIDEO URL" } ... ]
💡YouTube URL을 통한 동영상 업로드는 현재 Twelve Labs API v1.2에서 지원됩니다. 이 앱은 v1.1을 사용하므로, YouTube에서 동영상을 다운로드할 수 있게 해주는 라이브러리인 ytdl-core를 활용해 직접 이 기능을 구현하고 있습니다.
따라서 전체 프로세스는 제공된 YouTube URL에서 동영상을 다운로드하여 이를 Twelve Labs API에 제출하고 이를 인덱싱하는 과정으로 진행됩니다. 이 동작은 server.js의 "/download" 엔드포인트에 구현되어 있습니다. 먼저 요청 바디(body)에서 동영상 데이터와 인덱싱 정보를 추출합니다. 그런 다음 동영상을 청크(chunk) 단위로 다운로드하고, 안전한 파일명을 위해 제목을 정제(sanitize)한 뒤 인덱싱을 위해 제출합니다. 모든 동영상이 다운로드되고 인덱싱되면, 서버는 작업(task) ID와 인덱스 ID를 응답으로 보냅니다. 각 단계를 세분화하여 자세히 살펴보겠습니다.
1 - 요청에서 정보 추출하기
첫 번째 단계는 요청의 바디(body)에서 동영상 데이터와 인덱싱 정보를 추출하는 것입니다. 전체 동영상 수, 처리된 동영상 수, 그리고 동영상 다운로드 및 인덱싱을 위한 청크 크기(이 앱의 경우 5로 설정)를 추적할 변수들을 구성합니다. 또한 동영상 인덱싱 프로세스의 응답들을 저장할 배열을 초기화합니다.
/** 분석을 위해 동영상을 다운로드 및 인덱싱하고, 작업 ID와 인덱스 ID를 반환합니다 */ app.post( "/download", bodyParser.urlencoded(), async (request, response, next) => { try { // 1단계: 요청에서 동영상 데이터 및 인덱스 정보 추출 const jsonVideos = request.body.videoData; const totalVideos = jsonVideos.length; let processedVideosCount = 0; const chunk_size = 5; let videoIndexingResponses = []; console.log("동영상 다운로드 중...");
2 - 청크 단위로 동영상 다운로드
다음 단계는 동영상을 청크 단위로 다운로드하는 것입니다. 각 청크에 대해 동영상 데이터를 반복 실행하면서 ytdl-core 라이브러리를 사용해 제공된 URL에서 동영상을 다운로드합니다. 동영상 제목은 안전한 파일명을 만들기 위해 특수문자 등을 제거하여 정제됩니다. 동영상 다운로드가 완료되면 그 진행 상황이 로그에 기록됩니다.
💡여기서 다운로드된 동영상이 'videos' 폴더에 저장되도록 videoPath를 설정합니다.
// 2단계: 청크 단위로 동영상 다운로드 for (let i = 0; i < totalVideos; i += chunk_size) { const videoChunk = jsonVideos.slice(i, i + chunk_size); const chunkDownloadedVideos = []; // 현재 청크의 각 동영상 다운로드 await Promise.all( videoChunk.map(async (videoData) => { try { // 다운로드할 동영상의 안전한 파일명 생성 const safeName = sanitize(videoData.title); const videoPath = `videos/${safeName}.mp4`; // 제공된 URL에서 동영상 다운로드 const stream = ytdl(videoData.url, { filter: "videoandaudio", format: ".mp4", }); await streamPipeline(stream, fs.createWriteStream(videoPath)); console.log(`${videoPath} -- 다운로드 완료`); chunkDownloadedVideos.push({ videoPath: videoPath, videoData: videoData, }); } catch (error) { console.log(`${videoData.title} 다운로드 중 에러 발생`); console.error(error); } }) );
3 - 인덱싱을 위한 동영상 제출
동영상 청크 다운로드가 완료되면, 이 다운로드된 동영상들을 인덱싱을 위해 제출합니다. 모든 인덱싱 작업이 완료될 때까지 대기하며 인덱싱 제출 진행 상황을 기록합니다.
// 3단계: 다운로드된 동영상을 인덱싱을 위해 제출 console.log( `인덱싱을 위한 동영상 제출 중 | 청크 ${ Math.floor(i / chunk_size) + 1 }` ); const chunkVideoIndexingResponses = await Promise.all( chunkDownloadedVideos.map(async (chunkDownloadedVideo) => { console.log( `인덱싱을 위해 ${chunkDownloadedVideo.videoPath} 제출 중...` ); const indexingResponse = await indexVideo( chunkDownloadedVideo.videoPath, request.body.index_id ); // indexingResponse에 videoData 추가 indexingResponse.videoData = chunkDownloadedVideo.videoData; return indexingResponse; }) ).catch(next); console.log("청크에 대한 인덱싱 제출 완료 | 작업 ID 목록:"); processedVideosCount += videoChunk.length; console.log( `총 ${totalVideos}개 비디오 중 ${processedVideosCount}개 처리 완료` ); videoIndexingResponses = videoIndexingResponses.concat( chunkVideoIndexingResponses ); await new Promise((resolve) => setTimeout(resolve, 1000)); }
이어서 indexVideo 함수도 가볍게 살펴보겠습니다. 동영상 인덱싱 과정 자체는 꽤 명료합니다. 전형적인 API 호출과 마찬가지로, Twelve Labs API에 POST 요청을 보내어 인덱싱 작업을 시작합니다. 이 요청에는 몇 가지 필수 매개변수가 포함됩니다. 대상 인덱스를 지정하는 index_id, 동영상 데이터 스트림 파일인 video_file, 그리고 언어 설정(영어의 경우 'en')입니다.
💡동영상 인덱싱 작업 생성에 대한 자세한 내용은 API 레퍼런스를 참고하세요.
/** 다운로드된 동영상을 받아 인덱싱 프로세스를 시작합니다 */ const indexVideo = async (videoPath, indexId) => { const headers = { headers: { accept: "application/json", "Content-Type": "multipart/form-data", "x-api-key": TWELVE_LABS_API_KEY, }, }; let params = { index_id: indexId, video_file: fs.createReadStream(videoPath), language: "en", }; const response = await TWELVE_LABS_API.post("/tasks", params, headers); return await response.data; };
이 호출에서 반환되는 response.data는 아래와 같은 작업(task) ID를 리턴합니다. 이전 코드 스니펫에서 보셨듯이 각 작업 ID는 indexingResponse에 저장됩니다.
response.data
{ "_id": "6527732e23c1347ffbe3a802" }
다만, 우리는 나중에 각 동영상의 메타데이터에 videoData를 추가해 주어야 하므로, response.data 위에 videoData를 인입하여 최종 indexingResponse를 구성합니다. 최종 응답의 구조는 다음과 같으며, 이는 videoIndexingResponses 배열에 계속 결합(concat)됩니다.
videoIndexingResponses
[ { _id: '6527732e23c1347ffbe3a802', videoData: { url: 'https://www.youtube.com/watch?v=WuWnt2NHJxk&list=PLI8sddTC_LM0SG6JMYmrbrHZy7j5zUjMA&index=4&pp=iAQB', title: 'Everyday Makeup Using ONLY 3 Products !!', authorName: 'Smitha Deepak', thumbnails: [Array] } }, … ]
4 - 작업 ID와 함께 응답하기
모든 동영상 다운로드 및 인덱싱 제출이 완료되면 해당 작업 ID 객체(위의 배열)와 인덱스 ID를 클라이언트에 응답으로 반환합니다.
// 4단계: 인덱싱 작업들의 작업 ID 및 인덱스 ID 반환 console.log( "모든 동영상에 대한 인덱싱 제출 완료. 작업 ID 목록:" ); console.log(videoIndexingResponses); response.json({ taskIds: videoIndexingResponses, indexId: request.body.index_id, }); } catch (error) { next(error); } } );
3단계. 동영상 메타데이터 업데이트
일반적으로 이 단계는 선택 사항일 수 있지만, 저희 앱에서 유튜브 URL을 포함하고 채널 및 인플루언서별로 동영상을 보여주기 위해 꼭 필요합니다.
앱에서 동영상이 표시되는 방식

보시다시피, 앱은 각 동영상마다 연한 녹색 타원 모양의 필(pill) 디자인 안에 채널명을 보여줍니다. 또한 동영상은 제3자 서버 URL 대신 YouTube URL을 활용해 React Player를 통해 재생됩니다. 동영상의 기본 메타데이터에는 채널명과 YouTube URL이 포함되어 있지 않기 때문에, 각 동영상의 메타데이터에 이 정보들을 직접 넣어주어야 합니다.
다음은 기본 상태의 동영상 메타데이터 예시입니다.
"metadata": { "duration": 188.6, "filename": "3 MINUTE MAKEUP THAT WILL LAST ALL DAY.mp4", "fps": 25, "height": 720, "size": 40566200, "video_title": "3 MINUTE MAKEUP THAT WILL LAST ALL DAY.mp4", "width": 1280, }
여기에 새로운 키-값 쌍을 추가하거나 기존 메타데이터를 자유롭게 업데이트할 수 있습니다. 이 앱에서는 메타데이터에 세 가지 데이터를 추가할 예정입니다. 바로 1) author (채널명), 2) youtube url, 그리고 3) 이 앱을 통해 업로드된 동영상인지를 식별하기 위한 불리언(boolean) 값인 whoTalkedAboutUs입니다.
이 작업은 server.js 파일 내 동영상 메타데이터 업데이트를 처리하는 엔드포인트에서 제어됩니다. 여기서는 업데이트할 데이터를 담아 PUT 요청을 보내는 것을 확인하실 수 있습니다.
💡동영상 정보 업데이트에 대한 상세 기술 규격은 API 레퍼런스를 확인하세요.
/** 동영상 메타데이터를 업데이트합니다 */ app.put("/update/:indexId/:videoId", async (request, response, next) => { const indexId = request.params.indexId; const videoId = request.params.videoId; const data = request.body; const headers = { "Content-Type": "application/json", "x-api-key": TWELVE_LABS_API_KEY, }; try { const apiResponse = await TWELVE_LABS_API.put( `/indexes/${indexId}/videos/${videoId}`, data, { headers } ); response.json(apiResponse.data); } catch (error) { return next(error); } });
이제 이 로직이 UploadYouTubeVideo.js 파일 안에서 구체적으로 어떻게 구현되어 있는지 살펴보겠습니다.
UploadYouTubeVideo.js (190 - 223행)
async function updateMetadata() { const updatePromises = completeTasks.map(async (completeTask) => { const matchingVid = taskVideos.find( (taskVid) => `${sanitize(taskVid.title)}.mp4` === completeTask.metadata.filename ); if (matchingVid) { const authorName = matchingVid.author.name; const youtubeUrl = matchingVid.video_url || matchingVid.shortUrl; const data = { metadata: { author: authorName, youtubeUrl: youtubeUrl, whoTalkedAboutUs: true, }, }; try { await fetch( `${UPDATE_VIDEO_URL}/${currIndex}/${completeTask.video_id}`, { method: "PUT", headers: { "Content-Type": "application/json", }, body: JSON.stringify(data), } ); } catch (error) { console.error(error); } } }); await Promise.all(updatePromises); }
updateMetadata 함수는 전체 작업 비디오 중 완료된 작업 비디오와 매치되는 대상을 찾습니다. 일치하는 건을 찾으면 업로더(author) 이름과 YouTube URL을 추출하여 커스텀 메타데이터 객체를 구축합니다. whoTalkedAboutUs 키의 값은 모든 동영상에 대해 마찬가지로 true로 설정합니다. 그런 다음 서버에 이 변경 사항을 적용하라는 요청을 전송하게 됩니다.
💡제공하는 데이터는 'metadata'라는 키를 가진 객체 형식이어야 하며, 이 하단에서 여러분이 동영상 데이터를 개인화할 키-값 쌍을 자유롭게 추가하거나 수정할 수 있습니다.
이 프로세스를 마치면 동영상 메타데이터에 우리가 원하던 'author', 'youtubeUrl', 그리고 'whoTalkedAboutUs' 항목이 자랑스럽게 나타나게 됩니다! 메타데이터 업데이트는 수집해 둔 동영상 라이브러리에 고유한 식별 스탬프를 찍는 환상적인 방법입니다.
"metadata": { "author": "Justine Feather", //추가! "duration": 188.6, "filename": "3 MINUTE MAKEUP THAT WILL LAST ALL DAY.mp4", "fps": 25, "height": 720, "size": 40566200, "video_title": "3 MINUTE MAKEUP THAT WILL LAST ALL DAY.mp4", "whoTalkedAboutUs": true, //추가! "width": 1280, "youtubeUrl": "https://www.youtube.com/watch?v=aYQWSNAL4D8" //추가! }
4단계. 동영상 검색
마침내 여러분이 기다리시던 순간, 동영상 검색 단계입니다! 이제 인덱싱된 여러분의 전체 동영상 데이터베이스 속에서 필요한 동영상을 정밀 검색할 수 있습니다.
본 앱에서는 검색 결과를 보여주기 위해 페이지네이션(pagination)을 적용했습니다. 따라서 검색 결과를 가져와서 렌더링하는 프로세스는 최초 검색 결과를 획득하는 POST 요청 처리 부분과, 다음 페이지 토큰(next page token)을 활용해 이후 검색 결과 페이지들을 차례로 호출하는 GET 요청 처리 부분 두 가지로 나뉩니다.
검색 POST 요청 생성
최초 검색 결과를 획득하기 위해 server.js 내에 위치한 검색 엔드포인트에 POST 요청을 보냅니다. 들어온 요청으로부터 indexId와 query를 가져와 TwelveLabs API의 '/search' 엔드포인트로 POST 요청을 생성합니다.
'search_options'로는 visual, conversation, text_in_video, logo 네 가지를 모두 지정했습니다. 또한 threshold, sort_option, group_by 같은 여러 설정도 함께 제공했습니다. 이러한 필터링과 설정을 통해 저는 신뢰도 레벨 수준이 "중(medium)" 및 "고(high)"인 결과만을 고르고, 이를 동영상별로 묶어(group) 클립 개수를 기준으로 최상단에 오도록 정렬하고 있습니다.
💡검색 요청 생성에 관한 상세 안내사항은 API 레퍼런스를 참고하세요.
/** 특정 쿼리로 동영상을 검색합니다 */ app.post("/search", async (request, response, next) => { const headers = { accept: "application/json", "Content-Type": "application/json", "x-api-key": TWELVE_LABS_API_KEY, }; const data = { index_id: request.body.indexId, search_options: ["visual", "conversation", "text_in_video", "logo"], query: request.body.query, group_by: "video", sort_option: "clip_count", threshold: "medium", page_limit: 2, }; try { const apiResponse = await TWELVE_LABS_API.post("/search", data, { headers, }); response.json(apiResponse.data); } catch (error) { return next(error); } });
'/search' 엔드포인트를 열어두면, useGetVideosOfSearchResults 기능이 유기적으로 연동됩니다. 이 커스텀 hook은 React Query 기술을 바탕으로 동영상 검색 및 조회 기능을 병렬적으로 한 번에 처리합니다. SearchResults.js 단에서 가져와서 사용되며, 쿼리 응답 정보 중 다음 페이지 토큰, 검색 결과, 검색 매칭 동영상이 변수 영역으로 분리 추출됩니다.
/** 1차 검색 결과와 및 매칭되는 동영상들을 조회합니다 */ const { initialSearchData: { page_info: { next_page_token: initialNextPageToken } = {}, } = {}, initialSearchResults, initialSearchResultVideos, refetch, } = useGetVideosOfSearchResults(currIndex, finalSearchQuery);
useGetVideosOfSearchResults 함수는 apiHooks.js 파일에 정의되어 있습니다. 설명처럼, 이 함수는 1차 검색 결과 집합을 우선 획득한 뒤 연달아 각 매칭 결과물의 비디오 디테일 세부 내용까지 병렬로 함께 가져오기 위해 useQueries 훅을 사용해 동시 다발 쿼리를 처리합니다.
export function useGetVideosOfSearchResults(indexId, query) { const { data: initialSearchData, refetch, isLoading, } = useSearchVideo(indexId, query); const initialSearchResults = initialSearchData.data || []; const resultVideos = useQueries({ queries: initialSearchResults.map((searchResult) => ({ queryKey: [keys.SEARCH, indexId, searchResult.id], queryFn: () => apiConfig.TWELVE_LABS_API.get( `${apiConfig.INDEXES_URL}/${indexId}/videos/${searchResult.id}` ).then((res) => res.data), })), }); const initialSearchResultVideos = resultVideos.map(({ data }) => data); return { initialSearchData, initialSearchResults, initialSearchResultVideos, refetch, isLoading, }; }
보시면 동영상 검색 결과를 먼저 가져오기 위해 useSearchVideo hook을 내부적으로 처음 사용합니다.
export function useSearchVideo(indexId, query) { return useQuery({ queryKey: [keys.SEARCH, indexId, query], queryFn: () => apiConfig.TWELVE_LABS_API.post(apiConfig.SEARCH_URL, { indexId, query, }).then((res) => res.data), }); }
이러한 api 훅 호출 결과물로 확보하는 가공 전 1차 로우 데이터 목록인 initialSearchResults 및 initialSearchResultVideos 포맷은 대략 아래와 같습니다.
initialSearchResults
[ {clips: [ {score: 76.1, start: 21.15625, end: 37.15625, metadata: Array(1), video_id: '65651c021cbcab2e74d86eb3', confidence: “medium”, modules: [{...}]}, {…}, … ], id: '65651c021cbcab2e74d86eb3'}, {clips: Array(6), id:... }, … ]
개별 1차 검색 항목은 매칭된 정보 단위인 'clips' 구조와 이에 연동된 video_id 식별자인 'id' 속성으로 정리되어 나옵니다.
initialSearchResultVideos
[ { “created_at”: "2023-12-07T18:32:01Z" “hls”: {...}, “indexed_at”: "2023-12-07T18:34:06Z", “metadata”: {...}, “Updated_at”: "2023-12-07T18:35:01Z", “_id”: "65720fa11cbcab2e74d87aab" }, … ]
initialSearchResultVideos는 해당 검색 결과에 매칭되는 동영상들의 메타데이터를 포함한 동영상 객체들로 구성됩니다.
이렇게 최초 검색 결과와 매칭 동영상을 확보했습니다. 하지만 'next_page_token'이 존재한다면, 이는 아직 탐색되지 않은 검색 결과가 더 남아있음을 의미합니다. 이 점 때문에 저는 검색된 모든 정보와 동영상을 하나의 상태에 안전하게 합쳐 두고 지속 처리하기 위해 'combinedSearchResults'와 'combinedSearchResultVideos'라는 별도 보완 상태값을 두었습니다. 특히 이번 앱의 레이아웃 성격상 인플루언서(YouTube 채널)별 분류 그룹핑 형태로 화면에 정리해 보여주어야 하므로, 모든 파편화된 데이터 목록을 한 번에 통합 정제한 다음에 최종 출력하는 식의 구조 설계가 반드시 필요합니다.
그럼 이어서 다음 뒤편에 있는 성격의 검색 리스트 페이지 데이터들을 연계해 읽는 구조를 살펴보겠습니다.
검색 GET 요청 생성하기
앞서 최초 검색 데이터의 응답단에서 next_page_token 값을 확인해 둔 것 기억하시나요? 이 획득한 next_page_token을 바탕으로 뒤이은 항목에 대해 GET 전송 방식을 취해 결과물 리스트를 수집할 수 있습니다. 검색 정보 내부에 next_page_token 속성이 잡히지 않고 아예 null 등이 반환될 때까지 반복 순회하는 호출 설정을 마련하면 전체 데이터를 완전히 수렴해 모을 수 있게 됩니다.
const nextPageResultsData = await fetchNextPageSearchResults( queryClient, nextPageToken );
fetchNextPageSearchResults는 Twelve Labs API의 '/search' 다음 정보 리포지토리에 GET 호출을 일으켜 가져옵니다. 여기서는 유저 버튼 작동 제어 흐름에 맞추어 조건 반응식으로 동작을 이행하려고 fetchQuery 처리를 하였습니다.
export async function fetchNextPageSearchResults(queryClient, nextPageToken) { try { const response = await queryClient.fetchQuery({ queryKey: [keys.SEARCH, nextPageToken], queryFn: async () => { const response = await apiConfig.TWELVE_LABS_API.get( `${apiConfig.SEARCH_URL}/${nextPageToken}` ); const data = response.data; return data; }, }); return response; } catch (error) { console.error("검색 결과 다음 페이지 로드 중 에러 발생:", error); throw error; } }
추출된 개별 검색 속성 형태에 알맞게, 앞서 이행한 바와 유사한 구조로 fetchNextpageSearchResultVideos를 가동해서 상세 실체 비디오 객체도 일괄 획득해 오게 됩니다. 완료되면 각각 앞단의 combinedSearchResults와 combinedSearchResultVideos 보관 메모리에 인입 및 보완 처리합니다.
상태 관리 중인 combinedSearchResults 및 combinedSearchResultVideos 내역들이 변동 감지될 때마다 전체 디스플레이 포맷은 바이어스 없이 자동 재조정 처리 흐름을 밟으며 (구식 변동 로직 코드 바로가기), 레이아웃은 정렬되어 SearchResult 컴포넌트를 타고 사용자 화면에 깔끔하게 배치됩니다.
💡검색 리스트 페이지 뷰 처리 세부 핸들링 제어는 공식 API 가이드라인 내 페이징 부분을 자세히 확인해 보시기 바랍니다.
이제 우리가 필요로 하는 모든 데이터에 액세스할 수 있는 서버 및 API 훅 빌딩이 끝났으므로, 화면단 컴포넌트 구성을 단계적으로 밟아가며 조립하면 됩니다. 이 컴포넌트들을 모두 결합해 실전성 있는 파워풀한 인플루언서 발굴 정보 탐색 앱을 짜보겠습니다. 집중해 주세요!
3단계 - 컨테이너 컴포넌트(Container Components) 구축
컴포넌트를 만드는 과정에 있어서, 컨테이너 컴포넌트(Container Component)를 먼저 설계하는 편이 추후 작업을 단순화시킵니다. 실제로 유저 화면에 보이는 요소(Presentation Component)들은 대개 데이터 실체나 훅 상태에 맞물려 동작하는 면이 크기 때문입니다.
💡혹시 리액트 설계 명칭인 컨테이너 컴포넌트와 프레젠테이션 컴포넌트라는 개념 구분이 생소하시더라도 괜찮습니다. 컨테이너 컴포넌트(스마트 컴포넌트)는 어플리케이션 안의 제어 로직, 비즈니스 처리 및 데이터 API 호출, 그리고 전반적인 내부 흐름 상태를 장악해 조절하는 부모 컨트롤 격입니다. 반면 프레젠테이션 컴포넌트(덤 컴포넌트)는 오로지 전달받은 데이터를 정적인 UI에 이쁘게 표현 구성하고 버튼 트리거 등을 단순히 Props 함수로만 넘겨 작동하는, 뷰에 집중한 부품 단위입니다.
3.1 - VideoComponent.js
VideoComponents는 앱의 중심이 되는 부분으로 동영상 업로드, 동영상 검색, 인덱스 내 재생목록들과 유튜브 정보 디스플레이 등의 기능을 모두 통합 컨트롤하는 허브입니다. 유저는 손쉽게 다수의 파일을 올리고, 정밀 검색하고, 페이지 단위로 정리된 동영상 라이브러리에 액세스할 수 있습니다. 필요할 때 한 번에 인덱스를 지우는 제어도 이쪽에서 가능합니다. 동작 흐름을 추적해 볼까요?
/** 비디오 상호작용 관련 로직을 통제하는 허브 컴포넌트들 * * VideoIndex -> VideoComponents -> { UploadYouTubeVideo, VideoList, PageNav, * SearchForm, SearchResults } * */ export function VideoComponents({ currIndex, vidPage, setVidPage, taskVideos, setTaskVideos, }) { const [searchQuery, setSearchQuery] = useState(""); const [finalSearchQuery, setFinalSearchQuery] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); const { setIndexId } = useContext(setIndexIdContext); const queryClient = useQueryClient(); const { data: videosData, refetch: refetchVideos, isPreviousData, } = useGetVideos(currIndex, vidPage, VID_PAGE_LIMIT); const videos = videosData?.data; const { data: authors, refetch: refetchAuthors } = useGetAllAuthors(currIndex); function reset() { setSearchQuery(""); setFinalSearchQuery(""); } useEffect(() => { queryClient.invalidateQueries({ queryKey: [keys.VIDEOS, currIndex, vidPage], }); }, [taskVideos, currIndex, vidPage]); useEffect(() => { queryClient.invalidateQueries({ queryKey: [keys.AUTHORS, currIndex], }); }, [videos, currIndex]);
VideoComponents는 실제 동영상 리스트와 크리에이터(author) 목록을 받아오는 핵심 쿼리들이 가동되는 무대입니다. 또한 보정 완료되거나 수록 완료된 정보를 즉각 새로고침 동기화하고자 해당 데이터 쿼리의 데이터 영속 캐시값을 임의 만료(invalidating)처리하는 구조도 내장하고 있습니다. 입력값과 진행률 정보를 UploadYouTubeVideo 및 SearchResults 같은 내부 자식 컴포넌트들과 서로 폭넓게 리액티브하게 나누어 쓰고자 관련 모달 및 제어 상태를 이 부모 층위에 모아 선언해 두었습니다.
UploadYouTubeVideo 렌더링하기
각 VideoIndex는 기본적으로 YouTube 플레이리스트, 특정 채널 아이디 또는 비디오 주소들의 모음 형태인 JSON 파일을 지정하여 한 번에 벌크 형태로 정보를 올릴 수 있는 UploadYouTubeVideo 컴포넌트를 마운트하여 기안해 보여줍니다. 상세 업로드 통제 로직은 뒤 절에서 자세하게 살펴보겠습니다.
return ( <> <div className="videoUploadForm"> <div className="display-6 m-4">새 동영상 업로드하기> <UploadYoutubeVideo currIndex={currIndex} taskVideos={taskVideos} setTaskVideos={setTaskVideos} refetchVideos={refetchVideos} isSubmitting={isSubmitting} setIsSubmitting={setIsSubmitting} reset={reset} /> <

SearchForm 및 VideoList 렌더링하기
이미 인덱스 스페이스 내에 인동영상 자료가 수록 완료되어 안착해 있을 때에는, 파일 로더단 뿐만 아니라 비디오를 전용 필터링 검색하는 뷰 폼도 화면에 표시되어 활성화됩니다. 만일 아무런 입력 쿼리 액션을 실행하지 않고 초기 진입한 비워진 상태라면 일반 동영상 정보 리스트들을 페이지당 최대 12개 한계에 연동하는 PageNav 부가 모듈을 활용하여 무리 없이 로출하게 합니다. 상세 동영상 목록 컴포넌트단은 곧 이어 보다 더 자세히 다루어 볼 요량입니다.
videoComponents.js (97 - 162행)
export function VideoComponents({ ... }) { return ( ... {videos && videos.length > 0 && ( <div> <div className="videoSearchForm"> <div className="title">동영상 검색</div> {/* <div className="m-auto p-3"> */} <SearchForm setSearchQuery={setSearchQuery} searchQuery={searchQuery} setFinalSearchQuery={setFinalSearchQuery} /> {/* </div> */} </div> {!finalSearchQuery && ( <div> <div className="channelPills"> <ErrorBoundary FallbackComponent={({ error }) => ( <ErrorFallback error={error} /> )} onReset={() => refetchAuthors()} resetKeys={[keys.AUTHORS, currIndex]} > <div className="subtitle"> 인덱스 내의 모든 인플루언서 ({authors?.length || 0}){" "} </div> {authors.map((author) => ( <div key={author} className="channelPill"> <Suspense fallback={<LoadingSpinner />}> {author} </Suspense> </div> ))} </ErrorBoundary> </div> <Container fluid className="mb-2"> <Row> <ErrorBoundary FallbackComponent={({ error }) => ( <ErrorFallback error={error} /> )} onReset={() => refetchVideos()} resetKeys={[keys.VIDEOS, currIndex, vidPage]} > {videos && ( <Suspense fallback={<LoadingSpinner />}> <VideoList videos={videos} refetchVideos={refetchVideos} /> </Suspense> )} <Container fluid className="d-flex justify-content-center"> <PageNav page={vidPage} setPage={setVidPage} data={videosData} isPreviousData={isPreviousData} /> </Container> </ErrorBoundary> </Row> </Container> <
검색 폼 및 동영상 리스트

SearchResults 렌더링하기
실제 검색 제출이 활성화되어 쿼리가 돌고 있는 상황(finalSearchQuery 발생 상태) 하에서는 정적 무가 가동 전체 리스트 대신, 그 쿼리 정량 필터링 타겟팅 전용으로 필터링되어 선별 수집된 결과들이 대신 채워지며 뿌려집니다.
VideoComponents.js (164 - 186행)
{finalSearchQuery AND ( <div> <Container fluid className="m-3"> <Row> <SearchResults currIndex={currIndex} allAuthors={authors} finalSearchQuery={finalSearchQuery} /> </Row> </Container> <div className="resetButtonWrapper"> <button className="resetButton" onClick={reset}> {backIcon AND ( <img src={backIcon} alt="Icon" className="icon" > )} 전체 동영상 목록으로 돌아가기 </button> </div> </div> )} <
기회가 되시면 채널별 분류 결과 조합 통제가 유기적으로 돌아가는 설계 디테일을 한 번 참고해 보시길 권장합니다. 매칭 검색에 일치하는 대상이 존재하지 않더라도 다른 보유 유관 채널 정보들까지 함께 수렴해 표현하여, 이 앱이 갖는 고유한 인디케이션 특징을 풍부하게 보여주게 됩니다!
검색 결과

3.2 - UploadYoutubeVideo.js
UploadYoutubeVideo는 VideoComponents.js의 또 다른 핵심 역할을 분담하고 있는 스마트한 동영상 일괄 수신용 컴포넌트입니다. 유저 기안 제출에 반응해 백스페이스에서 타오르는 작업 큐를 잡아내고, 각 동영상 인덱싱 연계 동작이 어느 단계에 있는지 그 변동 퍼센티지를 리스닝해 비주얼적으로 화면에 안전하게 띄워 처리하는 막중한 임무를 처리합니다.
그래서 로컬 로더에서 읽은 JSON 원본 저장, 유튜브 스페이스 상 채널/플레이리스트 인식 스토리지, 인덱싱 백 채기 진행용 상태 모듈 등을 매우 치밀하게 통제 관리하고 작동시켜 내부 유기성을 유지합니다. 인덱스 맵 구조 작동 핵심에 배속된 indexYouTubeVideos 연계 루프를 보다 직관적인 소스코드로 밀도 있게 관찰해 볼까요?
UploadYoutubeVideo.js (155 - 188행)
const indexYouTubeVideos = async () => { setIsSubmitting(true); updateMainMessage( "동영상을 업로드하는 동안 페이지를 새로고침하지 마세요. 업로드 중에도 동영상 검색은 가능합니다!" ); const videoData = taskVideos.map((taskVideo) => { return { url: taskVideo.video_url || taskVideo.url, title: taskVideo.title, authorName: taskVideo.author.name, thumbnails: taskVideo.thumbnails, }; }); const requestData = { videoData: videoData, index_id: currIndex, }; const data = { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json", }, body: JSON.stringify(requestData), }; const response = await fetch(DOWNLOAD_URL.toString(), data); const json = await response.json(); setIndexId(json.indexId); setTaskIds(json.taskIds); };
indexYouTubeVideos 함수는 전반적인 유튜브 인덱싱 작동 사이클을 총괄 제어합니다. 유저에게 강제로 디스크 페이지를 이동 리로드해 소트를 잃지 말도록 선제 정적 정보 알림창 메시지를 뿌리며 동작을 스타트하며, 연계 팩 데이터에서 대상이 되는 파일 주소, 기재 제목, 계정 홀더 이름 및 정합 썸네일을 골고루 수집 배치해 요청(Payload) 구조를 조립합니다.
이렇게 빌드된 배열 세트와 대상이 될 수용 인덱스 고유 식별자 키값을 묶어 하나의 실질 데이터 구성을 빌드한 뒤, 다운로더 백단 허브 URL로 POST API 인젝션을 송신 실행합니다 (우리가 2단계 영역에서 살펴보았던 구조입니다). 리턴되는 응답 속에는 향후 큐 작업 단에 지속 대조 처리할 인큐잉 식별 테스크 지시자 목록과 확정 처리된 수납 공간 인덱스 코드가 있으며, 이는 상태 영역 변수에 그대로 연동 기재되게 돌아갑니다.
UploadYouTubeVideo 컴포넌트는 그 외에도 여러 하위 상태 모듈 및 유틸 기능 유닛이 복합 유기적으로 연계 엮여있습니다. 지면 한계상 이 문서에서 세부 속성 전부를 전수 정리해 나열하지는 않았으나, 언제든 깃허브 전체 소스코드를 편하게 디깅해 탐색해 보시고 막히거나 질문이 있으시면 언제든지 편하게 문의해 주시기를 바랍니다!
4단계 - 프레젠테이션 컴포넌트(Presentation Components) 구축
이제 까다로운 동작 제어부 빌드가 성공적으로 끝났고, 유저 레이아웃 출력 전담 단위 컴포넌트인 프레젠테이션 컴포넌트(Presentation Components)를 구성해 프로젝트를 마무리할 준비를 합니다. 데이터가 인입되면 그것을 단순히 그려내는 저옵션 설계이며, 부모 격인 VideoComponents 허브 단으로부터 Props 연계값으로 받아 React Player 모듈에 안전하게 흘려 재생만 통제해 주는 가벼운 요소들입니다. 이 프로그램에서는 대표적으로 VideoList 및 TaskVideo 등이 여기에 대입됩니다. 대입 기준인 VideoList 예시를 찬찬히 들여다볼 수 있습니다.
function VideoList({ videos, refetchVideos }) { const numVideos = videos.length; return videos.map((video, index) => ( <ErrorBoundary FallbackComponent={ErrorFallback} onReset={() => refetchVideos()} resetKeys={[keys.VIDEOS]} key={video._id + "-" + index} > <Suspense> <Col sm={12} md={numVideos > 1 ? 6 : 12} lg={numVideos > 1 ? 4 : 12} xl={numVideos > 1 ? 3 : 12} className="mb-5 mt-3" > {" "} <ReactPlayer url={video.metadata.youtubeUrl} controls width="100%" height="250px" /> <div className="channelAndVideoName"> <div className="channelPillSmall">{video.metadata.author}</div> <div className="filename-text"> {video.metadata.filename.replace(".mp4", "")} </div> </div> </Col> </Suspense> <
단순 전달 배열 영상 루프를 가동하여 ReactPlayer를 통해 하나씩 마운트하는 처리를 해 주며, 매칭되는 크리에이터 채널 라이터(author) 정보와 가공 정제 포맷 파일명을 가시성 있게 뿌려 주는 레이아웃 구성입니다. 이 비주얼 구조가 어플리케이션 안에서 안착해 돌아가는 상태는 저 앞단에 설명 기재된 'SearchForm 및 VideoList 렌더링하기' 스크린 영역에서 확인해 보실 수 있습니다.
결론
이번 가이드 글을 통하여 실무 레이아웃 상에서 Twelve Labs의 비디오 인텔리전트 검색 API 기술 스페이스를 어떻게 스마트적으로 조율하고, 실제 비즈니스 가치에 결부시켜 유용하게 변환 전개할 수 있는지 유익한 힌트를 얻으셨기를 기대합니다. 해당 구조는 하나의 샘플 예시 스펙일 뿐이며, 여러분만의 상상력과 해결할 실무 태스크 장벽 높이에 알맞게 얼마든지 유연하게 확장 적용할 수 있는 설계 자유를 지니고 있습니다. 멋진 프로덕트를 마음껏 만들어 가시기를 바랍니다!
다음 단계는 무엇인가요?
퀵스타트 가이드 문서를 상세 검토해 보고 Twelve Labs 기술을 사용한 혁신적인 앱을 직접 만들어 보세요.
저희 개발용 Playground에 로그인하여 성능을 만끽해 보세요. 기본 영구 무료 동영상 가용 한도는 10시간 파이프라인으로 구성되어 배송됩니다.
동작과 관련한 유용한 새 소식 릴리즈는 공식 X (구 트위터) 및 LinkedIn 팀 정보창을 즉시 연계 팔로우해 보세요.
Twelve Labs 공식 디스코드 포럼에 참가하여 전 세계 다른 크리에이티브 빌더 개발자들과 활발하게 소통하며 기량을 나눠 보세요.
소개
뷰티 산업의 마케팅 전문가로서 인플루언서 파트너십 분야에서 수년간 경험을 쌓으며, 브랜드에 가장 적합한 YouTube 또는 TikTok 인플루언서를 선정하는 데 있어 귀중한 교훈을 얻었습니다. (네, 저는 마케팅 전문가에서 소프트웨어 엔지니어로 전향했습니다 😉) 가장 성공적인 협업은 *이미* 귀사의 제품이나 브랜드에 진정 어린 관심을 가지고 있는 인플루언서들과 자연스럽게 일어나는 경향이 있습니다. 예를 들어, "A"라는 브랜드를 홍보하는 경우, 사전 접촉 없이도 "A"를 언급한 인플루언서들을 발견할 수 있습니다. 이들에게 연락을 취하면 대개 협업에 매우 긍정적인 반응을 보입니다.
하지만 이러한 인플루언서들을 찾아내는 것은 다소 까다로울 수 있습니다. 특히 동영상 제목이나 설명에 브랜드명이 명시적으로 언급되지 않았을 때는 더욱 그렇습니다. 이는 저 스스로도 겪었던 페인 포인트(pain point)이기도 합니다. 예를 들어, 한 유튜버가 브랜드명을 밝히지 않고 "겨울철 필수 아이템 탑 10"과 같은 동영상에서 여러분의 제품을 소개했다면, 일반적인 YouTube 키워드 검색으로는 이를 찾아낼 수 없습니다.
이러한 상황에서 Twelve Labs API를 활용한 'Who Talked About Us(누가 우리 이야기를 했을까)' 서비스는 게임 체인저가 될 수 있습니다. 일반적인 YouTube나 TikTok 검색과 달리, 이 API는 깊이 있는 맥락적 동영상 검색을 지원합니다. 단순히 제목이나 설명에 의존하지 않고, 동영상에서 움직임, 오브젝트, 인물, 소리, 화면 상의 텍스트, 음성 등 다양한 요소를 추출합니다. "MAC 골드 하이라이터 사용법"과 같이 키워드나 구체적인 묘사를 입력하는 것만으로, 여러분의 브랜드나 제품을 언급하는 동영상과 채널을 찾아내고 해당 언급이 등장하는 정확한 순간까지 포착할 수 있습니다.
이를 통해 연락을 취할 인플루언서 리스트는 물론, 이들이 언급한 제품 정보와 맥락에 대한 세부 정보까지 확보할 수 있습니다. 잠재적 인플루언서들에 대한 이러한 깊이 있는 인사이트는 이들과 더욱 효과적으로 소통하고 의미 있는 관계를 구축할 수 있는 강력한 무기가 됩니다.
자, 이제 Twelve Labs API를 활용하여 이러한 강력한 앱을 만드는 과정을 단계별로 함께 살펴보겠습니다!
사전 준비 단계
Twelve Labs API의 세계로 뛰어들기 전에, 먼저 회원 가입을 하고 API 키를 생성해야 합니다. Twelve Labs Playground를 방문하여 가입하고 API 키를 생성하세요. 가입 시 최대 10시간 분량의 동영상 콘텐츠를 인덱싱할 수 있는 무료 크레딧이 제공됩니다!
이 앱에 필요한 모든 파일이 담긴 저장소는 Github에서 확인하실 수 있습니다.
이 앱은 JavaScript, Node, React, React Query를 사용하여 제작되었습니다. 이러한 기술들에 익숙하시다면 도움이 되겠지만, 잘 모르시더라도 걱정하지 마세요. 이 글에서 가장 중요한 포인트는 Twelve Labs API가 무엇이고, 이 앱이 이를 어떻게 활용하는지 배우는 데 있으니까요.
1단계 - 컴포넌트 설계
이 앱은 React로 구축되었으며, React의 핵심은 요소들을 재사용 가능한 컴포넌트로 세분화하는 데 있습니다. 따라서 컴포넌트를 설계하는 것부터 시작했고, 물론 이 과정에서 여러 번의 수정을 거쳤습니다.

상위 수준에서 이 앱은 ExistingIndexForm, IndexForm, VideoIndex로 구성됩니다. IndexForm은 사용자가 새로운 인덱스를 생성할 수 있는 간단한 폼입니다. ExistingIndexForm은 사용자가 이 앱을 통해 이전에 이미 생성해 둔 인덱스의 ID를 제출할 수 있는 또 다른 간단한 폼입니다. VideoIndex는 인덱스의 상세 정보를 가져오는 호출이 이루어지고 VideoComponents가 위치하는 곳입니다.
VideoComponents는 동영상과 관련된 모든 컴포넌트로 구성됩니다. UploadYoutubeVideo 컴포넌트는 동영상 다운로드 및 인덱싱을 지원하며, 각 동영상 작업의 진행 상태를 보여줍니다. VideoList는 인덱스의 동영상들을 단순히 렌더링하며, PageNav를 사용해 페이지당 12개씩 가져와 보여줍니다. SearchForm은 사용자 입력(검색 쿼리)을 받아 검색 API 호출로 전달하는 동영상 검색 처리 컴포넌트입니다. SearchResults는 검색 결과와 동영상을 가져오는 API를 호출한 다음, 이를 인플루언서별로 정리합니다. 마지막으로 SearchResult는 각 검색 결과를 사용자가 보기 편한 방식으로 보여줍니다.
2단계 - 서버 및 API hook 구축
Server.js와 apiHooks.js는 Twelve Labs API 및 ytdl-core와 같은 다른 라이브러리로부터의 모든 API 호출을 관리하는 파일입니다. Server.js에는 Twelve Labs API 및 기타 API 호출을 처리하는 모든 엔드포인트가 위치합니다. apiHooks.js는 상태 관리, 캐시, 데이터 페칭(fetching)을 위한 커스텀 React Query hook 모음입니다. 서버, 아니 사실상 이 앱 전체의 핵심 기능은 Twelve Labs API를 활용한 동영상 검색이므로, 이를 어떻게 사용하는지 자세히 살펴보겠습니다.
Twelve Labs API 사용을 위한 4단계
첫 번째 단계는 동영상을 위한 인덱스를 생성하는 것입니다. 그 다음 이 인덱스에 동영상을 업로드합니다. 그런 다음 동영상 메타데이터를 업데이트하여 각 동영상에 YouTube 채널과 URL을 추가합니다 (이 단계는 본 앱에 특화된 단계이며 일반적으로는 선택 사항입니다). 마지막으로 동영상 검색을 시작할 준비가 끝납니다. 이 앱에서는 Twelve Labs 및 기타 API 호출과 동영상 업로드 관련 기능을 모두 server.js 파일에 정리해 두었습니다.
환경 설정
루트 디렉토리에 .env 파일을 생성하고 필요한 값을 업데이트합니다. 아래 내용을 그대로 복사하여 붙여넣고 값을 커스텀하시면 됩니다.
.env
REACT_APP_API_URL=https://api.twelvelabs.io/v1.1 REACT_APP_API_KEY=<YOUR API KEY> REACT_APP_SERVER_URL=<YOUR SERVER URL> REACT_APP_PORT_NUMBER=<YOUR PORT NUMBER>
REACT_APP_API_URL: 이 앱은 v1.1을 지원합니다.
REACT_APP_API_KEY: 이전 단계에서 생성한 API Key를 저장합니다.
REACT_APP_SERVER_URL: "http://localhost"와 같은 형식이 될 수 있습니다.
REACT_APP_PORT_NUMBER: 사용하고자 하는 포트 번호를 설정합니다 (예: 4001).
파일 내에서 환경 변수 값에 접근하려면 process.env를 활용할 수 있습니다. 예를 들어, server.js 파일에서는 process.env.REACT_APP_APP_URL을 사용하여 API_URL에 접근하고 이를 저장할 수 있습니다. 다음 예시는 이를 구현하는 방법을 보여줍니다.
/** 상수 정의 및 TL API 엔드포인트 구성 */ const TWELVE_LABS_API_KEY = process.env.REACT_APP_API_KEY; const API_BASE_URL = process.env.REACT_APP_API_URL; const TWELVE_LABS_API = axios.create({ baseURL: API_BASE_URL }); const PORT_NUMBER = process.env.REACT_APP_PORT_NUMBER;
1단계. 인덱스 생성
인덱스는 동영상을 업로드, 인덱싱 및 검색할 수 있는 일종의 동영상 라이브러리입니다. 날짜, 주제 또는 YouTube 채널별로 자신만의 인덱스를 생성할 수 있습니다. 인덱스를 생성하려면 메서드를 'POST'로, 엔드포인트를 'https://api.twelvelabs.io/v1.1/indexes'로 설정한 뒤 헤더를 추가하고 engine_id, index_options, index_name과 같이 필요한 데이터를 제공하면 됩니다. index_options의 경우 네 가지 옵션 중 일부를 선택할 수 있습니다. 이 앱에서는 네 가지 옵션이 모두 포함되었습니다.
💡인덱스 생성에 대한 자세한 내용은 API 레퍼런스를 확인하세요.
/** 인덱스를 생성합니다 */ app.post("/indexes", async (request, response, next) => { const headers = { "Content-Type": "application/json", "x-api-key": TWELVE_LABS_API_KEY, }; const data = { engine_id: "marengo2.5", index_options: ["visual", "conversation", "text_in_video", "logo"], index_name: request.body.indexName, }; try { const apiResponse = await TWELVE_LABS_API.post("/indexes", data, { headers, }); response.json(apiResponse.data); } catch (error) { console.error("서버 측 에러:", error); response.json({ error }); } });
2단계. YouTube URL을 통해 동영상 업로드하기
이 앱은 채널 ID, 재생목록 ID 또는 아래와 같은 형식의 URL 객체 배열이 포함된 JSON 파일을 통해 YouTube 동영상을 대량으로 업로드하는 기능을 지원합니다.
example.json
[ { "url": "VIDEO URL" }, { "url": "VIDEO URL" } ... ]
💡YouTube URL을 통한 동영상 업로드는 현재 Twelve Labs API v1.2에서 지원됩니다. 이 앱은 v1.1을 사용하므로, YouTube에서 동영상을 다운로드할 수 있게 해주는 라이브러리인 ytdl-core를 활용해 직접 이 기능을 구현하고 있습니다.
따라서 전체 프로세스는 제공된 YouTube URL에서 동영상을 다운로드하여 이를 Twelve Labs API에 제출하고 이를 인덱싱하는 과정으로 진행됩니다. 이 동작은 server.js의 "/download" 엔드포인트에 구현되어 있습니다. 먼저 요청 바디(body)에서 동영상 데이터와 인덱싱 정보를 추출합니다. 그런 다음 동영상을 청크(chunk) 단위로 다운로드하고, 안전한 파일명을 위해 제목을 정제(sanitize)한 뒤 인덱싱을 위해 제출합니다. 모든 동영상이 다운로드되고 인덱싱되면, 서버는 작업(task) ID와 인덱스 ID를 응답으로 보냅니다. 각 단계를 세분화하여 자세히 살펴보겠습니다.
1 - 요청에서 정보 추출하기
첫 번째 단계는 요청의 바디(body)에서 동영상 데이터와 인덱싱 정보를 추출하는 것입니다. 전체 동영상 수, 처리된 동영상 수, 그리고 동영상 다운로드 및 인덱싱을 위한 청크 크기(이 앱의 경우 5로 설정)를 추적할 변수들을 구성합니다. 또한 동영상 인덱싱 프로세스의 응답들을 저장할 배열을 초기화합니다.
/** 분석을 위해 동영상을 다운로드 및 인덱싱하고, 작업 ID와 인덱스 ID를 반환합니다 */ app.post( "/download", bodyParser.urlencoded(), async (request, response, next) => { try { // 1단계: 요청에서 동영상 데이터 및 인덱스 정보 추출 const jsonVideos = request.body.videoData; const totalVideos = jsonVideos.length; let processedVideosCount = 0; const chunk_size = 5; let videoIndexingResponses = []; console.log("동영상 다운로드 중...");
2 - 청크 단위로 동영상 다운로드
다음 단계는 동영상을 청크 단위로 다운로드하는 것입니다. 각 청크에 대해 동영상 데이터를 반복 실행하면서 ytdl-core 라이브러리를 사용해 제공된 URL에서 동영상을 다운로드합니다. 동영상 제목은 안전한 파일명을 만들기 위해 특수문자 등을 제거하여 정제됩니다. 동영상 다운로드가 완료되면 그 진행 상황이 로그에 기록됩니다.
💡여기서 다운로드된 동영상이 'videos' 폴더에 저장되도록 videoPath를 설정합니다.
// 2단계: 청크 단위로 동영상 다운로드 for (let i = 0; i < totalVideos; i += chunk_size) { const videoChunk = jsonVideos.slice(i, i + chunk_size); const chunkDownloadedVideos = []; // 현재 청크의 각 동영상 다운로드 await Promise.all( videoChunk.map(async (videoData) => { try { // 다운로드할 동영상의 안전한 파일명 생성 const safeName = sanitize(videoData.title); const videoPath = `videos/${safeName}.mp4`; // 제공된 URL에서 동영상 다운로드 const stream = ytdl(videoData.url, { filter: "videoandaudio", format: ".mp4", }); await streamPipeline(stream, fs.createWriteStream(videoPath)); console.log(`${videoPath} -- 다운로드 완료`); chunkDownloadedVideos.push({ videoPath: videoPath, videoData: videoData, }); } catch (error) { console.log(`${videoData.title} 다운로드 중 에러 발생`); console.error(error); } }) );
3 - 인덱싱을 위한 동영상 제출
동영상 청크 다운로드가 완료되면, 이 다운로드된 동영상들을 인덱싱을 위해 제출합니다. 모든 인덱싱 작업이 완료될 때까지 대기하며 인덱싱 제출 진행 상황을 기록합니다.
// 3단계: 다운로드된 동영상을 인덱싱을 위해 제출 console.log( `인덱싱을 위한 동영상 제출 중 | 청크 ${ Math.floor(i / chunk_size) + 1 }` ); const chunkVideoIndexingResponses = await Promise.all( chunkDownloadedVideos.map(async (chunkDownloadedVideo) => { console.log( `인덱싱을 위해 ${chunkDownloadedVideo.videoPath} 제출 중...` ); const indexingResponse = await indexVideo( chunkDownloadedVideo.videoPath, request.body.index_id ); // indexingResponse에 videoData 추가 indexingResponse.videoData = chunkDownloadedVideo.videoData; return indexingResponse; }) ).catch(next); console.log("청크에 대한 인덱싱 제출 완료 | 작업 ID 목록:"); processedVideosCount += videoChunk.length; console.log( `총 ${totalVideos}개 비디오 중 ${processedVideosCount}개 처리 완료` ); videoIndexingResponses = videoIndexingResponses.concat( chunkVideoIndexingResponses ); await new Promise((resolve) => setTimeout(resolve, 1000)); }
이어서 indexVideo 함수도 가볍게 살펴보겠습니다. 동영상 인덱싱 과정 자체는 꽤 명료합니다. 전형적인 API 호출과 마찬가지로, Twelve Labs API에 POST 요청을 보내어 인덱싱 작업을 시작합니다. 이 요청에는 몇 가지 필수 매개변수가 포함됩니다. 대상 인덱스를 지정하는 index_id, 동영상 데이터 스트림 파일인 video_file, 그리고 언어 설정(영어의 경우 'en')입니다.
💡동영상 인덱싱 작업 생성에 대한 자세한 내용은 API 레퍼런스를 참고하세요.
/** 다운로드된 동영상을 받아 인덱싱 프로세스를 시작합니다 */ const indexVideo = async (videoPath, indexId) => { const headers = { headers: { accept: "application/json", "Content-Type": "multipart/form-data", "x-api-key": TWELVE_LABS_API_KEY, }, }; let params = { index_id: indexId, video_file: fs.createReadStream(videoPath), language: "en", }; const response = await TWELVE_LABS_API.post("/tasks", params, headers); return await response.data; };
이 호출에서 반환되는 response.data는 아래와 같은 작업(task) ID를 리턴합니다. 이전 코드 스니펫에서 보셨듯이 각 작업 ID는 indexingResponse에 저장됩니다.
response.data
{ "_id": "6527732e23c1347ffbe3a802" }
다만, 우리는 나중에 각 동영상의 메타데이터에 videoData를 추가해 주어야 하므로, response.data 위에 videoData를 인입하여 최종 indexingResponse를 구성합니다. 최종 응답의 구조는 다음과 같으며, 이는 videoIndexingResponses 배열에 계속 결합(concat)됩니다.
videoIndexingResponses
[ { _id: '6527732e23c1347ffbe3a802', videoData: { url: 'https://www.youtube.com/watch?v=WuWnt2NHJxk&list=PLI8sddTC_LM0SG6JMYmrbrHZy7j5zUjMA&index=4&pp=iAQB', title: 'Everyday Makeup Using ONLY 3 Products !!', authorName: 'Smitha Deepak', thumbnails: [Array] } }, … ]
4 - 작업 ID와 함께 응답하기
모든 동영상 다운로드 및 인덱싱 제출이 완료되면 해당 작업 ID 객체(위의 배열)와 인덱스 ID를 클라이언트에 응답으로 반환합니다.
// 4단계: 인덱싱 작업들의 작업 ID 및 인덱스 ID 반환 console.log( "모든 동영상에 대한 인덱싱 제출 완료. 작업 ID 목록:" ); console.log(videoIndexingResponses); response.json({ taskIds: videoIndexingResponses, indexId: request.body.index_id, }); } catch (error) { next(error); } } );
3단계. 동영상 메타데이터 업데이트
일반적으로 이 단계는 선택 사항일 수 있지만, 저희 앱에서 유튜브 URL을 포함하고 채널 및 인플루언서별로 동영상을 보여주기 위해 꼭 필요합니다.
앱에서 동영상이 표시되는 방식

보시다시피, 앱은 각 동영상마다 연한 녹색 타원 모양의 필(pill) 디자인 안에 채널명을 보여줍니다. 또한 동영상은 제3자 서버 URL 대신 YouTube URL을 활용해 React Player를 통해 재생됩니다. 동영상의 기본 메타데이터에는 채널명과 YouTube URL이 포함되어 있지 않기 때문에, 각 동영상의 메타데이터에 이 정보들을 직접 넣어주어야 합니다.
다음은 기본 상태의 동영상 메타데이터 예시입니다.
"metadata": { "duration": 188.6, "filename": "3 MINUTE MAKEUP THAT WILL LAST ALL DAY.mp4", "fps": 25, "height": 720, "size": 40566200, "video_title": "3 MINUTE MAKEUP THAT WILL LAST ALL DAY.mp4", "width": 1280, }
여기에 새로운 키-값 쌍을 추가하거나 기존 메타데이터를 자유롭게 업데이트할 수 있습니다. 이 앱에서는 메타데이터에 세 가지 데이터를 추가할 예정입니다. 바로 1) author (채널명), 2) youtube url, 그리고 3) 이 앱을 통해 업로드된 동영상인지를 식별하기 위한 불리언(boolean) 값인 whoTalkedAboutUs입니다.
이 작업은 server.js 파일 내 동영상 메타데이터 업데이트를 처리하는 엔드포인트에서 제어됩니다. 여기서는 업데이트할 데이터를 담아 PUT 요청을 보내는 것을 확인하실 수 있습니다.
💡동영상 정보 업데이트에 대한 상세 기술 규격은 API 레퍼런스를 확인하세요.
/** 동영상 메타데이터를 업데이트합니다 */ app.put("/update/:indexId/:videoId", async (request, response, next) => { const indexId = request.params.indexId; const videoId = request.params.videoId; const data = request.body; const headers = { "Content-Type": "application/json", "x-api-key": TWELVE_LABS_API_KEY, }; try { const apiResponse = await TWELVE_LABS_API.put( `/indexes/${indexId}/videos/${videoId}`, data, { headers } ); response.json(apiResponse.data); } catch (error) { return next(error); } });
이제 이 로직이 UploadYouTubeVideo.js 파일 안에서 구체적으로 어떻게 구현되어 있는지 살펴보겠습니다.
UploadYouTubeVideo.js (190 - 223행)
async function updateMetadata() { const updatePromises = completeTasks.map(async (completeTask) => { const matchingVid = taskVideos.find( (taskVid) => `${sanitize(taskVid.title)}.mp4` === completeTask.metadata.filename ); if (matchingVid) { const authorName = matchingVid.author.name; const youtubeUrl = matchingVid.video_url || matchingVid.shortUrl; const data = { metadata: { author: authorName, youtubeUrl: youtubeUrl, whoTalkedAboutUs: true, }, }; try { await fetch( `${UPDATE_VIDEO_URL}/${currIndex}/${completeTask.video_id}`, { method: "PUT", headers: { "Content-Type": "application/json", }, body: JSON.stringify(data), } ); } catch (error) { console.error(error); } } }); await Promise.all(updatePromises); }
updateMetadata 함수는 전체 작업 비디오 중 완료된 작업 비디오와 매치되는 대상을 찾습니다. 일치하는 건을 찾으면 업로더(author) 이름과 YouTube URL을 추출하여 커스텀 메타데이터 객체를 구축합니다. whoTalkedAboutUs 키의 값은 모든 동영상에 대해 마찬가지로 true로 설정합니다. 그런 다음 서버에 이 변경 사항을 적용하라는 요청을 전송하게 됩니다.
💡제공하는 데이터는 'metadata'라는 키를 가진 객체 형식이어야 하며, 이 하단에서 여러분이 동영상 데이터를 개인화할 키-값 쌍을 자유롭게 추가하거나 수정할 수 있습니다.
이 프로세스를 마치면 동영상 메타데이터에 우리가 원하던 'author', 'youtubeUrl', 그리고 'whoTalkedAboutUs' 항목이 자랑스럽게 나타나게 됩니다! 메타데이터 업데이트는 수집해 둔 동영상 라이브러리에 고유한 식별 스탬프를 찍는 환상적인 방법입니다.
"metadata": { "author": "Justine Feather", //추가! "duration": 188.6, "filename": "3 MINUTE MAKEUP THAT WILL LAST ALL DAY.mp4", "fps": 25, "height": 720, "size": 40566200, "video_title": "3 MINUTE MAKEUP THAT WILL LAST ALL DAY.mp4", "whoTalkedAboutUs": true, //추가! "width": 1280, "youtubeUrl": "https://www.youtube.com/watch?v=aYQWSNAL4D8" //추가! }
4단계. 동영상 검색
마침내 여러분이 기다리시던 순간, 동영상 검색 단계입니다! 이제 인덱싱된 여러분의 전체 동영상 데이터베이스 속에서 필요한 동영상을 정밀 검색할 수 있습니다.
본 앱에서는 검색 결과를 보여주기 위해 페이지네이션(pagination)을 적용했습니다. 따라서 검색 결과를 가져와서 렌더링하는 프로세스는 최초 검색 결과를 획득하는 POST 요청 처리 부분과, 다음 페이지 토큰(next page token)을 활용해 이후 검색 결과 페이지들을 차례로 호출하는 GET 요청 처리 부분 두 가지로 나뉩니다.
검색 POST 요청 생성
최초 검색 결과를 획득하기 위해 server.js 내에 위치한 검색 엔드포인트에 POST 요청을 보냅니다. 들어온 요청으로부터 indexId와 query를 가져와 TwelveLabs API의 '/search' 엔드포인트로 POST 요청을 생성합니다.
'search_options'로는 visual, conversation, text_in_video, logo 네 가지를 모두 지정했습니다. 또한 threshold, sort_option, group_by 같은 여러 설정도 함께 제공했습니다. 이러한 필터링과 설정을 통해 저는 신뢰도 레벨 수준이 "중(medium)" 및 "고(high)"인 결과만을 고르고, 이를 동영상별로 묶어(group) 클립 개수를 기준으로 최상단에 오도록 정렬하고 있습니다.
💡검색 요청 생성에 관한 상세 안내사항은 API 레퍼런스를 참고하세요.
/** 특정 쿼리로 동영상을 검색합니다 */ app.post("/search", async (request, response, next) => { const headers = { accept: "application/json", "Content-Type": "application/json", "x-api-key": TWELVE_LABS_API_KEY, }; const data = { index_id: request.body.indexId, search_options: ["visual", "conversation", "text_in_video", "logo"], query: request.body.query, group_by: "video", sort_option: "clip_count", threshold: "medium", page_limit: 2, }; try { const apiResponse = await TWELVE_LABS_API.post("/search", data, { headers, }); response.json(apiResponse.data); } catch (error) { return next(error); } });
'/search' 엔드포인트를 열어두면, useGetVideosOfSearchResults 기능이 유기적으로 연동됩니다. 이 커스텀 hook은 React Query 기술을 바탕으로 동영상 검색 및 조회 기능을 병렬적으로 한 번에 처리합니다. SearchResults.js 단에서 가져와서 사용되며, 쿼리 응답 정보 중 다음 페이지 토큰, 검색 결과, 검색 매칭 동영상이 변수 영역으로 분리 추출됩니다.
/** 1차 검색 결과와 및 매칭되는 동영상들을 조회합니다 */ const { initialSearchData: { page_info: { next_page_token: initialNextPageToken } = {}, } = {}, initialSearchResults, initialSearchResultVideos, refetch, } = useGetVideosOfSearchResults(currIndex, finalSearchQuery);
useGetVideosOfSearchResults 함수는 apiHooks.js 파일에 정의되어 있습니다. 설명처럼, 이 함수는 1차 검색 결과 집합을 우선 획득한 뒤 연달아 각 매칭 결과물의 비디오 디테일 세부 내용까지 병렬로 함께 가져오기 위해 useQueries 훅을 사용해 동시 다발 쿼리를 처리합니다.
export function useGetVideosOfSearchResults(indexId, query) { const { data: initialSearchData, refetch, isLoading, } = useSearchVideo(indexId, query); const initialSearchResults = initialSearchData.data || []; const resultVideos = useQueries({ queries: initialSearchResults.map((searchResult) => ({ queryKey: [keys.SEARCH, indexId, searchResult.id], queryFn: () => apiConfig.TWELVE_LABS_API.get( `${apiConfig.INDEXES_URL}/${indexId}/videos/${searchResult.id}` ).then((res) => res.data), })), }); const initialSearchResultVideos = resultVideos.map(({ data }) => data); return { initialSearchData, initialSearchResults, initialSearchResultVideos, refetch, isLoading, }; }
보시면 동영상 검색 결과를 먼저 가져오기 위해 useSearchVideo hook을 내부적으로 처음 사용합니다.
export function useSearchVideo(indexId, query) { return useQuery({ queryKey: [keys.SEARCH, indexId, query], queryFn: () => apiConfig.TWELVE_LABS_API.post(apiConfig.SEARCH_URL, { indexId, query, }).then((res) => res.data), }); }
이러한 api 훅 호출 결과물로 확보하는 가공 전 1차 로우 데이터 목록인 initialSearchResults 및 initialSearchResultVideos 포맷은 대략 아래와 같습니다.
initialSearchResults
[ {clips: [ {score: 76.1, start: 21.15625, end: 37.15625, metadata: Array(1), video_id: '65651c021cbcab2e74d86eb3', confidence: “medium”, modules: [{...}]}, {…}, … ], id: '65651c021cbcab2e74d86eb3'}, {clips: Array(6), id:... }, … ]
개별 1차 검색 항목은 매칭된 정보 단위인 'clips' 구조와 이에 연동된 video_id 식별자인 'id' 속성으로 정리되어 나옵니다.
initialSearchResultVideos
[ { “created_at”: "2023-12-07T18:32:01Z" “hls”: {...}, “indexed_at”: "2023-12-07T18:34:06Z", “metadata”: {...}, “Updated_at”: "2023-12-07T18:35:01Z", “_id”: "65720fa11cbcab2e74d87aab" }, … ]
initialSearchResultVideos는 해당 검색 결과에 매칭되는 동영상들의 메타데이터를 포함한 동영상 객체들로 구성됩니다.
이렇게 최초 검색 결과와 매칭 동영상을 확보했습니다. 하지만 'next_page_token'이 존재한다면, 이는 아직 탐색되지 않은 검색 결과가 더 남아있음을 의미합니다. 이 점 때문에 저는 검색된 모든 정보와 동영상을 하나의 상태에 안전하게 합쳐 두고 지속 처리하기 위해 'combinedSearchResults'와 'combinedSearchResultVideos'라는 별도 보완 상태값을 두었습니다. 특히 이번 앱의 레이아웃 성격상 인플루언서(YouTube 채널)별 분류 그룹핑 형태로 화면에 정리해 보여주어야 하므로, 모든 파편화된 데이터 목록을 한 번에 통합 정제한 다음에 최종 출력하는 식의 구조 설계가 반드시 필요합니다.
그럼 이어서 다음 뒤편에 있는 성격의 검색 리스트 페이지 데이터들을 연계해 읽는 구조를 살펴보겠습니다.
검색 GET 요청 생성하기
앞서 최초 검색 데이터의 응답단에서 next_page_token 값을 확인해 둔 것 기억하시나요? 이 획득한 next_page_token을 바탕으로 뒤이은 항목에 대해 GET 전송 방식을 취해 결과물 리스트를 수집할 수 있습니다. 검색 정보 내부에 next_page_token 속성이 잡히지 않고 아예 null 등이 반환될 때까지 반복 순회하는 호출 설정을 마련하면 전체 데이터를 완전히 수렴해 모을 수 있게 됩니다.
const nextPageResultsData = await fetchNextPageSearchResults( queryClient, nextPageToken );
fetchNextPageSearchResults는 Twelve Labs API의 '/search' 다음 정보 리포지토리에 GET 호출을 일으켜 가져옵니다. 여기서는 유저 버튼 작동 제어 흐름에 맞추어 조건 반응식으로 동작을 이행하려고 fetchQuery 처리를 하였습니다.
export async function fetchNextPageSearchResults(queryClient, nextPageToken) { try { const response = await queryClient.fetchQuery({ queryKey: [keys.SEARCH, nextPageToken], queryFn: async () => { const response = await apiConfig.TWELVE_LABS_API.get( `${apiConfig.SEARCH_URL}/${nextPageToken}` ); const data = response.data; return data; }, }); return response; } catch (error) { console.error("검색 결과 다음 페이지 로드 중 에러 발생:", error); throw error; } }
추출된 개별 검색 속성 형태에 알맞게, 앞서 이행한 바와 유사한 구조로 fetchNextpageSearchResultVideos를 가동해서 상세 실체 비디오 객체도 일괄 획득해 오게 됩니다. 완료되면 각각 앞단의 combinedSearchResults와 combinedSearchResultVideos 보관 메모리에 인입 및 보완 처리합니다.
상태 관리 중인 combinedSearchResults 및 combinedSearchResultVideos 내역들이 변동 감지될 때마다 전체 디스플레이 포맷은 바이어스 없이 자동 재조정 처리 흐름을 밟으며 (구식 변동 로직 코드 바로가기), 레이아웃은 정렬되어 SearchResult 컴포넌트를 타고 사용자 화면에 깔끔하게 배치됩니다.
💡검색 리스트 페이지 뷰 처리 세부 핸들링 제어는 공식 API 가이드라인 내 페이징 부분을 자세히 확인해 보시기 바랍니다.
이제 우리가 필요로 하는 모든 데이터에 액세스할 수 있는 서버 및 API 훅 빌딩이 끝났으므로, 화면단 컴포넌트 구성을 단계적으로 밟아가며 조립하면 됩니다. 이 컴포넌트들을 모두 결합해 실전성 있는 파워풀한 인플루언서 발굴 정보 탐색 앱을 짜보겠습니다. 집중해 주세요!
3단계 - 컨테이너 컴포넌트(Container Components) 구축
컴포넌트를 만드는 과정에 있어서, 컨테이너 컴포넌트(Container Component)를 먼저 설계하는 편이 추후 작업을 단순화시킵니다. 실제로 유저 화면에 보이는 요소(Presentation Component)들은 대개 데이터 실체나 훅 상태에 맞물려 동작하는 면이 크기 때문입니다.
💡혹시 리액트 설계 명칭인 컨테이너 컴포넌트와 프레젠테이션 컴포넌트라는 개념 구분이 생소하시더라도 괜찮습니다. 컨테이너 컴포넌트(스마트 컴포넌트)는 어플리케이션 안의 제어 로직, 비즈니스 처리 및 데이터 API 호출, 그리고 전반적인 내부 흐름 상태를 장악해 조절하는 부모 컨트롤 격입니다. 반면 프레젠테이션 컴포넌트(덤 컴포넌트)는 오로지 전달받은 데이터를 정적인 UI에 이쁘게 표현 구성하고 버튼 트리거 등을 단순히 Props 함수로만 넘겨 작동하는, 뷰에 집중한 부품 단위입니다.
3.1 - VideoComponent.js
VideoComponents는 앱의 중심이 되는 부분으로 동영상 업로드, 동영상 검색, 인덱스 내 재생목록들과 유튜브 정보 디스플레이 등의 기능을 모두 통합 컨트롤하는 허브입니다. 유저는 손쉽게 다수의 파일을 올리고, 정밀 검색하고, 페이지 단위로 정리된 동영상 라이브러리에 액세스할 수 있습니다. 필요할 때 한 번에 인덱스를 지우는 제어도 이쪽에서 가능합니다. 동작 흐름을 추적해 볼까요?
/** 비디오 상호작용 관련 로직을 통제하는 허브 컴포넌트들 * * VideoIndex -> VideoComponents -> { UploadYouTubeVideo, VideoList, PageNav, * SearchForm, SearchResults } * */ export function VideoComponents({ currIndex, vidPage, setVidPage, taskVideos, setTaskVideos, }) { const [searchQuery, setSearchQuery] = useState(""); const [finalSearchQuery, setFinalSearchQuery] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); const { setIndexId } = useContext(setIndexIdContext); const queryClient = useQueryClient(); const { data: videosData, refetch: refetchVideos, isPreviousData, } = useGetVideos(currIndex, vidPage, VID_PAGE_LIMIT); const videos = videosData?.data; const { data: authors, refetch: refetchAuthors } = useGetAllAuthors(currIndex); function reset() { setSearchQuery(""); setFinalSearchQuery(""); } useEffect(() => { queryClient.invalidateQueries({ queryKey: [keys.VIDEOS, currIndex, vidPage], }); }, [taskVideos, currIndex, vidPage]); useEffect(() => { queryClient.invalidateQueries({ queryKey: [keys.AUTHORS, currIndex], }); }, [videos, currIndex]);
VideoComponents는 실제 동영상 리스트와 크리에이터(author) 목록을 받아오는 핵심 쿼리들이 가동되는 무대입니다. 또한 보정 완료되거나 수록 완료된 정보를 즉각 새로고침 동기화하고자 해당 데이터 쿼리의 데이터 영속 캐시값을 임의 만료(invalidating)처리하는 구조도 내장하고 있습니다. 입력값과 진행률 정보를 UploadYouTubeVideo 및 SearchResults 같은 내부 자식 컴포넌트들과 서로 폭넓게 리액티브하게 나누어 쓰고자 관련 모달 및 제어 상태를 이 부모 층위에 모아 선언해 두었습니다.
UploadYouTubeVideo 렌더링하기
각 VideoIndex는 기본적으로 YouTube 플레이리스트, 특정 채널 아이디 또는 비디오 주소들의 모음 형태인 JSON 파일을 지정하여 한 번에 벌크 형태로 정보를 올릴 수 있는 UploadYouTubeVideo 컴포넌트를 마운트하여 기안해 보여줍니다. 상세 업로드 통제 로직은 뒤 절에서 자세하게 살펴보겠습니다.
return ( <> <div className="videoUploadForm"> <div className="display-6 m-4">새 동영상 업로드하기> <UploadYoutubeVideo currIndex={currIndex} taskVideos={taskVideos} setTaskVideos={setTaskVideos} refetchVideos={refetchVideos} isSubmitting={isSubmitting} setIsSubmitting={setIsSubmitting} reset={reset} /> <

SearchForm 및 VideoList 렌더링하기
이미 인덱스 스페이스 내에 인동영상 자료가 수록 완료되어 안착해 있을 때에는, 파일 로더단 뿐만 아니라 비디오를 전용 필터링 검색하는 뷰 폼도 화면에 표시되어 활성화됩니다. 만일 아무런 입력 쿼리 액션을 실행하지 않고 초기 진입한 비워진 상태라면 일반 동영상 정보 리스트들을 페이지당 최대 12개 한계에 연동하는 PageNav 부가 모듈을 활용하여 무리 없이 로출하게 합니다. 상세 동영상 목록 컴포넌트단은 곧 이어 보다 더 자세히 다루어 볼 요량입니다.
videoComponents.js (97 - 162행)
export function VideoComponents({ ... }) { return ( ... {videos && videos.length > 0 && ( <div> <div className="videoSearchForm"> <div className="title">동영상 검색</div> {/* <div className="m-auto p-3"> */} <SearchForm setSearchQuery={setSearchQuery} searchQuery={searchQuery} setFinalSearchQuery={setFinalSearchQuery} /> {/* </div> */} </div> {!finalSearchQuery && ( <div> <div className="channelPills"> <ErrorBoundary FallbackComponent={({ error }) => ( <ErrorFallback error={error} /> )} onReset={() => refetchAuthors()} resetKeys={[keys.AUTHORS, currIndex]} > <div className="subtitle"> 인덱스 내의 모든 인플루언서 ({authors?.length || 0}){" "} </div> {authors.map((author) => ( <div key={author} className="channelPill"> <Suspense fallback={<LoadingSpinner />}> {author} </Suspense> </div> ))} </ErrorBoundary> </div> <Container fluid className="mb-2"> <Row> <ErrorBoundary FallbackComponent={({ error }) => ( <ErrorFallback error={error} /> )} onReset={() => refetchVideos()} resetKeys={[keys.VIDEOS, currIndex, vidPage]} > {videos && ( <Suspense fallback={<LoadingSpinner />}> <VideoList videos={videos} refetchVideos={refetchVideos} /> </Suspense> )} <Container fluid className="d-flex justify-content-center"> <PageNav page={vidPage} setPage={setVidPage} data={videosData} isPreviousData={isPreviousData} /> </Container> </ErrorBoundary> </Row> </Container> <
검색 폼 및 동영상 리스트

SearchResults 렌더링하기
실제 검색 제출이 활성화되어 쿼리가 돌고 있는 상황(finalSearchQuery 발생 상태) 하에서는 정적 무가 가동 전체 리스트 대신, 그 쿼리 정량 필터링 타겟팅 전용으로 필터링되어 선별 수집된 결과들이 대신 채워지며 뿌려집니다.
VideoComponents.js (164 - 186행)
{finalSearchQuery AND ( <div> <Container fluid className="m-3"> <Row> <SearchResults currIndex={currIndex} allAuthors={authors} finalSearchQuery={finalSearchQuery} /> </Row> </Container> <div className="resetButtonWrapper"> <button className="resetButton" onClick={reset}> {backIcon AND ( <img src={backIcon} alt="Icon" className="icon" > )} 전체 동영상 목록으로 돌아가기 </button> </div> </div> )} <
기회가 되시면 채널별 분류 결과 조합 통제가 유기적으로 돌아가는 설계 디테일을 한 번 참고해 보시길 권장합니다. 매칭 검색에 일치하는 대상이 존재하지 않더라도 다른 보유 유관 채널 정보들까지 함께 수렴해 표현하여, 이 앱이 갖는 고유한 인디케이션 특징을 풍부하게 보여주게 됩니다!
검색 결과

3.2 - UploadYoutubeVideo.js
UploadYoutubeVideo는 VideoComponents.js의 또 다른 핵심 역할을 분담하고 있는 스마트한 동영상 일괄 수신용 컴포넌트입니다. 유저 기안 제출에 반응해 백스페이스에서 타오르는 작업 큐를 잡아내고, 각 동영상 인덱싱 연계 동작이 어느 단계에 있는지 그 변동 퍼센티지를 리스닝해 비주얼적으로 화면에 안전하게 띄워 처리하는 막중한 임무를 처리합니다.
그래서 로컬 로더에서 읽은 JSON 원본 저장, 유튜브 스페이스 상 채널/플레이리스트 인식 스토리지, 인덱싱 백 채기 진행용 상태 모듈 등을 매우 치밀하게 통제 관리하고 작동시켜 내부 유기성을 유지합니다. 인덱스 맵 구조 작동 핵심에 배속된 indexYouTubeVideos 연계 루프를 보다 직관적인 소스코드로 밀도 있게 관찰해 볼까요?
UploadYoutubeVideo.js (155 - 188행)
const indexYouTubeVideos = async () => { setIsSubmitting(true); updateMainMessage( "동영상을 업로드하는 동안 페이지를 새로고침하지 마세요. 업로드 중에도 동영상 검색은 가능합니다!" ); const videoData = taskVideos.map((taskVideo) => { return { url: taskVideo.video_url || taskVideo.url, title: taskVideo.title, authorName: taskVideo.author.name, thumbnails: taskVideo.thumbnails, }; }); const requestData = { videoData: videoData, index_id: currIndex, }; const data = { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json", }, body: JSON.stringify(requestData), }; const response = await fetch(DOWNLOAD_URL.toString(), data); const json = await response.json(); setIndexId(json.indexId); setTaskIds(json.taskIds); };
indexYouTubeVideos 함수는 전반적인 유튜브 인덱싱 작동 사이클을 총괄 제어합니다. 유저에게 강제로 디스크 페이지를 이동 리로드해 소트를 잃지 말도록 선제 정적 정보 알림창 메시지를 뿌리며 동작을 스타트하며, 연계 팩 데이터에서 대상이 되는 파일 주소, 기재 제목, 계정 홀더 이름 및 정합 썸네일을 골고루 수집 배치해 요청(Payload) 구조를 조립합니다.
이렇게 빌드된 배열 세트와 대상이 될 수용 인덱스 고유 식별자 키값을 묶어 하나의 실질 데이터 구성을 빌드한 뒤, 다운로더 백단 허브 URL로 POST API 인젝션을 송신 실행합니다 (우리가 2단계 영역에서 살펴보았던 구조입니다). 리턴되는 응답 속에는 향후 큐 작업 단에 지속 대조 처리할 인큐잉 식별 테스크 지시자 목록과 확정 처리된 수납 공간 인덱스 코드가 있으며, 이는 상태 영역 변수에 그대로 연동 기재되게 돌아갑니다.
UploadYouTubeVideo 컴포넌트는 그 외에도 여러 하위 상태 모듈 및 유틸 기능 유닛이 복합 유기적으로 연계 엮여있습니다. 지면 한계상 이 문서에서 세부 속성 전부를 전수 정리해 나열하지는 않았으나, 언제든 깃허브 전체 소스코드를 편하게 디깅해 탐색해 보시고 막히거나 질문이 있으시면 언제든지 편하게 문의해 주시기를 바랍니다!
4단계 - 프레젠테이션 컴포넌트(Presentation Components) 구축
이제 까다로운 동작 제어부 빌드가 성공적으로 끝났고, 유저 레이아웃 출력 전담 단위 컴포넌트인 프레젠테이션 컴포넌트(Presentation Components)를 구성해 프로젝트를 마무리할 준비를 합니다. 데이터가 인입되면 그것을 단순히 그려내는 저옵션 설계이며, 부모 격인 VideoComponents 허브 단으로부터 Props 연계값으로 받아 React Player 모듈에 안전하게 흘려 재생만 통제해 주는 가벼운 요소들입니다. 이 프로그램에서는 대표적으로 VideoList 및 TaskVideo 등이 여기에 대입됩니다. 대입 기준인 VideoList 예시를 찬찬히 들여다볼 수 있습니다.
function VideoList({ videos, refetchVideos }) { const numVideos = videos.length; return videos.map((video, index) => ( <ErrorBoundary FallbackComponent={ErrorFallback} onReset={() => refetchVideos()} resetKeys={[keys.VIDEOS]} key={video._id + "-" + index} > <Suspense> <Col sm={12} md={numVideos > 1 ? 6 : 12} lg={numVideos > 1 ? 4 : 12} xl={numVideos > 1 ? 3 : 12} className="mb-5 mt-3" > {" "} <ReactPlayer url={video.metadata.youtubeUrl} controls width="100%" height="250px" /> <div className="channelAndVideoName"> <div className="channelPillSmall">{video.metadata.author}</div> <div className="filename-text"> {video.metadata.filename.replace(".mp4", "")} </div> </div> </Col> </Suspense> <
단순 전달 배열 영상 루프를 가동하여 ReactPlayer를 통해 하나씩 마운트하는 처리를 해 주며, 매칭되는 크리에이터 채널 라이터(author) 정보와 가공 정제 포맷 파일명을 가시성 있게 뿌려 주는 레이아웃 구성입니다. 이 비주얼 구조가 어플리케이션 안에서 안착해 돌아가는 상태는 저 앞단에 설명 기재된 'SearchForm 및 VideoList 렌더링하기' 스크린 영역에서 확인해 보실 수 있습니다.
결론
이번 가이드 글을 통하여 실무 레이아웃 상에서 Twelve Labs의 비디오 인텔리전트 검색 API 기술 스페이스를 어떻게 스마트적으로 조율하고, 실제 비즈니스 가치에 결부시켜 유용하게 변환 전개할 수 있는지 유익한 힌트를 얻으셨기를 기대합니다. 해당 구조는 하나의 샘플 예시 스펙일 뿐이며, 여러분만의 상상력과 해결할 실무 태스크 장벽 높이에 알맞게 얼마든지 유연하게 확장 적용할 수 있는 설계 자유를 지니고 있습니다. 멋진 프로덕트를 마음껏 만들어 가시기를 바랍니다!
다음 단계는 무엇인가요?
퀵스타트 가이드 문서를 상세 검토해 보고 Twelve Labs 기술을 사용한 혁신적인 앱을 직접 만들어 보세요.
저희 개발용 Playground에 로그인하여 성능을 만끽해 보세요. 기본 영구 무료 동영상 가용 한도는 10시간 파이프라인으로 구성되어 배송됩니다.
동작과 관련한 유용한 새 소식 릴리즈는 공식 X (구 트위터) 및 LinkedIn 팀 정보창을 즉시 연계 팔로우해 보세요.
Twelve Labs 공식 디스코드 포럼에 참가하여 전 세계 다른 크리에이티브 빌더 개발자들과 활발하게 소통하며 기량을 나눠 보세요.
소개
뷰티 산업의 마케팅 전문가로서 인플루언서 파트너십 분야에서 수년간 경험을 쌓으며, 브랜드에 가장 적합한 YouTube 또는 TikTok 인플루언서를 선정하는 데 있어 귀중한 교훈을 얻었습니다. (네, 저는 마케팅 전문가에서 소프트웨어 엔지니어로 전향했습니다 😉) 가장 성공적인 협업은 *이미* 귀사의 제품이나 브랜드에 진정 어린 관심을 가지고 있는 인플루언서들과 자연스럽게 일어나는 경향이 있습니다. 예를 들어, "A"라는 브랜드를 홍보하는 경우, 사전 접촉 없이도 "A"를 언급한 인플루언서들을 발견할 수 있습니다. 이들에게 연락을 취하면 대개 협업에 매우 긍정적인 반응을 보입니다.
하지만 이러한 인플루언서들을 찾아내는 것은 다소 까다로울 수 있습니다. 특히 동영상 제목이나 설명에 브랜드명이 명시적으로 언급되지 않았을 때는 더욱 그렇습니다. 이는 저 스스로도 겪었던 페인 포인트(pain point)이기도 합니다. 예를 들어, 한 유튜버가 브랜드명을 밝히지 않고 "겨울철 필수 아이템 탑 10"과 같은 동영상에서 여러분의 제품을 소개했다면, 일반적인 YouTube 키워드 검색으로는 이를 찾아낼 수 없습니다.
이러한 상황에서 Twelve Labs API를 활용한 'Who Talked About Us(누가 우리 이야기를 했을까)' 서비스는 게임 체인저가 될 수 있습니다. 일반적인 YouTube나 TikTok 검색과 달리, 이 API는 깊이 있는 맥락적 동영상 검색을 지원합니다. 단순히 제목이나 설명에 의존하지 않고, 동영상에서 움직임, 오브젝트, 인물, 소리, 화면 상의 텍스트, 음성 등 다양한 요소를 추출합니다. "MAC 골드 하이라이터 사용법"과 같이 키워드나 구체적인 묘사를 입력하는 것만으로, 여러분의 브랜드나 제품을 언급하는 동영상과 채널을 찾아내고 해당 언급이 등장하는 정확한 순간까지 포착할 수 있습니다.
이를 통해 연락을 취할 인플루언서 리스트는 물론, 이들이 언급한 제품 정보와 맥락에 대한 세부 정보까지 확보할 수 있습니다. 잠재적 인플루언서들에 대한 이러한 깊이 있는 인사이트는 이들과 더욱 효과적으로 소통하고 의미 있는 관계를 구축할 수 있는 강력한 무기가 됩니다.
자, 이제 Twelve Labs API를 활용하여 이러한 강력한 앱을 만드는 과정을 단계별로 함께 살펴보겠습니다!
사전 준비 단계
Twelve Labs API의 세계로 뛰어들기 전에, 먼저 회원 가입을 하고 API 키를 생성해야 합니다. Twelve Labs Playground를 방문하여 가입하고 API 키를 생성하세요. 가입 시 최대 10시간 분량의 동영상 콘텐츠를 인덱싱할 수 있는 무료 크레딧이 제공됩니다!
이 앱에 필요한 모든 파일이 담긴 저장소는 Github에서 확인하실 수 있습니다.
이 앱은 JavaScript, Node, React, React Query를 사용하여 제작되었습니다. 이러한 기술들에 익숙하시다면 도움이 되겠지만, 잘 모르시더라도 걱정하지 마세요. 이 글에서 가장 중요한 포인트는 Twelve Labs API가 무엇이고, 이 앱이 이를 어떻게 활용하는지 배우는 데 있으니까요.
1단계 - 컴포넌트 설계
이 앱은 React로 구축되었으며, React의 핵심은 요소들을 재사용 가능한 컴포넌트로 세분화하는 데 있습니다. 따라서 컴포넌트를 설계하는 것부터 시작했고, 물론 이 과정에서 여러 번의 수정을 거쳤습니다.

상위 수준에서 이 앱은 ExistingIndexForm, IndexForm, VideoIndex로 구성됩니다. IndexForm은 사용자가 새로운 인덱스를 생성할 수 있는 간단한 폼입니다. ExistingIndexForm은 사용자가 이 앱을 통해 이전에 이미 생성해 둔 인덱스의 ID를 제출할 수 있는 또 다른 간단한 폼입니다. VideoIndex는 인덱스의 상세 정보를 가져오는 호출이 이루어지고 VideoComponents가 위치하는 곳입니다.
VideoComponents는 동영상과 관련된 모든 컴포넌트로 구성됩니다. UploadYoutubeVideo 컴포넌트는 동영상 다운로드 및 인덱싱을 지원하며, 각 동영상 작업의 진행 상태를 보여줍니다. VideoList는 인덱스의 동영상들을 단순히 렌더링하며, PageNav를 사용해 페이지당 12개씩 가져와 보여줍니다. SearchForm은 사용자 입력(검색 쿼리)을 받아 검색 API 호출로 전달하는 동영상 검색 처리 컴포넌트입니다. SearchResults는 검색 결과와 동영상을 가져오는 API를 호출한 다음, 이를 인플루언서별로 정리합니다. 마지막으로 SearchResult는 각 검색 결과를 사용자가 보기 편한 방식으로 보여줍니다.
2단계 - 서버 및 API hook 구축
Server.js와 apiHooks.js는 Twelve Labs API 및 ytdl-core와 같은 다른 라이브러리로부터의 모든 API 호출을 관리하는 파일입니다. Server.js에는 Twelve Labs API 및 기타 API 호출을 처리하는 모든 엔드포인트가 위치합니다. apiHooks.js는 상태 관리, 캐시, 데이터 페칭(fetching)을 위한 커스텀 React Query hook 모음입니다. 서버, 아니 사실상 이 앱 전체의 핵심 기능은 Twelve Labs API를 활용한 동영상 검색이므로, 이를 어떻게 사용하는지 자세히 살펴보겠습니다.
Twelve Labs API 사용을 위한 4단계
첫 번째 단계는 동영상을 위한 인덱스를 생성하는 것입니다. 그 다음 이 인덱스에 동영상을 업로드합니다. 그런 다음 동영상 메타데이터를 업데이트하여 각 동영상에 YouTube 채널과 URL을 추가합니다 (이 단계는 본 앱에 특화된 단계이며 일반적으로는 선택 사항입니다). 마지막으로 동영상 검색을 시작할 준비가 끝납니다. 이 앱에서는 Twelve Labs 및 기타 API 호출과 동영상 업로드 관련 기능을 모두 server.js 파일에 정리해 두었습니다.
환경 설정
루트 디렉토리에 .env 파일을 생성하고 필요한 값을 업데이트합니다. 아래 내용을 그대로 복사하여 붙여넣고 값을 커스텀하시면 됩니다.
.env
REACT_APP_API_URL=https://api.twelvelabs.io/v1.1 REACT_APP_API_KEY=<YOUR API KEY> REACT_APP_SERVER_URL=<YOUR SERVER URL> REACT_APP_PORT_NUMBER=<YOUR PORT NUMBER>
REACT_APP_API_URL: 이 앱은 v1.1을 지원합니다.
REACT_APP_API_KEY: 이전 단계에서 생성한 API Key를 저장합니다.
REACT_APP_SERVER_URL: "http://localhost"와 같은 형식이 될 수 있습니다.
REACT_APP_PORT_NUMBER: 사용하고자 하는 포트 번호를 설정합니다 (예: 4001).
파일 내에서 환경 변수 값에 접근하려면 process.env를 활용할 수 있습니다. 예를 들어, server.js 파일에서는 process.env.REACT_APP_APP_URL을 사용하여 API_URL에 접근하고 이를 저장할 수 있습니다. 다음 예시는 이를 구현하는 방법을 보여줍니다.
/** 상수 정의 및 TL API 엔드포인트 구성 */ const TWELVE_LABS_API_KEY = process.env.REACT_APP_API_KEY; const API_BASE_URL = process.env.REACT_APP_API_URL; const TWELVE_LABS_API = axios.create({ baseURL: API_BASE_URL }); const PORT_NUMBER = process.env.REACT_APP_PORT_NUMBER;
1단계. 인덱스 생성
인덱스는 동영상을 업로드, 인덱싱 및 검색할 수 있는 일종의 동영상 라이브러리입니다. 날짜, 주제 또는 YouTube 채널별로 자신만의 인덱스를 생성할 수 있습니다. 인덱스를 생성하려면 메서드를 'POST'로, 엔드포인트를 'https://api.twelvelabs.io/v1.1/indexes'로 설정한 뒤 헤더를 추가하고 engine_id, index_options, index_name과 같이 필요한 데이터를 제공하면 됩니다. index_options의 경우 네 가지 옵션 중 일부를 선택할 수 있습니다. 이 앱에서는 네 가지 옵션이 모두 포함되었습니다.
💡인덱스 생성에 대한 자세한 내용은 API 레퍼런스를 확인하세요.
/** 인덱스를 생성합니다 */ app.post("/indexes", async (request, response, next) => { const headers = { "Content-Type": "application/json", "x-api-key": TWELVE_LABS_API_KEY, }; const data = { engine_id: "marengo2.5", index_options: ["visual", "conversation", "text_in_video", "logo"], index_name: request.body.indexName, }; try { const apiResponse = await TWELVE_LABS_API.post("/indexes", data, { headers, }); response.json(apiResponse.data); } catch (error) { console.error("서버 측 에러:", error); response.json({ error }); } });
2단계. YouTube URL을 통해 동영상 업로드하기
이 앱은 채널 ID, 재생목록 ID 또는 아래와 같은 형식의 URL 객체 배열이 포함된 JSON 파일을 통해 YouTube 동영상을 대량으로 업로드하는 기능을 지원합니다.
example.json
[ { "url": "VIDEO URL" }, { "url": "VIDEO URL" } ... ]
💡YouTube URL을 통한 동영상 업로드는 현재 Twelve Labs API v1.2에서 지원됩니다. 이 앱은 v1.1을 사용하므로, YouTube에서 동영상을 다운로드할 수 있게 해주는 라이브러리인 ytdl-core를 활용해 직접 이 기능을 구현하고 있습니다.
따라서 전체 프로세스는 제공된 YouTube URL에서 동영상을 다운로드하여 이를 Twelve Labs API에 제출하고 이를 인덱싱하는 과정으로 진행됩니다. 이 동작은 server.js의 "/download" 엔드포인트에 구현되어 있습니다. 먼저 요청 바디(body)에서 동영상 데이터와 인덱싱 정보를 추출합니다. 그런 다음 동영상을 청크(chunk) 단위로 다운로드하고, 안전한 파일명을 위해 제목을 정제(sanitize)한 뒤 인덱싱을 위해 제출합니다. 모든 동영상이 다운로드되고 인덱싱되면, 서버는 작업(task) ID와 인덱스 ID를 응답으로 보냅니다. 각 단계를 세분화하여 자세히 살펴보겠습니다.
1 - 요청에서 정보 추출하기
첫 번째 단계는 요청의 바디(body)에서 동영상 데이터와 인덱싱 정보를 추출하는 것입니다. 전체 동영상 수, 처리된 동영상 수, 그리고 동영상 다운로드 및 인덱싱을 위한 청크 크기(이 앱의 경우 5로 설정)를 추적할 변수들을 구성합니다. 또한 동영상 인덱싱 프로세스의 응답들을 저장할 배열을 초기화합니다.
/** 분석을 위해 동영상을 다운로드 및 인덱싱하고, 작업 ID와 인덱스 ID를 반환합니다 */ app.post( "/download", bodyParser.urlencoded(), async (request, response, next) => { try { // 1단계: 요청에서 동영상 데이터 및 인덱스 정보 추출 const jsonVideos = request.body.videoData; const totalVideos = jsonVideos.length; let processedVideosCount = 0; const chunk_size = 5; let videoIndexingResponses = []; console.log("동영상 다운로드 중...");
2 - 청크 단위로 동영상 다운로드
다음 단계는 동영상을 청크 단위로 다운로드하는 것입니다. 각 청크에 대해 동영상 데이터를 반복 실행하면서 ytdl-core 라이브러리를 사용해 제공된 URL에서 동영상을 다운로드합니다. 동영상 제목은 안전한 파일명을 만들기 위해 특수문자 등을 제거하여 정제됩니다. 동영상 다운로드가 완료되면 그 진행 상황이 로그에 기록됩니다.
💡여기서 다운로드된 동영상이 'videos' 폴더에 저장되도록 videoPath를 설정합니다.
// 2단계: 청크 단위로 동영상 다운로드 for (let i = 0; i < totalVideos; i += chunk_size) { const videoChunk = jsonVideos.slice(i, i + chunk_size); const chunkDownloadedVideos = []; // 현재 청크의 각 동영상 다운로드 await Promise.all( videoChunk.map(async (videoData) => { try { // 다운로드할 동영상의 안전한 파일명 생성 const safeName = sanitize(videoData.title); const videoPath = `videos/${safeName}.mp4`; // 제공된 URL에서 동영상 다운로드 const stream = ytdl(videoData.url, { filter: "videoandaudio", format: ".mp4", }); await streamPipeline(stream, fs.createWriteStream(videoPath)); console.log(`${videoPath} -- 다운로드 완료`); chunkDownloadedVideos.push({ videoPath: videoPath, videoData: videoData, }); } catch (error) { console.log(`${videoData.title} 다운로드 중 에러 발생`); console.error(error); } }) );
3 - 인덱싱을 위한 동영상 제출
동영상 청크 다운로드가 완료되면, 이 다운로드된 동영상들을 인덱싱을 위해 제출합니다. 모든 인덱싱 작업이 완료될 때까지 대기하며 인덱싱 제출 진행 상황을 기록합니다.
// 3단계: 다운로드된 동영상을 인덱싱을 위해 제출 console.log( `인덱싱을 위한 동영상 제출 중 | 청크 ${ Math.floor(i / chunk_size) + 1 }` ); const chunkVideoIndexingResponses = await Promise.all( chunkDownloadedVideos.map(async (chunkDownloadedVideo) => { console.log( `인덱싱을 위해 ${chunkDownloadedVideo.videoPath} 제출 중...` ); const indexingResponse = await indexVideo( chunkDownloadedVideo.videoPath, request.body.index_id ); // indexingResponse에 videoData 추가 indexingResponse.videoData = chunkDownloadedVideo.videoData; return indexingResponse; }) ).catch(next); console.log("청크에 대한 인덱싱 제출 완료 | 작업 ID 목록:"); processedVideosCount += videoChunk.length; console.log( `총 ${totalVideos}개 비디오 중 ${processedVideosCount}개 처리 완료` ); videoIndexingResponses = videoIndexingResponses.concat( chunkVideoIndexingResponses ); await new Promise((resolve) => setTimeout(resolve, 1000)); }
이어서 indexVideo 함수도 가볍게 살펴보겠습니다. 동영상 인덱싱 과정 자체는 꽤 명료합니다. 전형적인 API 호출과 마찬가지로, Twelve Labs API에 POST 요청을 보내어 인덱싱 작업을 시작합니다. 이 요청에는 몇 가지 필수 매개변수가 포함됩니다. 대상 인덱스를 지정하는 index_id, 동영상 데이터 스트림 파일인 video_file, 그리고 언어 설정(영어의 경우 'en')입니다.
💡동영상 인덱싱 작업 생성에 대한 자세한 내용은 API 레퍼런스를 참고하세요.
/** 다운로드된 동영상을 받아 인덱싱 프로세스를 시작합니다 */ const indexVideo = async (videoPath, indexId) => { const headers = { headers: { accept: "application/json", "Content-Type": "multipart/form-data", "x-api-key": TWELVE_LABS_API_KEY, }, }; let params = { index_id: indexId, video_file: fs.createReadStream(videoPath), language: "en", }; const response = await TWELVE_LABS_API.post("/tasks", params, headers); return await response.data; };
이 호출에서 반환되는 response.data는 아래와 같은 작업(task) ID를 리턴합니다. 이전 코드 스니펫에서 보셨듯이 각 작업 ID는 indexingResponse에 저장됩니다.
response.data
{ "_id": "6527732e23c1347ffbe3a802" }
다만, 우리는 나중에 각 동영상의 메타데이터에 videoData를 추가해 주어야 하므로, response.data 위에 videoData를 인입하여 최종 indexingResponse를 구성합니다. 최종 응답의 구조는 다음과 같으며, 이는 videoIndexingResponses 배열에 계속 결합(concat)됩니다.
videoIndexingResponses
[ { _id: '6527732e23c1347ffbe3a802', videoData: { url: 'https://www.youtube.com/watch?v=WuWnt2NHJxk&list=PLI8sddTC_LM0SG6JMYmrbrHZy7j5zUjMA&index=4&pp=iAQB', title: 'Everyday Makeup Using ONLY 3 Products !!', authorName: 'Smitha Deepak', thumbnails: [Array] } }, … ]
4 - 작업 ID와 함께 응답하기
모든 동영상 다운로드 및 인덱싱 제출이 완료되면 해당 작업 ID 객체(위의 배열)와 인덱스 ID를 클라이언트에 응답으로 반환합니다.
// 4단계: 인덱싱 작업들의 작업 ID 및 인덱스 ID 반환 console.log( "모든 동영상에 대한 인덱싱 제출 완료. 작업 ID 목록:" ); console.log(videoIndexingResponses); response.json({ taskIds: videoIndexingResponses, indexId: request.body.index_id, }); } catch (error) { next(error); } } );
3단계. 동영상 메타데이터 업데이트
일반적으로 이 단계는 선택 사항일 수 있지만, 저희 앱에서 유튜브 URL을 포함하고 채널 및 인플루언서별로 동영상을 보여주기 위해 꼭 필요합니다.
앱에서 동영상이 표시되는 방식

보시다시피, 앱은 각 동영상마다 연한 녹색 타원 모양의 필(pill) 디자인 안에 채널명을 보여줍니다. 또한 동영상은 제3자 서버 URL 대신 YouTube URL을 활용해 React Player를 통해 재생됩니다. 동영상의 기본 메타데이터에는 채널명과 YouTube URL이 포함되어 있지 않기 때문에, 각 동영상의 메타데이터에 이 정보들을 직접 넣어주어야 합니다.
다음은 기본 상태의 동영상 메타데이터 예시입니다.
"metadata": { "duration": 188.6, "filename": "3 MINUTE MAKEUP THAT WILL LAST ALL DAY.mp4", "fps": 25, "height": 720, "size": 40566200, "video_title": "3 MINUTE MAKEUP THAT WILL LAST ALL DAY.mp4", "width": 1280, }
여기에 새로운 키-값 쌍을 추가하거나 기존 메타데이터를 자유롭게 업데이트할 수 있습니다. 이 앱에서는 메타데이터에 세 가지 데이터를 추가할 예정입니다. 바로 1) author (채널명), 2) youtube url, 그리고 3) 이 앱을 통해 업로드된 동영상인지를 식별하기 위한 불리언(boolean) 값인 whoTalkedAboutUs입니다.
이 작업은 server.js 파일 내 동영상 메타데이터 업데이트를 처리하는 엔드포인트에서 제어됩니다. 여기서는 업데이트할 데이터를 담아 PUT 요청을 보내는 것을 확인하실 수 있습니다.
💡동영상 정보 업데이트에 대한 상세 기술 규격은 API 레퍼런스를 확인하세요.
/** 동영상 메타데이터를 업데이트합니다 */ app.put("/update/:indexId/:videoId", async (request, response, next) => { const indexId = request.params.indexId; const videoId = request.params.videoId; const data = request.body; const headers = { "Content-Type": "application/json", "x-api-key": TWELVE_LABS_API_KEY, }; try { const apiResponse = await TWELVE_LABS_API.put( `/indexes/${indexId}/videos/${videoId}`, data, { headers } ); response.json(apiResponse.data); } catch (error) { return next(error); } });
이제 이 로직이 UploadYouTubeVideo.js 파일 안에서 구체적으로 어떻게 구현되어 있는지 살펴보겠습니다.
UploadYouTubeVideo.js (190 - 223행)
async function updateMetadata() { const updatePromises = completeTasks.map(async (completeTask) => { const matchingVid = taskVideos.find( (taskVid) => `${sanitize(taskVid.title)}.mp4` === completeTask.metadata.filename ); if (matchingVid) { const authorName = matchingVid.author.name; const youtubeUrl = matchingVid.video_url || matchingVid.shortUrl; const data = { metadata: { author: authorName, youtubeUrl: youtubeUrl, whoTalkedAboutUs: true, }, }; try { await fetch( `${UPDATE_VIDEO_URL}/${currIndex}/${completeTask.video_id}`, { method: "PUT", headers: { "Content-Type": "application/json", }, body: JSON.stringify(data), } ); } catch (error) { console.error(error); } } }); await Promise.all(updatePromises); }
updateMetadata 함수는 전체 작업 비디오 중 완료된 작업 비디오와 매치되는 대상을 찾습니다. 일치하는 건을 찾으면 업로더(author) 이름과 YouTube URL을 추출하여 커스텀 메타데이터 객체를 구축합니다. whoTalkedAboutUs 키의 값은 모든 동영상에 대해 마찬가지로 true로 설정합니다. 그런 다음 서버에 이 변경 사항을 적용하라는 요청을 전송하게 됩니다.
💡제공하는 데이터는 'metadata'라는 키를 가진 객체 형식이어야 하며, 이 하단에서 여러분이 동영상 데이터를 개인화할 키-값 쌍을 자유롭게 추가하거나 수정할 수 있습니다.
이 프로세스를 마치면 동영상 메타데이터에 우리가 원하던 'author', 'youtubeUrl', 그리고 'whoTalkedAboutUs' 항목이 자랑스럽게 나타나게 됩니다! 메타데이터 업데이트는 수집해 둔 동영상 라이브러리에 고유한 식별 스탬프를 찍는 환상적인 방법입니다.
"metadata": { "author": "Justine Feather", //추가! "duration": 188.6, "filename": "3 MINUTE MAKEUP THAT WILL LAST ALL DAY.mp4", "fps": 25, "height": 720, "size": 40566200, "video_title": "3 MINUTE MAKEUP THAT WILL LAST ALL DAY.mp4", "whoTalkedAboutUs": true, //추가! "width": 1280, "youtubeUrl": "https://www.youtube.com/watch?v=aYQWSNAL4D8" //추가! }
4단계. 동영상 검색
마침내 여러분이 기다리시던 순간, 동영상 검색 단계입니다! 이제 인덱싱된 여러분의 전체 동영상 데이터베이스 속에서 필요한 동영상을 정밀 검색할 수 있습니다.
본 앱에서는 검색 결과를 보여주기 위해 페이지네이션(pagination)을 적용했습니다. 따라서 검색 결과를 가져와서 렌더링하는 프로세스는 최초 검색 결과를 획득하는 POST 요청 처리 부분과, 다음 페이지 토큰(next page token)을 활용해 이후 검색 결과 페이지들을 차례로 호출하는 GET 요청 처리 부분 두 가지로 나뉩니다.
검색 POST 요청 생성
최초 검색 결과를 획득하기 위해 server.js 내에 위치한 검색 엔드포인트에 POST 요청을 보냅니다. 들어온 요청으로부터 indexId와 query를 가져와 TwelveLabs API의 '/search' 엔드포인트로 POST 요청을 생성합니다.
'search_options'로는 visual, conversation, text_in_video, logo 네 가지를 모두 지정했습니다. 또한 threshold, sort_option, group_by 같은 여러 설정도 함께 제공했습니다. 이러한 필터링과 설정을 통해 저는 신뢰도 레벨 수준이 "중(medium)" 및 "고(high)"인 결과만을 고르고, 이를 동영상별로 묶어(group) 클립 개수를 기준으로 최상단에 오도록 정렬하고 있습니다.
💡검색 요청 생성에 관한 상세 안내사항은 API 레퍼런스를 참고하세요.
/** 특정 쿼리로 동영상을 검색합니다 */ app.post("/search", async (request, response, next) => { const headers = { accept: "application/json", "Content-Type": "application/json", "x-api-key": TWELVE_LABS_API_KEY, }; const data = { index_id: request.body.indexId, search_options: ["visual", "conversation", "text_in_video", "logo"], query: request.body.query, group_by: "video", sort_option: "clip_count", threshold: "medium", page_limit: 2, }; try { const apiResponse = await TWELVE_LABS_API.post("/search", data, { headers, }); response.json(apiResponse.data); } catch (error) { return next(error); } });
'/search' 엔드포인트를 열어두면, useGetVideosOfSearchResults 기능이 유기적으로 연동됩니다. 이 커스텀 hook은 React Query 기술을 바탕으로 동영상 검색 및 조회 기능을 병렬적으로 한 번에 처리합니다. SearchResults.js 단에서 가져와서 사용되며, 쿼리 응답 정보 중 다음 페이지 토큰, 검색 결과, 검색 매칭 동영상이 변수 영역으로 분리 추출됩니다.
/** 1차 검색 결과와 및 매칭되는 동영상들을 조회합니다 */ const { initialSearchData: { page_info: { next_page_token: initialNextPageToken } = {}, } = {}, initialSearchResults, initialSearchResultVideos, refetch, } = useGetVideosOfSearchResults(currIndex, finalSearchQuery);
useGetVideosOfSearchResults 함수는 apiHooks.js 파일에 정의되어 있습니다. 설명처럼, 이 함수는 1차 검색 결과 집합을 우선 획득한 뒤 연달아 각 매칭 결과물의 비디오 디테일 세부 내용까지 병렬로 함께 가져오기 위해 useQueries 훅을 사용해 동시 다발 쿼리를 처리합니다.
export function useGetVideosOfSearchResults(indexId, query) { const { data: initialSearchData, refetch, isLoading, } = useSearchVideo(indexId, query); const initialSearchResults = initialSearchData.data || []; const resultVideos = useQueries({ queries: initialSearchResults.map((searchResult) => ({ queryKey: [keys.SEARCH, indexId, searchResult.id], queryFn: () => apiConfig.TWELVE_LABS_API.get( `${apiConfig.INDEXES_URL}/${indexId}/videos/${searchResult.id}` ).then((res) => res.data), })), }); const initialSearchResultVideos = resultVideos.map(({ data }) => data); return { initialSearchData, initialSearchResults, initialSearchResultVideos, refetch, isLoading, }; }
보시면 동영상 검색 결과를 먼저 가져오기 위해 useSearchVideo hook을 내부적으로 처음 사용합니다.
export function useSearchVideo(indexId, query) { return useQuery({ queryKey: [keys.SEARCH, indexId, query], queryFn: () => apiConfig.TWELVE_LABS_API.post(apiConfig.SEARCH_URL, { indexId, query, }).then((res) => res.data), }); }
이러한 api 훅 호출 결과물로 확보하는 가공 전 1차 로우 데이터 목록인 initialSearchResults 및 initialSearchResultVideos 포맷은 대략 아래와 같습니다.
initialSearchResults
[ {clips: [ {score: 76.1, start: 21.15625, end: 37.15625, metadata: Array(1), video_id: '65651c021cbcab2e74d86eb3', confidence: “medium”, modules: [{...}]}, {…}, … ], id: '65651c021cbcab2e74d86eb3'}, {clips: Array(6), id:... }, … ]
개별 1차 검색 항목은 매칭된 정보 단위인 'clips' 구조와 이에 연동된 video_id 식별자인 'id' 속성으로 정리되어 나옵니다.
initialSearchResultVideos
[ { “created_at”: "2023-12-07T18:32:01Z" “hls”: {...}, “indexed_at”: "2023-12-07T18:34:06Z", “metadata”: {...}, “Updated_at”: "2023-12-07T18:35:01Z", “_id”: "65720fa11cbcab2e74d87aab" }, … ]
initialSearchResultVideos는 해당 검색 결과에 매칭되는 동영상들의 메타데이터를 포함한 동영상 객체들로 구성됩니다.
이렇게 최초 검색 결과와 매칭 동영상을 확보했습니다. 하지만 'next_page_token'이 존재한다면, 이는 아직 탐색되지 않은 검색 결과가 더 남아있음을 의미합니다. 이 점 때문에 저는 검색된 모든 정보와 동영상을 하나의 상태에 안전하게 합쳐 두고 지속 처리하기 위해 'combinedSearchResults'와 'combinedSearchResultVideos'라는 별도 보완 상태값을 두었습니다. 특히 이번 앱의 레이아웃 성격상 인플루언서(YouTube 채널)별 분류 그룹핑 형태로 화면에 정리해 보여주어야 하므로, 모든 파편화된 데이터 목록을 한 번에 통합 정제한 다음에 최종 출력하는 식의 구조 설계가 반드시 필요합니다.
그럼 이어서 다음 뒤편에 있는 성격의 검색 리스트 페이지 데이터들을 연계해 읽는 구조를 살펴보겠습니다.
검색 GET 요청 생성하기
앞서 최초 검색 데이터의 응답단에서 next_page_token 값을 확인해 둔 것 기억하시나요? 이 획득한 next_page_token을 바탕으로 뒤이은 항목에 대해 GET 전송 방식을 취해 결과물 리스트를 수집할 수 있습니다. 검색 정보 내부에 next_page_token 속성이 잡히지 않고 아예 null 등이 반환될 때까지 반복 순회하는 호출 설정을 마련하면 전체 데이터를 완전히 수렴해 모을 수 있게 됩니다.
const nextPageResultsData = await fetchNextPageSearchResults( queryClient, nextPageToken );
fetchNextPageSearchResults는 Twelve Labs API의 '/search' 다음 정보 리포지토리에 GET 호출을 일으켜 가져옵니다. 여기서는 유저 버튼 작동 제어 흐름에 맞추어 조건 반응식으로 동작을 이행하려고 fetchQuery 처리를 하였습니다.
export async function fetchNextPageSearchResults(queryClient, nextPageToken) { try { const response = await queryClient.fetchQuery({ queryKey: [keys.SEARCH, nextPageToken], queryFn: async () => { const response = await apiConfig.TWELVE_LABS_API.get( `${apiConfig.SEARCH_URL}/${nextPageToken}` ); const data = response.data; return data; }, }); return response; } catch (error) { console.error("검색 결과 다음 페이지 로드 중 에러 발생:", error); throw error; } }
추출된 개별 검색 속성 형태에 알맞게, 앞서 이행한 바와 유사한 구조로 fetchNextpageSearchResultVideos를 가동해서 상세 실체 비디오 객체도 일괄 획득해 오게 됩니다. 완료되면 각각 앞단의 combinedSearchResults와 combinedSearchResultVideos 보관 메모리에 인입 및 보완 처리합니다.
상태 관리 중인 combinedSearchResults 및 combinedSearchResultVideos 내역들이 변동 감지될 때마다 전체 디스플레이 포맷은 바이어스 없이 자동 재조정 처리 흐름을 밟으며 (구식 변동 로직 코드 바로가기), 레이아웃은 정렬되어 SearchResult 컴포넌트를 타고 사용자 화면에 깔끔하게 배치됩니다.
💡검색 리스트 페이지 뷰 처리 세부 핸들링 제어는 공식 API 가이드라인 내 페이징 부분을 자세히 확인해 보시기 바랍니다.
이제 우리가 필요로 하는 모든 데이터에 액세스할 수 있는 서버 및 API 훅 빌딩이 끝났으므로, 화면단 컴포넌트 구성을 단계적으로 밟아가며 조립하면 됩니다. 이 컴포넌트들을 모두 결합해 실전성 있는 파워풀한 인플루언서 발굴 정보 탐색 앱을 짜보겠습니다. 집중해 주세요!
3단계 - 컨테이너 컴포넌트(Container Components) 구축
컴포넌트를 만드는 과정에 있어서, 컨테이너 컴포넌트(Container Component)를 먼저 설계하는 편이 추후 작업을 단순화시킵니다. 실제로 유저 화면에 보이는 요소(Presentation Component)들은 대개 데이터 실체나 훅 상태에 맞물려 동작하는 면이 크기 때문입니다.
💡혹시 리액트 설계 명칭인 컨테이너 컴포넌트와 프레젠테이션 컴포넌트라는 개념 구분이 생소하시더라도 괜찮습니다. 컨테이너 컴포넌트(스마트 컴포넌트)는 어플리케이션 안의 제어 로직, 비즈니스 처리 및 데이터 API 호출, 그리고 전반적인 내부 흐름 상태를 장악해 조절하는 부모 컨트롤 격입니다. 반면 프레젠테이션 컴포넌트(덤 컴포넌트)는 오로지 전달받은 데이터를 정적인 UI에 이쁘게 표현 구성하고 버튼 트리거 등을 단순히 Props 함수로만 넘겨 작동하는, 뷰에 집중한 부품 단위입니다.
3.1 - VideoComponent.js
VideoComponents는 앱의 중심이 되는 부분으로 동영상 업로드, 동영상 검색, 인덱스 내 재생목록들과 유튜브 정보 디스플레이 등의 기능을 모두 통합 컨트롤하는 허브입니다. 유저는 손쉽게 다수의 파일을 올리고, 정밀 검색하고, 페이지 단위로 정리된 동영상 라이브러리에 액세스할 수 있습니다. 필요할 때 한 번에 인덱스를 지우는 제어도 이쪽에서 가능합니다. 동작 흐름을 추적해 볼까요?
/** 비디오 상호작용 관련 로직을 통제하는 허브 컴포넌트들 * * VideoIndex -> VideoComponents -> { UploadYouTubeVideo, VideoList, PageNav, * SearchForm, SearchResults } * */ export function VideoComponents({ currIndex, vidPage, setVidPage, taskVideos, setTaskVideos, }) { const [searchQuery, setSearchQuery] = useState(""); const [finalSearchQuery, setFinalSearchQuery] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); const { setIndexId } = useContext(setIndexIdContext); const queryClient = useQueryClient(); const { data: videosData, refetch: refetchVideos, isPreviousData, } = useGetVideos(currIndex, vidPage, VID_PAGE_LIMIT); const videos = videosData?.data; const { data: authors, refetch: refetchAuthors } = useGetAllAuthors(currIndex); function reset() { setSearchQuery(""); setFinalSearchQuery(""); } useEffect(() => { queryClient.invalidateQueries({ queryKey: [keys.VIDEOS, currIndex, vidPage], }); }, [taskVideos, currIndex, vidPage]); useEffect(() => { queryClient.invalidateQueries({ queryKey: [keys.AUTHORS, currIndex], }); }, [videos, currIndex]);
VideoComponents는 실제 동영상 리스트와 크리에이터(author) 목록을 받아오는 핵심 쿼리들이 가동되는 무대입니다. 또한 보정 완료되거나 수록 완료된 정보를 즉각 새로고침 동기화하고자 해당 데이터 쿼리의 데이터 영속 캐시값을 임의 만료(invalidating)처리하는 구조도 내장하고 있습니다. 입력값과 진행률 정보를 UploadYouTubeVideo 및 SearchResults 같은 내부 자식 컴포넌트들과 서로 폭넓게 리액티브하게 나누어 쓰고자 관련 모달 및 제어 상태를 이 부모 층위에 모아 선언해 두었습니다.
UploadYouTubeVideo 렌더링하기
각 VideoIndex는 기본적으로 YouTube 플레이리스트, 특정 채널 아이디 또는 비디오 주소들의 모음 형태인 JSON 파일을 지정하여 한 번에 벌크 형태로 정보를 올릴 수 있는 UploadYouTubeVideo 컴포넌트를 마운트하여 기안해 보여줍니다. 상세 업로드 통제 로직은 뒤 절에서 자세하게 살펴보겠습니다.
return ( <> <div className="videoUploadForm"> <div className="display-6 m-4">새 동영상 업로드하기> <UploadYoutubeVideo currIndex={currIndex} taskVideos={taskVideos} setTaskVideos={setTaskVideos} refetchVideos={refetchVideos} isSubmitting={isSubmitting} setIsSubmitting={setIsSubmitting} reset={reset} /> <

SearchForm 및 VideoList 렌더링하기
이미 인덱스 스페이스 내에 인동영상 자료가 수록 완료되어 안착해 있을 때에는, 파일 로더단 뿐만 아니라 비디오를 전용 필터링 검색하는 뷰 폼도 화면에 표시되어 활성화됩니다. 만일 아무런 입력 쿼리 액션을 실행하지 않고 초기 진입한 비워진 상태라면 일반 동영상 정보 리스트들을 페이지당 최대 12개 한계에 연동하는 PageNav 부가 모듈을 활용하여 무리 없이 로출하게 합니다. 상세 동영상 목록 컴포넌트단은 곧 이어 보다 더 자세히 다루어 볼 요량입니다.
videoComponents.js (97 - 162행)
export function VideoComponents({ ... }) { return ( ... {videos && videos.length > 0 && ( <div> <div className="videoSearchForm"> <div className="title">동영상 검색</div> {/* <div className="m-auto p-3"> */} <SearchForm setSearchQuery={setSearchQuery} searchQuery={searchQuery} setFinalSearchQuery={setFinalSearchQuery} /> {/* </div> */} </div> {!finalSearchQuery && ( <div> <div className="channelPills"> <ErrorBoundary FallbackComponent={({ error }) => ( <ErrorFallback error={error} /> )} onReset={() => refetchAuthors()} resetKeys={[keys.AUTHORS, currIndex]} > <div className="subtitle"> 인덱스 내의 모든 인플루언서 ({authors?.length || 0}){" "} </div> {authors.map((author) => ( <div key={author} className="channelPill"> <Suspense fallback={<LoadingSpinner />}> {author} </Suspense> </div> ))} </ErrorBoundary> </div> <Container fluid className="mb-2"> <Row> <ErrorBoundary FallbackComponent={({ error }) => ( <ErrorFallback error={error} /> )} onReset={() => refetchVideos()} resetKeys={[keys.VIDEOS, currIndex, vidPage]} > {videos && ( <Suspense fallback={<LoadingSpinner />}> <VideoList videos={videos} refetchVideos={refetchVideos} /> </Suspense> )} <Container fluid className="d-flex justify-content-center"> <PageNav page={vidPage} setPage={setVidPage} data={videosData} isPreviousData={isPreviousData} /> </Container> </ErrorBoundary> </Row> </Container> <
검색 폼 및 동영상 리스트

SearchResults 렌더링하기
실제 검색 제출이 활성화되어 쿼리가 돌고 있는 상황(finalSearchQuery 발생 상태) 하에서는 정적 무가 가동 전체 리스트 대신, 그 쿼리 정량 필터링 타겟팅 전용으로 필터링되어 선별 수집된 결과들이 대신 채워지며 뿌려집니다.
VideoComponents.js (164 - 186행)
{finalSearchQuery AND ( <div> <Container fluid className="m-3"> <Row> <SearchResults currIndex={currIndex} allAuthors={authors} finalSearchQuery={finalSearchQuery} /> </Row> </Container> <div className="resetButtonWrapper"> <button className="resetButton" onClick={reset}> {backIcon AND ( <img src={backIcon} alt="Icon" className="icon" > )} 전체 동영상 목록으로 돌아가기 </button> </div> </div> )} <
기회가 되시면 채널별 분류 결과 조합 통제가 유기적으로 돌아가는 설계 디테일을 한 번 참고해 보시길 권장합니다. 매칭 검색에 일치하는 대상이 존재하지 않더라도 다른 보유 유관 채널 정보들까지 함께 수렴해 표현하여, 이 앱이 갖는 고유한 인디케이션 특징을 풍부하게 보여주게 됩니다!
검색 결과

3.2 - UploadYoutubeVideo.js
UploadYoutubeVideo는 VideoComponents.js의 또 다른 핵심 역할을 분담하고 있는 스마트한 동영상 일괄 수신용 컴포넌트입니다. 유저 기안 제출에 반응해 백스페이스에서 타오르는 작업 큐를 잡아내고, 각 동영상 인덱싱 연계 동작이 어느 단계에 있는지 그 변동 퍼센티지를 리스닝해 비주얼적으로 화면에 안전하게 띄워 처리하는 막중한 임무를 처리합니다.
그래서 로컬 로더에서 읽은 JSON 원본 저장, 유튜브 스페이스 상 채널/플레이리스트 인식 스토리지, 인덱싱 백 채기 진행용 상태 모듈 등을 매우 치밀하게 통제 관리하고 작동시켜 내부 유기성을 유지합니다. 인덱스 맵 구조 작동 핵심에 배속된 indexYouTubeVideos 연계 루프를 보다 직관적인 소스코드로 밀도 있게 관찰해 볼까요?
UploadYoutubeVideo.js (155 - 188행)
const indexYouTubeVideos = async () => { setIsSubmitting(true); updateMainMessage( "동영상을 업로드하는 동안 페이지를 새로고침하지 마세요. 업로드 중에도 동영상 검색은 가능합니다!" ); const videoData = taskVideos.map((taskVideo) => { return { url: taskVideo.video_url || taskVideo.url, title: taskVideo.title, authorName: taskVideo.author.name, thumbnails: taskVideo.thumbnails, }; }); const requestData = { videoData: videoData, index_id: currIndex, }; const data = { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json", }, body: JSON.stringify(requestData), }; const response = await fetch(DOWNLOAD_URL.toString(), data); const json = await response.json(); setIndexId(json.indexId); setTaskIds(json.taskIds); };
indexYouTubeVideos 함수는 전반적인 유튜브 인덱싱 작동 사이클을 총괄 제어합니다. 유저에게 강제로 디스크 페이지를 이동 리로드해 소트를 잃지 말도록 선제 정적 정보 알림창 메시지를 뿌리며 동작을 스타트하며, 연계 팩 데이터에서 대상이 되는 파일 주소, 기재 제목, 계정 홀더 이름 및 정합 썸네일을 골고루 수집 배치해 요청(Payload) 구조를 조립합니다.
이렇게 빌드된 배열 세트와 대상이 될 수용 인덱스 고유 식별자 키값을 묶어 하나의 실질 데이터 구성을 빌드한 뒤, 다운로더 백단 허브 URL로 POST API 인젝션을 송신 실행합니다 (우리가 2단계 영역에서 살펴보았던 구조입니다). 리턴되는 응답 속에는 향후 큐 작업 단에 지속 대조 처리할 인큐잉 식별 테스크 지시자 목록과 확정 처리된 수납 공간 인덱스 코드가 있으며, 이는 상태 영역 변수에 그대로 연동 기재되게 돌아갑니다.
UploadYouTubeVideo 컴포넌트는 그 외에도 여러 하위 상태 모듈 및 유틸 기능 유닛이 복합 유기적으로 연계 엮여있습니다. 지면 한계상 이 문서에서 세부 속성 전부를 전수 정리해 나열하지는 않았으나, 언제든 깃허브 전체 소스코드를 편하게 디깅해 탐색해 보시고 막히거나 질문이 있으시면 언제든지 편하게 문의해 주시기를 바랍니다!
4단계 - 프레젠테이션 컴포넌트(Presentation Components) 구축
이제 까다로운 동작 제어부 빌드가 성공적으로 끝났고, 유저 레이아웃 출력 전담 단위 컴포넌트인 프레젠테이션 컴포넌트(Presentation Components)를 구성해 프로젝트를 마무리할 준비를 합니다. 데이터가 인입되면 그것을 단순히 그려내는 저옵션 설계이며, 부모 격인 VideoComponents 허브 단으로부터 Props 연계값으로 받아 React Player 모듈에 안전하게 흘려 재생만 통제해 주는 가벼운 요소들입니다. 이 프로그램에서는 대표적으로 VideoList 및 TaskVideo 등이 여기에 대입됩니다. 대입 기준인 VideoList 예시를 찬찬히 들여다볼 수 있습니다.
function VideoList({ videos, refetchVideos }) { const numVideos = videos.length; return videos.map((video, index) => ( <ErrorBoundary FallbackComponent={ErrorFallback} onReset={() => refetchVideos()} resetKeys={[keys.VIDEOS]} key={video._id + "-" + index} > <Suspense> <Col sm={12} md={numVideos > 1 ? 6 : 12} lg={numVideos > 1 ? 4 : 12} xl={numVideos > 1 ? 3 : 12} className="mb-5 mt-3" > {" "} <ReactPlayer url={video.metadata.youtubeUrl} controls width="100%" height="250px" /> <div className="channelAndVideoName"> <div className="channelPillSmall">{video.metadata.author}</div> <div className="filename-text"> {video.metadata.filename.replace(".mp4", "")} </div> </div> </Col> </Suspense> <
단순 전달 배열 영상 루프를 가동하여 ReactPlayer를 통해 하나씩 마운트하는 처리를 해 주며, 매칭되는 크리에이터 채널 라이터(author) 정보와 가공 정제 포맷 파일명을 가시성 있게 뿌려 주는 레이아웃 구성입니다. 이 비주얼 구조가 어플리케이션 안에서 안착해 돌아가는 상태는 저 앞단에 설명 기재된 'SearchForm 및 VideoList 렌더링하기' 스크린 영역에서 확인해 보실 수 있습니다.
결론
이번 가이드 글을 통하여 실무 레이아웃 상에서 Twelve Labs의 비디오 인텔리전트 검색 API 기술 스페이스를 어떻게 스마트적으로 조율하고, 실제 비즈니스 가치에 결부시켜 유용하게 변환 전개할 수 있는지 유익한 힌트를 얻으셨기를 기대합니다. 해당 구조는 하나의 샘플 예시 스펙일 뿐이며, 여러분만의 상상력과 해결할 실무 태스크 장벽 높이에 알맞게 얼마든지 유연하게 확장 적용할 수 있는 설계 자유를 지니고 있습니다. 멋진 프로덕트를 마음껏 만들어 가시기를 바랍니다!
다음 단계는 무엇인가요?
퀵스타트 가이드 문서를 상세 검토해 보고 Twelve Labs 기술을 사용한 혁신적인 앱을 직접 만들어 보세요.
저희 개발용 Playground에 로그인하여 성능을 만끽해 보세요. 기본 영구 무료 동영상 가용 한도는 10시간 파이프라인으로 구성되어 배송됩니다.
동작과 관련한 유용한 새 소식 릴리즈는 공식 X (구 트위터) 및 LinkedIn 팀 정보창을 즉시 연계 팔로우해 보세요.
Twelve Labs 공식 디스코드 포럼에 참가하여 전 세계 다른 크리에이티브 빌더 개발자들과 활발하게 소통하며 기량을 나눠 보세요.




