# Streaming Avatar 개발기 - v9: UI/UX 및 배포 ## 개요 사용자 친화적인 UI/UX를 구현하고, 프로덕션 환경에 배포합니다. ## 1. UI/UX 디자인 ### 디자인 원칙 ``` ┌─────────────────────────────────────────────────────────────────┐ │ UI/UX Design Principles │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ 1. 직관성 (Intuitive) │ │ - 원클릭 시작 │ │ - 명확한 상태 표시 │ │ - 자연스러운 대화 흐름 │ │ │ │ 2. 반응성 (Responsive) │ │ - 즉각적인 피드백 │ │ - 부드러운 애니메이션 │ │ - 로딩 상태 명확화 │ │ │ │ 3. 접근성 (Accessible) │ │ - 키보드 네비게이션 │ │ - 스크린 리더 지원 │ │ - 고대비 모드 │ │ │ │ 4. 신뢰성 (Trustworthy) │ │ - 에러 처리 명확화 │ │ - 연결 상태 표시 │ │ - 데이터 보안 안내 │ │ │ └─────────────────────────────────────────────────────────────────┘ ``` ### 레이아웃 ``` ┌─────────────────────────────────────────────────────────────────┐ │ Streaming Avatar UI │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ ┌─────────────────────────────────────────────────────────┐ │ │ │ │ │ │ │ ┌────────────┐ │ │ │ │ │ │ │ │ │ │ │ Avatar │ ← 중앙 배치 │ │ │ │ │ Video │ │ │ │ │ │ │ │ │ │ │ └────────────┘ │ │ │ │ │ │ │ │ ● ● ● ← 상태 표시 (듣기/처리/말하기) │ │ │ │ │ │ │ │ ┌─────────────────────────────────────────────────┐ │ │ │ │ │ 아바타: 안녕하세요! 무엇을 도와드릴까요? │ │ │ │ │ │ 사용자: 오늘 날씨 어때요? │ │ │ │ │ └─────────────────────────────────────────────────┘ │ │ │ │ ↑ 대화 기록 │ │ │ │ │ │ │ │ ┌─────────────────┐ │ │ │ │ │ 🎤 Press to │ ← PTT 버튼 │ │ │ │ │ Talk │ │ │ │ │ └─────────────────┘ │ │ │ │ │ │ │ │ ┌───────────────────────────────┐ │ │ │ │ │ Type a message... [Send] │ ← 텍스트 입력│ │ │ │ └───────────────────────────────┘ │ │ │ │ │ │ │ └─────────────────────────────────────────────────────────┘ │ │ │ │ [Settings] [Fullscreen] [End Session] │ │ │ └─────────────────────────────────────────────────────────────────┘ ``` ## 2. 프론트엔드 구현 ### 메인 컴포넌트 ```typescript // components/AvatarInterface.tsx import { useState, useRef, useCallback } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { useAvatarSession } from '@/hooks/useAvatarSession'; import { AvatarVideo } from './AvatarVideo'; import { ChatHistory } from './ChatHistory'; import { VoiceInput } from './VoiceInput'; import { TextInput } from './TextInput'; import { StatusIndicator } from './StatusIndicator'; interface AvatarInterfaceProps { avatarId: string; onSessionEnd?: () => void; } export function AvatarInterface({ avatarId, onSessionEnd }: AvatarInterfaceProps) { const { session, state, messages, sendText, startVoice, stopVoice, endSession } = useAvatarSession(avatarId); const [inputMode, setInputMode] = useState<'voice' | 'text'>('voice'); const [isFullscreen, setIsFullscreen] = useState(false); return (
{/* 아바타 비디오 영역 */}
{/* 상태 표시 */}
{/* 대화 기록 */} {/* 입력 영역 */}
{inputMode === 'voice' ? ( ) : ( )} {/* 모드 전환 */}
{/* 컨트롤 바 */}
); } ``` ### 음성 입력 컴포넌트 ```typescript // components/VoiceInput.tsx import { motion } from 'framer-motion'; import { useCallback, useState } from 'react'; interface VoiceInputProps { onStart: () => void; onStop: () => void; isRecording: boolean; } export function VoiceInput({ onStart, onStop, isRecording }: VoiceInputProps) { const [isPressed, setIsPressed] = useState(false); const handleStart = useCallback(() => { setIsPressed(true); onStart(); }, [onStart]); const handleStop = useCallback(() => { setIsPressed(false); onStop(); }, [onStop]); return (
{isRecording ? 'Release to stop' : 'Press to talk'}

