단계별 crud

odada·2025년 2월 16일

crud

기본 타입

// src/api/axios.js
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;

// src/api/postAPI.js
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;
  }
};

// src/pages/PostListPage.js
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>
  );
}

// src/pages/PostFormPage.js
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>
  );
}

// src/App.js
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>
  );
}

실무용

// src/api/axios.js
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; // 20초

const axiosInstance = axios.create({
  baseURL,
  timeout: TIMEOUT,
  headers: {
    'Content-Type': 'application/json',
  },
});

// Request Interceptor
axiosInstance.interceptors.request.use(
  (config) => {
    const token = getToken();
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
  (error) => {
    return Promise.reject(error);
  }
);

// Response Interceptor
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;

// src/api/postAPI.js
import axios from './axios';
import { QueryClient } from '@tanstack/react-query';

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: 1,
      retryDelay: 1000,
      staleTime: 300000, // 5분
      cacheTime: 600000, // 10분
      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;
  }
};

// src/hooks/usePostQuery.js
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);
};

// src/components/PostList.js
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) {
      // Error is handled by axios interceptor
      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>
  );
}

// src/components/PostForm.js
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) {
      // Error is handled by axios interceptor
      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>
  );
}

// src/schemas/postSchema.js
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자 이내로 입력해주세요.'),
});

// src/hooks/useDebounce.js
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;
}

0개의 댓글