본문으로 건너뛰기

안동민 개발노트

본문 시작

데이터 연동 페이지와 검증

데이터 연동 페이지와 라우터를 조합하고 조회·추가·삭제의 실패·지연·재시도를 검증합니다.

데이터 연동 페이지 구성

게시판의 목록·상세 조회와 추가·삭제는 같은 Axios 인스턴스를 사용하지만, 조회 상태와 mutation 상태는 서로 독립적으로 관리합니다.

PostsPage.jsx

게시물 목록과 게시물 추가 폼을 함께 표시합니다.

src/pages/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

단일 게시물 상세 페이지입니다.

src/pages/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 최종 통합)

라우팅과 함께 모든 컴포넌트를 통합합니다.

src/App.jsx
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;

구현이 끝난 뒤에는 조회·추가·삭제 동작을 요청, 화면 상태, 실패 복구 기준으로 함께 검증해야 합니다.

BrowserRouter의 목록과 상세 route가 조회 훅을 사용하고 추가와 삭제는 독립 pending과 error를 관리한 뒤 성공 시 목록 refetch를 기다리는 C/R/D 통합 구조와 loading, error, empty, retry, 404, stale 응답, 목록·상세 일관성, 서버 persistence 검증표

React · C/R/D integration

조회 상태와 mutation 상태를 분리하고, mutation 성공 뒤 목록 refetch까지 기다려야 화면 계약이 닫힙니다. POST·DELETE 실패와 저장은 성공했지만 갱신이 실패한 경우를 같은 오류로 합치지 않습니다.

Read · route driven

목록과 상세는 URL이 request key다

  • /posts → GET 목록 → Array.isArray로 확인하고 비배열 응답은 계약 오류로 처리
  • /posts/:postId → 문자열 ID 또는 undefined → 유효할 때만 상세 GET
  • 목록은 loading, error, empty, retry를 구분하고 상세는 not found를 404 화면으로 구분
  • ID가 빠르게 바뀌면 이전 요청을 abort하고 최신 request ID만 화면 상태를 commit
Create · independent state

POST pending과 error는 조회와 분리한다

  1. 폼 제출을 잠그고 create error를 비운다

  2. 활성 페이지에서만 POST 뒤 목록 refetch를 await한다

  3. POST 실패와 refresh 실패를 서로 다른 안내로 기록한다

Delete · keyed state

DELETE pending은 대상 ID를 가진다

  1. 삭제할 ID를 pending으로 표시하고 다른 삭제 실행을 잠근다

  2. 활성 페이지에서만 DELETE 뒤 목록 refetch를 await한다

  3. 로컬 서버의 삭제 persistence를 새로고침으로 확인한다

Manual architecture

BrowserRouter와 Axios의 책임을 코드에서 잇는다

이 실습은 route component가 request key를 만들고 custom hook과 mutation handler가 상태를 관리하는 수동 구조입니다. React Router Data Router의 loader·action을 쓰는 설계도 가능하지만 여기서는 별도 대안이며 두 모델을 섞지 않습니다.

구현 뒤 반드시 확인할 회귀 경로
검증 조작 통과 기준 놓치면 생기는 회귀
Loading네트워크 지연초기 요청과 mutation 진행이 독립적으로 보임전체 화면 잠금 또는 중복 제출
Error · retryAPI 중단 후 복구조회·refresh만 안전하게 retry하고 mutation 오류는 자동 재실행하지 않음저장 성공을 실패로 오인하거나 mutation 중복 실행
Emptyposts 배열빈 배열만 empty, 비배열 성공 응답은 계약 error깨진 응답을 빈 목록으로 오인
404없는 상세 IDnot found와 네트워크 장애를 구분빈 상세 또는 일반 오류로 합침
Stale · cancelID를 빠르게 변경이전 요청이 현재 상세를 덮어쓰지 않음URL과 다른 게시물 표시
List · detail추가·삭제 뒤 이동목록과 상세가 같은 서버 상태를 가리킴한 화면만 오래된 data 유지
Persistence브라우저 새로고침db.json의 추가·삭제 결과가 유지됨메모리 UI만 성공한 것으로 착각
Seed reset초기 seed 복원다음 실행이 같은 데이터에서 시작반복 실행마다 결과 변화
Loading

지연 중 상태를 분리한다

초기 조회, create, 대상 ID의 delete가 서로 다른 진행 상태로 보이는지 확인합니다.

Error · retry

실패 위치를 보존한다

조회·refresh만 안전하게 retry하며 mutation 오류는 구분해 표시하고 자동 재실행하지 않습니다.

Empty · 404

값 없음의 의미를 나눈다

빈 배열만 success empty이며 비배열 응답은 계약 error, 없는 상세는 404로 렌더링합니다.

Stale · cancel

빠른 route 변경을 시험한다

이전 요청을 abort하고 늦은 응답이 현재 URL의 상세를 덮어쓰지 않는지 확인합니다.

List · detail

화면 간 서버 상태를 맞춘다

추가·삭제 뒤 awaited refetch를 거쳐 목록과 상세가 같은 데이터를 가리키는지 확인합니다.

Persistence · reset

새로고침과 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)로 접근하여 에러 메시지가 잘 표시되는지 확인합니다.
  • 로딩·에러·재시도: 네트워크 속도를 늦추고 API를 잠시 중단해 초기 loading, error, retry가 순서대로 동작하는지 확인합니다.
  • 경합과 취소: 상세 ID를 빠르게 바꾸거나 페이지를 떠났을 때 이전 요청이 취소되고, 늦게 끝난 응답이 현재 화면을 덮어쓰지 않는지 확인합니다.
  • 일관성과 초기화: 목록과 상세가 같은 서버 상태를 가리키는지 확인하고, 반복 실습 전에는 보관한 seed로 db.json을 복원합니다.

Axios, useEffect, 커스텀 훅을 조합하면 데이터 페칭 로직을 컴포넌트 밖으로 분리하고 재사용할 수 있습니다.

이 실습에서는 다음 개발 패턴을 확인했습니다.

  • 비동기 요청을 위한 Axios 인스턴스 설정
  • GET 조회에 집중한 useAxios 커스텀 훅의 설계 및 구현
  • 로딩, 에러, 데이터 상태를 컴포넌트에 통합하여 사용자에게 시각적인 피드백 제공
  • C/R/D 작업을 위한 GET, POST, DELETE 요청 처리

이 실습의 핵심은 요청 함수를 외우는 것이 아니라, 조회·상세·추가·삭제·실패 응답을 같은 순서로 반복 검증하는 습관입니다.

화면이 보이는지만 확인하지 말고 로딩, 에러, 재시도, 목록 갱신까지 함께 확인해야 외부 API 연동의 회귀를 줄일 수 있습니다.