최종 업데이트: 2025-05-11 Spring Boot 백엔드 REST API 및 WebSocket 스펙
- API 개요
- 인증 (Authentication)
- 캐릭터 (Character)
- 채팅 분석 (Chat Analysis)
- 게임 (Game)
- 안전 관리 (Safety)
- 방송 통계 (Broadcast Statistics)
- 방송 스트림 (Broadcasting Stream)
- WebSocket
- 에러 처리
- 레이트 리미팅 & 보안
- Base URL:
http://localhost:8080 - API Version:
v1 - Base Path:
/api/v1 - Content-Type:
application/json - Authentication: Bearer Token (JWT)
모든 API 응답은 다음의 래퍼 형식을 따릅니다:
{
"success": true,
"data": { /* 실제 데이터 */ },
"message": "성공 메시지 (옵션)",
"timestamp": "2025-04-21T10:30:00Z"
}{
"success": false,
"statusCode": 400,
"message": "에러 메시지",
"error": "ERROR_CODE",
"timestamp": "2025-04-21T10:30:00Z"
}| 코드 | 설명 | 용도 |
|---|---|---|
| 200 | OK | 성공 |
| 201 | Created | 리소스 생성 성공 |
| 204 | No Content | 성공 (응답 본문 없음) |
| 400 | Bad Request | 잘못된 요청 |
| 401 | Unauthorized | 인증 필요 |
| 403 | Forbidden | 권한 부족 |
| 404 | Not Found | 리소스 없음 |
| 409 | Conflict | 중복 (예: 중복 이메일) |
| 429 | Too Many Requests | 레이트 리미트 |
| 500 | Internal Server Error | 서버 에러 |
엔드포인트: POST /api/v1/auth/register/email
요청 본문:
{
"email": "user@example.com",
"password": "SecurePassword123!",
"name": "사용자이름"
}요청 헤더:
Content-Type: application/json
응답 (201 Created):
{
"success": true,
"data": {
"user": {
"id": "user_123",
"email": "user@example.com",
"name": "사용자이름",
"image": null
},
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
"refreshToken": "eyJhbGciOiJIUzI1NiIs...",
"expiresIn": 3600
},
"message": "회원가입 성공"
}에러 응답:
- 400: 입력값 검증 실패 (이메일 형식, 비밀번호 강도)
- 409: 이미 가입된 이메일
비밀번호 요구사항:
- 최소 8글자
- 대문자, 소문자, 숫자, 특수문자 포함
엔드포인트: POST /api/v1/auth/login/email
요청 본문:
{
"email": "user@example.com",
"password": "SecurePassword123!"
}응답 (200 OK):
{
"success": true,
"data": {
"user": {
"id": "user_123",
"email": "user@example.com",
"name": "사용자이름",
"image": null
},
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
"refreshToken": "eyJhbGciOiJIUzI1NiIs...",
"expiresIn": 3600
},
"message": "로그인 성공"
}에러 응답:
- 401: 이메일 또는 비밀번호 불일치
- 404: 사용자 없음
엔드포인트: POST /api/v1/auth/oauth/google/callback
요청 본문:
{
"code": "authorization_code_from_google",
"redirectUri": "http://localhost:5173/auth/callback"
}응답 (200 OK 또는 201 Created):
{
"success": true,
"data": {
"user": {
"id": "user_456",
"email": "user@gmail.com",
"name": "Google User",
"image": "https://lh3.googleusercontent.com/..."
},
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
"refreshToken": "eyJhbGciOiJIUzI1NiIs...",
"expiresIn": 3600,
"isNewUser": true
}
}에러 응답:
- 400: 잘못된 인증 코드
- 500: Google API 통신 실패
엔드포인트: POST /api/v1/auth/refresh
요청 본문:
{
"refreshToken": "eyJhbGciOiJIUzI1NiIs..."
}응답 (200 OK):
{
"success": true,
"data": {
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
"refreshToken": "eyJhbGciOiJIUzI1NiIs...",
"expiresIn": 3600
}
}에러 응답:
- 401: 유효하지 않은 또는 만료된 리프레시 토큰
엔드포인트: POST /api/v1/auth/logout
요청 헤더:
Authorization: Bearer {accessToken}
응답 (204 No Content 또는 200 OK):
{
"success": true,
"message": "로그아웃 성공"
}설명:
- 서버 측에서 리프레시 토큰을 무효화
- 클라이언트에서는 로컬 스토리지 초기화 필요
엔드포인트: GET /api/v1/characters/settings
요청 헤더:
Authorization: Bearer {accessToken}
응답 (200 OK):
{
"success": true,
"data": {
"genders": ["MALE", "FEMALE"],
"speechTones": ["친근한 반말", "깍듯한 존댓말", "장난기 섞인 반말", "방송용 과장체"],
"personalities": ["활발함", "차분함", "유머러스", "진지함"],
"personas": ["게임 특화", "유머/예능", "진중/집중", "잡담/소통"],
"appearances": [
{
"id": "appearance_1",
"name": "남성 기본",
"gender": "MALE",
"previewUrl": "https://..."
},
{
"id": "appearance_2",
"name": "여성 기본",
"gender": "FEMALE",
"previewUrl": "https://..."
}
],
"voices": [
{
"id": "voice_1",
"name": "남성음 1",
"gender": "MALE",
"language": "ko-KR",
"sampleUrl": "https://..."
},
{
"id": "voice_2",
"name": "여성음 1",
"gender": "FEMALE",
"language": "ko-KR",
"sampleUrl": "https://..."
}
]
}
}에러 응답:
- 401: 인증 필요
엔드포인트: POST /api/v1/characters
요청 헤더:
Authorization: Bearer {accessToken}
Content-Type: application/json
요청 본문:
{
"name": "AI 동료",
"gender": "FEMALE",
"appearanceId": "appearance_2",
"voiceId": "voice_2",
"speechTone": "친근한 반말",
"personality": "유머러스",
"persona": "유머/예능",
"callWord": "동료",
"broadcastSettings": {
"chatReactionSensitivity": "HIGH",
"silenceResponseFrequency": 30,
"voiceSpeed": 1.0,
"voiceVolume": 100
}
}응답 (201 Created):
{
"success": true,
"data": {
"id": "char_123",
"userId": "user_123",
"name": "AI 동료",
"gender": "FEMALE",
"appearanceId": "appearance_2",
"voiceId": "voice_2",
"speechTone": "친근한 반말",
"personality": "유머러스",
"persona": "유머/예능",
"callWord": "동료",
"broadcastSettings": {
"chatReactionSensitivity": "HIGH",
"silenceResponseFrequency": 30,
"voiceSpeed": 1.0,
"voiceVolume": 100,
"chatReactionEnabled": true,
"silenceReactionEnabled": true
},
"isSelected": true,
"createdAt": "2025-04-21T10:30:00Z",
"updatedAt": "2025-04-21T10:30:00Z"
},
"message": "캐릭터 생성 성공"
}에러 응답:
- 400: 입력값 검증 실패 (이름 길이 2-10글자 등)
- 401: 인증 필요
- 409: 같은 이름의 캐릭터 이미 존재
엔드포인트: GET /api/v1/characters
요청 헤더:
Authorization: Bearer {accessToken}
쿼리 파라미터:
?page=1&limit=20&sortBy=createdAt&order=DESC
응답 (200 OK):
{
"success": true,
"data": {
"characters": [
{
"id": "char_123",
"userId": "user_123",
"name": "AI 동료",
"gender": "FEMALE",
"appearanceId": "appearance_2",
"voiceId": "voice_2",
"speechTone": "친근한 반말",
"personality": "유머러스",
"persona": "유머/예능",
"callWord": "동료",
"isSelected": true,
"createdAt": "2025-04-21T10:30:00Z"
}
],
"totalCount": 1,
"page": 1,
"limit": 20
}
}에러 응답:
- 401: 인증 필요
엔드포인트: GET /api/v1/characters/{characterId}
요청 헤더:
Authorization: Bearer {accessToken}
경로 파라미터:
characterId: 캐릭터 ID (예: char_123)
응답 (200 OK):
{
"success": true,
"data": {
"id": "char_123",
"userId": "user_123",
"name": "AI 동료",
"gender": "FEMALE",
"appearanceId": "appearance_2",
"voiceId": "voice_2",
"speechTone": "친근한 반말",
"personality": "유머러스",
"persona": "유머/예능",
"callWord": "동료",
"broadcastSettings": {
"chatReactionSensitivity": "HIGH",
"silenceResponseFrequency": 30,
"voiceSpeed": 1.0,
"voiceVolume": 100,
"chatReactionEnabled": true,
"silenceReactionEnabled": true
},
"isSelected": true,
"createdAt": "2025-04-21T10:30:00Z",
"updatedAt": "2025-04-21T10:30:00Z"
}
}에러 응답:
- 401: 인증 필요
- 404: 캐릭터 없음
엔드포인트: PUT /api/v1/characters/{characterId}
요청 헤더:
Authorization: Bearer {accessToken}
Content-Type: application/json
요청 본문 (모든 필드 선택사항):
{
"name": "새로운 이름",
"speechTone": "깍듯한 존댓말",
"personality": "활발함",
"broadcastSettings": {
"chatReactionSensitivity": "MEDIUM",
"silenceResponseFrequency": 60,
"voiceSpeed": 1.2,
"voiceVolume": 80
}
}응답 (200 OK):
{
"success": true,
"data": {
"id": "char_123",
"name": "새로운 이름",
"speechTone": "깍듯한 존댓말",
"personality": "활발함",
"broadcastSettings": {
"chatReactionSensitivity": "MEDIUM",
"silenceResponseFrequency": 60,
"voiceSpeed": 1.2,
"voiceVolume": 80
},
"updatedAt": "2025-04-21T10:35:00Z"
},
"message": "캐릭터 수정 성공"
}에러 응답:
- 400: 입력값 검증 실패
- 401: 인증 필요
- 404: 캐릭터 없음
엔드포인트: DELETE /api/v1/characters/{characterId}
요청 헤더:
Authorization: Bearer {accessToken}
응답 (204 No Content):
(응답 본문 없음)
에러 응답:
- 401: 인증 필요
- 404: 캐릭터 없음
엔드포인트: PATCH /api/v1/characters/{characterId}/select
요청 헤더:
Authorization: Bearer {accessToken}
응답 (200 OK):
{
"success": true,
"data": {
"id": "char_123",
"isSelected": true
},
"message": "캐릭터 선택 성공"
}설명:
- 기존 선택 캐릭터는 자동으로 비선택 처리
- 한 번에 하나의 캐릭터만 활성화
에러 응답:
- 401: 인증 필요
- 404: 캐릭터 없음
엔드포인트: GET /api/v1/chat-analysis/sentiment?minutes=10
요청 헤더:
Authorization: Bearer {accessToken}
쿼리 파라미터:
minutes: 분석 대상 시간 (5-1440, 기본값: 10)
응답 (200 OK):
{
"success": true,
"data": {
"positive": 45,
"neutral": 30,
"negative": 25,
"totalMessages": 100,
"analysisTime": "2025-04-21T10:30:00Z",
"timeWindow": 10
}
}설명:
- 긍정: 응원, 좋아요, 칭찬 표현
- 중립: 일반 대화, 질문
- 부정: 비판, 불평, 혐오
엔드포인트: POST /api/v1/chat-analysis/classify
요청 본문:
{
"message": "이 스트리머 정말 잘하네!",
"context": "게임 플레이 중"
}응답 (200 OK):
{
"success": true,
"data": {
"message": "이 스트리머 정말 잘하네!",
"sentiment": "POSITIVE",
"confidence": 0.95,
"keywords": ["스트리머", "잘하다"]
}
}엔드포인트: GET /api/v1/chat-analysis/speed?minutes=60
요청 헤더:
Authorization: Bearer {accessToken}
응답 (200 OK):
{
"success": true,
"data": {
"currentSpeed": 25,
"averageSpeed": 20,
"peakSpeed": 50,
"unit": "messages/minute",
"timeWindow": 60,
"timestamps": [
{
"time": "2025-04-21T09:30:00Z",
"speed": 15
},
{
"time": "2025-04-21T09:35:00Z",
"speed": 25
}
]
}
}엔드포인트: GET /api/v1/chat-analysis/keywords?limit=10
요청 헤더:
Authorization: Bearer {accessToken}
쿼리 파라미터:
limit: 조회할 키워드 개수 (기본값: 10)
응답 (200 OK):
{
"success": true,
"data": {
"keywords": [
{
"rank": 1,
"word": "재미있다",
"frequency": 45,
"trend": "UP"
},
{
"rank": 2,
"word": "스트리머",
"frequency": 38,
"trend": "STABLE"
},
{
"rank": 3,
"word": "게임",
"frequency": 32,
"trend": "DOWN"
}
],
"totalKeywords": 150
}
}엔드포인트: GET /api/v1/chat-analysis/filtered-statistics
요청 헤더:
Authorization: Bearer {accessToken}
응답 (200 OK):
{
"success": true,
"data": {
"totalFiltered": 15,
"byReason": {
"bannedWords": 8,
"inappropriate": 4,
"spam": 3
},
"filterRate": 3.5,
"unit": "percentage"
}
}엔드포인트: GET /api/v1/game/live-data?puuid={puuid}
요청 헤더:
Authorization: Bearer {accessToken}
쿼리 파라미터:
puuid: 플레이어 PUUID (Riot API에서 제공)
응답 (200 OK):
{
"success": true,
"data": {
"isInGame": true,
"gameMode": "CLASSIC",
"gameName": "Ranked Solo/Duo",
"gameStartTime": "2025-04-21T10:00:00Z",
"playerTeam": {
"player": {
"summonerName": "Streamer123",
"championName": "Ahri",
"kills": 5,
"deaths": 2,
"assists": 8,
"gold": 12500,
"cs": 180,
"level": 14
},
"allies": [
{
"summonerName": "AllyName1",
"championName": "Garen",
"kills": 3,
"deaths": 1,
"assists": 5
}
]
},
"enemyTeam": [
{
"summonerName": "Enemy1",
"championName": "Akali",
"kills": 2,
"deaths": 4,
"assists": 3
}
]
}
}에러 응답:
- 400: 유효하지 않은 PUUID
- 404: 게임 진행 중 아님
- 503: Riot API 통신 실패
엔드포인트: WebSocket 섹션 참고 (7. WebSocket)
엔드포인트: GET /api/v1/safety/banned-words
요청 헤더:
Authorization: Bearer {accessToken}
쿼리 파라미터:
?filterType=INPUT&page=1&limit=50
응답 (200 OK):
{
"success": true,
"data": {
"bannedWords": [
{
"id": "word_1",
"word": "욕설1",
"filterType": "INPUT",
"replacementText": "****",
"severity": "HIGH",
"createdAt": "2025-04-21T10:00:00Z"
}
],
"totalCount": 50,
"page": 1,
"limit": 50
}
}엔드포인트: POST /api/v1/safety/banned-words
요청 본문:
{
"word": "커스텀금지어",
"filterType": "INPUT",
"replacementText": "****",
"severity": "MEDIUM"
}응답 (201 Created):
{
"success": true,
"data": {
"id": "word_custom_1",
"word": "커스텀금지어",
"filterType": "INPUT",
"replacementText": "****",
"severity": "MEDIUM",
"createdAt": "2025-04-21T10:30:00Z"
}
}엔드포인트: DELETE /api/v1/safety/banned-words/{wordId}
요청 헤더:
Authorization: Bearer {accessToken}
응답 (204 No Content):
(응답 본문 없음)
엔드포인트: GET /api/v1/broadcast/stats/month?year=2026&month=6
요청 헤더:
Authorization: Bearer {accessToken}
쿼리 파라미터:
year: 조회 연도month: 조회 월
응답 (200 OK):
{
"status": 200,
"message": "요청이 성공적으로 처리되었습니다.",
"data": {
"broadcastMonthInfoList": [
{
"day": 9,
"broadcastId": 101,
"characterId": 7,
"characterName": "나형준",
"broadcastStatus": "TERMINATED",
"broadcastTime": 240
}
],
"broadcastYear": 2026,
"broadcastMonth": 6
}
}엔드포인트: GET /api/v1/broadcast/stats/day?broadcastId=101
요청 헤더:
Authorization: Bearer {accessToken}
쿼리 파라미터:
broadcastId: 조회할 방송 PK
응답 (200 OK):
{
"status": 200,
"message": "요청이 성공적으로 처리되었습니다.",
"data": {
"characterInfo": {
"name": "나형준",
"gender": "MALE",
"imageUrl": "https://...",
"persona": "FRIENDLY_CHATTER",
"triggerWords": ["형준아", "형준"]
},
"broadcastInfo": {
"streamId": "ABCDEFG123",
"status": "TERMINATED",
"startedAt": "2026-06-09-18:00:15",
"terminatedAt": "2026-06-09-22:00:15",
"lastFiveBroadcastDialogues": [
{
"cursorId": 5,
"subject": "AI_CHARACTER",
"content": "넵 ㅠㅠ 죄송해요 조용히하고 있을게요",
"createdAt": "2026-04-25-18:00:05"
}
],
"analysisResult": {
"majorContent": "게임을 주로 하는 편이나, 때때로 스몰토크나 일상 이야기를 하는 편이며...",
"majorMoodWithViewers": "자주 싸우는 분위기이나...",
"summary": "하루 종일 게임을 하다가...",
"totalAnalysis": "분석 결과: ...",
"catchPhrases": [
{
"content": "무야호",
"subject": "VIEWER",
"situationAnalysis": "시청자가 특정 상황에서 재미있다고 반응하며 반복 사용한 표현"
}
],
"timeLines": [
{
"content": "스트리머가 시청자와...",
"startTime": "2026-06-28 09:12:33",
"endTime": "2026-06-28 09:30:33"
}
]
}
},
"chatAnalysisInfo": {
"publicOpinion": {
"positiveChatCount": 150,
"neutralChatCount": 80,
"negativeChatCount": 20,
"totalChatCount": 250,
"positiveRatio": 60.0,
"neutralRatio": 32.0,
"negativeRatio": 8.0
},
"aiPartnerTendency": "POSITIVE",
"sentimentFlow": [
{
"timeLabel": "14:00",
"positiveRatio": 60.0,
"neutralRatio": 30.0,
"negativeRatio": 10.0
}
],
"topKeywords": ["롤", "ㅋㅋㅋ", "대박", "게임", "신난다", "최고", "재밌다", "gg", "실화", "미쳤다"]
}
}
}비고:
analysisResult.catchPhrases는 더 이상 문자열 리스트가 아닙니다.- 각 항목은 아래 구조의 객체입니다.
{
"content": "무야호",
"subject": "VIEWER",
"situationAnalysis": "시청자가 특정 상황에서 재미있다고 반응하며 반복 사용한 표현"
}엔드포인트: POST /api/v1/stream/start
요청 헤더:
Authorization: Bearer {accessToken}
Content-Type: application/json
쿼리 파라미터:
characterId: 방송할 캐릭터 ID (필수)
요청 본문:
{}응답 (200 OK):
{
"success": true,
"data": {
"broadcastStreamId": "stream_abc123def456",
"broadcastStartedAt": "2025-04-21T10:30:00Z"
},
"message": "방송 시작 성공"
}설명:
- 선택된 캐릭터로 새로운 방송 스트림을 시작합니다.
broadcastStreamId는 이후 스트림 정보 조회 및 WebSocket 연결에 필요합니다.- 반환된 ID는 클라이언트에서 저장하여 스트림 관리에 사용합니다.
에러 응답:
- 400: 유효하지 않은 캐릭터 ID 또는 이미 진행 중인 방송
- 401: 인증 필요
- 404: 캐릭터 없음
엔드포인트: POST /api/v1/stream/terminate
요청 헤더:
Authorization: Bearer {accessToken}
Content-Type: application/json
요청 본문:
{
"broadcastStreamId": "stream_abc123def456"
}응답 (200 OK):
{
"success": true,
"data": {
"terminatedBroadcastStreamId": "stream_abc123def456",
"broadcastStatus": "TERMINATED",
"broadcastTerminatedAt": "2025-04-21T10:35:00Z"
},
"message": "방송 종료 성공"
}설명:
- 진행 중인 방송 스트림을 종료합니다.
- 404 응답도 성공으로 처리됩니다 (멱등성 설계).
- 종료 후 WebSocket 연결은 자동으로 닫힙니다.
에러 응답:
- 400: 유효하지 않은 스트림 ID
- 401: 인증 필요
- 404: 스트림 없음 (성공으로 처리)
엔드포인트: GET /api/v1/stream/info
요청 헤더:
Authorization: Bearer {accessToken}
쿼리 파라미터:
size: 조회할 콘텐츠 개수 (기본값: 20, 최대: 100)
응답 (200 OK):
{
"success": true,
"data": {
"broadcastCharacterInfo": {
"characterId": "char_123",
"characterName": "AI 동료",
"gender": "FEMALE",
"appearanceId": "appearance_2",
"voiceId": "voice_2"
},
"content": [
{
"id": "dialogue_1",
"text": "안녕하세요!",
"timestamp": "2025-04-21T10:30:05Z",
"type": "AI_RESPONSE"
},
{
"id": "dialogue_2",
"text": "오늘 날씨가 좋네요.",
"timestamp": "2025-04-21T10:30:10Z",
"type": "AI_RESPONSE"
}
],
"size": 2,
"hasNext": true,
"nextCursor": "cursor_xyz789"
}
}설명:
- 현재 진행 중인 방송의 스트림 정보와 최근 대화 내용을 조회합니다.
hasNext가 true이면nextCursor를 사용하여 다음 페이지를 조회할 수 있습니다.- 콘텐츠는 시간순으로 정렬됩니다.
에러 응답:
- 401: 인증 필요
- 404: 진행 중인 방송 없음 (정보 조회 전용, 에러로 처리)
엔드포인트: GET /api/v1/stream/info/dialogues
요청 헤더:
Authorization: Bearer {accessToken}
쿼리 파라미터:
cursor: 페이지네이션 커서 (선택사항, 첫 요청 시 생략)size: 조회할 대화 개수 (기본값: 20, 최대: 100)
응답 (200 OK):
{
"success": true,
"data": {
"content": [
{
"id": "dialogue_1",
"text": "안녕하세요!",
"timestamp": "2025-04-21T10:30:05Z",
"type": "AI_RESPONSE",
"characterId": "char_123"
},
{
"id": "dialogue_2",
"text": "오늘 날씨가 좋네요.",
"timestamp": "2025-04-21T10:30:10Z",
"type": "AI_RESPONSE",
"characterId": "char_123"
}
],
"hasNext": true,
"nextCursor": "cursor_xyz789",
"size": 2
}
}설명:
- 방송 스트림의 모든 대화를 커서 기반 페이지네이션으로 조회합니다.
- 무한 스크롤 UI 구현에 사용됩니다.
hasNext가 true이면nextCursor를 사용하여 다음 배치를 조회합니다.
에러 응답:
- 401: 인증 필요
- 404: 진행 중인 방송 없음
URL: ws://localhost:8080/api/v1/ws?token={accessToken}
프로토콜: json
모든 WebSocket 메시지는 JSON 형식:
{
"type": "MESSAGE_TYPE",
"data": { /* 타입별 데이터 */ },
"timestamp": "2025-04-21T10:30:00Z"
}{
"type": "SUBSCRIBE_CHAT",
"data": {
"channelId": "stream_123"
}
}{
"type": "SUBSCRIBE_GAME",
"data": {
"characterId": "char_123"
}
}{
"type": "UNSUBSCRIBE",
"data": {
"subscriptionId": "sub_123"
}
}{
"type": "CHAT_MESSAGE",
"data": {
"messageId": "msg_123",
"username": "user_name",
"message": "채팅 내용",
"sentiment": "POSITIVE",
"timestamp": "2025-04-21T10:30:00Z"
}
}{
"type": "GAME_EVENT",
"data": {
"eventType": "KILL",
"champion": "Ahri",
"details": {
"kills": 5,
"deaths": 2,
"assists": 8
},
"timestamp": "2025-04-21T10:30:00Z"
}
}이벤트 타입: KILL, DEATH, ASSIST, MULTIKILL, BARON, DRAGON, TURRET, GAME_END
{
"type": "SENTIMENT_UPDATE",
"data": {
"positive": 450,
"neutral": 350,
"negative": 200,
"timeWindow": 10,
"timestamp": "2025-04-21T10:30:00Z"
}
}{
"type": "CONNECTION_STATUS",
"data": {
"status": "CONNECTED",
"message": "연결 성공"
}
}{
"type": "ERROR",
"data": {
"code": "SUBSCRIPTION_FAILED",
"message": "채팅 구독 실패",
"details": {}
}
}URL: ws://localhost:8080/api/v1/stream/ws
쿼리 파라미터:
broadcastStreamId: 방송 스트림 ID (필수, POST /stream/start에서 반환)accessToken: JWT 액세스 토큰 (필수)
프로토콜: Binary + Text 프레임 쌍
메시지 형식:
방송 스트림 WebSocket은 표준 JSON 메시지 대신 Binary 프레임 + Text 프레임 쌍을 사용합니다:
- Binary 프레임: TTS 생성 오디오 (Blob)
- Text 프레임: 메타데이터 (JSON)
{
"type": "AUDIO_METADATA",
"data": {
"duration": 2500,
"characterId": "char_123",
"text": "안녕하세요!",
"timestamp": "2025-04-21T10:30:05Z"
}
}연결 관리:
- 자동 재연결: 연결 끊김 시 3초 후 자동 재연결 시도
- 정상 종료 (close code 1000, 1008): 재연결 안 함
- 기타 에러: 3초 지연 후 재연결
에러 처리:
- 401 Unauthorized: 토큰 만료 또는 유효하지 않음
- 404 Not Found: 스트림 ID 유효하지 않음
- 403 Forbidden: 권한 부족
사용 예시 (TypeScript):
const ws = new WebSocket(
`ws://localhost:8080/api/v1/stream/ws?broadcastStreamId=${streamId}&accessToken=${token}`
);
ws.onmessage = (event) => {
if (event.data instanceof Blob) {
// Binary 프레임: 오디오 데이터
const audioBlob = event.data;
// 오디오 재생 로직
} else {
// Text 프레임: 메타데이터
const metadata = JSON.parse(event.data);
console.log('Audio metadata:', metadata);
}
};| 코드 | 설명 | HTTP 상태 |
|---|---|---|
| INVALID_INPUT | 입력값 검증 실패 | 400 |
| UNAUTHORIZED | 인증 필요 | 401 |
| FORBIDDEN | 권한 부족 | 403 |
| NOT_FOUND | 리소스 없음 | 404 |
| DUPLICATE | 중복 데이터 | 409 |
| RATE_LIMITED | 요청 제한 초과 | 429 |
| SERVER_ERROR | 서버 에러 | 500 |
| SERVICE_UNAVAILABLE | 서비스 이용 불가 | 503 |
{
"success": false,
"statusCode": 400,
"message": "이메일 형식이 올바르지 않습니다.",
"error": "INVALID_INPUT",
"details": {
"field": "email",
"value": "invalid-email"
},
"timestamp": "2025-04-21T10:30:00Z"
}| 엔드포인트 | 제한 | 시간 |
|---|---|---|
/auth/register/email |
5회 | 1시간 |
/auth/login/email |
10회 | 1시간 |
/characters (POST) |
50회 | 1시간 |
/chat-analysis/* |
100회 | 1시간 |
| 기타 | 1000회 | 1시간 |
레이트 리미트 헤더:
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 95
X-RateLimit-Reset: 1640000000
Access-Control-Allow-Origin: http://localhost:5173, http://localhost:3000
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 1; mode=block
Strict-Transport-Security: max-age=31536000
모든 입력값은 다음과 같이 검증됩니다:
- XSS 방지: HTML 특수문자 이스케이프
- SQL Injection 방지: Prepared Statement 사용
- CSRF 방지: CSRF 토큰 검증
프런트엔드는 src/features/*/api/ 에서 API 함수를 정의합니다:
// src/features/auth/api/authApi.ts
export async function loginEmail(data: LoginRequest): Promise<AuthResponse> {
const res = await apiClient.post<AuthResponse>(`${AUTH_BASE}/login/email`, data);
return res.data;
}
// src/features/auth/hooks/useLogin.ts
export function useLogin(): UseLoginReturn {
const login = useCallback(async (data: LoginRequest) => {
const response = await loginEmail(data);
// JWT 토큰 자동으로 localStorage에 저장되고 인터셉터에서 주입됨
}, []);
return { login, isPending, error };
}// src/shared/lib/axios.ts
apiClient.interceptors.request.use((config) => {
const accessToken = useAuthStore.getState().accessToken;
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
});
// 401 Unauthorized 시 자동 토큰 재발급
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
// 토큰 재발급 로직
}
return Promise.reject(error);
}
);| 버전 | 날짜 | 변경 사항 |
|---|---|---|
| 1.1 | 2025-05-11 | § 7 방송 스트림 API 추가 (POST /stream/start, POST /stream/terminate, GET /stream/info, GET /stream/info/dialogues) + § 8.6 방송 스트림 WebSocket 추가 |
| 1.0 | 2025-04-21 | 초본 작성 |