{isRecording ? '말씀하세요...' : '버튼을 누르고 말하세요'}

{/* 음성 시각화 */} {isRecording && }
); } function AudioVisualizer() { return (
{[...Array(5)].map((_, i) => ( ))}
); } ``` ### 상태 표시 컴포넌트 ```typescript // components/StatusIndicator.tsx import { motion } from 'framer-motion'; type SessionState = 'connecting' | 'connected' | 'listening' | 'processing' | 'speaking' | 'error'; interface StatusIndicatorProps { state: SessionState; className?: string; } const stateConfig = { connecting: { color: 'yellow', text: '연결 중...', icon: '🔄' }, connected: { color: 'green', text: '준비됨', icon: '✅' }, listening: { color: 'blue', text: '듣는 중...', icon: '🎤' }, processing: { color: 'purple', text: '생각 중...', icon: '🤔' }, speaking: { color: 'green', text: '말하는 중...', icon: '🗣️' }, error: { color: 'red', text: '오류 발생', icon: '❌' }, }; export function StatusIndicator({ state, className }: StatusIndicatorProps) { const config = stateConfig[state]; return ( {config.icon} {config.text} {/* 펄스 애니메이션 (활성 상태) */} {['listening', 'processing', 'speaking'].includes(state) && ( )} ); } ``` ### CSS 스타일 ```css /* styles/avatar.css */ .avatar-interface { display: flex; flex-direction: column; height: 100vh; max-width: 800px; margin: 0 auto; background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%); } .avatar-container { flex: 1; display: flex; align-items: center; justify-content: center; position: relative; padding: 20px; } .avatar-video { width: 100%; max-width: 480px; aspect-ratio: 1; border-radius: 24px; overflow: hidden; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); } .status-indicator { display: flex; align-items: center; gap: 8px; padding: 8px 16px; border-radius: 20px; background: rgba(255, 255, 255, 0.1); backdrop-filter: blur(10px); font-size: 14px; color: white; } .status-indicator.green { background: rgba(34, 197, 94, 0.2); } .status-indicator.blue { background: rgba(59, 130, 246, 0.2); } .status-indicator.purple { background: rgba(168, 85, 247, 0.2); } .status-indicator.red { background: rgba(239, 68, 68, 0.2); } .ptt-button { width: 80px; height: 80px; border-radius: 50%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border: none; color: white; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: transform 0.2s; } .ptt-button.recording { background: linear-gradient(135deg, #f43f5e 0%, #dc2626 100%); } .audio-visualizer { display: flex; align-items: center; gap: 4px; height: 40px; } .audio-visualizer .bar { width: 4px; background: #60a5fa; border-radius: 2px; } /* 다크 모드 */ @media (prefers-color-scheme: dark) { .avatar-interface { background: #0a0a0a; } } /* 모바일 */ @media (max-width: 640px) { .avatar-video { max-width: 320px; } .ptt-button { width: 64px; height: 64px; } } ``` ## 3. 프로덕션 배포 ### Docker Compose ```yaml # docker-compose.prod.yml version: '3.8' services: frontend: build: context: ./frontend dockerfile: Dockerfile.prod ports: - "3000:3000" environment: - NODE_ENV=production - NEXT_PUBLIC_API_URL=https://api.yourdomain.com - NEXT_PUBLIC_LIVEKIT_URL=wss://livekit.yourdomain.com depends_on: - api api: build: context: ./backend dockerfile: Dockerfile.prod ports: - "8000:8000" environment: - ENV=production - DATABASE_URL=postgresql://... - REDIS_URL=redis://redis:6379 - GOOGLE_API_KEY=${GOOGLE_API_KEY} - ELEVENLABS_API_KEY=${ELEVENLABS_API_KEY} depends_on: - redis - postgres deploy: resources: reservations: devices: - capabilities: [gpu] livekit: image: livekit/livekit-server:latest ports: - "7880:7880" - "7881:7881" - "7882:7882/udp" volumes: - ./livekit.yaml:/livekit.yaml command: --config /livekit.yaml redis: image: redis:7-alpine volumes: - redis_data:/data postgres: image: postgres:15-alpine environment: - POSTGRES_DB=streaming_avatar - POSTGRES_USER=${DB_USER} - POSTGRES_PASSWORD=${DB_PASSWORD} volumes: - postgres_data:/var/lib/postgresql/data nginx: image: nginx:alpine ports: - "80:80" - "443:443" volumes: - ./nginx.conf:/etc/nginx/nginx.conf - ./ssl:/etc/nginx/ssl depends_on: - frontend - api - livekit volumes: redis_data: postgres_data: ``` ### Nginx 설정 ```nginx # nginx.conf events { worker_connections 1024; } http { upstream frontend { server frontend:3000; } upstream api { server api:8000; } upstream livekit { server livekit:7880; } server { listen 80; server_name yourdomain.com; return 301 https://$server_name$request_uri; } server { listen 443 ssl http2; server_name yourdomain.com; ssl_certificate /etc/nginx/ssl/fullchain.pem; ssl_certificate_key /etc/nginx/ssl/privkey.pem; # 프론트엔드 location / { proxy_pass http://frontend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_cache_bypass $http_upgrade; } # API location /api/ { proxy_pass http://api/; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } # WebSocket location /ws/ { proxy_pass http://api/ws/; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 86400; } } # LiveKit server { listen 443 ssl http2; server_name livekit.yourdomain.com; ssl_certificate /etc/nginx/ssl/fullchain.pem; ssl_certificate_key /etc/nginx/ssl/privkey.pem; location / { proxy_pass http://livekit; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; } } } ``` ### GitHub Actions CI/CD ```yaml # .github/workflows/deploy.yml name: Deploy on: push: branches: [main] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Run tests run: | pip install pytest pytest-asyncio pytest tests/ build-and-push: needs: test runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Login to Docker Hub uses: docker/login-action@v2 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Build and push uses: docker/build-push-action@v4 with: context: . push: true tags: yourusername/streaming-avatar:latest deploy: needs: build-and-push runs-on: ubuntu-latest steps: - name: Deploy to server uses: appleboy/ssh-action@master with: host: ${{ secrets.SERVER_HOST }} username: ${{ secrets.SERVER_USER }} key: ${{ secrets.SERVER_SSH_KEY }} script: | cd /opt/streaming-avatar docker-compose -f docker-compose.prod.yml pull docker-compose -f docker-compose.prod.yml up -d ``` ## 4. 모니터링 ### Prometheus + Grafana ```yaml # monitoring/docker-compose.yml services: prometheus: image: prom/prometheus volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml ports: - "9090:9090" grafana: image: grafana/grafana ports: - "3001:3000" volumes: - grafana_data:/var/lib/grafana volumes: grafana_data: ``` ### 메트릭 수집 ```python # src/monitoring/metrics.py from prometheus_client import Counter, Histogram, Gauge # 세션 메트릭 active_sessions = Gauge( 'avatar_active_sessions', 'Number of active avatar sessions' ) session_duration = Histogram( 'avatar_session_duration_seconds', 'Duration of avatar sessions', buckets=[60, 300, 600, 1800, 3600] ) # 지연 시간 메트릭 pipeline_latency = Histogram( 'avatar_pipeline_latency_ms', 'End-to-end pipeline latency', ['component'], buckets=[50, 100, 200, 500, 1000, 2000] ) # 에러 메트릭 errors = Counter( 'avatar_errors_total', 'Total number of errors', ['type'] ) ``` ## 다음 단계 (v10) 최종 통합 테스트를 수행하고, 시스템 전체를 문서화합니다. --- *이 시리즈는 총 10개의 포스트로 구성되어 있습니다.*