유튜브 라이브 채팅 연동
YouTube 라이브 채팅으로 조작하는 실시간 물리 시뮬레이션 만들기
시청자들이 채팅 명령어로 물리 엔진을 실시간으로 조작할 수 있는 인터랙티브 라이브 스트림 시스템 구축기
![]()
📌 목차
🎯 프로젝트 개요
무엇을 만들었나?
YouTube 라이브 방송 시청자들이 채팅 명령어를 입력하면, 실시간으로 물리 시뮬레이션이 반응하는 인터랙티브 시스템입니다.
시청자: !ball 입력
→ 화면에 공이 떨어짐 (2-5초 이내)
시청자: !explode 입력
→ 모든 오브젝트가 폭발함
왜 만들었나?
- 시청자 참여형 콘텐츠: 일방적인 방송이 아닌 양방향 소통
- 실시간 인터랙션: 채팅과 화면이 즉각적으로 연동
- 기술 스택 학습: WebSocket, OAuth, 물리 엔진 통합 경험
데모 영상
[YouTube Live Stream Demo Link]
🛠 기술 스택
Frontend
- React 18 - UI 컴포넌트
- Vite - 빌드 도구 (빠른 HMR)
- Tailwind CSS - 스타일링
- Matter.js - 2D 물리 엔진
- WebSocket - 실시간 통신
Backend
- FastAPI - Python 비동기 웹 프레임워크
- WebSocket - 양방향 실시간 통신
- YouTube Data API v3 - 라이브 채팅 폴링
- Google OAuth 2.0 - 인증
- Uvicorn - ASGI 서버
Infrastructure
- OBS Studio - 방송 송출
- YouTube Live - 스트리밍 플랫폼
- Local Development - 개발 환경
🏗 시스템 아키텍처
전체 데이터 흐름
┌─────────────────┐
│ YouTube 채팅 │ !ball 입력
└────────┬────────┘
↓
┌─────────────────┐
│ YouTube API │ 2초마다 폴링
│ (REST API) │
└────────┬────────┘
↓
┌─────────────────────────────┐
│ FastAPI Backend │
│ ┌─────────────────────────┐ │
│ │ youtube_service.py │ │
│ │ - OAuth 인증 │ │
│ │ - 메시지 폴링 │ │
│ │ - 명령어 파싱 │ │
│ └───────────┬─────────────┘ │
│ ↓ │
│ ┌─────────────────────────┐ │
│ │ WebSocket Broadcaster │ │
│ │ - 모든 클라이언트에 │ │
│ │ 메시지 브로드캐스트 │ │
│ └───────────┬─────────────┘ │
└─────────────┼───────────────┘
↓
┌─────────┴──────────┐
↓ ↓
┌──────────────┐ ┌──────────────┐
│ localhost │ │ OBS Browser │
│ :5173 │ │ Source │
│ │ │ │
│ React + WS │ │ React + WS │
└──────┬───────┘ └──────┬───────┘
↓ ↓
┌──────────────┐ ┌──────────────┐
│ Matter.js │ │ Matter.js │
│ 물리 엔진 │ │ 물리 엔진 │
│ │ │ │
│ 🎱 공 생성 │ │ 🎱 공 생성 │
└──────────────┘ └──────┬───────┘
↓
┌──────────────┐
│ OBS Studio │
│ 인코딩 + 송출│
└──────┬───────┘
↓
┌──────────────┐
│ YouTube Live │
│ (2-5초 지연) │
└──────────────┘
핵심 컴포넌트
1. YouTube Live Chat Service
class YouTubeLiveChatService:
"""YouTube 라이브 채팅 폴링 서비스"""
def get_chat_messages(self):
# YouTube API로 새 메시지 가져오기
# 2초마다 폴링
async def poll_chat_messages(self, callback):
# 비동기로 지속적 폴링
# 콜백으로 메시지 전달
2. WebSocket Manager
class ConnectionManager:
"""WebSocket 연결 관리"""
async def broadcast(self, message):
# 모든 연결된 클라이언트에게 메시지 전송
3. Physics Engine Integration
const handlePhysicsCommand = (command, engine) => {
switch(command) {
case '!ball':
createBall(engine);
break;
case '!explode':
explodeAll(engine);
break;
}
};
💻 개발 과정
1단계: 프로젝트 초기 설정
백엔드 구조 생성
mkdir -p physics-chat-playground/backend/app
cd physics-chat-playground/backend
# 가상환경 생성
python -m venv venv
source venv/bin/activate
# 필수 패키지 설치
pip install fastapi uvicorn python-dotenv websockets
pip install google-auth google-auth-oauthlib google-api-python-client
프론트엔드 구조 생성
cd ../
npm create vite@latest frontend -- --template react
cd frontend
npm install
npm install matter-js
2단계: Google Cloud Console 설정
YouTube Data API 사용을 위한 OAuth 설정:
-
Google Cloud Console 접속
- https://console.cloud.google.com
-
새 프로젝트 생성
프로젝트 이름: Physics Chat Playground -
YouTube Data API v3 활성화
API 및 서비스 → 라이브러리 → YouTube Data API v3 검색 → 활성화 -
OAuth 클라이언트 ID 생성
API 및 서비스 → 사용자 인증 정보 → 사용자 인증 정보 만들기 → OAuth 클라이언트 ID 애플리케이션 유형: 웹 애플리케이션 승인된 리디렉션 URI: http://localhost:8000/auth/callback -
OAuth 동의 화면 설정
테스트 사용자 추가: 본인 Gmail -
.env 파일 생성
YOUTUBE_CLIENT_ID=your_client_id YOUTUBE_CLIENT_SECRET=your_client_secret REDIRECT_URI=http://localhost:8000/auth/callback
3단계: YouTube API 연동
youtube_service.py 구현
"""
YouTube Live Chat Service
실시간으로 YouTube 라이브 채팅을 가져오는 서비스
"""
import os
import asyncio
from typing import Optional, List, Dict, Callable, Tuple
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import Flow
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
class YouTubeLiveChatService:
def __init__(self, client_id: str, client_secret: str,
redirect_uri: str, token_file: str = "credentials.json"):
self.client_id = client_id
self.client_secret = client_secret
self.redirect_uri = redirect_uri
self.token_file = token_file
self.credentials: Optional[Credentials] = None
self.youtube = None
self.live_chat_id: Optional[str] = None
self.next_page_token: Optional[str] = None
self.is_running = False
# 저장된 토큰 자동 로드
self._load_credentials()
def _load_credentials(self):
"""저장된 토큰 파일에서 credentials 로드"""
if os.path.exists(self.token_file):
try:
self.credentials = Credentials.from_authorized_user_file(
self.token_file,
scopes=["https://www.googleapis.com/auth/youtube.readonly"]
)
# 토큰이 만료되었으면 갱신
if self.credentials and self.credentials.expired:
from google.auth.transport.requests import Request
self.credentials.refresh(Request())
self._save_credentials()
if self.credentials and self.credentials.valid:
self.youtube = build('youtube', 'v3',
credentials=self.credentials)
print(f"✓ 저장된 토큰 로드 성공")
return True
except Exception as e:
print(f"⚠️ 토큰 로드 실패: {e}")
return False
def _save_credentials(self):
"""credentials를 파일로 저장"""
if self.credentials:
try:
with open(self.token_file, 'w') as token:
token.write(self.credentials.to_json())
print(f"✓ 토큰 저장됨")
except Exception as e:
print(f"✗ 토큰 저장 실패: {e}")
def get_live_chat_id(self, video_id: str) -> Optional[str]:
"""비디오 ID로 라이브 채팅 ID 가져오기"""
if not self.youtube:
return None
try:
request = self.youtube.videos().list(
part="liveStreamingDetails",
id=video_id
)
response = request.execute()
if response['items']:
live_chat_id = response['items'][0].get(
'liveStreamingDetails', {}
).get('activeLiveChatId')
if live_chat_id:
self.live_chat_id = live_chat_id
print(f"✓ 라이브 채팅 ID: {live_chat_id}")
return live_chat_id
except HttpError as e:
print(f"✗ 라이브 채팅 ID 가져오기 실패: {e}")
return None
def get_chat_messages(self) -> Tuple[List[Dict], float]:
"""라이브 채팅 메시지 가져오기"""
if not self.live_chat_id or not self.youtube:
return [], 2
try:
request = self.youtube.liveChatMessages().list(
liveChatId=self.live_chat_id,
part="snippet,authorDetails",
pageToken=self.next_page_token
)
response = request.execute()
# 다음 페이지 토큰 저장
self.next_page_token = response.get('nextPageToken')
# 메시지 파싱
messages = []
for item in response.get('items', []):
snippet = item['snippet']
author = item['authorDetails']
message = {
'id': item['id'],
'username': author['displayName'],
'text': snippet['displayMessage'],
'timestamp': snippet['publishedAt'],
'isChatModerator': author.get('isChatModerator', False),
'isChatOwner': author.get('isChatOwner', False),
}
messages.append(message)
# 폴링 간격 최적화: 최대 2초로 제한
api_interval = response.get('pollingIntervalMillis', 2000) / 1000
polling_interval = min(api_interval, 2)
if messages:
print(f"✓ {len(messages)}개의 새 메시지")
return messages, polling_interval
except HttpError as e:
print(f"✗ 메시지 가져오기 실패: {e}")
return [], 2
async def poll_chat_messages(self, callback: Callable, interval: float = 2):
"""주기적으로 채팅 메시지 폴링"""
self.is_running = True
print("🚀 YouTube 채팅 폴링 시작...")
while self.is_running:
try:
messages, polling_interval = self.get_chat_messages()
if messages:
for message in messages:
await callback(message)
await asyncio.sleep(polling_interval)
except Exception as e:
print(f"✗ 폴링 중 오류: {e}")
await asyncio.sleep(2)
print("🛑 YouTube 채팅 폴링 종료")
핵심 포인트
- 토큰 영속성: OAuth 토큰을
credentials.json에 저장하여 서버 재시작 시에도 재인증 불필요 - 폴링 최적화: YouTube API 권장 간격과 2초 중 최소값 선택
- 에러 처리: API 에러 시 자동 재시도
4단계: FastAPI 백엔드 구현
main.py 구현
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, RedirectResponse
from typing import List
import json
import os
import asyncio
from datetime import datetime
from dotenv import load_dotenv
from youtube_service import YouTubeLiveChatService
load_dotenv()
app = FastAPI(title="Physics Chat Playground API")
# CORS 설정
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# WebSocket 연결 관리
class ConnectionManager:
def __init__(self):
self.active_connections: List[WebSocket] = []
async def connect(self, websocket: WebSocket):
await websocket.accept()
self.active_connections.append(websocket)
def disconnect(self, websocket: WebSocket):
self.active_connections.remove(websocket)
async def broadcast(self, message: dict):
for connection in self.active_connections:
try:
await connection.send_json(message)
except Exception as e:
print(f"Error broadcasting: {e}")
manager = ConnectionManager()
# YouTube Service 초기화
youtube_service = YouTubeLiveChatService(
client_id=os.getenv("YOUTUBE_CLIENT_ID"),
client_secret=os.getenv("YOUTUBE_CLIENT_SECRET"),
redirect_uri=os.getenv("REDIRECT_URI"),
token_file="credentials.json"
)
is_youtube_connected = youtube_service.is_authenticated()
polling_task = None
@app.get("/")
async def root():
return {
"message": "Physics Chat Playground API",
"youtube_connected": is_youtube_connected
}
@app.get("/auth/youtube")
async def youtube_auth():
"""YouTube 인증 시작"""
if not youtube_service:
return {"error": "YouTube service not initialized"}
auth_url = youtube_service.get_auth_url()
return RedirectResponse(url=auth_url)
@app.get("/auth/callback")
async def youtube_callback(code: str):
"""YouTube OAuth 콜백"""
global is_youtube_connected
if youtube_service.authenticate_with_code(code):
is_youtube_connected = True
return HTMLResponse("""
<html>
<body>
<h1>✅ YouTube 인증 완료!</h1>
<p>브라우저를 닫아도 됩니다.</p>
</body>
</html>
""")
return {"error": "Authentication failed"}
@app.post("/youtube/connect/{video_id}")
async def connect_youtube_chat(video_id: str):
"""YouTube 라이브 채팅 연결"""
global polling_task, is_youtube_connected
if not youtube_service.is_authenticated():
return {"error": "Please authenticate first"}
is_youtube_connected = True
# 이미 폴링 중이면 중지
if polling_task and not polling_task.done():
youtube_service.stop_polling()
await asyncio.sleep(1)
chat_id = youtube_service.get_live_chat_id(video_id)
if chat_id:
# 백그라운드에서 채팅 폴링 시작
async def handle_youtube_message(message):
print(f"📺 YouTube: [{message['username']}] {message['text']}")
broadcast_message = {
"type": "youtube",
"id": message['id'],
"username": message['username'],
"text": message['text'],
"timestamp": message['timestamp'],
"isYouTube": True,
}
await manager.broadcast(broadcast_message)
polling_task = asyncio.create_task(
youtube_service.poll_chat_messages(handle_youtube_message)
)
return {
"success": True,
"message": "Connected to YouTube Live Chat",
"chatId": chat_id,
}
return {"error": "Could not find live chat"}
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
await manager.connect(websocket)
try:
while True:
data = await websocket.receive_text()
message_data = json.loads(data)
message_data["timestamp"] = datetime.now().isoformat()
await manager.broadcast(message_data)
except WebSocketDisconnect:
manager.disconnect(websocket)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
5단계: React 프론트엔드 구현
WebSocket Hook 구현
// src/hooks/useWebSocket.js
import { useEffect, useRef, useState } from 'react';
export const useWebSocket = (url) => {
const [isConnected, setIsConnected] = useState(false);
const [lastMessage, setLastMessage] = useState(null);
const ws = useRef(null);
useEffect(() => {
ws.current = new WebSocket(url);
ws.current.onopen = () => {
setIsConnected(true);
console.log('✓ WebSocket Connected');
};
ws.current.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log('📩 Message received:', data);
setLastMessage(data);
};
ws.current.onclose = () => {
setIsConnected(false);
console.log('✗ WebSocket Disconnected');
};
return () => {
if (ws.current) {
ws.current.close();
}
};
}, [url]);
const sendMessage = (message) => {
if (ws.current?.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify(message));
}
};
return { isConnected, lastMessage, sendMessage };
};
물리 명령어 핸들러
// src/utils/physicsCommands.js
import Matter from 'matter-js';
const getRandomColor = () => {
const colors = [
'#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A',
'#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E2'
];
return colors[Math.floor(Math.random() * colors.length)];
};
export const handlePhysicsCommand = (command, engine) => {
const { world } = engine;
const canvasWidth = 800;
const canvasHeight = 600;
switch (command.toLowerCase()) {
case '!ball':
case '!공':
const ball = Matter.Bodies.circle(
Math.random() * (canvasWidth - 100) + 50,
50,
20 + Math.random() * 20,
{
restitution: 0.8,
render: { fillStyle: getRandomColor() }
}
);
Matter.World.add(world, ball);
return true;
case '!box':
case '!박스':
const box = Matter.Bodies.rectangle(
Math.random() * (canvasWidth - 100) + 50,
50,
40 + Math.random() * 40,
40 + Math.random() * 40,
{
restitution: 0.6,
render: { fillStyle: getRandomColor() }
}
);
Matter.World.add(world, box);
return true;
case '!explode':
case '!폭발':
const bodies = Matter.Composite.allBodies(world);
bodies.forEach((body) => {
if (!body.isStatic) {
const forceMagnitude = 0.05;
Matter.Body.applyForce(body, body.position, {
x: (Math.random() - 0.5) * forceMagnitude,
y: (Math.random() - 0.5) * forceMagnitude
});
}
});
return true;
case '!clear':
case '!초기화':
const allBodies = Matter.Composite.allBodies(world);
allBodies.forEach((body) => {
if (!body.isStatic) {
Matter.World.remove(world, body);
}
});
return true;
case '!rain':
case '!비':
for (let i = 0; i < 10; i++) {
setTimeout(() => {
const raindrop = Matter.Bodies.circle(
Math.random() * canvasWidth,
0,
8,
{
restitution: 0.5,
render: { fillStyle: getRandomColor() }
}
);
Matter.World.add(world, raindrop);
}, i * 100);
}
return true;
case '!pyramid':
case '!피라미드':
const rows = 5;
const boxSize = 40;
const startX = canvasWidth / 2;
const startY = canvasHeight - 100;
for (let row = 0; row < rows; row++) {
for (let col = 0; col <= row; col++) {
const x = startX + (col - row / 2) * (boxSize + 2);
const y = startY - row * (boxSize + 2);
const pyramidBox = Matter.Bodies.rectangle(
x, y, boxSize, boxSize,
{
restitution: 0.5,
render: { fillStyle: getRandomColor() }
}
);
Matter.World.add(world, pyramidBox);
}
}
return true;
default:
return false;
}
};
App.jsx 메인 컴포넌트
// src/App.jsx
import React, { useState, useRef, useEffect } from 'react';
import PhysicsCanvas from './components/PhysicsCanvas';
import ChatPanel from './components/ChatPanel';
import { handlePhysicsCommand } from './utils/physicsCommands';
import { useWebSocket } from './hooks/useWebSocket';
function App() {
const [messages, setMessages] = useState([]);
const [inputMessage, setInputMessage] = useState('');
const physicsRef = useRef(null);
const { isConnected, lastMessage, sendMessage } =
useWebSocket('ws://localhost:8000/ws');
// WebSocket 메시지 수신 처리
useEffect(() => {
if (lastMessage) {
setMessages(prev => [...prev, lastMessage]);
const engine = physicsRef.current?.getEngine();
if (engine && lastMessage.text) {
handlePhysicsCommand(lastMessage.text.trim(), engine);
}
}
}, [lastMessage]);
const sendLocalMessage = () => {
if (!inputMessage.trim()) return;
const newMessage = {
username: 'Local User',
text: inputMessage,
};
sendMessage(newMessage);
// 로컬에서도 명령어 실행
const engine = physicsRef.current?.getEngine();
if (engine) {
handlePhysicsCommand(inputMessage.trim(), engine);
}
setInputMessage('');
};
return (
<div className="flex h-screen bg-gradient-to-br from-gray-900 to-gray-800">
<div className="flex-1 flex flex-col items-center justify-center p-8">
<h1 className="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-purple-400 to-pink-400 mb-4">
Interactive Physics Playground
</h1>
<div className="mb-2">
<span className={`text-xs px-3 py-1 rounded-full ${
isConnected
? 'bg-green-500/20 text-green-400'
: 'bg-red-500/20 text-red-400'
}`}>
{isConnected ? '🟢 Connected' : '🔴 Disconnected'}
</span>
</div>
<PhysicsCanvas ref={physicsRef} />
</div>
<ChatPanel
messages={messages}
inputMessage={inputMessage}
setInputMessage={setInputMessage}
onSendMessage={sendLocalMessage}
/>
</div>
);
}
export default App;
6단계: OBS Studio 설정
OBS 다운로드 및 설치
https://obsproject.com/download
YouTube 연결 설정
-
OBS → 설정 → 방송
서비스: YouTube - RTMPS 계정 연결 클릭 Google 로그인 -
Browser Source 추가
소스 → + → Browser 이름: Physics Playground URL: http://localhost:5173 너비: 1920 높이: 1080 FPS: 30 ☑️ "페이지 준비 시 새로고침" ☐ "페이지가 보이지 않을 때 소스 종료" (체크 해제) -
출력 설정 (Mac)
설정 → 출력 인코더: Apple VT H264 Hardware Encoder 비트레이트: 4500 kbps 키프레임 간격: 2초 -
영상 설정
설정 → 영상 기본 해상도: 1920x1080 출력 해상도: 1920x1080 FPS: 30
🔥 주요 기능 구현
1. OAuth 토큰 영속성
문제: 서버 재시작 시마다 재인증 필요
해결: 토큰을 파일로 저장
def _save_credentials(self):
"""credentials를 파일로 저장"""
if self.credentials:
with open(self.token_file, 'w') as token:
token.write(self.credentials.to_json())
def _load_credentials(self):
"""저장된 토큰 파일에서 credentials 로드"""
if os.path.exists(self.token_file):
self.credentials = Credentials.from_authorized_user_file(
self.token_file,
scopes=["https://www.googleapis.com/auth/youtube.readonly"]
)
# 토큰 만료 시 자동 갱신
if self.credentials.expired and self.credentials.refresh_token:
self.credentials.refresh(Request())
self._save_credentials()
결과:
- ✅ 서버 재시작 시 자동으로 인증 상태 복구
- ✅ 토큰 만료 시 자동 갱신
- ✅ 개발 경험 개선
2. 폴링 간격 최적화
문제: YouTube API 기본 폴링 간격 5초 → 반응 느림
해결: 폴링 간격을 2초로 단축
def get_chat_messages(self):
# ...
api_interval = response.get('pollingIntervalMillis', 2000) / 1000
polling_interval = min(api_interval, 2) # 최대 2초
return messages, polling_interval
API Quota 계산:
일일 할당량: 10,000 units
liveChatMessages.list: 5 units per call
2초 간격 폴링:
- 30회/분 × 60분 × 5 units = 9,000 units/시간
- 약 1시간 방송 가능
결과:
- ✅ 채팅 입력 후 2-3초 내 반영
- ✅ 실시간 인터랙션 체감
- ⚠️ Quota 관리 필요
3. WebSocket 브로드캐스팅
구현:
class ConnectionManager:
async def broadcast(self, message: dict):
for connection in self.active_connections:
try:
await connection.send_json(message)
except Exception as e:
# 연결 끊긴 클라이언트 무시
pass
특징:
- 모든 연결된 클라이언트에 동시 전송
- localhost + OBS 동시 업데이트
- 에러 발생 시 다른 클라이언트에 영향 없음
4. 물리 엔진 통합
Matter.js 초기화:
const engine = Matter.Engine.create();
const render = Matter.Render.create({
element: canvasRef.current,
engine: engine,
options: {
width: 800,
height: 600,
wireframes: false,
background: '#1a1a2e'
}
});
// 바닥과 벽 생성
const ground = Matter.Bodies.rectangle(400, 590, 800, 20, {
isStatic: true
});
const leftWall = Matter.Bodies.rectangle(0, 300, 20, 600, {
isStatic: true
});
const rightWall = Matter.Bodies.rectangle(800, 300, 20, 600, {
isStatic: true
});
Matter.World.add(engine.world, [ground, leftWall, rightWall]);
Matter.Engine.run(engine);
Matter.Render.run(render);
동적 오브젝트 생성:
const ball = Matter.Bodies.circle(x, y, radius, {
restitution: 0.8, // 탄성
friction: 0.05, // 마찰
density: 0.001, // 밀도
render: {
fillStyle: getRandomColor()
}
});
Matter.World.add(engine.world, ball);
🐛 트러블슈팅
문제 1: relative import 에러
에러:
attempted relative import with no known parent package
원인: from .youtube_service import 구문
해결:
# 기존
from .youtube_service import YouTubeLiveChatService
# 수정
from youtube_service import YouTubeLiveChatService
문제 2: YouTube 폴링 로그 없음
증상: 서버 로그에 “✓ 1개의 새 메시지” 없음
원인: YouTube 연결이 안 됨
해결 순서:
# 1. 인증 상태 확인
curl http://localhost:8000/health
# 2. 재인증
open http://localhost:8000/auth/youtube
# 3. Video ID 연결
curl -X POST http://localhost:8000/youtube/connect/VIDEO_ID
문제 3: WebSocket 연결 반복 끊김
증상:
INFO: connection open
INFO: connection closed
INFO: connection open
원인:
- 프론트엔드 페이지 새로고침
- React 개발 모드 Hot Module Replacement
해결: 정상 동작 (개발 모드 특성)
문제 4: OBS 방송 시작 실패
에러: “출력을 시작하지 못했습니다”
Mac 해결책:
설정 → 출력
인코더: Apple VT H264 Hardware Encoder
(NVENC는 Mac에서 지원 안 됨)
문제 5: YouTube Live 지연 15초
원인: YouTube 서버 처리 시간 (정상)
해결:
YouTube Studio → 스트림 설정
지연 시간: "초저지연" 선택
결과: 15초 → 2-5초로 단축
⚡ 성능 최적화
1. 폴링 간격 최적화
# API 권장 간격과 2초 중 최소값 선택
api_interval = response.get('pollingIntervalMillis', 2000) / 1000
polling_interval = min(api_interval, 2)
효과:
- 반응 속도: 5초 → 2초
- API Quota: 3,600 units/hour → 9,000 units/hour
2. WebSocket 연결 관리
async def broadcast(self, message: dict):
for connection in self.active_connections:
try:
await connection.send_json(message)
except:
pass # 에러 무시하고 계속 진행
효과:
- 한 클라이언트 에러가 다른 클라이언트에 영향 없음
- 안정적인 브로드캐스팅
3. Matter.js 최적화
const engine = Matter.Engine.create({
enableSleeping: true, // 정지한 물체 계산 생략
velocityIterations: 8, // 정확도 vs 성능 균형
positionIterations: 6
});
효과:
- CPU 사용량 감소
- 부드러운 애니메이션 유지
4. 오브젝트 수 제한
const MAX_OBJECTS = 100;
function cleanupOldObjects(engine) {
const bodies = Matter.Composite.allBodies(engine.world);
if (bodies.length > MAX_OBJECTS) {
const toRemove = bodies
.filter(b => !b.isStatic)
.slice(0, bodies.length - MAX_OBJECTS);
toRemove.forEach(body => {
Matter.World.remove(engine.world, body);
});
}
}
효과:
- 메모리 관리
- 프레임 드롭 방지
🚀 배포 및 운영
로컬 개발 환경 실행
# Terminal 1: 백엔드
cd physics-chat-playground/backend
python app/main.py
# Terminal 2: 프론트엔드
cd physics-chat-playground/frontend
npm run dev
# Terminal 3: YouTube 연결
curl -X POST http://localhost:8000/youtube/connect/VIDEO_ID
방송 시작 체크리스트
□ 백엔드 실행 (port 8000)
□ 프론트엔드 실행 (port 5173)
□ YouTube 인증 완료
□ Video ID 연결
□ OBS Browser Source 추가
□ OBS 방송 시작
□ YouTube Live 확인
모니터링
# 연결 상태 확인
curl http://localhost:8000/health
# 응답 예시
{
"status": "healthy",
"connections": 2, // localhost + OBS
"youtube_status": "connected"
}
로그 확인
백엔드 로그:
✓ 1개의 새 메시지
📺 YouTube 메시지: [@user] !ball
🔊 브로드캐스트 시도: 2개 연결
✅ 브로드캐스트 완료
프론트엔드 콘솔 (F12):
✓ WebSocket Connected
📩 Message received: {type: "youtube", text: "!ball"}
🎮 Executing command: !ball
📊 결과 및 회고
달성한 것
✅ 실시간 인터랙티브 시스템 구축
- YouTube 채팅 → 화면 반영 2-5초 이내
- WebSocket 기반 실시간 동기화
- 물리 엔진 통합
✅ 안정적인 OAuth 인증 시스템
- 토큰 영속성 구현
- 자동 토큰 갱신
- 서버 재시작 시에도 인증 유지
✅ 최적화된 폴링 시스템
- 2초 간격 폴링
- API Quota 관리
- 에러 처리
기술적 학습
🎓 WebSocket 실시간 통신
- 양방향 통신 구현
- 브로드캐스팅 패턴
- 연결 관리
🎓 OAuth 2.0 인증 플로우
- Google OAuth 통합
- 토큰 관리
- 보안 고려사항
🎓 YouTube Data API
- 라이브 채팅 폴링
- API Quota 관리
- Rate Limiting 대응
🎓 물리 엔진 통합
- Matter.js 활용
- 동적 오브젝트 생성
- 성능 최적화
개선할 점
⚠️ YouTube Live 지연
- 현재: 2-5초 (초저지연 모드)
- 개선: WebRTC 기반 P2P 스트리밍 고려
⚠️ API Quota 제한
- 현재: 약 1시간 방송 가능
- 개선: Quota 증설 신청 또는 폴링 간격 동적 조정
⚠️ 오브젝트 수 제한
- 현재: 100개 제한
- 개선: 공간 파티셔닝으로 더 많은 오브젝트 처리
⚠️ 에러 처리
- 현재: 기본적인 try-catch
- 개선: Sentry 같은 에러 트래킹 도구 통합
향후 계획
🚀 기능 확장
- Twitch 채팅 통합
- 커스텀 명령어 시스템
- 포인트 시스템 (명령어당 포인트 소비)
- 리플레이 기능
🚀 배포
- 프론트엔드: Vercel
- 백엔드: Railway/Heroku
- 도메인 연결
🚀 확장성
- Redis로 WebSocket 확장
- Kubernetes 배포
- 모니터링 대시보드
🎬 데모 및 소스코드
라이브 데모
- YouTube Live: [링크]
- localhost:5173: [스크린샷]
GitHub Repository
https://github.com/yourusername/physics-chat-playground
프로젝트 구조
physics-chat-playground/
├── backend/
│ ├── app/
│ │ ├── main.py
│ │ └── youtube_service.py
│ ├── .env
│ ├── credentials.json
│ └── requirements.txt
├── frontend/
│ ├── src/
│ │ ├── components/
│ │ │ ├── PhysicsCanvas.jsx
│ │ │ └── ChatPanel.jsx
│ │ ├── hooks/
│ │ │ └── useWebSocket.js
│ │ ├── utils/
│ │ │ └── physicsCommands.js
│ │ └── App.jsx
│ ├── package.json
│ └── vite.config.js
├── .gitignore
└── README.md
📝 마치며
이 프로젝트를 통해 실시간 인터랙티브 시스템의 전체 스택을 경험할 수 있었습니다.
특히 YouTube API 폴링, WebSocket 실시간 통신, 물리 엔진 통합이라는 세 가지 핵심 기술을 하나로 연결하는 과정에서 많은 것을 배웠습니다.
가장 인상 깊었던 점은 시청자 참여형 콘텐츠의 힘이었습니다. 단순히 방송을 보는 것이 아니라, 직접 명령어를 입력하고 그 결과를 화면에서 확인하는 경험은 기존 라이브 스트림과는 차원이 다른 몰입감을 제공합니다.
앞으로 더 많은 인터랙티브 요소를 추가하고, 다양한 플랫폼으로 확장할 계획입니다.
읽어주셔서 감사합니다! 🙏
질문이나 피드백은 댓글로 남겨주세요! 💬
🏷️ 태그
#YouTube` `#LiveStreaming` `#WebSocket` `#FastAPI` `#React` `#MatterJS` `#OAuth` `#RealTime` `#Interactive``#PhysicsEngine` `#OBS` `#Python` `#JavaScript` `#WebDevelopment` `#FullStack
작성일: 2025년 1월 12일 작성자: [Your Name] GitHub: [Your GitHub] Email: [Your Email]