일상적인 피드 게시물 생성, 조회, 수정, 삭제 및 상호작용(좋아요, 북마크, 댓글) API를 제공합니다. 모든 API는 로그인이 필요하며, JWT 토큰 인증을 사용합니다.
- Base URL:
http://localhost:3001 - 인증 방식: JWT Bearer Token (Authorization 헤더) 또는 HTTP-only 쿠키
- 응답 형식: JSON
{
"code": 200,
"message": "요청 성공 메시지",
"data": {
// 응답 데이터
}
}{
"code": 400,
"message": "오류 메시지",
"errors": [
{
"field": "필드명",
"message": "상세 오류 메시지"
}
]
}POST /feeds/upload/images
피드에 사용할 이미지들을 업로드합니다.
- Content-Type:
multipart/form-data - Body:
images필드에 이미지 파일들 (최대 10개)
{
"code": 200,
"message": "이미지가 업로드되었습니다.",
"data": {
"files": [
{
"filename": "image_1640000000000_abc123.jpg",
"path": "/uploads-feed/2024/01/15/image_1640000000000_abc123.jpg",
"url": "http://localhost:3001/uploads-feed/2024/01/15/image_1640000000000_abc123.jpg",
"size": 1024000
},
{
"filename": "image_1640000000001_def456.png",
"path": "/uploads-feed/2024/01/15/image_1640000000001_def456.png",
"url": "http://localhost:3001/uploads-feed/2024/01/15/image_1640000000001_def456.png",
"size": 2048000
}
]
}
}- 지원 형식: .jpg, .jpeg, .png, .gif, .webp, .heic, .heif
- 최대 파일 크기: 10MB
- 최대 업로드 개수: 10개
POST /feeds/upload/video
피드에 사용할 영상을 업로드하고 자동으로 썸네일을 생성합니다.
- Content-Type:
multipart/form-data - Body:
video필드에 영상 파일 1개
{
"code": 200,
"message": "영상과 썸네일이 업로드되었습니다.",
"data": {
"video": {
"filename": "video_1640000000000_abc123.mp4",
"path": "/uploads-feed-video/2024/01/15/video_1640000000000_abc123.mp4",
"url": "http://localhost:3001/uploads-feed-video/2024/01/15/video_1640000000000_abc123.mp4",
"size": 52428800
},
"thumbnail": {
"filename": "thumbnail_video_1640000000000_abc123.jpg",
"path": "/uploads-feed-video-thumbnail/2024/01/15/thumbnail_video_1640000000000_abc123.jpg",
"url": "http://localhost:3001/uploads-feed-video-thumbnail/2024/01/15/thumbnail_video_1640000000000_abc123.jpg",
"size": 245760
}
}
}썸네일 생성 실패 시 응답:
{
"code": 200,
"message": "영상이 업로드되었습니다. (썸네일 생성 실패)",
"data": {
"video": {
"filename": "video_1640000000000_abc123.mp4",
"path": "/uploads-feed-video/2024/01/15/video_1640000000000_abc123.mp4",
"url": "http://localhost:3001/uploads-feed-video/2024/01/15/video_1640000000000_abc123.mp4",
"size": 52428800
},
"thumbnail": null
}
}- 지원 형식: .mp4, .avi, .mov, .wmv, .flv
- 최대 파일 크기: 100MB
- 업로드 개수: 1개만
- 썸네일: 영상의 50% 지점에서 자동 생성 (생성 실패 시 null 반환)
POST /feeds/create-with-files
피드에 사용할 이미지/영상 파일들과 콘텐츠 블록을 함께 전송하여 피드를 생성합니다. 파일들은 자동으로 업로드되고 경로가 생성됩니다.
| 헤더명 | 타입 | 필수 | 설명 |
|---|---|---|---|
Idempotency-Key |
String | 필수 | 중복 요청 방지용 UUID v4 |
- Content-Type:
multipart/form-data - Body:
content_blocks필드: JSON 문자열 형태의 콘텐츠 블록 배열images필드: 이미지 파일들 (최대 10개)videos필드: 영상 파일들 (최대 5개)
[
{
"type": "text",
"value": "피드 텍스트 내용입니다.",
"sequence": 0
},
{
"type": "image",
"value": null,
"sequence": 1
},
{
"type": "video",
"value": null,
"editInfo": {
"trimStart": 1000,
"trimEnd": 5000,
"cropArea": {
"x": 0.1,
"y": 0.1,
"width": 0.8,
"height": 0.6
}
},
"sequence": 2
}
]const formData = new FormData();
// 콘텐츠 블록 JSON
const contentBlocks = [
{
type: 'text',
value: '새로운 피드를 작성합니다!',
sequence: 0
},
{
type: 'image',
value: null, // 파일이 업로드될 예정
sequence: 1
},
{
type: 'video',
value: null, // 파일이 업로드될 예정
sequence: 2
}
];
formData.append('content_blocks', JSON.stringify(contentBlocks));
// 파일들 추가
formData.append('images', imageFile1);
formData.append('images', imageFile2);
formData.append('videos', videoFile1);
fetch('/feeds/create-with-files', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
},
body: formData
});{
"code": 201,
"message": "피드가 작성되었습니다.",
"data": {
"feedId": 1,
"created_at": "2024-01-15T10:30:00.000Z"
}
}- 이미지 파일:
images필드의 파일들이content_blocks에서value: null인 이미지 타입 블록에 순서대로 매핑됩니다. - 영상 파일:
videos필드의 파일들이content_blocks에서value: null인 비디오 타입 블록에 순서대로 매핑됩니다. - 편집 정보: 비디오 블록에
editInfo가 포함된 경우 자동으로 trim + crop 처리가 수행됩니다. - 스토리지: 환경변수
STORAGE_DRIVER에 따라 로컬 또는 네이버 클라우드에 파일이 저장됩니다.
PUT /feeds/{feedId}/update-with-files
피드의 텍스트, 이미지, 비디오를 한 번에 수정합니다. 기존 파일들은 자동으로 정리됩니다.
- Method:
PUT - URL:
/feeds/{feedId}/update-with-files - Content-Type:
multipart/form-data - 인증: JWT Bearer Token 필수 (Authorization 헤더)
- Path Parameters:
feedId(number, 필수): 수정할 피드 ID
| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
content_blocks |
string | 필수 | 콘텐츠 블록 배열의 JSON 문자열 |
images[] |
File[] | 선택 | 새 이미지 파일들 (다중 가능, 필드명: images) |
videos[] |
File[] | 선택 | 새 비디오 파일들 (다중 가능, 필드명: videos) |
[
{
"type": "text",
"value": "수정된 텍스트 블록 내용",
"sequence": 0
},
{
"type": "image",
"value": "/uploads-feed/image/2025/01/18/image_123.jpg", // 기존 파일 URL (유지)
"sequence": 1
},
{
"type": "image",
"value": null, // 새 파일 (업로드됨)
"sequence": 2
},
{
"type": "video",
"value": null, // 새 파일
"editInfo": {
"trimStart": 1000,
"trimEnd": 5000,
"cropArea": {
"x": 0.1,
"y": 0.1,
"width": 0.8,
"height": 0.8
}
},
"sequence": 3
}
]비디오 블록에 editInfo를 포함하면 자동으로 trim + crop 편집이 적용됩니다:
| 필드 | 타입 | 필수 | 설명 | 단위 |
|---|---|---|---|---|
trimStart |
number | 필수 | 편집 시작 시간 | 밀리초 (ms) |
trimEnd |
number | 필수 | 편집 종료 시간 | 밀리초 (ms) |
cropArea.x |
number | 필수 | 크롭 시작 X좌표 | 비율 (0.0 ~ 1.0) |
cropArea.y |
number | 필수 | 크롭 시작 Y좌표 | 비율 (0.0 ~ 1.0) |
cropArea.width |
number | 필수 | 크롭 너비 | 비율 (0.0 ~ 1.0) |
cropArea.height |
number | 필수 | 크롭 높이 | 비율 (0.0 ~ 1.0) |
새 파일 업로드를 트리거하는 조건들:
| 조건 | 설명 | 예시 |
|---|---|---|
value: null |
표준 방식 - 새 파일 업로드 | "value": null |
value.startsWith('file://') |
로컬 파일 URI - 호환성 지원 | "value": "file:///Users/.../image.jpg" |
| 그 외 | 기존 파일 유지 | "value": "/uploads/image123.jpg" |
📱 React Native / Expo:
const updateFeedWithFiles = async (feedId, updateData) => {
const formData = new FormData();
// content_blocks JSON
const contentBlocks = [
{
type: 'text',
value: '수정된 피드 텍스트',
sequence: 0
},
{
type: 'image',
value: '/uploads-feed/image/2025/01/18/image_123.jpg', // 기존 이미지 유지
sequence: 1
},
{
type: 'image',
value: null, // 새 이미지 업로드
sequence: 2
},
{
type: 'video',
value: null, // 새 비디오 업로드
editInfo: {
trimStart: 1000,
trimEnd: 5000,
cropArea: { x: 0.1, y: 0.1, width: 0.8, height: 0.8 }
},
sequence: 3
}
];
formData.append('content_blocks', JSON.stringify(contentBlocks));
// 새 파일들 추가
updateData.newImages.forEach((image, index) => {
formData.append('images', {
uri: image.uri,
type: image.type || 'image/jpeg',
name: image.name || `image_${index}.jpg`,
} as any);
});
updateData.newVideos.forEach((video, index) => {
formData.append('videos', {
uri: video.uri,
type: video.type || 'video/mp4',
name: video.name || `video_${index}.mp4`,
} as any);
});
const response = await fetch(`/feeds/${feedId}/update-with-files`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
// Content-Type은 자동으로 설정됨
},
body: formData
});
return response.json();
};
// 사용 예시
const result = await updateFeedWithFiles(feedId, {
newImages: [imageFile],
newVideos: [videoFile]
});🌐 웹 (React):
const updateFeedWithFiles = async (feedId, formData) => {
const response = await fetch(`/feeds/${feedId}/update-with-files`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
// Content-Type은 자동으로 설정됨
},
body: formData,
});
return response.json();
};
// 사용 예시
const handleUpdate = async () => {
const formData = new FormData();
// content_blocks JSON
const contentBlocks = [
{ type: 'text', value: '수정된 텍스트', sequence: 0 },
{ type: 'image', value: '/existing/image.jpg', sequence: 1 }, // 기존 유지
{ type: 'image', value: null, sequence: 2 }, // 새 파일
];
formData.append('content_blocks', JSON.stringify(contentBlocks));
// 새 파일들
selectedImages.forEach(file => formData.append('images', file));
selectedVideos.forEach(file => formData.append('videos', file));
const result = await updateFeedWithFiles(feedId, formData);
};- 기존 콘텐츠 블록 조회 → 현재 파일들 파악
- 새 데이터 파싱 → FormData에서 텍스트/파일 분리
- 콘텐츠 블록 재구성 → 새 파일 업로드 + 기존 파일 유지
- 파일 비교 → 사용하지 않는 기존 파일들 식별
- DB 업데이트 → 트랜잭션으로 안전하게 저장
- 파일 정리 → 사용하지 않는 파일들 자동 삭제
- 자동 파일 정리: 기존 파일들 중 사용하지 않는 것들은 자동 삭제
- 트랜잭션 보장: DB 업데이트 실패 시 파일 정리 취소
- 비디오 편집 지원: 수정 시에도 trim + crop 적용 가능
- URL 기반 비교: sequence와 무관하게 파일 존재 여부로 정리
- 경쟁 상태 해결: 게시물 API와 동일한 안전한 파일 매핑 로직
- 이미지: 최대 10개, 각 10MB, 지원 형식: .jpg, .jpeg, .png, .gif, .webp, .heic, .heif
- 영상: 최대 5개, 각 100MB, 지원 형식: .mp4, .avi, .mov, .wmv, .flv
- 썸네일: 영상 파일에 대해 자동 생성
{
"code": 200,
"message": "피드가 수정되었습니다.",
"data": {
"feedId": 123
}
}- 권한 없음: 본인이 작성한 피드만 수정 가능
- 피드 없음: 존재하지 않는 피드 ID
- 파일 정리 실패: DB는 업데이트되지만 파일 삭제 실패 시 로그 기록
- 트랜잭션 롤백: 업데이트 중 오류 발생 시 모든 변경사항 취소
// 1. 기존 파일들 수집
const existingFileUrls = new Set<string>();
existingBlocks.forEach(block => {
if ((block.type === 'image' || block.type === 'video') && block.value) {
existingFileUrls.add(block.value);
}
if (block.type === 'video' && block.thumbnail_url) {
existingFileUrls.add(block.thumbnail_url);
}
});
// 2. 새 파일들 수집
const newFileUrls = new Set<string>();
processedBlocks.forEach(block => {
if (block.type === 'image' || block.type === 'video') {
newFileUrls.add(block.value);
}
if (block.type === 'video' && block.thumbnail_url) {
newFileUrls.add(block.thumbnail_url);
}
});
// 3. 삭제할 파일들 계산
const filesToDelete = Array.from(existingFileUrls).filter(
url => !newFileUrls.has(url)
);- 파일 URL 매칭: 정확한 파일 경로 비교로 불필요한 삭제 방지
- 트랜잭션 안전성: DB 변경 후 파일 정리 실패 시 로그만 기록
- 성능 최적화: Promise.all로 병렬 파일 삭제 처리
- 에러 격리: 파일 삭제 실패가 API 성공에 영향 미치지 않음
- 이미지: 최대 10개, 각 10MB, 지원 형식: .jpg, .jpeg, .png, .gif, .webp, .heic, .heif
- 영상: 최대 5개, 각 100MB, 지원 형식: .mp4, .avi, .mov, .wmv, .flv
- 썸네일: 영상 파일에 대해 자동 생성
GET /feeds/{feedId}
특정 피드의 상세 정보를 조회합니다.
| 파라미터 | 타입 | 필수 | 설명 |
|---|---|---|---|
| feedId | number | 필수 | 피드 ID |
GET /feeds/1
{
"code": 200,
"message": "피드를 조회했습니다.",
"data": {
"id": 1,
"created_at": "2024-01-15T10:30:00.000Z",
"updated_at": "2024-01-15T10:30:00.000Z",
"user": {
"id": 1,
"nickname": "사용자닉네임",
"profile_img": "http://localhost:3001/uploads/profile.jpg"
},
"content_blocks": [
{
"id": 1,
"type": "text",
"value": "피드 텍스트 내용입니다.",
"sequence": 0
},
{
"id": 2,
"type": "image",
"value": "http://localhost:3001/uploads/2024/01/15/image_123.jpg",
"sequence": 1,
"path": "/uploads/2024/01/15/image_123.jpg"
}
],
"media_count": 2,
"like_count": 12,
"bookmark_count": 3,
"comment_count": 5,
"is_liked": true,
"is_bookmarked": false,
"is_author": true
}
}Root Data Object:
| Field | Type | Required | Description |
|---|---|---|---|
| id | number | 필수 | 피드 고유 ID |
| created_at | string | 필수 | 피드 작성 시간 (ISO 8601) |
| updated_at | string | 필수 | 피드 수정 시간 (ISO 8601) |
| user | object | 필수 | 작성자 정보 |
| content_blocks | array | 필수 | 콘텐츠 블록 배열 |
| media_count | number | 필수 | 미디어 블록 총 개수 (이미지/영상) |
| like_count | number | 필수 | 좋아요 수 |
| bookmark_count | number | 필수 | 북마크 수 |
| comment_count | number | 필수 | 댓글 수 |
| is_liked | boolean | 필수 | 사용자 좋아요 여부 |
| is_bookmarked | boolean | 필수 | 사용자 북마크 여부 |
| is_author | boolean | 필수 | 피드 작성자 여부 |
User Object:
| Field | Type | Required | Description |
|---|---|---|---|
| id | number | 필수 | 사용자 고유 ID |
| nickname | string | 필수 | 사용자 닉네임 |
| profile_img | string? | 선택 | 프로필 이미지 URL |
Content Block Object:
| Field | Type | Required | Description |
|---|---|---|---|
| id | number | 필수 | 블록 고유 ID |
| type | string | 필수 | 블록 타입 ("text", "image", "video") |
| value | string | 필수 | 블록 내용 (전체 URL) |
| sequence | number | 필수 | 블록 순서 |
| path | string? | 조건부 | 상대 경로 (image/video 타입일 때만 제공) |
| thumbnail_value | string? | 조건부 | 썸네일 전체 URL (video 타입일 때만 제공) |
| thumbnail_path | string? | 조건부 | 썸네일 상대 경로 (video 타입일 때만 제공) |
주의사항:
is_liked,is_bookmarked: 로그인한 사용자에게만 제공됩니다.profile_img: 사용자가 프로필 이미지를 설정하지 않은 경우 null입니다.
DELETE /feeds/{feedId}
피드를 삭제합니다. 작성자 본인만 삭제 가능하며, 연결된 모든 블록과 상호작용 데이터도 함께 삭제됩니다.
| 파라미터 | 타입 | 필수 | 설명 |
|---|---|---|---|
| feedId | number | 필수 | 피드 ID |
DELETE /feeds/1
{
"code": 200,
"message": "피드가 삭제되었습니다.",
"data": null
}Root Data Object: null
주의사항:
- 피드 삭제 시 다음 데이터도 함께 삭제됩니다:
- FeedPostContent (콘텐츠 블록)
- FeedLike (좋아요)
- FeedBookmark (북마크)
- FeedComment (댓글)
GET /feeds/random
한 달 내 작성된 공개 프로필 사용자의 피드 중 인기도가 높은 피드들을 대상으로 랜덤하게 하나를 반환합니다. 피드 다양성 확보를 위한 용도로, 호출할 때마다 이전에 받은 피드와 중복되지 않도록 제외 ID를 지정할 수 있습니다.
| 파라미터 | 타입 | 필수 | 기본값 | 설명 |
|---|---|---|---|---|
| exclude_ids | string | 선택 | null | 제외할 피드 ID 목록 (쉼표로 구분, 최대 50개) |
# 첫 번째 호출
GET /feeds/random
# 중복 방지 적용
GET /feeds/random?exclude_ids=123,456,789
# 다수의 제외 ID
GET /feeds/random?exclude_ids=1,2,3,4,5,6,7,8,9,10// 랜덤 인기 피드 조회 API 호출
const response = await fetch('/feeds/random', {
headers: {
'Authorization': `Bearer ${token}`
}
});
const data = await response.json();
console.log('랜덤 인기 피드:', data.data);1단계: 파라미터 검증
exclude_ids 파싱 및 유효성 검증
→ 최대 50개 ID까지만 허용 (과도한 쿼리 방지)
→ 숫자형 ID로 변환 및 검증
2단계: 전체 공개 피드 수 확인
한 달 내 공개 프로필 사용자 피드 개수 조회
→ 동적 후보 수 계산: MAX(MIN(total_count * 0.2, 50), 5)
3단계: 인기 피드 후보 추출호
engagement_score = like_count + (comment_count * 1.5) + (bookmark_count * 2)
→ 상위 N개 후보 추출 (N = 동적 계산값)
→ exclude_ids 필터링 적용
4단계: 랜덤 선택 및 상세 조회
후보 중 랜덤으로 하나 선택
→ 선택된 피드의 상세 정보 조회
→ 사용자 상호작용 상태 포함
Engagement Score 계산:
// 좋아요 1점, 댓글 1.5점, 북마크 2점으로 가중치 적용
engagement_score = like_count + (comment_count * 1.5) + (bookmark_count * 2.0)
// 시간 가중치 (선택적)
time_weight = EXP(-DATEDIFF(NOW(), created_at) / 30) // 30일 반감기
final_score = engagement_score * time_weight동적 후보 수 계산:
totalPublicFeeds = await countPublicFeedsLastMonth();
candidateCount = Math.min(
Math.max(Math.floor(totalPublicFeeds * 0.2), 5), // 최소 5개
50 // 최대 50개
);성공 응답 (랜덤 인기 피드 반환):
{
"code": 200,
"message": "랜덤 인기 피드를 조회했습니다.",
"data": {
"id": 456,
"created_at": "2025-01-20T14:22:00.000Z",
"user": {
"id": 25,
"nickname": "popular_creator",
"profile_img": "https://cdn.example.com/profiles/user25.jpg"
},
"content_blocks": [
{
"id": 789,
"type": "text",
"value": "인기 피드 내용입니다!",
"sequence": 0
},
{
"id": 790,
"type": "image",
"value": "https://cdn.example.com/feeds/456/image1.jpg",
"sequence": 1,
"path": "/uploads-feed/2025/01/20/image1.jpg"
}
],
"media_count": 1,
"like_count": 89,
"bookmark_count": 12,
"comment_count": 23,
"is_liked": false,
"is_bookmarked": false,
"is_author": false
}
}성공 응답 (후보 없음):
{
"code": 200,
"message": "랜덤 인기 피드를 조회했습니다.",
"data": null
}Data Object (FeedDetail):
| Field | Type | Required | Description |
|---|---|---|---|
| id | number | 필수 | 피드 고유 ID |
| created_at | string | 필수 | 피드 작성 시간 (ISO 8601) |
| updated_at | string | 필수 | 피드 수정 시간 (ISO 8601) |
| user | object | 필수 | 작성자 정보 |
| content_blocks | array | 필수 | 콘텐츠 블록 배열 |
| media_count | number | 필수 | 미디어 블록 총 개수 |
| like_count | number | 필수 | 좋아요 수 |
| bookmark_count | number | 필수 | 북마크 수 |
| comment_count | number | 필수 | 댓글 수 |
| is_liked | boolean | 필수 | 사용자 좋아요 상태 |
| is_bookmarked | boolean | 필수 | 사용자 북마크 상태 |
| is_author | boolean | 필수 | 사용자의 소유권 여부 |
Query Parameters:
- exclude_ids:
- 쉼표로 구분된 숫자 목록
- 각 ID는 1 이상의 정수
- 최대 50개 ID까지만 허용
- 형식:
1,2,3,4,5
필터링 조건:
- 한 달 내 작성된 피드 (
created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)) - 공개 프로필 사용자 (
User.profile_visibility = 'public') - 제외 ID 필터링 (
id NOT IN (excludeIds))
데이터베이스 최적화:
- 3단계 쿼리: COUNT → 후보 ID 조회 → 상세 조회
- INDEX 활용:
FeedPost(created_at),User(profile_visibility) - 배치 처리: 후보 ID 목록으로 단일 상세 조회
- N+1 방지: JOIN 쿼리로 최적화
쿼리 비용 절감:
- 동적 LIMIT으로 불필요한 정렬 방지
- 후보 수 제한 (최대 50개)으로 메모리 사용 최적화
- exclude_ids로 IN 조건 최적화
잘못된 exclude_ids 형식:
{
"code": 400,
"message": "exclude_ids는 숫자 목록이어야 합니다.",
"errors": [
{
"field": "exclude_ids",
"message": "exclude_ids는 숫자 목록이어야 합니다."
}
]
}exclude_ids가 너무 많음:
{
"code": 400,
"message": "exclude_ids는 최대 50개까지만 허용됩니다.",
"errors": [
{
"field": "exclude_ids",
"message": "exclude_ids는 최대 50개까지만 허용됩니다."
}
]
}인증 실패:
{
"code": 401,
"message": "인증이 필요합니다.",
"errors": []
}쿼리 단계별 구현:
// 1단계: 전체 공개 피드 수 확인
async getPublicFeedsCountLastMonth(excludeIds: number[]): Promise<number> {
const result = await sequelize.query(
`SELECT COUNT(*) as total FROM FeedPost f
JOIN User u ON f.user_id = u.id
WHERE f.created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
AND u.profile_visibility = 'public'
AND f.id NOT IN (?)`,
{ replacements: [excludeIds] }
);
return result[0].total;
}
// 2단계: 인기 후보 추출
async getTopPopularFeedCandidateIds(limit: number, excludeIds: number[]): Promise<number[]> {
const result = await sequelize.query(
`SELECT f.id
FROM FeedPost f
JOIN User u ON f.user_id = u.id
WHERE f.created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
AND u.profile_visibility = 'public'
AND f.id NOT IN (?)
ORDER BY (f.like_count + f.comment_count * 1.5 + f.bookmark_count * 2) DESC
LIMIT ?`,
{ replacements: [excludeIds, limit] }
);
return result.map(row => row.id);
}
// 3단계: 랜덤 선택 + 상세 조회
const selectedId = candidateIds[Math.floor(Math.random() * candidateIds.length)];
return await this.getFeedById(selectedId, userId);클라이언트 중복 방지 구현:
class RandomFeedManager {
private seenIds = new Set<number>();
async fetchRandomPopularFeed(): Promise<FeedDetail | null> {
const excludeIds = Array.from(this.seenIds).join(',');
const query = excludeIds ? `?exclude_ids=${excludeIds}` : '';
const response = await fetch(`/feeds/random${query}`);
const data = await response.json();
if (data.data) {
this.seenIds.add(data.data.id);
// 최대 100개 유지
if (this.seenIds.size > 100) {
this.seenIds = new Set(Array.from(this.seenIds).slice(-50));
}
}
return data.data;
}
}피드 다양성 확보:
async function loadFeedWithRandomInsertions(feedCursor?: number) {
// 일반 피드 9개 로드
const feedResponse = await fetch(`/feeds?cursor=${feedCursor || ''}&limit=9`);
const feedData = await feedResponse.json();
// 랜덤 인기 피드 1개 로드
const randomManager = new RandomFeedManager();
const randomFeed = await randomManager.fetchRandomPopularFeed();
// 9개 피드 + 1개 랜덤 = 10개 조합
const combinedFeeds = [
...feedData.data.feeds.slice(0, 5), // 앞 5개
randomFeed, // 중간에 랜덤 인기 피드 삽입
...feedData.data.feeds.slice(5) // 나머지 4개
].filter(Boolean); // null 제거
return {
feeds: combinedFeeds,
pagination: feedData.data.pagination
};
}이 API는 피드 다양성 확보를 위한 핵심 기능으로, 인기도 기반으로 검증된 콘텐츠를 랜덤하게 제공합니다. 동적 후보 수 계산과 N+1 문제 방지로 최적화된 성능을 유지합니다.
GET /feeds
피드 목록을 커서 기반 페이지네이션으로 조회합니다. 최신순으로 정렬됩니다.
| 파라미터 | 타입 | 필수 | 설명 |
|---|---|---|---|
| cursor | number | 선택 | 마지막으로 조회한 피드 ID (다음 페이지 조회용) |
| limit | number | 선택 | 조회할 항목 수 (기본값: 20, 최대: 50) |
GET /feeds
GET /feeds?cursor=10&limit=15
{
"code": 200,
"message": "피드 목록을 조회했습니다.",
"data": {
"feeds": [
{
"id": 15,
"created_at": "2024-01-15T12:30:00.000Z",
"user": {
"id": 2,
"nickname": "다른사용자",
"profile_img": "http://localhost:3001/uploads/profile2.jpg"
},
"content_blocks": [
{
"type": "text",
"value": "피드 텍스트 내용",
"sequence": 0
},
{
"type": "image",
"value": "http://localhost:3001/uploads/2024/01/15/image_456.jpg",
"sequence": 1,
"path": "/uploads/2024/01/15/image_456.jpg"
},
{
"type": "image",
"value": "http://localhost:3001/uploads/2024/01/15/image_789.jpg",
"sequence": 2,
"path": "/uploads/2024/01/15/image_789.jpg"
},
{
"type": "video",
"value": "http://localhost:3001/uploads/2024/01/15/video_123.mp4",
"path": "/uploads/2024/01/15/video_123.mp4",
"thumbnail_value": "http://localhost:3001/uploads-feed-video-thumbnail/2024/01/15/thumbnail_video_123.jpg",
"thumbnail_path": "/uploads-feed-video-thumbnail/2024/01/15/thumbnail_video_123.jpg",
"sequence": 3
}
],
"media_count": 3,
"like_count": 8,
"bookmark_count": 2,
"comment_count": 3,
"is_liked": false,
"is_bookmarked": true,
"is_author": false
}
],
"pagination": {
"next_cursor": 10,
"has_next": true
}
}
}Root Data Object:
| Field | Type | Required | Description |
|---|---|---|---|
| feeds | array | 필수 | 피드 목록 배열 |
| pagination | object | 필수 | 페이지네이션 정보 |
Feed List Item Object:
| Field | Type | Required | Description |
|---|---|---|---|
| id | number | 필수 | 피드 고유 ID |
| created_at | string | 필수 | 피드 작성 시간 (ISO 8601) |
| user | object | 필수 | 작성자 정보 |
| content_blocks | array | 필수 | 콘텐츠 블록 배열 |
| media_count | number | 필수 | 미디어 블록 총 개수 (이미지/영상) |
| like_count | number | 필수 | 좋아요 수 |
| bookmark_count | number | 필수 | 북마크 수 |
| comment_count | number | 필수 | 댓글 수 |
| is_liked | boolean | 필수 | 사용자 좋아요 여부 |
| is_bookmarked | boolean | 필수 | 사용자 북마크 여부 |
Pagination Object:
| Field | Type | Required | Description |
|---|---|---|---|
| next_cursor | number? | 선택 | 다음 페이지 조회용 커서 |
| has_next | boolean | 필수 | 다음 페이지 존재 여부 |
주의사항:
next_cursor: 다음 페이지가 없는 경우 null입니다.- 커서 기반 페이지네이션으로 무한 스크롤을 지원합니다.
POST /feeds/{feedId}/like
피드에 좋아요를 추가하거나 취소합니다. 이미 좋아요를 누른 경우 취소됩니다.
| 파라미터 | 타입 | 필수 | 설명 |
|---|---|---|---|
| feedId | number | 필수 | 피드 ID |
POST /feeds/1/like
{
"code": 200,
"message": "피드를 좋아요했습니다.",
"data": {
"is_liked": true,
"like_count": 13
}
}Root Data Object:
| Field | Type | Required | Description |
|---|---|---|---|
| is_liked | boolean | 필수 | 좋아요 상태 |
| like_count | number | 필수 | 업데이트된 좋아요 수 |
주의사항:
- 좋아요 추가 시:
is_liked: true,like_count증가 - 좋아요 취소 시:
is_liked: false,like_count감소
POST /feeds/{feedId}/bookmark
피드를 북마크하거나 취소합니다. 이미 북마크한 경우 취소됩니다.
| 파라미터 | 타입 | 필수 | 설명 |
|---|---|---|---|
| feedId | number | 필수 | 피드 ID |
POST /feeds/1/bookmark
{
"code": 200,
"message": "피드를 북마크했습니다.",
"data": {
"is_bookmarked": true,
"bookmark_count": 4
}
}Root Data Object:
| Field | Type | Required | Description |
|---|---|---|---|
| is_bookmarked | boolean | 필수 | 북마크 상태 |
| bookmark_count | number | 필수 | 업데이트된 북마크 수 |
주의사항:
- 북마크 추가 시:
is_bookmarked: true,bookmark_count증가 - 북마크 취소 시:
is_bookmarked: false,bookmark_count감소
POST /feeds/{feedId}/comments
피드에 댓글을 작성합니다.
| 파라미터 | 타입 | 필수 | 설명 |
|---|---|---|---|
| feedId | number | 필수 | 피드 ID |
{
"content": "댓글 내용입니다.",
"parent_comment_id": 5
}| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
| content | string | 필수 | 댓글 내용 (1-1000자) |
| parent_comment_id | number | 선택 | 부모 댓글 ID (대댓글인 경우) |
| mention_user_id | number | 선택 | 언급할 사용자 ID (멘션 기능) |
- content: 1-1000자 길이 제한
- parent_comment_id:
- 선택사항
- 대댓글인 경우 유효한 댓글 ID여야 함
// 일반 댓글
{
"content": "정말 멋진 피드네요!"
}
// 대댓글
{
"content": "동감합니다!",
"parent_comment_id": 5
}{
"code": 201,
"message": "댓글이 작성되었습니다.",
"data": {
"commentId": 10,
"created_at": "2024-01-15T16:30:00.000Z"
}
}Root Data Object:
| Field | Type | Required | Description |
|---|---|---|---|
| commentId | number | 필수 | 생성된 댓글 고유 ID |
| created_at | string | 필수 | 작성 시간 (ISO 8601) |
GET /feeds/{feedId}/comments
특정 피드의 댓글 목록을 커서 기반 페이지네이션으로 조회합니다. 대댓글은 부모 댓글의 replies 배열에 포함되어 반환되며, 최적화된 쿼리로 성능이 향상되었습니다.
| 파라미터 | 타입 | 필수 | 설명 |
|---|---|---|---|
| feedId | number | 필수 | 댓글을 조회할 피드의 고유 ID |
| 파라미터 | 타입 | 필수 | 설명 |
|---|---|---|---|
| cursor | string | 선택 | 마지막으로 조회한 댓글 ID (다음 페이지 조회용) |
| limit | number | 선택 | 조회할 항목 수 (기본값: 20, 최대: 50) |
| 헤더명 | 설명 | 필수 |
|---|---|---|
| Authorization | Bearer 토큰 (JWT) | 필수 |
{
"code": 200,
"message": "댓글 목록을 조회했습니다.",
"data": {
"items": [
{
"id": 5,
"content": "정말 멋진 글이네요!",
"created_at": "2024-01-15T10:30:00.000Z",
"updated_at": "2024-01-15T10:30:00.000Z",
"user": {
"id": 2,
"nickname": "user2",
"profile_img": "http://localhost:3001/uploads/profile2.jpg"
},
"parent_comment_id": null,
"mention_user": null,
"like_count": 3,
"is_liked": true,
"is_author": false,
"is_deleted": false,
"replies": [
{
"id": 10,
"content": "동감합니다!",
"created_at": "2024-01-15T11:30:00.000Z",
"updated_at": "2024-01-15T11:30:00.000Z",
"user": {
"id": 3,
"nickname": "user3",
"profile_img": "http://localhost:3001/uploads/profile3.jpg"
},
"parent_comment_id": 5,
"mention_user": {
"id": 2,
"nickname": "user2"
},
"like_count": 1,
"is_liked": false,
"is_author": false,
"is_deleted": false
}
]
}
],
"next_cursor": "100"
}
}Root Data Object:
| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
| items | array | 필수 | 댓글 목록 배열 |
| next_cursor | string|null | 필수 | 다음 페이지 조회용 커서 |
Item Object (댓글 및 대댓글 공통):
| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
| id | number | 필수 | 댓글 고유 ID |
| content | string | 필수 | 댓글 내용 |
| created_at | string | 필수 | 작성 시간 (ISO 8601) |
| updated_at | string | 필수 | 수정 시간 (ISO 8601) |
| user | object | 필수 | 작성자 정보 객체 |
| user.id | number | 필수 | 작성자 ID |
| user.nickname | string | 필수 | 작성자 닉네임 |
| user.profile_img | string|null | 선택 | 작성자 프로필 이미지 URL |
| parent_comment_id | number|null | 선택 | 부모 댓글 ID (대댓글인 경우) |
| mention_user | object|null | 선택 | 멘션 대상 사용자 정보 ({ id, nickname }) |
| like_count | number | 필수 | 좋아요 수 |
| is_liked | boolean | 필수 | 현재 사용자 기준 좋아요 여부 |
| is_author | boolean | 필수 | 현재 사용자 기준 작성자 여부 |
| is_deleted | boolean | 필수 | 삭제된 댓글 여부 (삭제 시 내용/작성자 마스킹) |
| replies | array | 선택 | 대댓글 목록 (동일 구조의 배열) |
첫 페이지 조회:
GET /feeds/1/comments
두 번째 페이지 조회:
GET /feeds/1/comments?cursor=100&limit=30
- feedId: 유효한 피드 ID
- cursor: 유효한 숫자 문자열 또는 null
- limit: 1-50 사이의 정수 (기본값: 20)
- 병렬 쿼리 처리: 좋아요 카운트와 사용자 좋아요 상태를 동시에 조회
- 속성 제한: 필요없는 데이터베이스 컬럼은 조회하지 않음
- 커서 기반 페이지네이션: ID 기반으로 빠른 페이지네이션
- N+1 문제 해결: 최적화된 쿼리로 한번에 모든 데이터 조회
유효하지 않은 cursor:
{
"code": 400,
"message": "Invalid cursor",
"errors": []
}limit 범위를 초과:
{
"code": 400,
"message": "Validation failed",
"errors": [
{
"field": "limit",
"message": "limit must be between 1 and 50"
}
]
}PATCH /feeds/{feedId}/comments/{commentId}
선택에 따라 댓글의 내용을 수정합니다. 오직 댓글 작성자만 수정할 수 있습니다.
- feedId (필수): 댓글의 피드 ID
- commentId (필수): 수정할 댓글 ID
{
"content": "수정할 댓글 내용"
}- 댓글 내용은 1자 이상, 1000자 이하여야 합니다
- 작성자만 댓글을 수정할 수 있습니다
- 존재하지 않는 댓글은 수정할 수 없습니다
- 논리적으로 삭제된 댓글은 수정할 수 없습니다
{
"code": 200,
"message": "댓글이 수정되었습니다.",
"data": {
"id": 10,
"updated_at": "2024-01-15T16:45:00.000Z"
}
}- 댓글 존재하지 않음: 404 Not Found
- 수정 권한 없음: 403 Forbidden
- 유효하지 않은 내용: 400 Bad Request
- 삭제된 댓글: 400 Bad Request
DELETE /feeds/{feedId}/comments/{commentId}
피드 댓글을 소프트 삭제합니다. 작성자 본인만 삭제 가능합니다.
| 파라미터 | 타입 | 필수 | 설명 |
|---|---|---|---|
| feedId | number | 필수 | 피드 ID |
| commentId | number | 필수 | 댓글 ID |
DELETE /feeds/1/comments/10
{
"code": 200,
"message": "댓글이 삭제되었습니다.",
"data": null
}Root Data Object: null
주의사항 (소프트 삭제 동작):
- 댓글은 물리적으로 삭제되지 않으며
is_deleted: true로 표시됩니다. - 삭제된 댓글은 내용이
"삭제된 댓글입니다."로 마스킹되고, 작성자 정보는{ id: 0, nickname: "알 수 없음", profile_img: null }로 대체됩니다. - 삭제된 댓글의
like_count는 0,is_liked/is_author는 false,mention_user는 null로 반환됩니다. - 대댓글(자식 댓글)은 유지됩니다.
댓글을 찾을 수 없음:
{
"code": 404,
"message": "댓글을 찾을 수 없습니다.",
"errors": []
}수정 권한 없음:
{
"code": 403,
"message": "댓글 삭제 권한이 없습니다.",
"errors": []
}잘못된 피드의 댓글:
{
"code": 400,
"message": "잘못된 피드의 댓글입니다.",
"errors": []
}+++
피드 댓글 삭제 API 수정 완료! 이제 올바르게 DELETE 메소드로 표기되었습니다.
DELETE /feeds/{feedId}/comments/{commentId}
피드 댓글을 소프트 삭제합니다. 작성자 본인만 삭제 가능합니다.
| 파라미터 | 타입 | 필수 | 설명 |
|---|---|---|---|
| feedId | number | 필수 | 피드 ID |
| commentId | number | 필수 | 댓글 ID |
DELETE /feeds/1/comments/10
{
"code": 200,
"message": "댓글이 삭제되었습니다.",
"data": null
}Root Data Object: null
주의사항 (소프트 삭제 동작):
- 댓글은 물리적으로 삭제되지 않으며
is_deleted: true로 표시됩니다. - 삭제된 댓글은 내용이
"삭제된 댓글입니다."로 마스킹되고, 작성자 정보는{ id: 0, nickname: "알 수 없음", profile_img: null }로 대체됩니다. - 삭제된 댓글의
like_count는 0,is_liked/is_author는 false,mention_user는 null로 반환됩니다. - 대댓글(자식 댓글)은 유지됩니다.
POST /feeds/{feedId}/comments/{commentId}/like
피드 댓글에 좋아요를 추가하거나 취소합니다. 이미 좋아요를 누른 경우 취소됩니다.
| 파라미터 | 타입 | 필수 | 설명 |
|---|---|---|---|
| feedId | number | 필수 | 피드 ID |
| commentId | number | 필수 | 댓글 ID |
POST /feeds/1/comments/10/like
{
"code": 200,
"message": "댓글을 좋아요했습니다.",
"data": {
"is_liked": true,
"like_count": 5
}
}Root Data Object:
| Field | Type | Required | Description |
|---|---|---|---|
| is_liked | boolean | 필수 | 좋아요 상태 |
| like_count | number | 필수 | 업데이트된 좋아요 수 |
| 현재 상태 | 액션 | 응답 메시지 | is_liked | like_count 변화 |
|---|---|---|---|---|
| 좋아요 안 함 | 좋아요 추가 | "댓글을 좋아요했습니다." | true | +1 |
| 좋아요 함 | 좋아요 취소 | "댓글 좋아요를 취소했습니다." | false | -1 |
- feedId, commentId: 유효한 숫자여야 함
- 게시글 존재 여부: 존재하지 않는 피드를 지정할 경우 404 Not Found
- 댓글 존재 여부: 존재하지 않는 댓글을 지정할 경우 404 Not Found
- 소프트 삭제된 댓글: 삭제된 댓글에는 좋아요 불가 (400 Bad Request)
존재하지 않는 피드:
{
"code": 404,
"message": "피드를 찾을 수 없습니다.",
"errors": []
}존재하지 않는 댓글:
{
"code": 404,
"message": "댓글을 찾을 수 없습니다.",
"errors": []
}삭제된 댓글에 좋아요 시도:
{
"code": 400,
"message": "삭제된 댓글에는 좋아요를 할 수 없습니다.",
"errors": []
}GET /feeds/bookmarks
사용자가 북마크한 피드 목록을 오프셋 기반 페이지네이션으로 조회합니다. 최신 북마크순으로 정렬됩니다.
| 파라미터 | 타입 | 필수 | 설명 |
|---|---|---|---|
| offset | number | 선택 | 건너뛸 항목 수 (기본값: 0) |
| limit | number | 선택 | 조회할 항목 수 (기본값: 20, 최대: 100) |
GET /feeds/bookmarks
GET /feeds/bookmarks?offset=20&limit=15
{
"code": 200,
"message": "북마크한 피드 목록을 조회했습니다.",
"data": {
"feeds": [
{
"id": 15,
"created_at": "2024-01-15T12:30:00.000Z",
"preview_image": "http://localhost:3001/uploads-feed/2024/01/15/image_456.jpg",
"preview_content_type": "image"
},
{
"id": 8,
"created_at": "2024-01-14T09:15:00.000Z",
"preview_image": "http://localhost:3001/uploads-feed-video-thumbnail/2024/01/14/thumbnail_video_123.jpg",
"preview_content_type": "video"
}
],
"pagination": {
"offset": 0,
"limit": 20,
"total": 45,
"has_next": true
}
}
}Root Data Object:
| Field | Type | Required | Description |
|---|---|---|---|
| feeds | array | 필수 | 북마크한 피드 목록 배열 |
| pagination | object | 필수 | 페이지네이션 정보 |
Feed List Item Object:
| Field | Type | Required | Description |
|---|---|---|---|
| id | number | 필수 | 피드 고유 ID |
| created_at | string | 필수 | 피드 작성 시간 (ISO 8601) |
| preview_image | string? | 선택 | 첫 번째 콘텐츠의 이미지 또는 비디오 썸네일 URL |
| preview_content_type | string? | 선택 | 미리보기 콘텐츠 타입 ("image" 또는 "video") |
Pagination Object:
| Field | Type | Required | Description |
|---|---|---|---|
| offset | number | 필수 | 현재 오프셋 |
| limit | number | 필수 | 조회 제한 수 |
| total | number | 필수 | 전체 북마크 수 |
| has_next | boolean | 필수 | 다음 페이지 존재 여부 |
- offset: 0 이상의 정수 (기본값: 0)
- limit: 1-100 사이의 정수 (기본값: 20)
- Single Query with Join: FeedBookmark와 FeedPost를 join하여 N+1 문제 해결
- Content Extraction: FeedPostContent에서 미리보기 이미지/썸네일 추출
- Indexing: FeedBookmark.user_id + FeedBookmark.created_at 인덱스 활용
offset 범위를 초과:
{
"code": 400,
"message": "offset은 0 이상의 숫자여야 합니다.",
"errors": []
}limit 범위를 초과:
{
"code": 400,
"message": "limit은 1-100 사이의 값이어야 합니다.",
"errors": []
}| HTTP 상태 코드 | 설명 |
|---|---|
| 400 Bad Request | 잘못된 요청 (유효성 검사 실패) |
| 401 Unauthorized | 인증되지 않은 요청 |
| 403 Forbidden | 권한이 없는 요청 (작성자가 아닌 경우) |
| 404 Not Found | 리소스를 찾을 수 없음 |
| 409 Conflict | 중복된 요청 (이미 좋아요한 피드 등) |
| 500 Internal Server Error | 서버 내부 오류 |
모든 API는 JWT 토큰을 통한 인증이 필요합니다.
Authorization: Bearer YOUR_JWT_TOKEN
accessToken쿠키가 자동으로 전송됩니다.- HttpOnly 쿠키로 설정되어 있어 JavaScript에서 접근할 수 없습니다.
- 최소 블록 수: 1개 (텍스트 블록 필수)
- 최대 블록 수: 10개
- 텍스트 블록:
- 반드시 1개만 존재
- sequence는 반드시 0
- 길이: 1-1000자
- 이미지/영상 블록:
- 최대 9개
- sequence는 1부터 순차적으로 증가
- 유효한 파일 URL 형식
- 길이: 1-1000자
- limit: 1-50 사이의 정수 (기본값: 20)
- 커서: 유효한 피드 ID 또는 null
// 텍스트 + 이미지 피드 생성
const response = await fetch('/feeds', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
content_blocks: [
{
type: 'text',
value: '오늘 점심 메뉴입니다!',
sequence: 0
},
{
type: 'image',
value: 'http://localhost:3001/uploads/2024/01/15/lunch.jpg',
sequence: 1
}
]
})
});
const result = await response.json();
console.log('생성된 피드 ID:', result.data.feedId);// 첫 페이지 조회
let response = await fetch('/feeds?limit=20', {
headers: {
'Authorization': `Bearer ${token}`
}
});
let data = await response.json();
let feeds = data.data.feeds;
let nextCursor = data.data.pagination.next_cursor;
// 다음 페이지 조회
while (nextCursor) {
response = await fetch(`/feeds?cursor=${nextCursor}&limit=20`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
data = await response.json();
feeds = feeds.concat(data.data.feeds);
nextCursor = data.data.pagination.next_cursor;
}
console.log('전체 피드 수:', feeds.length);const response = await fetch('/feeds/1/like', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
}
});
const result = await response.json();
if (result.data.is_liked) {
console.log('좋아요 완료!');
} else {
console.log('좋아요 취소!');
}FeedPost.id(Primary Key)FeedPost.user_idFeedPost.created_at(DESC)FeedPostContent.feed_post_id+FeedPostContent.sequenceFeedLike.feed_post_id+FeedLike.user_idFeedBookmark.feed_post_id+FeedBookmark.user_idFeedComment.feed_post_id+FeedComment.created_at
- 피드 목록: 30초 캐시
- 피드 상세: 1분 캐시
- 좋아요/북마크 상태: 5분 캐시
- 커서 기반 페이지네이션으로 성능 향상
- 인덱스를 활용한 빠른 조회
- 최대 50개까지 한 번에 조회 가능
피드에 사용할 이미지/영상은 별도 업로드 API를 통해 먼저 업로드해야 합니다.
- API:
POST /feeds/upload/images - 지원 형식: .jpg, .jpeg, .png, .gif, .webp, .heic, .heif
- 최대 크기: 10MB
- 최대 개수: 10개
- 저장 경로:
uploads-feed/년/월/일/
- API:
POST /feeds/upload/video - 지원 형식: .mp4, .avi, .mov, .wmv, .flv
- 최대 크기: 100MB
- 최대 개수: 1개
- 저장 경로:
uploads-feed-video/년/월/일/ - 썸네일 경로:
uploads-feed-video-thumbnail/년/월/일/ - 썸네일 자동 생성: 영상의 50% 지점에서 추출
1. 파일 선택
2. 피드 파일 업로드 API 호출
3. 반환받은 URL을 피드 생성 시 사용
4. 피드 생성