데이터 연동 페이지와 검증
데이터 연동 페이지와 라우터를 조합하고 조회·추가·삭제의 실패·지연·재시도를 검증합니다.
앞 글에서는 게시물 목록과 상세, 추가 폼 컴포넌트를 만들었습니다. 이 글에서는 이 컴포넌트들을 페이지에 연결해 조회와 추가, 삭제 요청을 보내고, App.jsx에서 라우트를 구성한 뒤, 정해진 순서로 지연과 실패 상황을 점검합니다. 게시판의 목록·상세 조회와 추가·삭제는 같은 Axios 인스턴스를 쓰지만, 조회의 상태와 변경(mutation)의 상태는 서로 따로 관리합니다.
PostsPage.jsx
게시물 목록과 추가 폼을 함께 보여 주는 페이지입니다. 목록 조회는 useAxios('/posts')가 맡고, 추가와 삭제는 이 페이지가 mockApi로 직접 요청합니다. 그래서 조회의 loading, error와 별도로 추가와 삭제의 진행 상태와 오류를 각자의 state로 둡니다. pageActiveRef는 마운트될 때 true, 정리 함수에서 false가 되어, 페이지가 사라진 뒤에 늦게 끝난 요청이 state를 바꾸지 않도록 확인하는 데 쓰입니다.
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 자체가 실패하면 각 변경 요청의 오류를 보여 줍니다. 저장은 성공했지만 이어지는 GET이 실패하면 기존 posts를 유지한 채 refreshError를 화면을 막지 않는 안내로 보여 주므로, 사용자는 같은 변경 요청을 다시 보내지 않고 목록 조회만 다시 시도할 수 있습니다. 페이지가 사라진 뒤에 변경 요청이 끝났다면 pageActiveRef가 이어지는 GET의 시작과 상태 쓰기를 막습니다.
responseShapeError는 null이 아닌데 배열이 아닌 값을 오류로 보여 줍니다. 다만 useAxios의 초기 data와 성공 응답의 본문이 모두 null일 수 있으므로, 이 조건만으로는 HTTP 200과 JSON null을 빈 목록과 구별하지 못합니다. 뒤의 성공 응답 검증을 더하면 null까지 포함해 배열이 아닌 응답을 오류로 처리할 수 있습니다. 삭제는 이 예제에서 한 번에 하나만 실행하며, deletingId가 있는 동안 다른 삭제 버튼을 잠가 앞선 요청의 finally가 뒤 요청의 표시를 지우는 경쟁을 막습니다.
저장은 성공했는데 갱신이 실패하는 경우
refreshAfterMutation은 GET의 실패를 refreshError로 기록하고 안에서 받아 냅니다. 그래서 활성 페이지의 POST가 성공했다면 목록 갱신이 실패해도 handleAddPost는 true를 돌려주고, 폼은 비워지며 기존 목록은 남고, 사용자는 목록 조회만 다시 시도합니다. POST를 다시 보내면 같은 제목의 게시물이 하나 더 생길 수 있으므로, 이 상황을 저장 실패라고 안내하지 않는 것이 중요합니다.
이 구현은 부모의 상태가 다음 렌더에 반영되어 버튼을 잠그는 일반적인 사용자 클릭을 전제로 합니다. 같은 렌더의 콜백을 외부 코드가 동시에 여러 번 부르는 범용 변경 요청 큐나 서버의 중복 방지 키를 구현한 예제는 아닙니다. 조회 요청을 취소한다고 이미 끝난 POST나 DELETE를 서버에서 되돌리는(rollback) 것도 아닙니다.
성공 응답의 null과 초기 상태 구별하기
배열인지 검사하는 일을 HTTP 응답이 도착한 자리에서 하면, 초기 state를 성공 값으로 잘못 읽지 않습니다. Axios 게시판 조회·추가·삭제의 src/api/mockApi.js에서 인스턴스를 만든 다음, 기존의 export default mockApi 바로 앞에 아래 코드를 더하세요. 모듈이 초기화될 때 한 번만 등록되며, 컴포넌트의 렌더나 Effect마다 등록하지 않습니다. useAxios와 페이지 코드는 그대로 두고 검증 단계만 보완하는 별도의 예제입니다.
mockApi.interceptors.response.use((response) => {
const isPostsList =
response.config.method === 'get' && response.config.url === '/posts';
if (isPostsList && !Array.isArray(response.data)) {
const shapeError = new Error('GET /posts 응답이 배열이 아닙니다.');
shapeError.name = 'ResponseShapeError';
throw shapeError;
}
return response;
});이 조건은 이 실습에서 정확히 mockApi.get('/posts')로 부르는 목록 조회에만 적용되며, 상세 GET과 POST, DELETE의 응답은 검사하지 않습니다. 쿼리나 URL의 표현을 바꾸거나 다른 목록 API를 더하면 대상을 고르는 조건도 그 요청에 맞게 바꿔야 합니다.
아직 응답이 오지 않았다면 인터셉터는 실행되지 않으므로 초기의 null은 오류가 아닙니다. 성공 응답이 빈 배열이면 검사를 통과해 빈 목록으로 보이고, 200 응답의 본문이 null이나 객체라면 ResponseShapeError를 던져 useAxios의 catch가 error를 기록하며, 처음 목록에서는 error 분기가 빈 목록보다 먼저 선택됩니다. 이 오류는 HTTP 실패가 아니라 성공한 본문의 모양이 어긋난 것이므로 error.response.status가 있다고 가정하지 않으며, ErrorDisplay는 일반 Error의 message도 보여 줄 수 있습니다.
저장한 뒤의 재조회에서 모양 오류가 발견되어도 이미 끝난 POST나 DELETE를 되돌리지는 않으며, refreshAfterMutation은 이를 refreshError로 나누고 기존 목록을 유지합니다. 처음부터 잘못된 본문이 온 경우와, 기존 목록을 가진 채 갱신이 실패한 경우를 따로 확인하세요.
SinglePostPage.jsx
단일 게시물의 상세 페이지입니다. URL의 postId로 요청 주소를 만들어 같은 useAxios 훅에 넘기고, 화면 표시는 앞 글의 PostDetail에 맡깁니다. 이 페이지가 직접 처리하는 것은 404 응답 하나뿐입니다.
import { useParams } from 'react-router';
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;postId가 있을 때만 /posts/${postId}를 요청 key로 넘기고, 없으면 null을 넘겨 요청하지 않습니다. 서버가 404로 응답하면 error.response.status로 이를 알아보고, 일반 오류 화면 대신 “요청한 게시물을 찾을 수 없습니다.”를 보여 줍니다. 네트워크 장애 같은 다른 오류는 PostDetail의 오류 화면과 다시 시도 버튼으로 갑니다.
App.jsx (라우팅과 Axios의 최종 통합)
라우팅과 함께 모든 컴포넌트를 모읍니다. 라우트는 목록 /posts, 상세 /posts/:postId, 그 밖의 경로를 받는 *의 세 가지이고, 루트 /는 따로 화면을 두지 않고 목록으로 보냅니다. 찾을 수 없는 경로의 화면은 파일을 따로 만들지 않고 App.jsx 안의 작은 컴포넌트 DefaultNotFoundPage로 둡니다.
import { BrowserRouter, Routes, Route, Link, Navigate } from 'react-router';
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;/는 Navigate의 replace로 /posts에 보내므로 뒤로 가기에 루트가 남지 않고, 어떤 라우트에도 맞지 않는 경로는 path="*"의 DefaultNotFoundPage가 받습니다. 이 화면은 클라이언트의 대체 화면이며, 앞의 SinglePostPage가 처리하는 API 리소스의 404와는 다른 실패입니다.
구현을 마친 뒤에는 조회와 추가, 삭제를 요청과 화면 상태, 실패 복구의 기준으로 함께 점검해야 합니다. 아래 그림의 검증표에서 배열이 아닌 성공 응답의 검사는 앞에서 보완한 성공 응답 검증을 등록한 상태를 기준으로 합니다.
게시판 조회·추가·삭제 통합과 검증 순서 — 상태 추적
POST/DELETE pending은 조회와 독립이며 저장 성공 뒤 await refetch 성공/실패는 다른 결과다. 서버 변경/조회 data/화면 안내를 추적해 성공 저장을 재실행하지 않도록 보강할 가치가 크다.
React · C/R/D integration
조회·추가·삭제의 독립 상태와 검증 기준
조회 상태와 mutation 상태를 분리하고, mutation 성공 뒤 목록 refetch까지 기다려야 화면 계약이 닫힙니다. POST·DELETE 실패와 저장은 성공했지만 갱신이 실패한 경우를 같은 오류로 합치지 않습니다.
서버 변경 성공과 목록 갱신 성공은 다르다
조회·create·대상 ID delete의 pending은 독립입니다. 저장 성공 뒤 refresh 실패는 POST/DELETE 재실행 허가가 아니며 조회만 안전하게 retry합니다. 전체 검증 기준은 원표에 남깁니다.
원래 설명과 상세 기준
목록과 상세는 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 | 브라우저 새로고침 | 통과: 미충족: 메모리 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를 함께 기록합니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 조건·주석 | /posts → GET 목록 → Array.isArray로 확인하고 비배열 응답은 계약 오류로 처리 |
| 원도표 표 행 | 검증 | 조작 | 통과 기준과 실패 시 회귀 |
| 원도표 표 행 | Loading | 네트워크 지연 | 통과: 초기 요청과 mutation 진행이 독립적으로 보임; 미충족: 전체 화면 잠금 또는 중복 제출 |
| 원도표 표 행 | Error · retry | API 중단 후 복구 | 통과: 조회·refresh만 안전하게 retry하고 mutation 오류는 자동 재실행하지 않음; 미충족: 저장 성공을 실패로 오인하거나 mutation 중복 실행 |
| 원도표 표 행 | Empty | 빈 posts 배열 | 통과: 빈 배열만 empty, 비배열 성공 응답은 계약 error; 미충족: 깨진 응답을 빈 목록으로 오인 |
| 원도표 표 행 | 404 | 없는 상세 ID | 통과: not found와 네트워크 장애를 구분; 미충족: 빈 상세 또는 일반 오류로 합침 |
| 원도표 표 행 | Stale · cancel | ID를 빠르게 변경 | 통과: 이전 요청이 현재 상세를 덮어쓰지 않음; 미충족: URL과 다른 게시물 표시 |
| 원도표 표 행 | List · detail | 추가·삭제 뒤 이동 | 통과: 목록과 상세가 같은 서버 상태를 가리킴; 미충족: 한 화면만 오래된 data 유지 |
| 원도표 설명 | 통과: db.json의 추가·삭제 결과가 유지됨 |
| 원도표 표 행 | Persistence | 브라우저 새로고침 | 통과: db.json의 추가·삭제 결과가 유지됨; 미충족: 메모리 UI만 성공한 것으로 착각 |
| 원도표 표 행 | Seed reset | 초기 seed 복원 | 통과: 다음 실행이 같은 데이터에서 시작; 미충족: 반복 실행마다 결과 변화 |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| mutation 시작 | read: 별도 조회 상태 / mutation: 진행 중 | POST 또는 대상 ID DELETE의 독립 pending을 켠다 | 중복 제출과 다른 삭제의 허용 정책을 분리한다 |
| 서버 성공 | server: 변경 완료 / list: 이전 결과 가능 | 서버 변경 뒤 목록 refetch를 await한다 | 저장 성공이 새 목록의 반영 완료와 같지는 않다 |
| refresh 결과 | 저장: 성공 유지 / refresh: 성공/실패별 안내 | 갱신 성공은 현재 목록, 실패는 저장 성공과 별도 refresh 오류를 표시한다 | 갱신 실패를 POST나 DELETE의 재실행 허가로 해석하지 않는다 |
게시판 조회·추가·삭제 통합과 검증 순서 — 상태 추적
POST/DELETE pending은 조회와 독립이며 저장 성공 뒤 await refetch 성공/실패는 다른 결과다. 서버 변경/조회 data/화면 안내를 추적해 성공 저장을 재실행하지 않도록 보강할 가치가 크다.
React · C/R/D integration
조회·추가·삭제의 독립 상태와 검증 기준
조회 상태와 mutation 상태를 분리하고, mutation 성공 뒤 목록 refetch까지 기다려야 화면 계약이 닫힙니다. POST·DELETE 실패와 저장은 성공했지만 갱신이 실패한 경우를 같은 오류로 합치지 않습니다.
서버 변경 성공과 목록 갱신 성공은 다르다
조회·create·대상 ID delete의 pending은 독립입니다. 저장 성공 뒤 refresh 실패는 POST/DELETE 재실행 허가가 아니며 조회만 안전하게 retry합니다. 전체 검증 기준은 원표에 남깁니다.
원래 설명과 상세 기준
목록과 상세는 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 | 브라우저 새로고침 | 통과: 미충족: 메모리 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를 함께 기록합니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 조건·주석 | /posts → GET 목록 → Array.isArray로 확인하고 비배열 응답은 계약 오류로 처리 |
| 원도표 표 행 | 검증 | 조작 | 통과 기준과 실패 시 회귀 |
| 원도표 표 행 | Loading | 네트워크 지연 | 통과: 초기 요청과 mutation 진행이 독립적으로 보임; 미충족: 전체 화면 잠금 또는 중복 제출 |
| 원도표 표 행 | Error · retry | API 중단 후 복구 | 통과: 조회·refresh만 안전하게 retry하고 mutation 오류는 자동 재실행하지 않음; 미충족: 저장 성공을 실패로 오인하거나 mutation 중복 실행 |
| 원도표 표 행 | Empty | 빈 posts 배열 | 통과: 빈 배열만 empty, 비배열 성공 응답은 계약 error; 미충족: 깨진 응답을 빈 목록으로 오인 |
| 원도표 표 행 | 404 | 없는 상세 ID | 통과: not found와 네트워크 장애를 구분; 미충족: 빈 상세 또는 일반 오류로 합침 |
| 원도표 표 행 | Stale · cancel | ID를 빠르게 변경 | 통과: 이전 요청이 현재 상세를 덮어쓰지 않음; 미충족: URL과 다른 게시물 표시 |
| 원도표 표 행 | List · detail | 추가·삭제 뒤 이동 | 통과: 목록과 상세가 같은 서버 상태를 가리킴; 미충족: 한 화면만 오래된 data 유지 |
| 원도표 설명 | 통과: db.json의 추가·삭제 결과가 유지됨 |
| 원도표 표 행 | Persistence | 브라우저 새로고침 | 통과: db.json의 추가·삭제 결과가 유지됨; 미충족: 메모리 UI만 성공한 것으로 착각 |
| 원도표 표 행 | Seed reset | 초기 seed 복원 | 통과: 다음 실행이 같은 데이터에서 시작; 미충족: 반복 실행마다 결과 변화 |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| mutation 시작 | read: 별도 조회 상태 / mutation: 진행 중 | POST 또는 대상 ID DELETE의 독립 pending을 켠다 | 중복 제출과 다른 삭제의 허용 정책을 분리한다 |
| 서버 성공 | server: 변경 완료 / list: 이전 결과 가능 | 서버 변경 뒤 목록 refetch를 await한다 | 저장 성공이 새 목록의 반영 완료와 같지는 않다 |
| refresh 결과 | 저장: 성공 유지 / refresh: 성공/실패별 안내 | 갱신 성공은 현재 목록, 실패는 저장 성공과 별도 refresh 오류를 표시한다 | 갱신 실패를 POST나 DELETE의 재실행 허가로 해석하지 않는다 |
위 그림은 조회와 추가, 삭제를 하나로 엮은 구조와 그 검증 기준을 보여 줍니다. 그림의 요약처럼 조회의 상태와 변경 요청의 상태를 나누고, 변경이 성공한 뒤 목록의 재조회까지 기다려야 화면의 결과가 맞춰지며, POST나 DELETE의 실패와 저장은 성공했지만 갱신이 실패한 경우를 같은 오류로 합치지 않습니다. 위쪽의 흐름에서 POST나 DELETE는 별도의 진행 상태를 가지며, 실패하면 변경 오류가 되고, 성공하면 활성 페이지에서 재조회를 기다려 성공 시 새 목록을 반영하고 실패 시 “저장 성공과 갱신 오류”를 함께 남깁니다. 갱신의 실패는 변경 요청을 다시 보내도 된다는 뜻이 아니며 조회만 안전하게 다시 시도합니다.
그림 가운데의 네 칸은 영역마다 규칙을 정리합니다. 조회에서는 URL이 요청 key이며, /posts는 Array.isArray로 확인하고, /posts/:postId는 ID가 있을 때만 상세 GET을 보내 없는 게시물을 404 화면으로 구분하며, ID가 빠르게 바뀌면 이전 요청을 abort하고 최신 요청 ID만 상태를 씁니다. 추가는 폼 제출을 잠그고 활성 페이지에서만 POST 뒤의 재조회를 기다리며 POST 실패와 갱신 실패를 다른 안내로 기록하고, 삭제는 대상 ID를 진행 상태로 표시해 다른 삭제를 잠그고 새로고침으로 삭제가 남았는지 확인합니다. 마지막 칸은 이 실습이 라우트 컴포넌트가 요청 key를 만들고 커스텀 훅과 변경 처리 함수가 상태를 관리하는 수동 구조이며, React Router Data Router의 loader와 action을 쓰는 설계는 별도의 대안이라 두 방식을 섞지 않는다고 밝힙니다.
맨 아래의 표는 반드시 확인할 여덟 가지 회귀 경로입니다. 네트워크를 늦추면 초기 요청과 변경 진행이 따로 보여야 하고, API를 멈췄다 되살리면 조회와 갱신만 다시 시도하며, 빈 posts 배열만 빈 목록이고 배열이 아닌 성공 응답은 오류여야 합니다. 없는 상세 ID는 네트워크 장애와 구분되는 찾을 수 없음 화면이어야 하고, ID를 빠르게 바꿔도 이전 요청이 현재 상세를 덮어쓰지 않아야 하며, 추가와 삭제 뒤에는 목록과 상세가 같은 서버 상태를 가리켜야 합니다. 새로고침한 뒤에도 db.json의 추가와 삭제가 남아 있어야 하고, seed를 되돌리면 다음 실행이 같은 데이터에서 시작해야 합니다. 그림의 마지막 설명처럼 검증 순서는 목록 조회, 상세 조회, 추가, 삭제, 실패와 지연, 빠른 라우트 변경, 새로고침, seed 복원으로 정하고 단계마다 네트워크 응답과 화면의 state를 함께 기록합니다.
검증 순서와 확인 사항
실습 검증은 목록 조회, 상세 조회, 추가, 삭제, 실패 응답의 순서로 고정합니다. 이 순서로 진행하면 조회와 추가, 삭제의 경로마다 회귀를 단계적으로 확인할 수 있습니다.
먼저 Axios 게시판 조회·추가·삭제에서 Vite 프로젝트와 react-router, Axios, json-server, db.json, db.seed.json, api 스크립트를 준비했는지 확인합니다. 그 글의 구조대로 index.css, api/mockApi.js, hooks/useAxios.js, components와 pages 폴더의 모든 .jsx 파일, App.jsx까지 코드를 붙여 넣은 뒤, 터미널 A에서 npm run api, 터미널 B에서 npm run dev를 실행하고 목 API가 4000번 포트에서 응답하는지 확인합니다. 그다음 기능을 하나씩 시험합니다.
| 화면 | 조작 | 기대 결과 |
|---|---|---|
/ | 루트로 들어가기 | /posts로 이동 |
/posts | 처음 열기 | 로딩 스피너 뒤에 게시물 목록, 빈 배열이면 오류가 아닌 “게시물이 없습니다.” |
/posts | 배열이 아닌 200 응답 | 빈 목록이 아니라 응답 모양의 오류 |
/posts | 새 게시물 추가 | 재조회가 끝난 뒤에 새 항목이 나타남, 재조회만 실패하면 추가 오류가 아닌 목록 갱신 오류 |
/posts | 삭제 버튼 | 그 ID의 버튼만 “삭제 중...”, 끝나면 목록을 다시 조회, 새로고침해도 삭제가 남음 |
/posts/1 | 제목을 누르거나 주소를 입력 | 로딩 스피너 뒤에 상세 내용 |
/posts/99999 | 없는 ID로 들어가기 | “요청한 게시물을 찾을 수 없습니다.” |
개발자 도구의 Network 탭에서는 GET, POST, DELETE 요청이 올바르게 전송되는지 확인합니다. 네트워크 속도를 늦추고 API를 잠시 멈춰 초기 로딩과 오류, 다시 시도가 순서대로 동작하는지 보고, 상세 ID를 빠르게 바꾸거나 페이지를 떠났을 때 이전 요청이 취소되며 늦게 끝난 응답이 현재 화면을 덮어쓰지 않는지 확인합니다. 마지막으로 목록과 상세가 같은 서버 상태를 가리키는지 보고, 되풀이해 실습하기 전에는 보관한 seed로 db.json을 되돌립니다.
검증 기록 예시
정상적으로 시작하면 GET /posts가 한 건의 배열을 돌려주고, 추가에 성공한 뒤에는 POST 한 번과 두 건을 돌려주는 GET을, 새로 만든 게시물을 지운 뒤에는 DELETE 한 번과 한 건을 돌려주는 GET을 기대합니다. 단계마다 실제 HTTP 상태와 본문, 화면의 항목 수를 기록하세요. seed의 1번 게시물을 지웠다면 새로 만든 항목만 남으므로, 삭제한 뒤에 언제나 원래 게시물이 남는다고 외우지 않습니다. 없는 상세 ID는 서버의 404와 SinglePostPage의 전용 안내가 이어지는지 확인하며, SPA의 잘못된 화면 경로와 API 리소스의 404는 서로 다른 검사입니다.
이 실습에서는 비동기 요청을 위한 Axios 인스턴스를 설정하고, GET 조회에 집중한 useAxios 커스텀 훅을 설계해 구현했으며, 로딩과 오류, 데이터의 상태를 컴포넌트에 모아 사용자에게 보여 주고, 조회와 추가, 삭제를 GET, POST, DELETE 요청으로 처리했습니다. Axios와 useEffect, 커스텀 훅을 엮으면 데이터를 가져오는 로직을 컴포넌트 밖으로 떼어 내 재사용할 수 있습니다. 이 실습의 핵심은 요청 함수를 외우는 것이 아니라, 조회와 상세, 추가, 삭제, 실패 응답을 같은 순서로 되풀이해 점검하는 습관이며, 화면이 보이는지만이 아니라 로딩과 오류, 다시 시도, 목록 갱신까지 함께 확인해야 외부 API 연동의 회귀를 줄일 수 있습니다.
정리
- 조회의 상태와 추가·삭제의 상태는 따로 관리하며, 변경 요청이 성공한 뒤에는 활성 페이지에서만 목록을 다시 조회합니다.
- 저장은 성공했지만 재조회가 실패하면 저장 실패가 아닌 갱신 오류로 안내하고, 변경 요청이 아닌 조회만 다시 시도합니다.
- 성공 응답의 모양은 응답이 도착한 자리인 인터셉터에서 검사해야 초기의
null과 성공한null을 구별할 수 있습니다. - 상세 페이지는 서버의 404를
error.response.status로 알아보고, 클라이언트의path="*"대체 화면과 구분합니다. - 검증은 목록, 상세, 추가, 삭제, 실패와 지연, 빠른 라우트 변경, 새로고침, seed 복원의 순서로 고정합니다.
연습 문제
1. 성공 응답의 세 가지 본문
처음 조회이고 HTTP 상태는 모두 200이라고 가정합니다. 성공 응답 검증을 더한 뒤 본문이 빈 배열, null, 게시물 한 건의 배열일 때 각각 빈 목록과 오류, 목록 가운데 무엇이 선택될까요? 요청하기 전의 초기 null은 언제 검사될까요?
해설 보기
빈 배열은 배열 검사를 통과해 빈 목록, null은 응답이 도착한 뒤 모양 오류, 한 건의 배열은 목록입니다. 초기의 null은 아직 응답이 도착하지 않았으므로 검사되지 않습니다.
검증을 더하기 전에는 responseShapeError가 null을 빼고 검사하므로, 200 응답의 null이 초기 상태와 구별되지 않아 빈 목록으로 보입니다. 인터셉터를 등록하면 이 경우가 오류로 바뀝니다.
2. 저장 성공과 갱신 실패
seed에 게시물이 한 건 있습니다. 새 게시물의 POST는 서버에 반영되었고 이어지는 GET은 네트워크 오류로 실패했으며, 페이지는 계속 활성 상태이고 아직 다시 시도하지 않았습니다. 서버의 게시물 수, 화면의 목록 수, 폼의 입력, createError와 refreshError, 그리고 올바른 다음 요청을 적어 보세요.
해설 보기
서버에는 두 건이 있고 화면은 이전 GET의 결과인 한 건을 유지합니다. POST 뒤의 GET 실패는 refreshAfterMutation이 처리하므로 createError는 null이고 refreshError가 기록되며, 활성 페이지에서 handleAddPost가 true를 돌려주므로 폼의 입력은 비워집니다.
다음 행동은 목록 조회의 다시 시도이며, POST를 다시 보내면 이미 저장한 게시물이 하나 더 생길 수 있습니다. 다시 시도한 GET이 두 건을 받으면 화면도 두 건으로 맞춰집니다. 이 수는 요청 결과가 가정대로 정해진 경우이며, 응답을 받지 못한 모든 요청을 서버 저장 성공으로 단정하는 규칙은 아닙니다.
3. 404와 네트워크 장애
/posts/99999로 들어갔을 때와, 목 API를 끈 채 /posts/1로 들어갔을 때 SinglePostPage는 각각 무엇을 보여 줄까요?
해설 보기
/posts/99999는 서버가 404로 응답하므로 error.response.status === 404가 참이 되어 “요청한 게시물을 찾을 수 없습니다.”만 보입니다. 이 화면에는 다시 시도 버튼이 없습니다.
목 API를 끄면 응답이 없으므로 error.response가 없고, PostDetail이 받은 post도 없으므로 ErrorDisplay가 “오류 메시지: Network Error”와 다시 시도 버튼을 보여 줍니다. 서버를 다시 켜고 버튼을 누르면 상세 내용이 나타납니다.
Axios의 인터셉터는 Axios 문서의 인터셉터에서 더 자세히 확인할 수 있습니다.
이것으로 8장을 마칩니다. 8장에서는 자바스크립트의 비동기 처리에서 시작해, useEffect로 데이터를 가져오고 로딩과 오류를 처리하는 방법, Axios로 요청을 보내고 인스턴스로 공통 설정을 모으는 방법을 익히고, 이를 모아 게시판의 조회와 추가, 삭제를 구현했습니다. 다음 장의 제어 컴포넌트와 비제어 컴포넌트부터는 폼을 다루며, 입력 값을 React 상태에 연결하는 방식과 DOM ref로 읽는 방식을 비교하는 것으로 시작합니다.