데이터 연동 페이지와 검증
데이터 연동 페이지와 라우터를 조합하고 조회·추가·삭제의 실패·지연·재시도를 검증합니다.
데이터 연동 페이지 구성
게시판의 목록·상세 조회와 추가·삭제는 같은 Axios 인스턴스를 사용하지만, 조회 상태와 mutation 상태는 서로 독립적으로 관리합니다.
PostsPage.jsx
게시물 목록과 게시물 추가 폼을 함께 표시합니다.
import { useEffect, useRef, useState } from 'react';
import useAxios from '../hooks/useAxios';
import PostList from '../components/PostList';
import AddPostForm from '../components/AddPostForm';
import ErrorDisplay from '../components/ErrorDisplay';
import mockApi from '../api/mockApi';
function PostsPage() {
const { data, loading, error, fetchData: refetchPosts } = useAxios('/posts');
const posts = Array.isArray(data) ? data : [];
const responseShapeError =
data !== null && !Array.isArray(data)
? new Error('GET /posts 응답이 배열이 아닙니다.')
: null;
const [createLoading, setCreateLoading] = useState(false);
const [createError, setCreateError] = useState(null);
const [deletingId, setDeletingId] = useState(null);
const [deleteError, setDeleteError] = useState(null);
const [refreshError, setRefreshError] = useState(null);
const pageActiveRef = useRef(false);
useEffect(() => {
pageActiveRef.current = true;
return () => {
pageActiveRef.current = false;
};
}, []);
const retryPosts = () => {
void refetchPosts().catch(() => {});
};
const retryRefresh = () => {
setRefreshError(null);
void refetchPosts().catch((retryError) => {
if (retryError.code !== 'ERR_CANCELED') {
setRefreshError(retryError);
}
});
};
const refreshAfterMutation = async () => {
if (!pageActiveRef.current) return;
setRefreshError(null);
try {
await refetchPosts();
} catch (refreshRequestError) {
if (
pageActiveRef.current &&
refreshRequestError.code !== 'ERR_CANCELED'
) {
setRefreshError(refreshRequestError);
}
}
};
const handleAddPost = async (newPost) => {
setCreateLoading(true);
setCreateError(null);
try {
await mockApi.post('/posts', newPost);
if (!pageActiveRef.current) return false;
await refreshAfterMutation();
return pageActiveRef.current;
} catch (mutationError) {
if (pageActiveRef.current) {
setCreateError(mutationError);
}
return false;
} finally {
if (pageActiveRef.current) {
setCreateLoading(false);
}
}
};
const handleDeletePost = async (id) => {
if (deletingId !== null) {
return;
}
if (!window.confirm(`${id}번 게시물을 정말 삭제하시겠습니까?`)) {
return;
}
setDeletingId(id);
setDeleteError(null);
try {
await mockApi.delete(`/posts/${id}`);
if (!pageActiveRef.current) return;
await refreshAfterMutation();
} catch (mutationError) {
if (pageActiveRef.current) {
setDeleteError(mutationError);
}
} finally {
if (pageActiveRef.current) {
setDeletingId(null);
}
}
};
return (
<div style={{ padding: '20px' }}>
<h1 style={{ textAlign: 'center', marginBottom: '40px', color: 'var(--header-color)' }}>게시판</h1>
<AddPostForm onAddPost={handleAddPost} loading={createLoading} error={createError} />
{deletingId && <p>{deletingId}번 게시물을 삭제하는 중입니다.</p>}
<ErrorDisplay error={deleteError} />
<ErrorDisplay error={refreshError} onRetry={retryRefresh} />
<PostList
posts={posts}
loading={loading}
error={refreshError ? null : error ?? responseShapeError}
onDelete={handleDeletePost}
onRetry={retryPosts}
deletingId={deletingId}
/>
</div>
);
}
export default PostsPage;POST 또는 DELETE 자체가 실패하면 각 mutation error를 표시합니다. 저장은 성공했지만 뒤이은 GET이 실패하면 기존 posts를 유지하고 refreshError를 비차단 안내로 표시하므로, 사용자가 같은 mutation을 중복 실행하지 않고 목록 조회만 다시 시도할 수 있습니다. 페이지가 unmount된 뒤 mutation이 끝난 경우에는 pageActiveRef가 후속 GET 시작과 상태 쓰기를 막습니다.
목록 응답이 배열이 아니면 빈 목록으로 바꾸지 않고 responseShapeError로 표시합니다. 삭제는 이 예제에서 한 번에 하나만 실행하며, deletingId가 있는 동안 다른 삭제 버튼을 잠가 앞선 요청의 finally가 뒤 요청의 표시를 지우는 경쟁을 막습니다.
SinglePostPage.jsx
단일 게시물 상세 페이지입니다.
import { useParams } from 'react-router-dom';
import useAxios from '../hooks/useAxios';
import PostDetail from '../components/PostDetail';
function SinglePostPage() {
const { postId } = useParams(); // URL 파라미터에서 postId 가져오기
// useAxios 훅을 사용하여 특정 게시물 조회
const { data: post, loading, error, fetchData: refetchPost } = useAxios(
postId ? `/posts/${postId}` : null // postId가 있을 때만 요청 (null이면 요청X)
);
const retryPost = () => {
void refetchPost().catch(() => {});
};
if (error?.response?.status === 404) {
return <p>요청한 게시물을 찾을 수 없습니다.</p>;
}
return (
<div style={{ padding: '20px' }}>
<PostDetail post={post} loading={loading} error={error} onRetry={retryPost} />
</div>
);
}
export default SinglePostPage;App.jsx (라우팅 + Axios 최종 통합)
라우팅과 함께 모든 컴포넌트를 통합합니다.
import { BrowserRouter, Routes, Route, Link, Navigate } from 'react-router-dom';
import PostsPage from './pages/PostsPage';
import SinglePostPage from './pages/SinglePostPage';
const DefaultNotFoundPage = () => (
<div style={{ textAlign: 'center', padding: '50px' }}>
<h1>404 - 페이지를 찾을 수 없습니다.</h1>
<p>요청하신 페이지가 존재하지 않습니다.</p>
<Link to="/" className="button">홈으로 돌아가기</Link>
</div>
);
function App() {
return (
<BrowserRouter>
<div className="main-content">
<Routes>
<Route path="/" element={<Navigate to="/posts" replace />} />
<Route path="/posts" element={<PostsPage />} />
<Route path="/posts/:postId" element={<SinglePostPage />} />
<Route path="*" element={<DefaultNotFoundPage />} />
</Routes>
</div>
</BrowserRouter>
);
}
export default App;구현이 끝난 뒤에는 조회·추가·삭제 동작을 요청, 화면 상태, 실패 복구 기준으로 함께 검증해야 합니다.
React · C/R/D integration
조회 상태와 mutation 상태를 분리하고, mutation 성공 뒤 목록 refetch까지 기다려야 화면 계약이 닫힙니다. POST·DELETE 실패와 저장은 성공했지만 갱신이 실패한 경우를 같은 오류로 합치지 않습니다.
목록과 상세는 URL이 request key다
/posts→ GET 목록 →Array.isArray로 확인하고 비배열 응답은 계약 오류로 처리/posts/:postId→ 문자열 ID 또는undefined→ 유효할 때만 상세 GET- 목록은 loading, error, empty, retry를 구분하고 상세는 not found를 404 화면으로 구분
- ID가 빠르게 바뀌면 이전 요청을 abort하고 최신 request ID만 화면 상태를 commit
POST pending과 error는 조회와 분리한다
폼 제출을 잠그고 create error를 비운다
활성 페이지에서만 POST 뒤 목록 refetch를 await한다
POST 실패와 refresh 실패를 서로 다른 안내로 기록한다
DELETE pending은 대상 ID를 가진다
삭제할 ID를 pending으로 표시하고 다른 삭제 실행을 잠근다
활성 페이지에서만 DELETE 뒤 목록 refetch를 await한다
로컬 서버의 삭제 persistence를 새로고침으로 확인한다
BrowserRouter와 Axios의 책임을 코드에서 잇는다
이 실습은 route component가 request key를 만들고 custom hook과 mutation handler가 상태를 관리하는 수동 구조입니다. React Router Data Router의 loader·action을 쓰는 설계도 가능하지만 여기서는 별도 대안이며 두 모델을 섞지 않습니다.
| 검증 | 조작 | 통과 기준 | 놓치면 생기는 회귀 |
|---|---|---|---|
| Loading | 네트워크 지연 | 초기 요청과 mutation 진행이 독립적으로 보임 | 전체 화면 잠금 또는 중복 제출 |
| Error · retry | API 중단 후 복구 | 조회·refresh만 안전하게 retry하고 mutation 오류는 자동 재실행하지 않음 | 저장 성공을 실패로 오인하거나 mutation 중복 실행 |
| Empty | 빈 posts 배열 | 빈 배열만 empty, 비배열 성공 응답은 계약 error | 깨진 응답을 빈 목록으로 오인 |
| 404 | 없는 상세 ID | not found와 네트워크 장애를 구분 | 빈 상세 또는 일반 오류로 합침 |
| Stale · cancel | ID를 빠르게 변경 | 이전 요청이 현재 상세를 덮어쓰지 않음 | URL과 다른 게시물 표시 |
| List · detail | 추가·삭제 뒤 이동 | 목록과 상세가 같은 서버 상태를 가리킴 | 한 화면만 오래된 data 유지 |
| Persistence | 브라우저 새로고침 | db.json의 추가·삭제 결과가 유지됨 | 메모리 UI만 성공한 것으로 착각 |
| Seed reset | 초기 seed 복원 | 다음 실행이 같은 데이터에서 시작 | 반복 실행마다 결과 변화 |
지연 중 상태를 분리한다
초기 조회, create, 대상 ID의 delete가 서로 다른 진행 상태로 보이는지 확인합니다.
실패 위치를 보존한다
조회·refresh만 안전하게 retry하며 mutation 오류는 구분해 표시하고 자동 재실행하지 않습니다.
값 없음의 의미를 나눈다
빈 배열만 success empty이며 비배열 응답은 계약 error, 없는 상세는 404로 렌더링합니다.
빠른 route 변경을 시험한다
이전 요청을 abort하고 늦은 응답이 현재 URL의 상세를 덮어쓰지 않는지 확인합니다.
화면 간 서버 상태를 맞춘다
추가·삭제 뒤 awaited refetch를 거쳐 목록과 상세가 같은 데이터를 가리키는지 확인합니다.
새로고침과 seed 복원을 확인한다
변경이 db.json에 남는지 확인하고 다음 반복 전에 보관한 seed를 복원합니다.
검증 순서는 목록 조회 → 상세 조회 → 추가 → 삭제 → 실패·지연 → 빠른 route 변경 → 새로고침 → seed 복원으로 고정합니다. 각 단계에서 네트워크 응답과 화면 state를 함께 기록합니다.
검증 순서 및 확인 사항: 조회·추가·삭제 경로 점검
실습 검증은 목록 조회 -> 상세 조회 -> 추가 -> 삭제 -> 실패 응답 순서로 고정합니다.
이 순서로 진행하면 C/R/D 경로별 회귀를 단계적으로 확인할 수 있습니다.
준비 계약 확인: 앞 문서에서 Vite 프로젝트 생성, react-router-dom@7, Axios, json-server, db.json, db.seed.json, api script 준비를 마쳤는지 확인합니다.
파일 구조 생성: 위에 제시된 디렉토리 구조에 따라 파일들을 생성합니다.
코드 복사/붙여넣기: 각 파일에 해당하는 코드를 정확히 복사하여 붙여넣습니다. (index.css, api/mockApi.js, hooks/useAxios.js, components 폴더 안의 모든 .jsx 파일, pages 폴더 안의 모든 .jsx 파일, App.jsx까지)
두 프로세스 실행: 터미널 A에서 npm run api, 터미널 B에서 npm run dev를 실행하고 Mock API가 4000번 포트에서 응답하는지 확인합니다.
- 루트 (
/):/posts로 이동합니다. -
게시판 (
/posts)- 로딩 스피너가 잠시 보인 후, 게시물 목록이 나타나는지 확인합니다.
- 응답이 빈 배열이면 오류 화면 대신 빈 목록 안내가 나타나는지 확인합니다.
- 200 응답이어도 body가 배열이 아니면 빈 목록이 아니라 응답 계약 오류로 표시되는지 확인합니다.
- 새 게시물 추가 후 refetch가 끝나야 목록에 새 항목이 나타나는지 확인합니다. mutation은 성공했지만 refetch가 실패한 경우에는 추가 오류가 아니라 목록 갱신 오류로 안내해야 합니다.
- 각 게시물 옆의 삭제 버튼을 클릭해 해당 ID의 pending 상태만 표시되는지, 완료 후 목록이 다시 조회되는지 확인합니다. 로컬
json-server의 DELETE는db.json에 반영되므로 새로고침 뒤에도 삭제 상태가 유지됩니다. - 네트워크 탭에서 GET, POST, DELETE 요청이 올바르게 전송되는지 확인합니다.
-
게시물 상세 (
/posts/1또는 다른 ID)- 목록에서 게시물 제목을 클릭하거나 URL에 직접
/posts/게시물ID를 입력하여 특정 게시물의 상세 내용이 로딩 스피너 후 나타나는지 확인합니다. - 존재하지 않는 게시물 ID (예:
/posts/99999)로 접근하여 에러 메시지가 잘 표시되는지 확인합니다.
- 목록에서 게시물 제목을 클릭하거나 URL에 직접
- 로딩·에러·재시도: 네트워크 속도를 늦추고 API를 잠시 중단해 초기 loading, error, retry가 순서대로 동작하는지 확인합니다.
- 경합과 취소: 상세 ID를 빠르게 바꾸거나 페이지를 떠났을 때 이전 요청이 취소되고, 늦게 끝난 응답이 현재 화면을 덮어쓰지 않는지 확인합니다.
- 일관성과 초기화: 목록과 상세가 같은 서버 상태를 가리키는지 확인하고, 반복 실습 전에는 보관한 seed로
db.json을 복원합니다.
Axios, useEffect, 커스텀 훅을 조합하면 데이터 페칭 로직을 컴포넌트 밖으로 분리하고 재사용할 수 있습니다.
이 실습에서는 다음 개발 패턴을 확인했습니다.
- 비동기 요청을 위한
Axios인스턴스 설정 - GET 조회에 집중한
useAxios커스텀 훅의 설계 및 구현 - 로딩, 에러, 데이터 상태를 컴포넌트에 통합하여 사용자에게 시각적인 피드백 제공
- C/R/D 작업을 위한 GET, POST, DELETE 요청 처리
이 실습의 핵심은 요청 함수를 외우는 것이 아니라, 조회·상세·추가·삭제·실패 응답을 같은 순서로 반복 검증하는 습관입니다.
화면이 보이는지만 확인하지 말고 로딩, 에러, 재시도, 목록 갱신까지 함께 확인해야 외부 API 연동의 회귀를 줄일 수 있습니다.