crud
기본 타입
import axios from 'axios';
const baseURL = process.env.REACT_APP_API_URL || 'http://localhost:3000/api';
const axiosInstance = axios.create({
baseURL,
timeout: 5000,
headers: {
'Content-Type': 'application/json',
},
});
export default axiosInstance;
import axios from './axios';
export const postAPI = {
getList: async () => {
const response = await axios.get('/posts');
return response.data;
},
getById: async (id) => {
const response = await axios.get(`/posts/${id}`);
return response.data;
},
create: async (postData) => {
const response = await axios.post('/posts', postData);
return response.data;
},
update: async (id, postData) => {
const response = await axios.put(`/posts/${id}`, postData);
return response.data;
},
delete: async (id) => {
const response = await axios.delete(`/posts/${id}`);
return response.data;
}
};
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { postAPI } from '../api/postAPI';
function PostListPage() {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const navigate = useNavigate();
const fetchPosts = async () => {
try {
setLoading(true);
setError(null);
const data = await postAPI.getList();
setPosts(data);
} catch (err) {
setError('게시글을 불러오는데 실패했습니다.');
console.error(err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchPosts();
}, []);
const handleDelete = async (id) => {
if (!window.confirm('정말 삭제하시겠습니까?')) return;
try {
setLoading(true);
await postAPI.delete(id);
setPosts(posts.filter(post => post.id !== id));
} catch (err) {
setError('삭제에 실패했습니다.');
console.error(err);
} finally {
setLoading(false);
}
};
if (loading) return <div>로딩중...</div>;
if (error) return <div>에러: {error}</div>;
return (
<div className="max-w-4xl mx-auto p-4">
<div className="flex justify-between mb-4">
<h1 className="text-2xl font-bold">게시글 목록</h1>
<button
onClick={() => navigate('/posts/new')}
className="px-4 py-2 bg-blue-500 text-white rounded"
>
새 글 작성
</button>
</div>
<div className="space-y-4">
{posts.map((post) => (
<div key={post.id} className="border p-4 rounded">
<h2 className="text-xl font-semibold">{post.title}</h2>
<p className="text-gray-600 mt-2">{post.content.substring(0, 100)}...</p>
<div className="mt-4 space-x-2">
<button
onClick={() => navigate(`/posts/${post.id}`)}
className="px-3 py-1 bg-gray-500 text-white rounded"
>
보기
</button>
<button
onClick={() => navigate(`/posts/${post.id}/edit`)}
className="px-3 py-1 bg-blue-500 text-white rounded"
>
수정
</button>
<button
onClick={() => handleDelete(post.id)}
className="px-3 py-1 bg-red-500 text-white rounded"
>
삭제
</button>
</div>
</div>
))}
</div>
</div>
);
}
import React, { useState, useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { postAPI } from '../api/postAPI';
function PostFormPage() {
const { id } = useParams();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [form, setForm] = useState({
title: '',
content: ''
});
useEffect(() => {
if (id) {
fetchPost();
}
}, [id]);
const fetchPost = async () => {
try {
setLoading(true);
const data = await postAPI.getById(id);
setForm({
title: data.title,
content: data.content
});
} catch (err) {
setError('게시글을 불러오는데 실패했습니다.');
console.error(err);
} finally {
setLoading(false);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
try {
setLoading(true);
if (id) {
await postAPI.update(id, form);
} else {
await postAPI.create(form);
}
navigate('/posts');
} catch (err) {
setError('저장에 실패했습니다.');
console.error(err);
} finally {
setLoading(false);
}
};
const handleChange = (e) => {
const { name, value } = e.target;
setForm(prev => ({
...prev,
[name]: value
}));
};
if (loading) return <div>로딩중...</div>;
if (error) return <div>에러: {error}</div>;
return (
<div className="max-w-2xl mx-auto p-4">
<h1 className="text-2xl font-bold mb-4">
{id ? '게시글 수정' : '새 게시글 작성'}
</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="title" className="block mb-2">제목</label>
<input
type="text"
id="title"
name="title"
value={form.title}
onChange={handleChange}
className="w-full p-2 border rounded"
required
/>
</div>
<div>
<label htmlFor="content" className="block mb-2">내용</label>
<textarea
id="content"
name="content"
value={form.content}
onChange={handleChange}
rows="10"
className="w-full p-2 border rounded"
required
/>
</div>
<div className="flex gap-2">
<button
type="submit"
className="px-4 py-2 bg-blue-500 text-white rounded"
disabled={loading}
>
{id ? '수정하기' : '작성하기'}
</button>
<button
type="button"
onClick={() => navigate('/posts')}
className="px-4 py-2 bg-gray-500 text-white rounded"
>
취소
</button>
</div>
</form>
</div>
);
}
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import PostListPage from './pages/PostListPage';
import PostFormPage from './pages/PostFormPage';
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/posts" element={<PostListPage />} />
<Route path="/posts/new" element={<PostFormPage />} />
<Route path="/posts/:id/edit" element={<PostFormPage />} />
</Routes>
</BrowserRouter>
);
}
실무용
import axios from 'axios';
import { toast } from 'react-toastify';
import { getToken, removeToken } from '../utils/auth';
const baseURL = process.env.REACT_APP_API_URL;
const TIMEOUT = 20000;
const axiosInstance = axios.create({
baseURL,
timeout: TIMEOUT,
headers: {
'Content-Type': 'application/json',
},
});
axiosInstance.interceptors.request.use(
(config) => {
const token = getToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
axiosInstance.interceptors.response.use(
(response) => response,
(error) => {
if (error.response) {
switch (error.response.status) {
case 401:
removeToken();
window.location.href = '/login';
break;
case 403:
toast.error('접근 권한이 없습니다.');
break;
case 404:
toast.error('요청하신 리소스를 찾을 수 없습니다.');
break;
case 429:
toast.error('요청이 너무 많습니다. 잠시 후 다시 시도해주세요.');
break;
case 500:
toast.error('서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.');
break;
default:
if (!error.response.config.suppressErrorMessage) {
toast.error('오류가 발생했습니다. 잠시 후 다시 시도해주세요.');
}
}
} else if (error.code === 'ECONNABORTED') {
toast.error('요청 시간이 초과되었습니다. 네트워크 상태를 확인해주세요.');
} else {
toast.error('네트워크 오류가 발생했습니다. 연결 상태를 확인해주세요.');
}
return Promise.reject(error);
}
);
export default axiosInstance;
import axios from './axios';
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
retryDelay: 1000,
staleTime: 300000,
cacheTime: 600000,
refetchOnWindowFocus: false,
useErrorBoundary: true,
},
},
});
const POSTS_KEY = 'posts';
export const postAPI = {
getList: async ({ page = 1, limit = 10, search = '', sort = 'createdAt:desc' }) => {
const params = new URLSearchParams({
page: String(page),
limit: String(limit),
search,
sort,
});
const response = await axios.get(`/posts?${params}`);
return response.data;
},
getById: async (id) => {
const response = await axios.get(`/posts/${id}`);
return response.data;
},
create: async (postData) => {
const response = await axios.post('/posts', postData);
await queryClient.invalidateQueries([POSTS_KEY]);
return response.data;
},
update: async ({ id, postData }) => {
const response = await axios.put(`/posts/${id}`, postData);
await queryClient.invalidateQueries([POSTS_KEY]);
await queryClient.invalidateQueries([POSTS_KEY, id]);
return response.data;
},
delete: async (id) => {
const response = await axios.delete(`/posts/${id}`);
await queryClient.invalidateQueries([POSTS_KEY]);
return response.data;
}
};
import { useQuery, useMutation } from '@tanstack/react-query';
import { postAPI } from '../api/postAPI';
export const usePostList = (params) => {
return useQuery(
['posts', params],
() => postAPI.getList(params),
{
keepPreviousData: true,
}
);
};
export const usePost = (id) => {
return useQuery(
['posts', id],
() => postAPI.getById(id),
{
enabled: !!id,
}
);
};
export const useCreatePost = () => {
return useMutation(postAPI.create);
};
export const useUpdatePost = () => {
return useMutation(postAPI.update);
};
export const useDeletePost = () => {
return useMutation(postAPI.delete);
};
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { usePostList, useDeletePost } from '../hooks/usePostQuery';
import { Pagination } from '../components/common/Pagination';
import { SearchBar } from '../components/common/SearchBar';
import { SortSelect } from '../components/common/SortSelect';
import { Button } from '../components/common/Button';
import { PostCard } from '../components/PostCard';
import { useDebounce } from '../hooks/useDebounce';
import { ErrorBoundary } from 'react-error-boundary';
import { ErrorFallback } from '../components/common/ErrorFallback';
const ITEMS_PER_PAGE = 10;
function PostList() {
const navigate = useNavigate();
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [sort, setSort] = useState('createdAt:desc');
const debouncedSearch = useDebounce(search, 500);
const { data, isLoading, error } = usePostList({
page,
limit: ITEMS_PER_PAGE,
search: debouncedSearch,
sort,
});
const deleteMutation = useDeletePost();
const handleSearch = (value) => {
setSearch(value);
setPage(1);
};
const handleSort = (value) => {
setSort(value);
setPage(1);
};
const handleDelete = async (id) => {
if (!window.confirm('정말 삭제하시겠습니까?')) return;
try {
await deleteMutation.mutateAsync(id);
toast.success('게시글이 삭제되었습니다.');
} catch (error) {
console.error('Delete failed:', error);
}
};
return (
<div className="max-w-4xl mx-auto p-4">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">게시글 목록</h1>
<Button
onClick={() => navigate('/posts/new')}
variant="primary"
>
새 글 작성
</Button>
</div>
<div className="flex gap-4 mb-6">
<SearchBar
value={search}
onChange={handleSearch}
placeholder="제목 또는 내용 검색..."
className="flex-1"
/>
<SortSelect
value={sort}
onChange={handleSort}
options={[
{ value: 'createdAt:desc', label: '최신순' },
{ value: 'createdAt:asc', label: '오래된순' },
{ value: 'title:asc', label: '제목순' },
]}
/>
</div>
<ErrorBoundary FallbackComponent={ErrorFallback}>
{isLoading ? (
<PostListSkeleton count={ITEMS_PER_PAGE} />
) : error ? (
<ErrorMessage error={error} />
) : (
<>
<div className="space-y-4">
{data.items.map((post) => (
<PostCard
key={post.id}
post={post}
onView={() => navigate(`/posts/${post.id}`)}
onEdit={() => navigate(`/posts/${post.id}/edit`)}
onDelete={() => handleDelete(post.id)}
/>
))}
</div>
<Pagination
className="mt-6"
currentPage={page}
totalPages={Math.ceil(data.total / ITEMS_PER_PAGE)}
onPageChange={setPage}
/>
</>
)}
</ErrorBoundary>
</div>
);
}
import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { usePost, useCreatePost, useUpdatePost } from '../hooks/usePostQuery';
import { postSchema } from '../schemas/postSchema';
import { Input } from '../components/common/Input';
import { TextArea } from '../components/common/TextArea';
import { Button } from '../components/common/Button';
import { ErrorBoundary } from 'react-error-boundary';
import { ErrorFallback } from '../components/common/ErrorFallback';
import { Prompt } from '../components/common/Prompt';
function PostForm() {
const { id } = useParams();
const navigate = useNavigate();
const formRef = useRef(null);
const { data: post, isLoading: fetchLoading } = usePost(id);
const createMutation = useCreatePost();
const updateMutation = useUpdatePost();
const {
register,
handleSubmit,
formState: { errors, isDirty },
reset,
} = useForm({
resolver: zodResolver(postSchema),
defaultValues: id ? { title: '', content: '' } : {},
});
useEffect(() => {
if (post) {
reset(post);
}
}, [post, reset]);
const onSubmit = async (data) => {
try {
if (id) {
await updateMutation.mutateAsync({ id, postData: data });
toast.success('게시글이 수정되었습니다.');
} else {
await createMutation.mutateAsync(data);
toast.success('게시글이 작성되었습니다.');
}
navigate('/posts');
} catch (error) {
console.error('Save failed:', error);
}
};
const isLoading = fetchLoading || createMutation.isLoading || updateMutation.isLoading;
return (
<div className="max-w-2xl mx-auto p-4">
<Prompt
when={isDirty}
message="작성 중인 내용이 있습니다. 페이지를 나가시겠습니까?"
/>
<h1 className="text-2xl font-bold mb-6">
{id ? '게시글 수정' : '새 게시글 작성'}
</h1>
<ErrorBoundary FallbackComponent={ErrorFallback}>
<form ref={formRef} onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div>
<Input
label="제목"
{...register('title')}
error={errors.title?.message}
disabled={isLoading}
/>
</div>
<div>
<TextArea
label="내용"
{...register('content')}
error={errors.content?.message}
rows={10}
disabled={isLoading}
/>
</div>
<div className="flex gap-2">
<Button
type="submit"
variant="primary"
loading={isLoading}
>
{id ? '수정하기' : '작성하기'}
</Button>
<Button
type="button"
variant="secondary"
onClick={() => {
if (isDirty) {
if (window.confirm('작성 중인 내용이 있습니다. 취소하시겠습니까?')) {
navigate('/posts');
}
} else {
navigate('/posts');
}
}}
>
취소
</Button>
</div>
</form>
</ErrorBoundary>
</div>
);
}
import { z } from 'zod';
export const postSchema = z.object({
title: z
.string()
.min(1, '제목을 입력해주세요.')
.max(100, '제목은 100자 이내로 입력해주세요.'),
content: z
.string()
.min(1, '내용을 입력해주세요.')
.max(2000, '내용은 2000자 이내로 입력해주세요.'),
});
import { useState, useEffect } from 'react';
export function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}