# 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개의 포스트로 구성되어 있습니다.*