들어가며
웹 메모 서비스에는 요약 기능이 있다. 유튜브 영상에서 요약 기능을 활성화하면 유튜브 영상 스크립트를 기반으로 요약을 하고 싶다. Node.js 라이브러리인 youtube-transcript를 활용해서 요약을 하고 있었는데, 언제부터인가 요약이 안된다. 스크립트를 불러오면 빈 배열만 오는 것을 확인할 수 있었다. youtue-transcript의 이슈를 확인해보니 더 이상 지원을 하지 않는 것으로 보인다.
다른 해결 방법을 강구해보자.
선택지
공식 Google API라 안정성과 신뢰성은 높지만, 정작 자막 콘텐츠는 직접 제공하지 않는다.
- 확장 프로그램 내에서 content-script를 가져와서 직접 자막을 가져오기
사용자가 보고 있는 화면에서 직접 자막을 가져오니 정확하게 동기화된다. 대신 구현 난이도가 상당히 높고, 유튜브 내부 구조가 바뀔 때마다 직접 고쳐야 한다.
- Python 기반의 YouTubeTranscriptApi 활용
Python 환경이 필요하긴 하지만, 활발하게 유지보수되고 있고 테스트 결과 잘 동작했다.
활발하게 업데이트를 하고 있는 YouTubeTranscriptApi를 사용하기로 결정했다.
개발
YouTubeTranscriptApi
이번 개발에서 사용할 파이썬 라이브러리는 YouTubeTranscriptApi이다. 이 API를 활용하면 자막이나 부제목 등의 유튜브 영상에 대한 정보를 얻어올 수 있다.
Python으로 유튜브 스크립트 가져오기
전체 코드
import argparse
from youtube_transcript_api import YouTubeTranscriptApi
from youtube_transcript_api.formatters import TextFormatter
def main():
parser = argparse.ArgumentParser(description='YouTube 동영상 자막을 다운로드합니다.')
parser.add_argument('--video-id', required=True, help='YouTube 동영상 ID')
args = parser.parse_args()
video_id = args.video_id
try:
ytt_api = YouTubeTranscriptApi()
transcript_list = ytt_api.list(video_id)
transcript = transcript_list.find_transcript(['ko', 'en'])
transcript_data = transcript.fetch()
except Exception as e:
print(f"자막을 가져오는 중 오류 발생: {e}")
exit(1)
text_formatter = TextFormatter()
text_formatted = text_formatter.format_transcript(transcript_data)
print(text_formatted)
if __name__ == "__main__":
main()
- argparse는 전달한 인자를 얻어오기 위한 라이브러리이다. 해당 스크립트를 실행할 때,
pythonxx.py—-video-id=”~”로 video-id를 인자로 전달할 수 있다.
parser = argparse.ArgumentParser(description='YouTube 동영상 자막을 다운로드합니다.')
parser.add_argument('--video-id', required=True, help='YouTube 동영상 ID')
args = parser.parse_args()
video_id = args.video_id
만약 필요한 인자를 전달하지 않으면 아래와 같이 에러를 발생시킬 수 있다.
- 이제, 위에서 설명한 YoutubeTranscriptApi를 활용해서 유튜브 스크립트를 얻어오자.
ytt_api = YouTubeTranscriptApi() # YouTubeTranscriptApi 인스턴스 생성
transcript_list = ytt_api.list(video_id) # 해당 비디오의 자막 리스트 가져오기
transcript = transcript_list.find_transcript(['ko', 'en']) # 한국어 또는 영어 자막 찾기
transcript_data = transcript.fetch() # 자막 데이터 가져오기
- 텍스트 포매팅
text_formatter = TextFormatter()
text_formatted = text_formatter.format_transcript(transcript_data)
print(text_formatted)
클라이언트에 반환하기 위해 TextFormatter의 포매팅 함수 format_transcript를 사용한다.
- 결과
Flask 프레임워크로 엔드포인트 만들기
전체 코드
# python-server.app.py
from flask import Flask, request, jsonify, Response
from flask_cors import CORS
import os
import json
from youtube_transcript_api import YouTubeTranscriptApi
from youtube_transcript_api.formatters import TextFormatter
app = Flask(__name__)
app.config['JSON_AS_ASCII'] = False
app.config['JSONIFY_MIMETYPE'] = 'application/json; charset=utf-8'
CORS(app)
def create_json_response(data, status_code=200):
"""UTF-8 인코딩을 보장하는 JSON 응답 생성"""
return Response(
json.dumps(data, ensure_ascii=False, indent=2),
status=status_code,
mimetype='application/json; charset=utf-8'
)
def get_youtube_transcript(video_id):
"""YouTube 동영상 자막을 가져오는 함수"""
try:
ytt_api = YouTubeTranscriptApi()
transcript_list = ytt_api.list(video_id)
transcript = transcript_list.find_transcript(['ko', 'en'])
transcript_data = transcript.fetch()
text_formatter = TextFormatter()
text_formatted = text_formatter.format_transcript(transcript_data)
return {
'success': True,
'transcript': text_formatted,
'video_id': video_id
}
except Exception as e:
return {
'success': False,
'error': str(e),
'video_id': video_id
}
@app.route('/api/youtube-transcript', methods=['GET'])
def youtube_transcript():
"""YouTube 자막 API 엔드포인트"""
video_id = request.args.get('video_id')
if not video_id:
return create_json_response({
'success': False,
'error': 'video_id 파라미터가 필요합니다.'
}, 400)
result = get_youtube_transcript(video_id)
if result['success']:
return create_json_response(result)
else:
return create_json_response(result, 500)
@app.route('/health', methods=['GET'])
def health_check():
"""서버 상태 확인"""
return create_json_response({
'status': 'healthy',
'message': 'YouTube Transcript Server is running'
})
@app.route('/', methods=['GET'])
def index():
"""기본 엔드포인트"""
return create_json_response({
'message': 'YouTube Transcript API Server',
'endpoints': {
'/api/youtube-transcript': 'GET - YouTube 자막 가져오기 (video_id 파라미터 필요)',
'/health': 'GET - 서버 상태 확인'
}
})
if __name__ == '__main__':
port = int(os.environ.get('PORT', 8085))
app.run(host='0.0.0.0', port=port, debug=True)
- Flask 서버를 초기화한다.
# packages/python-server/app.py
from flask import Flask
app = Flask(__name__) # Flas 애플리케이션 인스턴스 생성
app.config['JSON_AS_ASCII'] = False # JSON 응답이 UTF-8로 인코딩되도록 설정
app.config['JSONIFY_MIMETYPE'] = 'application/json; charset=utf-8' # JSONIFY의 MIME 타입을 UTF-8로 지정
CORS(app) # CORS(Cross-Origin Resource Sharing) 활성화
- 서버를 실행한다.
if __name__ == '__main__': # 이 파일이 메인으로 실행될 때만 아래 코드 실행
port = int(os.environ.get('PORT', 8085)) # 환경변수 PORT가 있으면 사용, 없으면 8085 포트 사용
app.run(host='0.0.0.0', port=port, debug=True) # 모든 IP에서 접근 가능, 지정 포트로 서버 실행, 디버그 모드 활성화
- 엔드포인트를 만든다.
엔드 포인트는 /api/youtube-transcript이고, 비디오 ID는 파라미터로 전달받을 것이다.
@app.route('/api/youtube-transcript', methods=['GET'])
def youtube_transcript():
"""YouTube 자막 API 엔드포인트"""
video_id = request.args.get('video_id') # 쿼리 파라미터에서 video_id 값을 가져옴
if not video_id: # video_id가 없으면
return create_json_response({
'success': False,
'error': 'video_id 파라미터가 필요합니다.' # video_id 파라미터가 필요하다는 에러 메시지 반환
}, 400) # HTTP 400(Bad Request) 상태 코드로 응답
result = get_youtube_transcript(video_id) # video_id로 자막을 가져오는 함수 호출
if result['success']: # 자막 가져오기에 성공한 경우
return create_json_response(result) # 성공 결과를 JSON으로 반환
else: # 자막 가져오기에 실패한 경우
return create_json_response(result, 500) # 실패 결과를 HTTP 500(Internal Server Error)로 반환
- 위에서 유튜브 자막을 가져오는 로직을
get_youtube_transcript라는 함수로 만들고, 자막을 return한다.
def get_youtube_transcript(video_id):
"""YouTube 동영상 자막을 가져오는 함수"""
try:
ytt_api = YouTubeTranscriptApi() # YouTubeTranscriptApi 인스턴스 생성
transcript_list = ytt_api.list(video_id) # 해당 비디오의 자막 리스트 가져오기
transcript = transcript_list.find_transcript(['ko', 'en']) # 한국어 또는 영어 자막 찾기
transcript_data = transcript.fetch() # 자막 데이터 가져오기
text_formatter = TextFormatter() # 텍스트 포매터 인스턴스 생성
text_formatted = text_formatter.format_transcript(transcript_data) # 자막 데이터를 텍스트로 변환
return {
'success': True,
'transcript': text_formatted,
'video_id': video_id
}
except Exception as e:
return {
'success': False,
'error': str(e),
'video_id': video_id
}
- 사용에 필요한 라이브러리를 정의한다.
# packages/python-server/requirements.txt
Flask==2.3.3
Flask-CORS==4.0.0
youtube-transcript-api==1.2.2
전부 위에서 사용한 라이브러리들이라는 것을 확인할 수 있다.
로컬에서 Flask 서버 실행하기
- 해당 패키지가 있는 폴더로 이동한다.
cd packages/python-server
- requirements.txt에 있는 라이브러리를 모두 설치한다.
pip install -r requirements.txt
- 서버를 실행한다.
python app.py
- localhost:8085 포트에 요청한다.
Railway로 Flask 서버 배포하기
Railway는 서버리스 플랫폼으로, 코드를 푸시하면 자동으로 빌드하고 배포해주는 서비스이다. Vercel과 상당히 유사한 서비스이다.
Railway CLI를 사용해서 아래와 같이 간편하게 배포할 수 있다.
railway loginrailway initrailway uprailway domain
트러블 슈팅
list()가 없습니다.
- youtube transcript api의 최신 버전인 1.2.2로 변경하여 해결
다른 방법
AWS Lambda 서버리스를 사용하는 방법도 있다. 아래와 같이 구현을 할 수 있지만 AWS Lambda는 콜드 스타트라는 단점이 있고, 로컬에서 테스트해보기 어렵다는 단점이 있어 최종적으로 선택하지 않았다. (찾아보니 콜드 스타트와 로컬에서 테스트하는 것 또한 해결할 수 있다고는 한다)
AWS Lambda 서버리스로 배포
- requirements.txt 파일 생성
youtube-transcript-api==0.6.1
- 로컬에서 배포 패키지 생성
# 가상환경 생성 및 활성화
python -m venv lambda-env
source lambda-env/bin/activate # Windows: lambda-env\Scripts\activate
# 패키지 설치
pip install youtube-transcript-api
# 배포 패키지 생성
pip install -t . youtube-transcript-api
zip -r lambda-function.zip .
- zip파일 업로드
- lambda_function.py에서 위 패키지 활용하는 코드 작성
코드
import json
from youtube_transcript_api import YouTubeTranscriptApi
from youtube_transcript_api.formatters import TextFormatter
from youtube_transcript_api.proxies import WebshareProxyConfig
def lambda_handler(event, context):
"""
AWS Lambda handler function for YouTube transcript extraction
"""
try:
# CORS 헤더 설정
headers = {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type'
}
# OPTIONS 요청 처리 (CORS preflight)
if event.get('httpMethod') == 'OPTIONS':
return {
'statusCode': 200,
'headers': headers,
'body': json.dumps({'message': 'CORS preflight'})
}
# GET 파라미터에서 video_id 추출
video_id = None
# API Gateway를 통한 요청인 경우
if 'queryStringParameters' in event and event['queryStringParameters']:
video_id = event['queryStringParameters'].get('video')
# 직접 Lambda 호출인 경우
elif 'video' in event:
video_id = event['video']
# video_id가 없는 경우
if not video_id:
return {
'statusCode': 400,
'headers': headers,
'body': json.dumps({
'error': 'video parameter is required',
'usage': 'Add ?video=VIDEO_ID to your request'
}, ensure_ascii=False)
}
ytt_api = YouTubeTranscriptApi(
proxy_config=WebshareProxyConfig(
proxy_username="sojdnauc",
proxy_password="70h2hp3xbhg8",
)
)
transcript_list = ytt_api.list(video_id)
transcript = transcript_list.find_transcript(['ko', 'en'])
transcript_data = transcript.fetch()
text_formatter = TextFormatter()
transcript = text_formatter.format_transcript(transcript_data)
return {
'statusCode': 200,
'headers': headers,
'body': json.dumps({
'success': True,
'video_id': video_id,
'transcript': transcript
}, ensure_ascii=False)
}
except Exception as e:
return {
'statusCode': 500,
'headers': headers,
'body': json.dumps({
'success': False,
'error': str(e)
}, ensure_ascii=False)
}
# 로컬 테스트용 함수 (선택사항)
if __name__ == "__main__":
# 테스트 이벤트
test_event = {
'httpMethod': 'GET',
'queryStringParameters': {
'video': 'kbfmeHOHnl0'
}
}
result = lambda_handler(test_event, None)
print(json.dumps(result, indent=2, ensure_ascii=False))
- transcript를 잘 가져오기는 하지만 시간이 20초로, 너무 오래 걸린다.
- 429(Too Much Request) Error
Vercel Functions로 배포
Vercel Functions는 Node.js 뿐 아니라 Python, Go, Ruby 등의 다양한 언어 환경을 서버리스로 제공한다.
전체 코드
# web/api/transcript.py
from http.server import BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
import json
from youtube_transcript_api import YouTubeTranscriptApi
from youtube_transcript_api.formatters import TextFormatter
class handler(BaseHTTPRequestHandler):
def do_GET(self):
try:
# URL 파라미터 파싱
parsed = urlparse(self.path)
params = parse_qs(parsed.query)
video_id = params.get('video', [None])[0]
if not video_id:
self._send_json_response(400, {"error": "video parameter required"})
return
# 여기서 실제 YouTube 처리 로직 실행
transcript = self.get_youtube_transcript(video_id)
self._send_json_response(200, {"transcript": transcript})
except Exception as e:
self._send_json_response(500, {"error": str(e)})
def _send_json_response(self, status, data):
self.send_response(status)
self.send_header('Content-type', 'application/json')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(json.dumps(data, ensure_ascii=False).encode('utf-8'))
def get_youtube_transcript(self, video_id):
try:
ytt_api = YouTubeTranscriptApi()
transcript_list = ytt_api.list(video_id)
transcript = transcript_list.find_transcript(['ko', 'en'])
transcript_data = transcript.fetch()
text_formatter = TextFormatter()
text_formatted = text_formatter.format_transcript(transcript_data)
return text_formatted
except Exception as e:
raise Exception(f"자막을 가져오는 중 오류 발생: {e}")
로컬에서는 잘 동작하는 것을 확인했지만, 배포된 환경에서는 시도를 해봐도 안되어서 포기를 했다.
맺으며
AWS Lambda, Vercel Functions를 시도했을 때 분명히 해결 방법이 있었을텐데 일찍이 포기한 것이 아쉬웠다. 유튜브 자막 요약이 안되는 것을 해결하는 것이 1순위였기에 하나의 해결 방법을 끝까지 해보는 것보다, 해결하는 것이 더 우선이기 때문에 택했던 방법이었다. 돌이켜보면 문제 해결을 더 높은 우선순위로 두었다는 점에서 잘한 선택이라는 생각이 든다.
처음 쓰는 Flask프레임워크와 Railway였음에도 AI 덕분에 빠르게 코드를 작성하고, 배포까지 할 수 있었고 큰 어려움이 없었다. 이제는 AI를 활용하면 서버도 이렇게 빠르게 만들어 배포까지 할 수 있구나 하는 생각이 들었다. 물론 프로젝트 규모가 커지고, 더 깊게 들어가면 깊이 있는 지식이 필요할 것이다. 그럼에도 기능 1-2개의 간단한 서버는 이렇게 쉽게 만들 수 있구나 깨달았다.
언어의 장벽이 확실히 낮아지고 있다. 이전에 Python, Java는 모두 학교에서 배운 언어이기 때문에 익숙한 것도 물론 있을 것이다. 만약 모른다고 하더라도 직관적인 문법(print 등), 혹은 유사한 문법(try:, except:)이기 때문에 조금만 익숙해진다면 빠르게 익힐 수 있지 않을까. 혹은 AI를 활용해서 학습을 할 수도 있고 말이다.
앞으로도 JavaScript, React.js라는 언어와 생태계에만 갇혀 있지 말자. 필요에 따라 언어와 도구를 골라 쓰는 ‘소프트웨어 엔지니어’, 제품을 중심에 두는 ‘프로덕트 엔지니어’ 쪽에 가까워지고 싶다.