| 항목 | 내용 |
|---|---|
| 문서명 | Syfity API Spec |
| 버전 | v2.6 |
| 상태 | Room 복구 시 채팅 초기화 정책 반영 |
| 작성 목적 | Syfity REST API 계약 정의 |
| 기반 문서 | 01-prd.md, 02-system-architecture.md, 03-realtime-sync-design.md, 04-database-design.md, 06-socket-event-spec.md |
로컬: http://localhost:4000/api/v1
운영: https://api.syfity.site/api/v1
일반 API는 JWT Access Token을 httpOnly 쿠키로 받는다. Access Token 만료는 1시간, Refresh Token 만료는 30일이며 Refresh Token Rotation을 적용한다.
{
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax',
}인증이 필요 없는 엔드포인트는 GET /health, OAuth 시작·콜백, POST /auth/refresh, Swagger UI뿐이다. GitHub Actions용 내부 정리 API는 사용자 쿠키 대신 CRON_SECRET을 사용한다.
// 성공
{ success: true, data: { ... } }
// 실패
{ success: false, error: { code: string, message: string } }204 No Content 응답은 body를 반환하지 않는 예외다.
| Prefix | 도메인 |
|---|---|
AUTH_ |
인증·인가 |
ROOM_ |
Room·참여자·수명 주기 |
PLAYLIST_ |
Room Playlist·YouTube 영상 |
PERSONAL_PLAYLIST_ |
개인 Playlist |
SEARCH_ |
YouTube 검색 |
SERVER_ |
내부·외부 API 오류 |
공통 오류는 AUTH_UNAUTHORIZED(401), AUTH_TOKEN_EXPIRED(401), AUTH_FORBIDDEN(403), SERVER_INTERNAL_ERROR(500)다.
요청 본문·query·path parameter의 형식 또는 값 조합 검증 실패는 모두 VALIDATION_ERROR(400)로 반환한다.
- REST는 Room·Playlist·채팅·개인 Playlist의 영속 데이터와 Home 관리 작업을 처리한다.
- 현재 곡, 재생 위치, 반복·셔플, 셔플 큐, 재생 이력은 REST로 조회·변경하지 않는다.
POST /room-memberships는 입장 권한과 참여 이력만 처리한다. 입장 뒤 Socketroom:join의room:joinedsnapshot으로 재생 세션, Playlist, 참여자, 최근 채팅을 받는다.- 재생 제어와
playback:reset을 포함한 실시간 이벤트는06-socket-event-spec.md에서 정의한다.
GET /health
GET /api-docs
GET /auth/google
GET /auth/google/callback
POST /auth/refresh
POST /auth/logout
GET /me
PATCH /me
PATCH /me/nickname
DELETE /me
POST /me/profile-image/upload-url
POST /me/profile-image/confirm
DELETE /me/profile-image
GET /rooms/recent
GET /rooms/mine
POST /rooms
POST /room-memberships
GET /rooms/:roomId
PATCH /rooms/:roomId
DELETE /rooms/:roomId
GET /rooms/:roomId/members?status=kicked
PATCH /rooms/:roomId/members/:memberId
GET /rooms/:roomId/playlist
POST /rooms/:roomId/playlist
DELETE /rooms/:roomId/playlist/:itemId
PATCH /rooms/:roomId/playlist
POST /rooms/:roomId/playlist-imports
GET /rooms/:roomId/chats
GET /personal-playlists
POST /personal-playlists
GET /personal-playlists/:playlistId
PATCH /personal-playlists/:playlistId
DELETE /personal-playlists/:playlistId
POST /personal-playlists/:playlistId/items
DELETE /personal-playlists/:playlistId/items/:itemId
PATCH /personal-playlists/:playlistId/items
GET /search
POST /internal/rooms/inactivate-stale
POST /internal/playlist-items/refresh-stale-metadata
POST /internal/profile-image-objects/cleanup
인증 없이 Backend 상태를 확인한다. UptimeRobot은 이 엔드포인트를 10분마다 호출한다.
// 200
{ success: true, data: { status: 'ok' } }| Endpoint | 설명 |
|---|---|
GET /auth/google?returnUrl= |
Google OAuth 로그인 시작. 성공 뒤 returnUrl 또는 Home으로 이동 |
GET /auth/google/callback |
JWT 쿠키 발급 뒤 FE로 리다이렉트 |
POST /auth/refresh |
Access·Refresh Token 동시 갱신 |
POST /auth/logout |
쿠키 삭제와 users.refresh_token 초기화 |
POST /auth/refresh 실패 시 AUTH_REFRESH_EXPIRED(401)를 반환한다.
현재 로그인 사용자 정보를 반환한다.
{
success: true,
data: { id: string, email: string, nickname: string, profileImage: string | null, onboardedAt: string | null }
}PATCH /me는 닉네임과 연령·약관 동의로 최초 온보딩을 완료한다. PATCH /me/nickname은 이후 닉네임을 수정한다. DELETE /me는 계정을 즉시 익명화하고 개인 Playlist를 삭제한다. 프로필 이미지는 signed upload URL 발급 → Supabase Storage 직접 업로드 → confirm의 3단계로 처리한다.
사용자가 참여했던 active Room 중 kicked가 아닌 Room을 lastJoinedAt 내림차순으로 반환한다.
{ success: true, data: { rooms: [{ id, name, inviteCode, lastJoinedAt }] } }Host가 만든 active·closed Room을 반환한다. Home의 내 Room 목록 전용이다. 조회 전에 closedAt 30일 경과 Room을 inactive로 보정하며, inactive로 전환된 Room은 응답에서 제외한다.
{
success: true,
data: {
rooms: [{ id, name, status: 'active' | 'closed', closedAt, updatedAt }]
}
}새 private Room을 생성한다. 생성자는 Host가 되며, DB에는 Room과 Host 참여 이력만 만든다. 인메모리 재생 세션은 생성하지 않는다.
// request
{ name: string }
// 201
{ success: true, data: { id, name, inviteCode, status: 'active', createdAt } }SERVER_INVITE_CODE_GENERATION_FAILED(500)는 초대 코드 생성 재시도가 모두 실패했을 때 반환한다.
초대 코드로 현재 사용자의 active Room 참여 관계를 생성하거나 복원한다. 신규 참여자만 room_members를 left 상태로 만들고, 기존 참여 이력은 유지한다. recent_rooms를 upsert하며, 실제 online 전환은 이후 Socket room:join에서 수행한다. 이 응답에는 재생 상태를 포함하지 않는다.
// request
{ inviteCode: string }
// 신규 참여: 201, 기존 참여 이력 복원: 200
{ success: true, data: { room: { id, name, status: 'active', inviteCode, hostId } } }성공 뒤 클라이언트는 Socket room:join을 호출해 room:joined snapshot을 받는다.
| 코드 | HTTP | 설명 |
|---|---|---|
ROOM_NOT_FOUND |
404 | 초대 코드에 해당하는 Room 없음 |
ROOM_CLOSED |
403 | closed Room |
ROOM_INACTIVE |
403 | inactive Room |
ROOM_MEMBER_KICKED |
403 | Host가 추방을 해제하기 전 재입장 불가 |
active Room의 기본 영속 정보를 반환한다. Room 참여 이력이 있는 사용자만 조회할 수 있고, kicked 사용자는 거부한다.
{ success: true, data: { id, name, status, inviteCode, hostId, createdAt } }Host가 Room의 이름 또는 상태를 변경한다. 이름은 active Room에서만 수정할 수 있다. 상태 변경은 허용된 수명 주기 전이만 지원하며, name과 status는 한 요청에서 함께 변경할 수 없다.
// 이름 변경 요청
{ name: string }
// active Room 명시적 종료 요청
{ status: 'closed' }
// closed Room 복구 요청
{ status: 'active' }
// 200
{ success: true, data: { id, name, status, closedAt, updatedAt } }status: 'closed'는 active Room만 closed로 전환한다. closedAt을 기록하고, 재생 세션을 종료하며 모든 Socket Room 연결을 해제한다. 참여 이력과 채팅은 유지한다.
status: 'active'는 closed Room만 복구한다. 30일 만료 여부를 먼저 보정하고, 복구 가능하면 Playlist·채팅을 삭제한 뒤 인메모리 재생 세션을 제거한다. 참여 이력·추방 상태는 유지한다.
Host의 Socket 연결 해제는 이 API를 호출하지 않는다. 1분 재연결 유예 뒤 서버가 status: 'closed'와 같은 종료 처리를 수행한다.
Host가 closed Room을 논리 삭제한다. 실제 DB 행은 보존하되 status를 inactive로 전환한다. active Room의 직접 삭제와 inactive Room 복구는 허용하지 않는다.
성공 시 204 No Content를 반환한다.
| 코드 | HTTP | 설명 |
|---|---|---|
ROOM_NOT_FOUND |
404 | Room 없음 |
ROOM_NOT_ACTIVE |
409 | active 상태가 필요한 작업에 closed·inactive Room 요청 |
ROOM_NOT_CLOSED |
409 | 복구·삭제 대상이 closed가 아님 |
ROOM_CLOSED |
403 | closed Room 입장·변경 요청 |
ROOM_INACTIVE |
403 | inactive Room 입장·변경 요청 |
ROOM_RECOVERY_EXPIRED |
409 | 30일 경과로 inactive 처리되어 복구 불가 |
AUTH_FORBIDDEN |
403 | Host 전용 작업을 Member가 요청 |
Host만 Room Member 목록을 조회한다. status 쿼리가 없으면 현재 활성 로스터(online/offline)를, status=kicked면 추방 목록을 반환한다.
// GET /rooms/:roomId/members
{
success: true,
data: { members: [{ id, userId, nickname, profileImage, role, status: 'online' | 'offline' }] }
}// GET /rooms/:roomId/members?status=kicked
{
success: true,
data: { members: [{ id, userId, nickname, profileImage, kickedAt: string }] }
}kickedAt은 room_members.updated_at을 응답 필드로 직렬화한 값이다.
Host가 Room Member의 상태를 변경한다.
// active Room Member 추방
{ status: 'kicked' }
// 추방 해제
{ status: 'left' }
// 200
{ success: true, data: { memberId: string, status: 'kicked' | 'left' } }status: 'kicked'는 active Room에서만 가능하며 Host 자신은 추방할 수 없다. 대상의 모든 Socket Room 연결을 해제한다. status: 'left'는 kicked Member만 대상으로 하며, 자동 재입장시키지 않는다.
Room 상태 오류(ROOM_CLOSED/ROOM_INACTIVE)는 5.8 Room 수명 주기 오류를 따른다.
| 코드 | HTTP | 설명 |
|---|---|---|
ROOM_MEMBER_NOT_FOUND |
404 | 해당 Room의 Member 없음 |
ROOM_MEMBER_NOT_KICKED |
409 | status: 'left' 변경 대상이 kicked가 아님 |
ROOM_CANNOT_KICK_HOST |
409 | Host 자신 추방 시도 |
모든 Room Playlist·채팅 변경 API는 active Room의 kicked가 아닌 참여자만 사용할 수 있다. Room 상태·권한 오류는 5.8절을 따른다.
Room Playlist를 position 오름차순으로 반환한다.
{ success: true, data: { playlist: [PlaylistItem] } }videoId 또는 youtubeUrl 정확히 하나를 받아 YouTube videos.list 검증 후 Room Playlist 끝에 추가한다. 같은 videoId는 available·unavailable 여부와 관계없이 중복 추가할 수 없다.
// request
{ videoId?: string, youtubeUrl?: string }
// 201
{ success: true, data: PlaylistItem }성공 뒤 playlist:updated를 broadcast한다.
두 필드가 모두 없거나 모두 있으면 VALIDATION_ERROR(400)를 반환한다.
| 코드 | HTTP | 설명 |
|---|---|---|
PLAYLIST_INVALID_URL |
400 | videoId 파싱 불가 URL |
PLAYLIST_VIDEO_UNAVAILABLE |
400 | 재생·임베드 불가 영상 |
PLAYLIST_NOT_MUSIC |
400 | Music 카테고리가 아닌 영상 |
PLAYLIST_DUPLICATE_VIDEO |
409 | 동일 영상이 이미 있음 |
SERVER_YOUTUBE_API_ERROR |
502 | YouTube API 실패 |
Host는 모든 곡을, Member는 자신이 추가한 곡만 삭제한다. 현재 곡을 삭제하면 서버의 인메모리 반복·셔플 정책으로 다음 상태를 결정하고, playback:change-track 또는 playback:pause를 전파한다. 성공 뒤 playlist:updated를 전파한다.
성공 시 204 No Content를 반환한다.
Host만 전체 순서 배열을 받아 DB 트랜잭션으로 position을 갱신한다. 진행 중인 셔플 큐는 다시 만들지 않는다.
{
items: [{ id: string, position: number }];
}items는 현재 Room Playlist의 모든 항목 id를 정확히 한 번씩 포함해야 하며, id 집합이 일치하지 않으면 PLAYLIST_ITEM_NOT_FOUND(404)를 반환한다. position은 1부터 항목 수까지 중복 없이 연속이어야 하며, 위반 시 VALIDATION_ERROR(400)를 반환한다.
성공 시 204 No Content를 반환하고, 서버는 playlist:updated로 갱신된 전체 Playlist를 Room에 전파한다.
active Room의 Host가 자신의 개인 Playlist를 Room Playlist 끝에 일회성 복사한다.
// request
{ personalPlaylistId: string }
// 200
{
success: true,
data: { addedCount: number, duplicateCount: number, unavailableCount: number }
}- Room에 이미 있는 곡과 재생·임베드 불가 곡은 건너뛴다.
- 동시 요청으로 같은 곡이 추가되며 unique 충돌이 나면 가져오기를 재시도하고, 재조회된 곡은
duplicateCount에 포함한다. 재시도 한도를 넘으면PLAYLIST_DUPLICATE_VIDEO(409)를 반환한다. - 완료 후
playlist:updated를 한 번만 broadcast한다. - 셔플이 켜져 있으면 서버는 추가된 곡을 남은 인메모리 큐의 무작위 위치에 넣는다.
personalPlaylistId가 없으면PERSONAL_PLAYLIST_NOT_FOUND(404), 요청자가 소유하지 않으면PERSONAL_PLAYLIST_ACCESS_DENIED(403)를 반환한다.
과거 채팅을 (createdAt, id) 복합 커서로 조회한다.
| Query | 타입 | 필수 | 설명 |
|---|---|---|---|
| cursorTime | string | Yes | 기준 시각(ISO 8601) |
| cursorId | string | Yes | 기준 메시지 ID |
| limit | number | No | 기본 50 |
{ success: true, data: { chats: [ChatMessage], hasMore: boolean } }Unicode 이모지는 기존 message TEXT에 포함되며 별도 REST API가 필요하지 않다.
모든 개인 Playlist API는 소유자만 접근할 수 있다. 존재하지 않으면 PERSONAL_PLAYLIST_NOT_FOUND(404), 다른 사용자의 Playlist면 PERSONAL_PLAYLIST_ACCESS_DENIED(403)를 반환한다.
| Endpoint | 요청 | 성공 응답 | 설명 |
|---|---|---|---|
GET /personal-playlists |
- | { success: true, data: { playlists: [PersonalPlaylist] } } |
내 목록 |
POST /personal-playlists |
{ name } |
201 { success: true, data: { id, name, createdAt } } |
생성 |
GET /personal-playlists/:playlistId |
- | { success: true, data: { playlist, items } } |
상세·곡 목록 |
PATCH /personal-playlists/:playlistId |
{ name } |
{ success: true, data: { id, name, updatedAt } } |
이름 변경 |
DELETE /personal-playlists/:playlistId |
- | 204 No Content |
항목까지 cascade hard delete |
| Endpoint | 요청 | 성공 응답 | 설명 |
|---|---|---|---|
POST /personal-playlists/:playlistId/items |
{ videoId?: string, youtubeUrl?: string } |
201 { success: true, data: PersonalPlaylistItem } |
YouTube 검증 후 끝에 추가 |
DELETE /personal-playlists/:playlistId/items/:itemId |
- | 204 No Content |
항목 삭제 |
PATCH /personal-playlists/:playlistId/items |
{ items: [{ id, position }] } |
{ success: true, data: { items: [PersonalPlaylistItem] } } |
전체 순서 변경 |
개인 Playlist 곡 추가도 videoId와 youtubeUrl 중 정확히 하나를 요구하며, 둘 다 없거나 모두 있으면 VALIDATION_ERROR(400)를 반환한다. 순서 변경의 items는 대상 Playlist의 모든 항목 id를 정확히 한 번씩 포함해야 하며, id 집합이 일치하지 않으면 PERSONAL_PLAYLIST_ITEM_NOT_FOUND(404)를 반환한다. position은 1부터 항목 수까지 연속이어야 하며, 위반 시 VALIDATION_ERROR(400)를 반환한다. 동일 videoId 중복은 PERSONAL_PLAYLIST_DUPLICATE_VIDEO(409)로 거부한다. 추가 시 영상 재생·임베드 검증에 실패하면 PLAYLIST_VIDEO_UNAVAILABLE(400), Music 카테고리가 아니면 PLAYLIST_NOT_MUSIC(400)을 반환한다. Playlist는 존재하지만 대상 곡이 없으면 PERSONAL_PLAYLIST_ITEM_NOT_FOUND(404)를 반환한다.
| 코드 | HTTP | 설명 |
|---|---|---|
PLAYLIST_INVALID_URL |
400 | videoId 파싱 불가 URL |
PLAYLIST_VIDEO_UNAVAILABLE |
400 | 재생·임베드 불가 영상 |
PLAYLIST_NOT_MUSIC |
400 | Music 카테고리가 아닌 영상 |
PERSONAL_PLAYLIST_DUPLICATE_VIDEO |
409 | 동일 영상이 이미 있음 |
PERSONAL_PLAYLIST_ITEM_NOT_FOUND |
404 | 대상 곡이 없음 |
YouTube Music 영상 검색이다. 서버는 search.list와 videos.list로 Music 카테고리를 검증하고 상위 10개를 반환한다. 동일 검색어는 5분간 캐시한다.
// query: ?q=...
{ success: true, data: { items: [{ videoId, title, channelTitle, thumbnailUrl, duration }] } }SEARCH_QUERY_REQUIRED(400), SERVER_YOUTUBE_API_ERROR(502), SERVER_YOUTUBE_QUOTA_EXCEEDED(429)를 사용한다.
POST /internal/playlist-items/refresh-stale-metadata는 CRON_SECRET 인증으로 25일 경과한 Room·개인 Playlist 메타데이터를 YouTube Data API에서 갱신하고, 삭제·비공개·임베드 불가·madeForKids 영상은 unavailable로 전환한다. 각 테이블은 (metadataRefreshedAt, id) cursor 기준 100개씩 독립 조회·반영해 대량 stale row도 한 트랜잭션에 누적하지 않는다. cron-job.org가 이 엔드포인트, Room 비활성화, 프로필 이미지 정리 엔드포인트를 호출한다.
GitHub Actions가 하루 한 번 호출한다. Swagger와 일반 FE 클라이언트에는 노출하지 않는다.
Authorization: Bearer <CRON_SECRET>closed 상태이고 closedAt이 30일 이상 지난 Room을 inactive로 바꾼다. 여러 번 호출해도 이미 inactive인 Room을 다시 변경하지 않는 멱등 작업이다.
// 200
{ success: true, data: { inactivatedCount: number } }유효한 Bearer Token이 없으면 AUTH_FORBIDDEN(403)을 반환한다.
Authorization: Bearer <CRON_SECRET>발급 뒤 1시간이 지난 pending 업로드와 프로필 교체·초기화·계정 탈퇴 중 Storage 삭제에 실패한 delete_pending 객체를 Supabase Storage에서 삭제한다. 실패한 객체 row는 남겨 다음 cron 실행에서 재시도한다.
// 200
{ success: true, data: { deletedCount: number, failedCount: number } }