refactor: API 중앙화 시작 (1/3)

- api/ 디렉토리 구조 생성
  - index.js: 공통 fetch 래퍼
  - schedules.js, albums.js, members.js: 공개 API
  - admin/: 어드민 API (bots, albums, categories, members, schedules)
- Schedule.jsx: API 모듈 적용
- AdminScheduleBots.jsx: API 모듈 적용
This commit is contained in:
caadiq 2026-01-09 21:56:32 +09:00
parent 12e95003ae
commit 9886048a4c
11 changed files with 341 additions and 79 deletions

View file

@ -0,0 +1,50 @@
/**
* 어드민 앨범 관리 API
*/
import { fetchAdminApi, fetchAdminFormData } from "../index";
// 앨범 목록 조회
export async function getAlbums() {
return fetchAdminApi("/api/admin/albums");
}
// 앨범 상세 조회
export async function getAlbum(id) {
return fetchAdminApi(`/api/admin/albums/${id}`);
}
// 앨범 생성
export async function createAlbum(formData) {
return fetchAdminFormData("/api/admin/albums", formData, "POST");
}
// 앨범 수정
export async function updateAlbum(id, formData) {
return fetchAdminFormData(`/api/admin/albums/${id}`, formData, "PUT");
}
// 앨범 삭제
export async function deleteAlbum(id) {
return fetchAdminApi(`/api/admin/albums/${id}`, { method: "DELETE" });
}
// 앨범 사진 목록 조회
export async function getAlbumPhotos(albumId) {
return fetchAdminApi(`/api/admin/albums/${albumId}/photos`);
}
// 앨범 사진 업로드
export async function uploadAlbumPhotos(albumId, formData) {
return fetchAdminFormData(
`/api/admin/albums/${albumId}/photos`,
formData,
"POST"
);
}
// 앨범 사진 삭제
export async function deleteAlbumPhoto(albumId, photoId) {
return fetchAdminApi(`/api/admin/albums/${albumId}/photos/${photoId}`, {
method: "DELETE",
});
}

View file

@ -0,0 +1,34 @@
/**
* 어드민 관리 API
*/
import { fetchAdminApi } from "../index";
// 봇 목록 조회
export async function getBots() {
return fetchAdminApi("/api/admin/bots");
}
// 봇 시작
export async function startBot(id) {
return fetchAdminApi(`/api/admin/bots/${id}/start`, { method: "POST" });
}
// 봇 정지
export async function stopBot(id) {
return fetchAdminApi(`/api/admin/bots/${id}/stop`, { method: "POST" });
}
// 봇 전체 동기화
export async function syncAllVideos(id) {
return fetchAdminApi(`/api/admin/bots/${id}/sync-all`, { method: "POST" });
}
// 할당량 경고 조회
export async function getQuotaWarning() {
return fetchAdminApi("/api/admin/quota-warning");
}
// 할당량 경고 해제
export async function dismissQuotaWarning() {
return fetchAdminApi("/api/admin/quota-warning", { method: "DELETE" });
}

View file

@ -0,0 +1,40 @@
/**
* 어드민 카테고리 관리 API
*/
import { fetchAdminApi } from "../index";
// 카테고리 목록 조회
export async function getCategories() {
return fetchAdminApi("/api/admin/schedule-categories");
}
// 카테고리 생성
export async function createCategory(data) {
return fetchAdminApi("/api/admin/schedule-categories", {
method: "POST",
body: JSON.stringify(data),
});
}
// 카테고리 수정
export async function updateCategory(id, data) {
return fetchAdminApi(`/api/admin/schedule-categories/${id}`, {
method: "PUT",
body: JSON.stringify(data),
});
}
// 카테고리 삭제
export async function deleteCategory(id) {
return fetchAdminApi(`/api/admin/schedule-categories/${id}`, {
method: "DELETE",
});
}
// 카테고리 순서 변경
export async function reorderCategories(orderedIds) {
return fetchAdminApi("/api/admin/schedule-categories/reorder", {
method: "PUT",
body: JSON.stringify({ orderedIds }),
});
}

View file

