feat(frontend): Phase 4 - API 계층 구현

- api/client.js: fetch 래퍼, ApiError, 헬퍼 함수 (get/post/put/del)
- api/auth.js: 로그인, 토큰 검증
- api/schedules.js: 스케줄/카테고리 API (공개 + 어드민)
- api/albums.js: 앨범 API (공개 + 어드민)
- api/members.js: 멤버 API (공개 + 어드민)
- docs: useQuery 사용 가이드라인 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-21 17:17:56 +09:00
parent cba7e4b522
commit fe067ca8c8
9 changed files with 2184 additions and 41 deletions

1590
docs/frontend-refactoring.md Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,30 +1,48 @@
import { useState } from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom"; import { BrowserRouter, Routes, Route } from "react-router-dom";
import { isMobile } from "react-device-detect"; import { isMobile } from "react-device-detect";
import { useQuery } from "@tanstack/react-query";
import { cn, getTodayKST, formatFullDate } from "@/utils"; import { cn, getTodayKST, formatFullDate } from "@/utils";
import { useAuthStore, useScheduleStore, useUIStore } from "@/stores"; import { useAuthStore, useUIStore } from "@/stores";
import { memberApi, scheduleApi } from "@/api";
/** /**
* 프로미스나인 팬사이트 메인 * 프로미스나인 팬사이트 메인
* *
* Phase 3: 스토어 완료 * Phase 4: API 계층 완료
* - useAuthStore: 인증 상태 (localStorage 지속) * - api/client.js: 기본 fetch 클라이언트 (공개/인증)
* - useScheduleStore: 스케줄 페이지 상태 * - api/schedules.js: 스케줄 API
* - useUIStore: UI 상태 (모달, 토스트, 라이트박스 ) * - api/albums.js: 앨범 API
* - api/members.js: 멤버 API
* - api/auth.js: 인증 API
*/ */
function App() { function App() {
const today = getTodayKST(); const today = getTodayKST();
const { isAuthenticated, login, logout } = useAuthStore(); const { isAuthenticated } = useAuthStore();
const { viewMode, setViewMode, selectedCategories, toggleCategory } = useScheduleStore();
const { showSuccess, showError, toasts } = useUIStore(); const { showSuccess, showError, toasts } = useUIStore();
const [apiTest, setApiTest] = useState(null);
const handleTestLogin = () => { // React Query
login("test-token", { name: "테스트 유저" }); const { data: members, isLoading: membersLoading } = useQuery({
showSuccess("로그인 성공!"); queryKey: ["members"],
}; queryFn: memberApi.getMembers,
});
const handleTestLogout = () => { // React Query
logout(); const { data: categories, isLoading: categoriesLoading } = useQuery({
showError("로그아웃됨"); queryKey: ["categories"],
queryFn: scheduleApi.getCategories,
});
const handleTestApi = async () => {
try {
const data = await memberApi.getMembers();
setApiTest(`멤버 ${data.length}명 조회 성공!`);
showSuccess("API 호출 성공!");
} catch (error) {
setApiTest(`에러: ${error.message}`);
showError("API 호출 실패");
}
}; };
return ( return (
@ -33,48 +51,69 @@ function App() {
<Route <Route
path="/" path="/"
element={ element={
<div className="min-h-screen flex items-center justify-center bg-gray-50"> <div className="min-h-screen flex items-center justify-center bg-gray-50 p-4">
<div className="text-center space-y-4"> <div className="text-center space-y-4 max-w-md w-full">
<h1 className="text-2xl font-bold text-primary mb-2"> <h1 className="text-2xl font-bold text-primary mb-2">
fromis_9 Frontend Refactoring fromis_9 Frontend Refactoring
</h1> </h1>
<p className="text-gray-600">Phase 3 완료 - 스토어</p> <p className="text-gray-600">Phase 4 완료 - API 계층</p>
<p className={cn("text-sm", isMobile ? "text-blue-500" : "text-green-500")}> <p className={cn("text-sm", isMobile ? "text-blue-500" : "text-green-500")}>
디바이스: {isMobile ? "모바일" : "PC"} 디바이스: {isMobile ? "모바일" : "PC"}
</p> </p>
<div className="mt-6 p-4 bg-white rounded-lg shadow text-left text-sm space-y-3"> <div className="mt-6 p-4 bg-white rounded-lg shadow text-left text-sm space-y-3">
<p><strong>오늘:</strong> {formatFullDate(today)}</p> <p><strong>오늘:</strong> {formatFullDate(today)}</p>
<p><strong>인증:</strong> {isAuthenticated ? "✅" : "❌"}</p>
<div className="border-t pt-3"> <div className="border-t pt-3">
<p className="font-semibold mb-2">useAuthStore 테스트</p> <p className="font-semibold mb-2">API 테스트 (React Query)</p>
<p>인증 상태: {isAuthenticated ? "✅ 로그인됨" : "❌ 로그아웃"}</p>
<div className="flex gap-2 mt-2"> <div className="space-y-2">
<button onClick={handleTestLogin} className="px-3 py-1 bg-primary text-white rounded text-xs">로그인</button> <p>
<button onClick={handleTestLogout} className="px-3 py-1 bg-gray-500 text-white rounded text-xs">로그아웃</button> <strong>멤버:</strong>{" "}
{membersLoading ? "로딩 중..." : `${members?.length || 0}`}
</p>
{members && (
<div className="flex flex-wrap gap-1">
{members.map((m) => (
<span key={m.id} className="px-2 py-0.5 bg-gray-100 rounded text-xs">
{m.name}
</span>
))}
</div>
)}
</div>
<div className="space-y-2 mt-3">
<p>
<strong>카테고리:</strong>{" "}
{categoriesLoading ? "로딩 중..." : `${categories?.length || 0}`}
</p>
{categories && (
<div className="flex flex-wrap gap-1">
{categories.map((c) => (
<span
key={c.id}
className="px-2 py-0.5 rounded text-xs text-white"
style={{ backgroundColor: c.color }}
>
{c.name}
</span>
))}
</div>
)}
</div> </div>
</div> </div>
<div className="border-t pt-3"> <div className="border-t pt-3">
<p className="font-semibold mb-2">useScheduleStore 테스트</p> <p className="font-semibold mb-2">직접 API 호출 테스트</p>
<p> 모드: {viewMode}</p> <button
<div className="flex gap-2 mt-2"> onClick={handleTestApi}
<button onClick={() => setViewMode("list")} className={cn("px-3 py-1 rounded text-xs", viewMode === "list" ? "bg-primary text-white" : "bg-gray-200")}>리스트</button> className="px-3 py-1 bg-primary text-white rounded text-xs"
<button onClick={() => setViewMode("calendar")} className={cn("px-3 py-1 rounded text-xs", viewMode === "calendar" ? "bg-primary text-white" : "bg-gray-200")}>캘린더</button> >
</div> 멤버 API 호출
<p className="mt-2">선택된 카테고리: {selectedCategories.length > 0 ? selectedCategories.join(", ") : "없음"}</p> </button>
<div className="flex gap-2 mt-2"> {apiTest && <p className="mt-2 text-xs text-gray-600">{apiTest}</p>}
{[1, 2, 3].map((id) => (
<button key={id} onClick={() => toggleCategory(id)} className={cn("px-3 py-1 rounded text-xs", selectedCategories.includes(id) ? "bg-primary text-white" : "bg-gray-200")}>
카테고리 {id}
</button>
))}
</div>
</div>
<div className="border-t pt-3">
<p className="font-semibold mb-2">useUIStore 토스트</p>
<p>활성 토스트: {toasts.length}</p>
</div> </div>
</div> </div>
</div> </div>

View file

@ -0,0 +1,101 @@
/**
* 앨범 API
*/
import { fetchApi, fetchAuthApi, fetchFormData } from './client';
// ==================== 공개 API ====================
/**
* 앨범 목록 조회
*/
export async function getAlbums() {
return fetchApi('/albums');
}
/**
* 앨범 상세 조회 (ID)
*/
export async function getAlbum(id) {
return fetchApi(`/albums/${id}`);
}
/**
* 앨범 상세 조회 (이름)
*/
export async function getAlbumByName(name) {
return fetchApi(`/albums/by-name/${encodeURIComponent(name)}`);
}
/**
* 앨범 사진 조회
*/
export async function getAlbumPhotos(albumId) {
return fetchApi(`/albums/${albumId}/photos`);
}
/**
* 앨범 트랙 조회
*/
export async function getAlbumTracks(albumId) {
return fetchApi(`/albums/${albumId}/tracks`);
}
/**
* 트랙 상세 조회 (앨범명, 트랙명으로)
*/
export async function getTrack(albumName, trackTitle) {
return fetchApi(
`/albums/by-name/${encodeURIComponent(albumName)}/track/${encodeURIComponent(trackTitle)}`
);
}
/**
* 앨범 티저 조회
*/
export async function getAlbumTeasers(albumId) {
return fetchApi(`/albums/${albumId}/teasers`);
}
// ==================== 어드민 API ====================
/**
* [Admin] 앨범 생성
*/
export async function createAlbum(formData) {
return fetchFormData('/albums', formData, 'POST');
}
/**
* [Admin] 앨범 수정
*/
export async function updateAlbum(id, formData) {
return fetchFormData(`/albums/${id}`, formData, 'PUT');
}
/**
* [Admin] 앨범 삭제
*/
export async function deleteAlbum(id) {
return fetchAuthApi(`/albums/${id}`, { method: 'DELETE' });
}
/**
* [Admin] 앨범 사진 업로드
*/
export async function uploadAlbumPhotos(albumId, formData) {
return fetchFormData(`/albums/${albumId}/photos`, formData, 'POST');
}
/**
* [Admin] 앨범 사진 삭제
*/
export async function deleteAlbumPhoto(albumId, photoId) {
return fetchAuthApi(`/albums/${albumId}/photos/${photoId}`, { method: 'DELETE' });
}
/**
* [Admin] 앨범 티저 삭제
*/
export async function deleteAlbumTeaser(albumId, teaserId) {
return fetchAuthApi(`/albums/${albumId}/teasers/${teaserId}`, { method: 'DELETE' });
}

View file

@ -0,0 +1,37 @@
/**
* 인증 API
*/
import { fetchApi, fetchAuthApi } from './client';
/**
* 로그인
* @param {string} username - 사용자명
* @param {string} password - 비밀번호
* @returns {Promise<{token: string, user: object}>}
*/
export async function login(username, password) {
return fetchApi('/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
});
}
/**
* 토큰 검증
* @returns {Promise<{valid: boolean, user: object}>}
*/
export async function verifyToken() {
return fetchAuthApi('/auth/verify');
}
/**
* 비밀번호 변경
* @param {string} currentPassword - 현재 비밀번호
* @param {string} newPassword - 비밀번호
*/
export async function changePassword(currentPassword, newPassword) {
return fetchAuthApi('/auth/change-password', {
method: 'POST',
body: JSON.stringify({ currentPassword, newPassword }),
});
}

View file

@ -0,0 +1,152 @@
/**
* API 클라이언트
* 모든 API 호출에서 사용되는 기본 fetch 래퍼
*/
import { useAuthStore } from '@/stores';
const API_BASE = '/api';
/**
* API 에러 클래스
*/
export class ApiError extends Error {
constructor(message, status, data = null) {
super(message);
this.name = 'ApiError';
this.status = status;
this.data = data;
}
}
/**
* 응답 처리 헬퍼
*/
async function handleResponse(response) {
if (!response.ok) {
const error = await response.json().catch(() => ({ error: '요청 실패' }));
throw new ApiError(
error.error || error.message || `HTTP ${response.status}`,
response.status,
error
);
}
// 204 No Content 처리
if (response.status === 204) {
return null;
}
return response.json();
}
/**
* 공개 API fetch
* @param {string} endpoint - API 엔드포인트 (/api )
* @param {RequestInit} options - fetch 옵션
*/
export async function fetchApi(endpoint, options = {}) {
const url = endpoint.startsWith('/api') ? endpoint : `${API_BASE}${endpoint}`;
const headers = { ...options.headers };
// body가 있고 FormData가 아닐 때만 Content-Type 설정
if (options.body && !(options.body instanceof FormData)) {
headers['Content-Type'] = 'application/json';
}
const response = await fetch(url, {
...options,
headers,
});
return handleResponse(response);
}
/**
* 인증된 API fetch (토큰 자동 추가)
* @param {string} endpoint - API 엔드포인트
* @param {RequestInit} options - fetch 옵션
*/
export async function fetchAuthApi(endpoint, options = {}) {
const token = useAuthStore.getState().token;
if (!token) {
throw new ApiError('인증이 필요합니다.', 401);
}
return fetchApi(endpoint, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${token}`,
},
});
}
/**
* FormData 전송용 (이미지 업로드 )
* @param {string} endpoint - API 엔드포인트
* @param {FormData} formData - 전송할 FormData
* @param {string} method - HTTP 메서드 (기본: POST)
*/
export async function fetchFormData(endpoint, formData, method = 'POST') {
const token = useAuthStore.getState().token;
const url = endpoint.startsWith('/api') ? endpoint : `${API_BASE}${endpoint}`;
const headers = {};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch(url, {
method,
headers,
body: formData,
});
return handleResponse(response);
}
/**
* GET 요청 헬퍼
*/
export const get = (endpoint) => fetchApi(endpoint);
export const authGet = (endpoint) => fetchAuthApi(endpoint);
/**
* POST 요청 헬퍼
*/
export const post = (endpoint, data) =>
fetchApi(endpoint, {
method: 'POST',
body: JSON.stringify(data),
});
export const authPost = (endpoint, data) =>
fetchAuthApi(endpoint, {
method: 'POST',
body: JSON.stringify(data),
});
/**
* PUT 요청 헬퍼
*/
export const put = (endpoint, data) =>
fetchApi(endpoint, {
method: 'PUT',
body: JSON.stringify(data),
});
export const authPut = (endpoint, data) =>
fetchAuthApi(endpoint, {
method: 'PUT',
body: JSON.stringify(data),
});
/**
* DELETE 요청 헬퍼
*/
export const del = (endpoint) =>
fetchApi(endpoint, { method: 'DELETE' });
export const authDel = (endpoint) =>
fetchAuthApi(endpoint, { method: 'DELETE' });

View file

@ -0,0 +1,31 @@
/**
* API 통합 export
*/
// 클라이언트
export {
fetchApi,
fetchAuthApi,
fetchFormData,
ApiError,
get,
authGet,
post,
authPost,
put,
authPut,
del,
authDel,
} from './client';
// 인증
export * as authApi from './auth';
// 스케줄
export * as scheduleApi from './schedules';
// 앨범
export * as albumApi from './albums';
// 멤버
export * as memberApi from './members';

View file

@ -0,0 +1,43 @@
/**
* 멤버 API
*/
import { fetchApi, fetchAuthApi, fetchFormData } from './client';
// ==================== 공개 API ====================
/**
* 멤버 목록 조회
*/
export async function getMembers() {
return fetchApi('/members');
}
/**
* 멤버 상세 조회
*/
export async function getMember(id) {
return fetchApi(`/members/${id}`);
}
// ==================== 어드민 API ====================
/**
* [Admin] 멤버 생성
*/
export async function createMember(formData) {
return fetchFormData('/admin/members', formData, 'POST');
}
/**
* [Admin] 멤버 수정
*/
export async function updateMember(id, formData) {
return fetchFormData(`/admin/members/${id}`, formData, 'PUT');
}
/**
* [Admin] 멤버 삭제
*/
export async function deleteMember(id) {
return fetchAuthApi(`/admin/members/${id}`, { method: 'DELETE' });
}

View file

@ -0,0 +1,150 @@
/**
* 스케줄 API
*/
import { fetchApi, fetchAuthApi, fetchFormData } from './client';
import { getTodayKST } from '@/utils';
/**
* API 응답을 플랫 배열로 변환
* 백엔드가 날짜별 그룹화된 객체를 반환하므로 변환 필요
*/
function flattenScheduleResponse(data) {
const schedules = [];
for (const [date, dayData] of Object.entries(data)) {
for (const schedule of dayData.schedules || []) {
const category = schedule.category || {};
schedules.push({
...schedule,
date,
categoryId: category.id,
categoryName: category.name,
categoryColor: category.color,
});
}
}
return schedules;
}
// ==================== 공개 API ====================
/**
* 스케줄 목록 조회 (월별)
*/
export async function getSchedules(year, month) {
const data = await fetchApi(`/schedules?year=${year}&month=${month}`);
return flattenScheduleResponse(data);
}
/**
* 다가오는 스케줄 조회
*/
export async function getUpcomingSchedules(limit = 3) {
const today = getTodayKST();
return fetchApi(`/schedules?startDate=${today}&limit=${limit}`);
}
/**
* 스케줄 검색 (Meilisearch)
*/
export async function searchSchedules(query, { offset = 0, limit = 20 } = {}) {
return fetchApi(
`/schedules?search=${encodeURIComponent(query)}&offset=${offset}&limit=${limit}`
);
}
/**
* 스케줄 상세 조회
*/
export async function getSchedule(id) {
return fetchApi(`/schedules/${id}`);
}
/**
* X 프로필 정보 조회
*/
export async function getXProfile(username) {
return fetchApi(`/schedules/x-profile/${encodeURIComponent(username)}`);
}
/**
* 카테고리 목록 조회
*/
export async function getCategories() {
return fetchApi('/schedules/categories');
}
// ==================== 어드민 API ====================
/**
* [Admin] 스케줄 검색
*/
export async function adminSearchSchedules(query) {
return fetchAuthApi(`/admin/schedules/search?q=${encodeURIComponent(query)}`);
}
/**
* [Admin] 스케줄 상세 조회
*/
export async function adminGetSchedule(id) {
return fetchAuthApi(`/admin/schedules/${id}`);
}
/**
* [Admin] 스케줄 생성
*/
export async function createSchedule(formData) {
return fetchFormData('/admin/schedules', formData, 'POST');
}
/**
* [Admin] 스케줄 수정
*/
export async function updateSchedule(id, formData) {
return fetchFormData(`/admin/schedules/${id}`, formData, 'PUT');
}
/**
* [Admin] 스케줄 삭제
*/
export async function deleteSchedule(id) {
return fetchAuthApi(`/schedules/${id}`, { method: 'DELETE' });
}
// ==================== 카테고리 어드민 API ====================
/**
* [Admin] 카테고리 생성
*/
export async function createCategory(data) {
return fetchAuthApi('/admin/schedule-categories', {
method: 'POST',
body: JSON.stringify(data),
});
}
/**
* [Admin] 카테고리 수정
*/
export async function updateCategory(id, data) {
return fetchAuthApi(`/admin/schedule-categories/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
/**
* [Admin] 카테고리 삭제
*/
export async function deleteCategory(id) {
return fetchAuthApi(`/admin/schedule-categories/${id}`, { method: 'DELETE' });
}
/**
* [Admin] 카테고리 순서 변경
*/
export async function reorderCategories(orders) {
return fetchAuthApi('/admin/schedule-categories-order', {
method: 'PUT',
body: JSON.stringify({ orders }),
});
}