@ -0,0 +1,19 @@
/**
* 어드민 멤버 관리 API
*/
import { fetchAdminApi, fetchAdminFormData } from "../index";
// 멤버 목록 조회
export async function getMembers() {
return fetchAdminApi("/api/admin/members");
}
// 멤버 상세 조회
export async function getMember(id) {
return fetchAdminApi(`/api/admin/members/${id}`);
}
// 멤버 수정
export async function updateMember(id, formData) {
return fetchAdminFormData(`/api/admin/members/${id}`, formData, "PUT");
}

View file

@ -0,0 +1,36 @@
/**
* 어드민 일정 관리 API
*/
import { fetchAdminApi, fetchAdminFormData } from "../index";
// 일정 목록 조회 (월별)
export async function getSchedules(year, month) {
return fetchAdminApi(`/api/admin/schedules?year=${year}&month=${month}`);
}
// 일정 검색 (Meilisearch)
export async function searchSchedules(query) {
return fetchAdminApi(
`/api/admin/schedules/search?q=${encodeURIComponent(query)}`
);
}
// 일정 상세 조회
export async function getSchedule(id) {
return fetchAdminApi(`/api/admin/schedules/${id}`);
}
// 일정 생성
export async function createSchedule(formData) {
return fetchAdminFormData("/api/admin/schedules", formData, "POST");
}
// 일정 수정
export async function updateSchedule(id, formData) {
return fetchAdminFormData(`/api/admin/schedules/${id}`, formData, "PUT");
}
// 일정 삭제
export async function deleteSchedule(id) {
return fetchAdminApi(`/api/admin/schedules/${id}`, { method: "DELETE" });
}

View file

@ -0,0 +1,19 @@
/**
* 앨범 관련 공개 API
*/
import { fetchApi } from "./index";
// 앨범 목록 조회
export async function getAlbums() {
return fetchApi("/api/albums");
}
// 앨범 상세 조회
export async function getAlbum(id) {
return fetchApi(`/api/albums/${id}`);
}
// 앨범 사진 조회
export async function getAlbumPhotos(albumId) {
return fetchApi(`/api/albums/${albumId}/photos`);
}

60
frontend/src/api/index.js Normal file
View file

@ -0,0 +1,60 @@
/**
* 공통 API 유틸리티
* 모든 API 호출에서 사용되는 기본 fetch 래퍼
*/
// 기본 fetch 래퍼
export async function fetchApi(url, options = {}) {
const response = await fetch(url, {
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: "요청 실패" }));
throw new Error(error.error || `HTTP ${response.status}`);
}
return response.json();
}
// 어드민 토큰 가져오기
export function getAdminToken() {
return localStorage.getItem("adminToken");
}
// 어드민 API용 fetch 래퍼 (토큰 자동 추가)
export async function fetchAdminApi(url, options = {}) {
const token = getAdminToken();
return fetchApi(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${token}`,
},
});
}
// FormData 전송용 (이미지 업로드 등)
export async function fetchAdminFormData(url, formData, method = "POST") {
const token = getAdminToken();
const response = await fetch(url, {
method,
headers: {
Authorization: `Bearer ${token}`,
},
body: formData,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: "요청 실패" }));
throw new Error(error.error || `HTTP ${response.status}`);
}
return response.json();
}

View file

@ -0,0 +1,14 @@
/**
* 멤버 관련 공개 API
*/
import { fetchApi } from "./index";
// 멤버 목록 조회
export async function getMembers() {
return fetchApi("/api/members");
}
// 멤버 상세 조회
export async function getMember(id) {
return fetchApi(`/api/members/${id}`);
}

View file

@ -0,0 +1,28 @@
/**
* 일정 관련 공개 API
*/
import { fetchApi } from "./index";
// 일정 목록 조회 (월별)
export async function getSchedules(year, month) {
return fetchApi(`/api/schedules?year=${year}&month=${month}`);
}
// 일정 검색 (Meilisearch)
export async function searchSchedules(query, { offset = 0, limit = 20 } = {}) {
return fetchApi(
`/api/schedules?search=${encodeURIComponent(
query
)}&offset=${offset}&limit=${limit}`
);
}
// 일정 상세 조회
export async function getSchedule(id) {
return fetchApi(`/api/schedules/${id}`);
}
// 카테고리 목록 조회
export async function getCategories() {
return fetchApi("/api/schedule-categories");
}

View file

@ -5,6 +5,7 @@ import { Clock, ChevronLeft, ChevronRight, ChevronDown, Tag, Search, ArrowLeft,
import { useInfiniteQuery } from '@tanstack/react-query'; import { useInfiniteQuery } from '@tanstack/react-query';
import { useInView } from 'react-intersection-observer'; import { useInView } from 'react-intersection-observer';
import { getTodayKST } from '../../utils/date'; import { getTodayKST } from '../../utils/date';
import { getSchedules, getCategories, searchSchedules as searchSchedulesApi } from '../../api/schedules';
function Schedule() { function Schedule() {
const navigate = useNavigate(); const navigate = useNavigate();
@ -84,24 +85,21 @@ function Schedule() {
// //
// () // ()
useEffect(() => { useEffect(() => {
fetchCategories(); loadCategories();
}, []); }, []);
// //
useEffect(() => { useEffect(() => {
const year = currentDate.getFullYear(); const year = currentDate.getFullYear();
const month = currentDate.getMonth(); const month = currentDate.getMonth();
fetchSchedules(year, month + 1); loadSchedules(year, month + 1);
}, [currentDate]); }, [currentDate]);
const fetchSchedules = async (year, month) => { const loadSchedules = async (year, month) => {
setLoading(true); setLoading(true);
try { try {
const response = await fetch(`/api/schedules?year=${year}&month=${month}`); const data = await getSchedules(year, month);
if (response.ok) {
const data = await response.json();
setSchedules(data); setSchedules(data);
}
} catch (error) { } catch (error) {
console.error('일정 로드 오류:', error); console.error('일정 로드 오류:', error);
} finally { } finally {
@ -109,13 +107,10 @@ function Schedule() {
} }
}; };
const fetchCategories = async () => { const loadCategories = async () => {
try { try {
const response = await fetch('/api/schedule-categories'); const data = await getCategories();
if (response.ok) {
const data = await response.json();
setCategories(data); setCategories(data);
}
} catch (error) { } catch (error) {
console.error('카테고리 로드 오류:', error); console.error('카테고리 로드 오류:', error);
} }

View file

@ -7,6 +7,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import Toast from '../../../components/Toast'; import Toast from '../../../components/Toast';
import Tooltip from '../../../components/Tooltip'; import Tooltip from '../../../components/Tooltip';
import * as botsApi from '../../../api/admin/bots';
function AdminScheduleBots() { function AdminScheduleBots() {
const navigate = useNavigate(); const navigate = useNavigate();
@ -44,15 +45,8 @@ function AdminScheduleBots() {
const fetchBots = async () => { const fetchBots = async () => {
setLoading(true); setLoading(true);
try { try {
const token = localStorage.getItem('adminToken'); const data = await botsApi.getBots();
const response = await fetch('/api/admin/bots', {
headers: { Authorization: `Bearer ${token}` }
});
if (response.ok) {
const data = await response.json();
setBots(data); setBots(data);
}
} catch (error) { } catch (error) {
console.error('봇 목록 조회 오류:', error); console.error('봇 목록 조회 오류:', error);
setToast({ type: 'error', message: '봇 목록을 불러올 수 없습니다.' }); setToast({ type: 'error', message: '봇 목록을 불러올 수 없습니다.' });
@ -64,29 +58,19 @@ function AdminScheduleBots() {
// //
const fetchQuotaWarning = async () => { const fetchQuotaWarning = async () => {
try { try {
const token = localStorage.getItem('adminToken'); const data = await botsApi.getQuotaWarning();
const response = await fetch('/api/admin/quota-warning', {
headers: { Authorization: `Bearer ${token}` }
});
if (response.ok) {
const data = await response.json();
if (data.active) { if (data.active) {
setQuotaWarning(data); setQuotaWarning(data);
} }
}
} catch (error) { } catch (error) {
console.error('할당량 경고 조회 오류:', error); console.error('할당량 경고 조회 오류:', error);
} }
}; };
// //
const dismissQuotaWarning = async () => { const handleDismissQuotaWarning = async () => {
try { try {
const token = localStorage.getItem('adminToken'); await botsApi.dismissQuotaWarning();
await fetch('/api/admin/quota-warning', {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }
});
setQuotaWarning(null); setQuotaWarning(null);
} catch (error) { } catch (error) {
console.error('할당량 경고 해제 오류:', error); console.error('할당량 경고 해제 오류:', error);
@ -102,15 +86,14 @@ function AdminScheduleBots() {
// / // /
const toggleBot = async (botId, currentStatus, botName) => { const toggleBot = async (botId, currentStatus, botName) => {
try { try {
const token = localStorage.getItem('adminToken');
const action = currentStatus === 'running' ? 'stop' : 'start'; const action = currentStatus === 'running' ? 'stop' : 'start';
const response = await fetch(`/api/admin/bots/${botId}/${action}`, { if (action === 'start') {
method: 'POST', await botsApi.startBot(botId);
headers: { Authorization: `Bearer ${token}` } } else {
}); await botsApi.stopBot(botId);
}
if (response.ok) {
// ( ) // ( )
setBots(prev => prev.map(bot => setBots(prev => prev.map(bot =>
bot.id === botId bot.id === botId
@ -121,42 +104,26 @@ function AdminScheduleBots() {
type: 'success', type: 'success',
message: action === 'start' ? `${botName} 봇이 시작되었습니다.` : `${botName} 봇이 정지되었습니다.` message: action === 'start' ? `${botName} 봇이 시작되었습니다.` : `${botName} 봇이 정지되었습니다.`
}); });
} else {
const data = await response.json();
setToast({ type: 'error', message: data.error || '작업 실패' });
}
} catch (error) { } catch (error) {
console.error('봇 토글 오류:', error); console.error('봇 토글 오류:', error);
setToast({ type: 'error', message: '작업 중 오류가 발생했습니다.' }); setToast({ type: 'error', message: error.message || '작업 중 오류가 발생했습니다.' });
} }
}; };
// //
const syncAllVideos = async (botId) => { const handleSyncAllVideos = async (botId) => {
setSyncing(botId); setSyncing(botId);
try { try {
const token = localStorage.getItem('adminToken'); const data = await botsApi.syncAllVideos(botId);
const response = await fetch(`/api/admin/bots/${botId}/sync-all`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` }
});
if (response.ok) {
const data = await response.json();
setToast({ setToast({
type: 'success', type: 'success',
message: `${data.addedCount}개 일정이 추가되었습니다. (전체 ${data.total}개)` message: `${data.addedCount}개 일정이 추가되었습니다. (전체 ${data.total}개)`
}); });
} else {
const data = await response.json();
setToast({ type: 'error', message: data.error || '동기화 실패' });
}
// /
fetchBots(); fetchBots();
} catch (error) { } catch (error) {
console.error('전체 동기화 오류:', error); console.error('전체 동기화 오류:', error);
setToast({ type: 'error', message: '동기화 중 오류가 발생했습니다.' }); setToast({ type: 'error', message: error.message || '동기화 중 오류가 발생했습니다.' });
fetchBots(); // fetchBots();
} finally { } finally {
setSyncing(null); setSyncing(null);
} }
@ -320,7 +287,7 @@ function AdminScheduleBots() {
</div> </div>
</div> </div>
<button <button
onClick={dismissQuotaWarning} onClick={handleDismissQuotaWarning}
className="text-red-400 hover:text-red-600 transition-colors text-sm px-2 py-1" className="text-red-400 hover:text-red-600 transition-colors text-sm px-2 py-1"
> >
닫기 닫기
@ -418,7 +385,7 @@ function AdminScheduleBots() {
<div className="p-4 border-t border-gray-100"> <div className="p-4 border-t border-gray-100">
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={() => syncAllVideos(bot.id)} onClick={() => handleSyncAllVideos(bot.id)}
disabled={syncing === bot.id} disabled={syncing === bot.id}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 bg-blue-500 text-white rounded-lg font-medium transition-colors hover:bg-blue-600 disabled:opacity-50" className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 bg-blue-500 text-white rounded-lg font-medium transition-colors hover:bg-blue-600 disabled:opacity-50"
> >