React/타입스크립트로 배우는 리액트(React.js) : 기초부터 최신 기술까지

섹션 16. 데이터 통신

favor 2026. 6. 11. 18:38

데이터 패칭 기초 개념

데이터 통신 : 컴포넌트가 서버에 외부 데이터를 요청하고, 그 응답을 받아 상태로 반영하여 UI를 업데이트하는 과정

 

API(Application Programming Interface) : 클라이언트가 서버와 통신하기 위한 통신 규칙

  • REST API
  • GraphQL

 

REST API : 주소로서 데이터를 구분하는 API 설계 방식

ex) https://api.example.com/user/1?lang=ko

- 전체적인 주소 : URL

- https:// : 프로토콜

- api.example.com : 도메인

- user, 1 : path, 세그먼트

- lang=ko : Query String

 

HTTP(S) 통신 : 내부적으로 메소드를 활용하여 데이터 통신의 역할을 구분 

  • GET
    • 데이터 요청
  • POST
    • 데이터 추가
  • PUT/PATCH
    • 데이터 수정
  • DELETE
    • 데이터 삭제

 

json-server

json-server : JSON 파일만 있으면 바로 실행해볼 수 있는 가짜 REST API 서버 도구

 

npm i json-server@0.17.3 명령어를 사용하여 다운로드

npm run server 명령어를 사용하여 서버 구동

 

thunder client

thunder client Extension : Visual Studio Code에서 API를 호출하고 그 결과를 눈으로 볼 수 있게 해주는 도구

 

PUT, PATCH의 차이점

  • PUT
    • 전체 데이터를 변경할 때
  • PATCH
    • 데이터의 일부를 변경할 때

 

* 그러나 실무에서 둘을 구분하지 않는 경우가 많음

 

json-server 추가 기능

  1. 페이징(Pagination) 기능
    • _page_limit 쿼리 스트링을 사용하여 대량의 데이터를 쪼개서 요청 가능
    • 기본 페이징 (1페이지 당 기본 10개 출력)
      • GET) http://localhost:3000/posts?_page=1
      • --> 1~10번까지의 데이터 10개 출력 
      • GET) http://localhost:3000/posts?_page=2
      • --> 11~20번까지의 데이터 10개 출력
    • 페이지 당 개수 제한(limit) 설정
      • GET) http://localhost:3000/posts?_page=1&_limit=5
      • --> 1페이지에 해당하는 데이터 5개 출력
  2. 전체 텍스트 검색(Full-text Search) 기능
    • q 파라미터를 사용하면 데이터 객체 내의 모든 필드(id, title, content 등)를 대상으로 키워드 포함 여부를 검사
    • 기본 검색
      • GET) http://localhost:3000/posts?q=Post #22
      • --> 데이터 중 어느 필드에든 "Post #22"라는 문자열이 포함된 데이터를 모두 찾아 출력
  3. 응답 지연(Delay) 시뮬레이션 기능
    • 느린 네트워크 환경에서 로딩 상태나 비동기 처리가 잘 작동하는지 테스트하기 위해 응답을 의도적으로 지연시키는 기능
    • package.json 스크립트 등록 
      • "scripts" : { "delay" : "npx json-server server/db.json --delay 2000" }
      • --> 터미널에 npm run delay 명령어를 입력했을 때, 모든 API 요청이 내가 지정한 시간(2초)만큼 지연된 후에 응답함

 

fetch - 1 (basic)

리액트에서 데이터 통신은 사이드 이펙트로 취급 

--> 사이드 이펙트를 처리할 때 사용하는 useEffect 훅 이용 

 

// App.tsx

import Fetch from "./components/Fetch";

export default function App() {
  return (
    <>
      <Fetch />
    </>
  );
}
// src/components/Fetch.tsx

import { useEffect, useState } from "react";

interface Posts { // 서버에서 받아올 데이터의 타입 정의
  id: number;
  title: string;
  views: number;
}

export default function Fetch() {

  const [posts, setPosts] = useState<Posts[]>([]); // 서버에서 가져온 데이터들을 저장할 변수 선언 

  // useEffect [] 빈 배열 --> 컴포넌트가 처음 켜질 때 딱 한 번만 실행 
  useEffect(() => {
    fetch("http://localhost:3000/posts").then((response) => {
      console.log(response);
      return response.json();
    }).then((data) => setPosts(data));
  }, []);

  return (
    <>
      <h3>Fetch</h3>
      <ul>
        {posts.map((post) => <li key={post.id}>{post.title}</li>)}
      </ul>
    </>
  );
}

 

useEffect에서 의존성 배열의 3가지 상태와 타이밍

  • 배열이 비어있을 때
    • useEffect(() => {}, []);
    • 컴포넌트가 브라우저 화면에 최초로 딱 한 번 등장했을 때만 본문 함수를 실행
    • 다른 상태가 바뀌어서 컴포넌트가 리렌더링되어도 재실행 X
    • 초기 화면을 구성하기 위한 서버 데이터 최초 요청을 위해 쓰임 
  • 배열을 아예 적지 않았을 때
    • useEffect(() => {});
    • 컴포넌트가 처음 켜질 때를 비롯하여 컴포넌트 내부의 상태가 하나라도 바뀌어서 리렌더링 될 때마다 본문 함수 재실행
  • 배열 안에 특정 상태값을 넣었을 때 
    • useEffect(() => {}, [keyword]);
    • 컴포넌트가 처음 켜질 때 실행되고, 이후에 keyword라는 변수의 값이 바뀔 때마다 본문 함수를 실행 
    • 사용자가 검색창에 타이핑을 할 때마다 실시간으로 서버에서 다른 데이터를 검색해서 가져와야 할 때 쓰임 

 

.then() : 기다렸다가 서버 응답을 받은 후에 순서대로 코드를 처리할 수 있도록 하는 비동기 안전장치 

ㄴ 자바스크립트는 서버에서 데이터를 받아오는 속도를 기다려주지 않코 다음 밑줄의 코드를 그냥 실행해버리는 성질을 가짐

--> 만약 .then()이 없다면 데이터를 다 받지도 않았는데 화면을 그리려 해서 undefined 에러가 발생

 

fetch - 2 (loading)

새로고침을 하면 렌더링된 데이터가 깜빡거림 

∵ 컴포넌트가 화면에 그려진 이후에 useEffect가 실행돼서 fetch API조차 비동기로 동작하기 때문

--> isLoading 상태를 정의하여 관리 

 

// src/components/Fetch.tsx

import { useEffect, useState } from "react";

interface Posts { 
  id: number;
  title: string;
  views: number;
}

export default function Fetch() {

  const [posts, setPosts] = useState<Posts[]>([]); 
  const [isLoading, setIsLoading] = useState(false); // 로딩 중일 때를 관리하기 위한 상태 정의

  useEffect(() => {
    setIsLoading(true); // 바꿔주기
    fetch("http://localhost:3000/posts").then((response) => {
      console.log(response);
      return response.json();
    }).then((data) => setPosts(data))
    .finally(() => { // 바꿔주기
      setIsLoading(false);
    });
  }, []);

  if (isLoading) return <p>Loading...</p>;

  return (
    <>
      <h3>Fetch</h3>
      <ul>
        {posts.map((post) => <li key={post.id}>{post.title}</li>)}
      </ul>
    </>
  );
}

 

fetch - 3 (error)

데이터 통신 에러

  • 404 Error
    • 서버 응답 에러
    • 브라우저가 보낸 요청이 물리적으로 서버 컴퓨터에 완벽하게 도착했고, 서버가 Response를 돌려준 상황
    • ㄴ 서버의 응답 : URL에 해당하는 데이터나 페이지가 내 컴퓨터에 존재하지 않음 
    • ex) API 주소에 오타가 났을 때, 서버 데이터베이스에서 삭제된 데이터를 조회하려고 할 때
  • (failed) network 에러
    • 네트워크 연결 에러
    • HTTP 통신 단계까지 도달하지 못함 
    • 브라우저가 서버 IP 주소를 못 찾거나, 인터넷 선이 끊겨있어 아예 연결을 맺지 못함
    • ex) 로컬에서 server를 안 켜놓고 프론트엔드에서 fetch를 날렸을 때, CORS 에러가 발생해 브라우저가 강제로 요청을 차단했을 때 

 

// src/components/Fetch.tsx

import { useEffect, useState } from "react";

interface Posts {
  id: number;
  title: string;
  views: number;
}

export default function Fetch() {
  const [posts, setPosts] = useState<Posts[]>([]);
  const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    setIsLoading(true);
    fetch("http://localhost:3000/posts")
      .then((response) => {
        if (!response.ok) throw new Error("네트워크 통신 오류");
        console.log(response);
        return response.json();
      })
      .then((data) => setPosts(data))
      .catch((e) => {
        console.log(e);
      })
      .finally(() => {
        setIsLoading(false);
      });
  }, []);

  if (isLoading) return <p>Loading...</p>;

  return (
    <>
      <h3>Fetch</h3>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </>
  );
}

 

상황 A: 정상 작동

  1. setIsLoading(true) --> 화면에 Loading... 출력
  2. fetch 성공 --> response.ok가 true이므로 if문 통과
  3. .json() 포장 뜯기 성공 --> setPosts(data)로 데이터 저장 
  4. .finally() 발동 --> setIsLoading(false)
  5. 화면에 정상적인 포스트 리스트 출력

상황 B: API 주소가 오타났을 때 (404 에러)

  1. setIsLoading(true) --> 화면에 Loading... 출력
  2. 서버 컴퓨터엔 갔는데 주소가 존재 X --> response.ok가 false가 됨 
  3. throw new Error(...) 실행 --> 강제 에러 
  4. .then()은 패스, .catch()로 이동
  5. .catch((e) => { ... }) 내부가 실행되면서 콘솔창에 에러 기록
  6. .finally()는 무조건 실행되므로 setIsLoading(false)
  7. 화면 로딩은 멈추고 빈 리스트 출력

상황 C: json-server를 아예 안 켰을 때 (failed Network 에러)

  1. setIsLoading(true) --> 화면에 Loading... 출력
  2. 서버 컴퓨터를 못 찾아서 fetch가 시작하자마자 에러 
  3. .then()은 패스, .catch()로 이동
  4. .catch((e) => { ... }) 내부가 실행되면서 콘솔창에 에러 기록
  5. .finally()는 무조건 실행되므로 setIsLoading(false)

--> error 상태를 정의하여 관리 

 

// src/components/Fetch.tsx

import { useEffect, useState } from "react";

interface Posts {
  id: number;
  title: string;
  views: number;
}

export default function Fetch() {
  const [posts, setPosts] = useState<Posts[]>([]);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState("");

  useEffect(() => {
    setIsLoading(true);
    setError(""); // 빈 문자열
    fetch("http://localhost:3000/posts")
      .then((response) => {
        if (!response.ok) throw new Error("네트워크 통신 오류");
        console.log(response);
        return response.json();
      })
      .then((data) => setPosts(data))
      .catch((e) => { // 에러가 발생했을 때 
        console.log(e);
        setError(e instanceof Error ? e.message : "unknown error"); 
      })
      .finally(() => {
        setIsLoading(false);
      });
  }, []);

  if (isLoading) return <p>Loading...</p>;

  if (error) return <p>Error: {error}</p> // 에러 출력

  return (
    <>
      <h3>Fetch</h3>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </>
  );
}

 

* 리액트에서 데이터 통신을 할 때는 최소 3개의 상태가 필요

  • 실제 요청한 데이터를 저장할 상태
  • 로딩을 제어할 상태
  • 에러를 제어할 상태 

 

fetch - 4 (signal)

AbortController : 웹 브라우저가 제공하는 내장 API로, 하나 이상의 웹 요청을 원하는 시점에 강제로 중단(Abort)할 수 있는 역할

Signal : controller와 fetch 요청을 연결해줌 

 

작동 메커니즘 

  1. new AbortController()를 통해 인스턴스 객체 생성 (signal 세트)
  2. fetch() 매개변수에 signal을 넘겨주어, 해당 요청이 controller의 명령을 받도록 연결
  3. 원하는 시점에 controller.abort() 메소드를 호출하면, 연결되어 있던 fetch 요청이 즉시 취소됨

 

// src/components/Fetch.tsx

import { useEffect, useState } from "react";

interface Posts {
  id: number;
  title: string;
  views: number;
}

export default function Fetch() {
  const [posts, setPosts] = useState<Posts[]>([]);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState("");

  useEffect(() => {

    const controller = new AbortController(); // Abort 객체 

    setIsLoading(true);
    setError(""); 
    fetch("http://localhost:3000/posts", {signal: controller.signal}) // signal 넘겨주기
      .then((response) => {
        if (!response.ok) throw new Error("네트워크 통신 오류");
        console.log(response);
        return response.json();
      })
      .then((data) => setPosts(data))
      .catch((e) => { 
        console.log(e);
        if (e instanceof Error && e.name !== "AbortError") setError(e.message);
      })
      .finally(() => {
        if (!controller.signal.aborted) setIsLoading(false);
      });

      return () => controller.abort(); // 취소

  }, []);

  if (isLoading) return <p>Loading...</p>;

  if (error) return <p>Error: {error}</p> 

  return (
    <>
      <h3>Fetch</h3>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </>
  );
}

 

fetch - 5 (async)

useEffect 훅 자체를 async로 지정 불가능

--> use Effect 내부에서 async 함수를 새롭게 정의한 뒤 바로 실행시키기 가능 

 

async/await 방식

  • 동기식 코드
  • try-catch 문 사용

 

// src/components/Fetch.tsx

import { useEffect, useState } from "react";
interface Posts {
  id: number;
  title: string;
  views: number;
}
export default function Fetch() {
  const [posts, setPosts] = useState<Posts[]>([]);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState("");

  useEffect(() => {
    const controller = new AbortController();

    const fetchPosts = async () => {
      // 데이터 통신이 일어나기 전에 실행돼야 함 
      setIsLoading(true);
      setError("");

      // try-catch 문
      try {
        const response = await fetch("http://localhost:3000/posts", {
          signal: controller.signal,
        });
        if (!response.ok) throw new Error("네트워크 통신 오류");
        const data = await response.json();
        setPosts(data);
      } catch (e) {
        if (e instanceof Error && e.name !== "AbortError") setError(e.message);
      } finally {
        if (!controller.signal.aborted) setIsLoading(false);
      }
    };

    fetchPosts();

    return () => controller.abort();
  }, []);

  if (error) return <p>Error: {error}</p>;

  return (
    <>
      <h3>Fetch</h3>
      <ul>
        {isLoading ? (
          <p>Loading... </p>
        ) : (
          posts.map((post) => <li key={post.id}>{post.title}</li>)
        )}
      </ul>
    </>
  );
}

 

fetch - crud 

POST/PUT/PATCH처럼 가공할 데이터가 있을 때에는 headers, body 필요

  • headers
    • body 안에 보낼 데이터의 형식을 알림
    • "Content-Type": "application/json" : JSON 텍스트 양식임을 명시
  • body
    • 자바스크립트 객체를 서버가 읽을 수 있는 문자열, JSON으로 변환해서 주입 
    • ㄴ JSON.stringify({ ... })
// src/components/FetchCrud.tsx

export default function FetchCrud () {

  const fetchGet = async() => {
    const response = await fetch("http://localhost:3000/posts");
    const data = await response.json();
    console.log(data);
  };

  const fetchPost = async() => {
    const response = await fetch("http://localhost:3000/posts", {
      method: "POST",
      headers: {
        "Content-Type": "application/json"
      },
      body: JSON.stringify({
        title: "a new title",
        views: 155
      }),
    });

    const data = await response.json();

    console.log(data);
  };

  const fetchPut = async() => {
    const response = await fetch("http://localhost:3000/posts/1", {
      method: "PUT",
      headers: {
        "Content-Type": "application/json"
      },
      body: JSON.stringify({
        title: "a modify title",
        views: 155
      }),
    });

    const data = await response.json();

    console.log(data);
  };

  const fetchPatch = async() => {
    const response = await fetch("http://localhost:3000/posts/1", {
      method: "PATCH",
      headers: {
        "Content-Type": "application/json"
      },
      body: JSON.stringify({
        title: "a patch title",
        views: 155
      }),
    });

    const data = await response.json();

    console.log(data);
  };

  const fetchDelete = async() => {
    // 돌려받을 내용물이 없기 때문에 const data = await response.json() 생략
   
    await fetch("http://localhost:3000/posts/101", {
      method: "DELETE"
    });
  };

  return (
    <>
      <button onClick={fetchGet}>GET</button>
      <button onClick={fetchPost}>POST</button>
      <button onClick={fetchPut}>PUT</button>
      <button onClick={fetchPatch}>PATCH</button>
      <button onClick={fetchDelete}>DELETE</button>
    </>
  );
}

 

fetch - axios

npm install axios 명령어를 사용하여 다운로드

 

Fetch 대비 장점

  • 자동 변환 
    • 응답 스트림을 객체로 변환하는 .json() 과정이 생략
    • 서버가 돌려준 데이터는 항상 response.data 안에 객체 형태로 자동 바인딩
  • 자동 직렬화
    • POST/PUT 요청 시 JSON.stringify() 가공이나 헤더(Content-Type) 지정을 생략하고, 순수 자바스크립트 객체를 바디에 곧바로 담아 전송 가능
  • 직관적인 에러 핸들링
    • fetch와 달리 404, 500 발생 시 별도의 if (!response.ok) 검증 없이도 자동으로 catch 구역으로 예외를 던져줌

 

// src/components/AxiosCrud.tsx

import axios from "axios";

export default function AxiosCrud() {
  const fetchGet = async () => {
    const { data } = await axios.get("http://localhost:3000/posts");
    console.log(data);
  };
  
  const fetchPost = async () => {
    const { data } = await axios.post("http://localhost:3000/posts", {
      title: "a axios data",
      views: 50,
    });
    console.log(data);
  };
  
  const fetchPut = async () => {
    const { data } = await axios.put("http://localhost:3000/posts/101", {
      title: "a axios modify data",
      view: 500,
    });
    console.log(data);
  };
  
  const fetchPatch = async () => {
    const { data } = await axios.patch("http://localhost:3000/posts/101", {
      title: "a axios modify data",
      view: 500,
    });
    console.log(data);
  };
  
  const fetchDelete = async () => {
    const { data, status } = await axios.delete(
      "http://localhost:3000/posts/101",
    );
    console.log(data, status);
  };
  
  return (
    <>
      <button onClick={fetchGet}>GET</button>
      <button onClick={fetchPost}>POST</button>
      <button onClick={fetchPut}>PUT</button>
      <button onClick={fetchPatch}>PATCH</button>
      <button onClick={fetchDelete}>DELETE</button>
    </>
  );
}

 

axios - total

// src/components/Axios.tsx

import { useEffect, useState } from "react";
import axios from "axios";

interface Posts {
  id: number;
  title: string;
  views: number;
}
export default function Axios() {
  const [posts, setPosts] = useState<Posts[]>([]);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState("");

  useEffect(() => {
    const controller = new AbortController();
    const axiosPosts = async () => {
      setIsLoading(true);
      setError("");
      try {
        const { data } = await axios.get("http://localhost:3000/posts", {
          signal: controller.signal,
        });
        setPosts(data);

      } catch (e) {
        if (e instanceof Error && e.name !== "CanceledError") setError(e.message);
      } finally {
        if (!controller.signal.aborted) setIsLoading(false);
      }
    };

    axiosPosts();

    return () => controller.abort();
  }, []);

  if (error) return <p>Error: {error}</p>;

  return (
    <>
      <h3>Axios</h3>
      <ul>
        {isLoading ? (
          <p>Loading... </p>
        ) : (
          posts.map((post) => <li key={post.id}>{post.title}</li>)
        )}
      </ul>
    </>
  );
}

 

 

axios - instance

axios.create() 메소드를 활용하여 인스턴스 객체를 간편하게 생성 가능 

ㄴ 공통으로 사용할 baseURL, timeout, headers 등의 설정을 미리 주입

 

// src/api/axios.ts

import axios from "axios";

// axios 인스턴스 객체 생성
export const axiosInstance = axios.create({
  baseURL: "http://localhost:3000",
  timeout: 5000, // 5s
  headers: { // 생략 가능
    "Content-Type": "application/json",
  },
});
// src/components/Axios.tsx

import { useEffect, useState } from "react";
import { axiosInstance } from "../api/axios";

interface Posts {
  id: number;
  title: string;
  views: number;
}
export default function Axios() {
  const [posts, setPosts] = useState<Posts[]>([]);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState("");

  useEffect(() => {
    const controller = new AbortController();
    const axiosPosts = async () => {
      setIsLoading(true);
      setError("");
      try {
        // axios --> axiosInstance
        const { data } = await axiosInstance.get("/posts", {
          signal: controller.signal,
        });
        setPosts(data);

      } catch (e) {
        if (e instanceof Error && e.name !== "CanceledError") setError(e.message);
      } finally {
        if (!controller.signal.aborted) setIsLoading(false);
      }
    };

    axiosPosts();

    return () => controller.abort();
  }, []);

  if (error) return <p>Error: {error}</p>;

  return (
    <>
      <h3>Axios</h3>
      <ul>
        {isLoading ? (
          <p>Loading... </p>
        ) : (
          posts.map((post) => <li key={post.id}>{post.title}</li>)
        )}
      </ul>
    </>
  );
}

 

Post Browser 소개

스켈레톤 UI : 데이터가 로딩 중일 때, 멈춰있는 듯한 회전 스피너 대신 실제 콘텐츠가 그려질 레이아웃의 뼈대를 회색 음영이나 애니메이션으로 먼저 보여주는 패턴 

 

Post Browser 데이터 렌더링

axios 인스턴스 객체 생성

// src/api/axios.ts

import axios from "axios";

export const axiosInstance = axios.create({
  baseURL: "http://localhost:3000",
  timeout: 5000,
  headers: {
    "Content-Type": "application/json"
  }
});

 

Posts 타입 정의

// src/types/post.d.ts

interface Posts {
  id: number;
  title: string;
  views: number;
}

 

PostList.tsx 수정 - 데이터가 있을 때 

// src/components/PostList.tsx

import { useEffect, useState } from "react";
import PostCard from "./PostCard";
import { axiosInstance } from "../api/axios";
import LoadingState from "./LoadingState";
import ErrorState from "./ErrorState";

export default function PostList() {
  const [posts, setPosts] = useState<Posts[]>([]);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState("");

  useEffect(() => {
    const fetchPosts = async () => {
      try {
        setIsLoading(true);
        const { data } = await axiosInstance.get("/posts");
        setPosts(data);
      } catch (e) {
        setError(e instanceof Error ? e.message : "unknown error");
      } finally {
        setIsLoading(false);
      }
    };
    fetchPosts();
  }, []);

  return (
    <div className="mb-8">
      {/* 데이터가 없을 때 */}
      {/* <NoData /> */}
      {/* 로딩 중일 때 */}
      {/* <LoadingState /> */}
      {/* 에러가 발생했을 때 */}
      {/* <ErrorState /> */}

      {/* 데이터가 있을 때 */}
      {isLoading ? (
        <LoadingState />
      ) : error ? (
        <ErrorState />
      ) : (
        <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
          {posts.map((post) => <PostCard key={post.id} {...post} />)}
        </div>
      )}
    </div>
  );
}

 

PostCard.tsx 수정

// src/components/PostCard.tsx

import { Eye } from "lucide-react";

export default function PostCard({id, title, views}: Posts) {
  return (
    <div className="bg-white rounded-lg shadow-sm hover:shadow-md transition-shadow duration-200 overflow-hidden border border-gray-100">
      <div className="h-2 bg-gradient-to-r from-indigo-500 to-teal-500"></div>
      <div className="p-6">
        <div className="flex justify-between items-start mb-4">
          <h2 className="text-lg font-semibold text-gray-900 truncate">
            {title}
          </h2>
          <div className="flex items-center text-gray-500 font-medium text-sm">
            <Eye className="h-4 w-4 mr-1" />
            <span>{views}</span>
          </div>
        </div>

        <div className="mt-4 flex justify-between items-center">
          <span className="text-xs font-medium px-2.5 py-0.5 rounded-full bg-indigo-50 text-indigo-700">
            ID: {id}
          </span>
        </div>
      </div>
    </div>
  );
}

 

Post Browser 페이징

Zunstand + Immer 페이징 스토어

// src/store/postStore.ts

import { create } from "zustand";
import { immer } from "zustand/middleware/immer";

interface PostStore {
  currentPage: number;
  limit: number;
  getTotalPages: () => number;
  setCurrentPages: (page: number) => void;
  setLimit: (amount: number) => void;
}

export const usePostStore = create<PostStore>()(
  immer((set, get) => ({
    currentPage: 1,
    limit: 10,
    getTotalPages: () => Math.ceil(100 / get().limit),
    setCurrentPages: (page: number) => set((state) => {
      state.currentPage = page;
    }),
    setLimit: (amount: number) => set((state) => {
      state.limit = amount;
    })
  })),
);

 

Pagination.tsx 수정 

// src/components/Pagination.tsx

import {
  ChevronLeft,
  ChevronRight,
  ChevronsLeft,
  ChevronsRight,
} from "lucide-react";
import { usePostStore } from "../store/postStore";
import { twMerge } from "tailwind-merge";

export default function Pagination() {
  const currentPage = usePostStore((state) => state.currentPage);
  const totalPages = usePostStore((state) => state.getTotalPages());
  const setCurrentPages = usePostStore((state) => state.setCurrentPages);

  const getPageNumbers = () => {
    const pageNumbers = [];
    const maxPagesToShow = 5;

    if (totalPages < maxPagesToShow) {
      for (let i = 1; i <= totalPages; i++) {
        pageNumbers.push(i);
      }
    } else {
      let startPage = Math.max(1, currentPage - Math.floor(maxPagesToShow / 2));
      let endPage = startPage + maxPagesToShow - 1;

      if (endPage > totalPages) {
        endPage = totalPages;
        startPage = Math.max(1, endPage - maxPagesToShow + 1);
      }

      for (let i = startPage; i <= endPage; i++) {
        pageNumbers.push(i);
      }
    }
    return pageNumbers;
  };

  const pageNumbers = getPageNumbers();
  console.log(currentPage, totalPages, pageNumbers);

  return (
    <div className="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6 rounded-lg shadow-sm">
      <div className="flex flex-1 justify-between sm:hidden">
        {/* 현재 페이지가 1페이지면 : text-gray-300 cursor-not-allowed  */}
        {/* 그게 아니라면(기본값): text-gray-700 hover:bg-gray-50 */}
        <button
          className={twMerge(
            `relative inline-flex items-center rounded-md px-4 py-2 text-sm font-medium`,
            currentPage === 1
              ? "text-gray-300 cursor-not-allowed"
              : "text-gray-700 hover:bg-gray-50",
          )}
          onClick={() => setCurrentPages(Math.max(1, currentPage - 1))}
        >
          Previous
        </button>
        {/* page === totalPage -> text-gray-300 cursor-not-allowed */}
        {/* text-gray-700 hover:bg-gray-50 */}
        <button
          className={twMerge(
            `relative ml-3 inline-flex items-center rounded-md px-4 py-2 text-sm font-medium`,
            currentPage === totalPages
              ? "text-gray-300 cursor-not-allowed"
              : "text-gray-700 hover:bg-gray-50",
          )}
          onClick={() => setCurrentPages(Math.min(currentPage + 1, totalPages))}
        >
          Next
        </button>
      </div>

      <div className="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
        <div>
          <p className="text-sm text-gray-700">
            Showing page <span className="font-medium">1</span> of{" "}
            <span className="font-medium">1</span> pages
          </p>
        </div>

        <div>
          <nav
            className="isolate inline-flex -space-x-px rounded-md shadow-sm"
            aria-label="Pagination"
          >
            {/* 클릭하면 1 페이지로 */}
            {/* page === 1 -> cursor-not-allowed */}
            {/* hover:bg-gray-50 */}
            <button
              className={twMerge(
                `relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 focus:z-20 focus:outline-offset-0`,
                currentPage === 1 ? "cursor-not-allowed" : "hover:bg-gray-50",
              )}
              onClick={() => setCurrentPages(1)}
            >
              <span className="sr-only">First page</span>
              <ChevronsLeft className="h-5 w-5" aria-hidden="true" />
            </button>

            {/* 클릭하면 1페이지 감소 */}
            {/* page === 1 -> cursor-not-allowed */}
            {/* hover:bg-gray-50 */}
            <button
              className={twMerge(
                `relative inline-flex items-center px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 focus:z-20 focus:outline-offset-0`,
                currentPage === 1 ? "cursor-not-allowed" : "hover:bg-gray-50",
              )}
              onClick={() => setCurrentPages(Math.max(1, currentPage - 1))}
            >
              <span className="sr-only">Previous</span>
              <ChevronLeft className="h-5 w-5" aria-hidden="true" />
            </button>

            {/* 현재 페이지는 z-10 bg-indigo-600 text-white focus:z-20 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 */}
            {/* 그게아니라면(기본값) text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0 */}
            {pageNumbers.map((page) => (
              <button
                key={page}
                className={twMerge(
                  `relative inline-flex items-center px-4 py-2 text-sm font-semibold`,
                  currentPage === page
                    ? "z-10 bg-indigo-600 text-white focus:z-20 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
                    : "text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0",
                )}
                onClick={() => setCurrentPages(page)}
              >
                {page}
              </button>
            ))}

            {/* 클릭하면 맨 1페이지 증가 */}
            {/* 현재 페이지가 총 페이지랑 같으면 : cursor-not-allowed */}
            {/* 그게 아니라면(기본값): hover:bg-gray-50 */}
            <button
              className={twMerge(
                `relative inline-flex items-center px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 focus:z-20 focus:outline-offset-0 hover:bg-gray-50`,

                currentPage === totalPages
                  ? "cursor-not-allowed"
                  : "hover:bg-gray-50",
              )}
              onClick={() => setCurrentPages(Math.min(currentPage + 1, totalPages))}
            >
              <span className="sr-only">Next</span>
              <ChevronRight className="h-5 w-5" aria-hidden="true" />
            </button>

            {/* 클릭하면 맨 마지막 페이지로 */}
            {/* 현재 페이지가 총 페이지랑 같으면 : cursor-not-allowed */}
            {/* 그게 아니라면(기본값): hover:bg-gray-50 */}
            <button
              className={twMerge(
                `relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 focus:z-20 focus:outline-offset-0 hover:bg-gray-50`,
                currentPage === totalPages
                  ? "cursor-not-allowed"
                  : "hover:bg-gray-50",
              )}
              onClick={() => setCurrentPages(totalPages)}
            >
              <span className="sr-only">Last page</span>
              <ChevronsRight className="h-5 w-5" aria-hidden="true" />
            </button>
          </nav>
        </div>
      </div>
    </div>
  );
}

 

PostHeader.tsx 수정

// src/components/PostHeader.tsx

import { LayoutGrid, Search } from "lucide-react";
import { usePostStore } from "../store/postStore";

export default function PostHeader() {
  const setLimit = usePostStore((state) => state.setLimit);
  const setCurrentPages = usePostStore((state) => state.setCurrentPages);

  return (
    <div className="mb-8 space-y-4">
      <div className="flex items-center justify-between">
        <h1 className="text-2xl sm:text-3xl font-bold text-gray-900">
          <span className="inline-flex items-center gap-2">
            <LayoutGrid className="h-6 w-6 text-indigo-600" />
            <span>Posts Browser</span>
          </span>
        </h1>

        <div className="flex items-center gap-2">
          <label
            htmlFor="pageSize"
            className="text-sm font-medium text-gray-700"
          >
            Show:
          </label>
          <select
            id="pageSize"
            className="block w-20 rounded-md border-gray-300 py-1.5 px-3 bg-white text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm"
            onChange={(e) => {
              setCurrentPages(1);
              setLimit(Number(e.target.value));
            }}
            defaultValue={10}
          >
            <option value={5}>5</option>
            <option value={10}>10</option>
            <option value={20}>20</option>
            <option value={50}>50</option>
          </select>
        </div>
      </div>

      <div className="relative rounded-md shadow-sm  w-full">
        <div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 w-full">
          <Search className="h-5 w-5 text-gray-400" aria-hidden="true" />
        </div>
        <input
          type="text"
          placeholder="Search posts by title..."
          className="block w-full rounded-md border-0 py-3 pl-10 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-500 sm:text-sm"
        />
      </div>
    </div>
  );
}

 

Post Browser 검색

검색을 위한 변수 추가 선언 

// src/store/postStore.ts

import { create } from "zustand";
import { immer } from "zustand/middleware/immer";

interface PostStore {
  currentPage: number;
  limit: number;
  term: string;
  getTotalPages: () => number;
  setCurrentPages: (page: number) => void;
  setLimit: (amount: number) => void;
  setTerm: (text: string) => void;
}

export const usePostStore = create<PostStore>()(
  immer((set, get) => ({
    currentPage: 1,
    limit: 10,
    term: "", // 검색을 위한 변수 선언 
    getTotalPages: () => Math.ceil(100 / get().limit),
    setCurrentPages: (page: number) => set((state) => {
      state.currentPage = page;
    }),
    setLimit: (amount: number) => set((state) => {
      state.limit = amount;
    }),
    setTerm: (text: string) => set((state) => {
      state.term = text;
    })
  })),
);

 

PostHeader.tsx 수정 - 검색 기능 

// src/components/PostHeader.tsx

import { LayoutGrid, Search } from "lucide-react";
import { usePostStore } from "../store/postStore";
import { useEffect, useState } from "react";

export default function PostHeader() {
  const setLimit = usePostStore((state) => state.setLimit);
  const setCurrentPages = usePostStore((state) => state.setCurrentPages);
  const setTerm = usePostStore((state) => state.setTerm);

  // 디바운스 기법 활용 
  const [input, setInput] = useState("");
  useEffect(() => {
    const timer = setTimeout(() => {
      setCurrentPages(1);
      setTerm(input);
    }, 1000);

    return () => clearTimeout(timer);
  }, [input, setTerm]);

  return (
    <div className="mb-8 space-y-4">
      <div className="flex items-center justify-between">
        <h1 className="text-2xl sm:text-3xl font-bold text-gray-900">
          <span className="inline-flex items-center gap-2">
            <LayoutGrid className="h-6 w-6 text-indigo-600" />
            <span>Posts Browser</span>
          </span>
        </h1>

        <div className="flex items-center gap-2">
          <label
            htmlFor="pageSize"
            className="text-sm font-medium text-gray-700"
          >
            Show:
          </label>
          <select
            id="pageSize"
            className="block w-20 rounded-md border-gray-300 py-1.5 px-3 bg-white text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm"
            onChange={(e) => {
              setCurrentPages(1);
              setLimit(Number(e.target.value));
            }}
            defaultValue={10}
          >
            <option value={5}>5</option>
            <option value={10}>10</option>
            <option value={20}>20</option>
            <option value={50}>50</option>
          </select>
        </div>
      </div>

      <div className="relative rounded-md shadow-sm  w-full">
        <div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 w-full">
          <Search className="h-5 w-5 text-gray-400" aria-hidden="true" />
        </div>
        <input
          type="text"
          placeholder="Search posts by title..."
          className="block w-full rounded-md border-0 py-3 pl-10 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-500 sm:text-sm"
          onChange={(e) => setInput(e.target.value)}
        />
      </div>
    </div>
  );
}

 

PostList.tsx 수정 - 데이터가 없을 때

// src/components/PostList.tsx

import { useEffect, useState } from "react";
import PostCard from "./PostCard";
import { axiosInstance } from "../api/axios";
import LoadingState from "./LoadingState";
import ErrorState from "./ErrorState";
import { usePostStore } from "../store/postStore";
import NoData from "./NoData";

export default function PostList() {
  const [posts, setPosts] = useState<Posts[]>([]);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState("");

  // 사용자가 입력한 검색어 
  const term = usePostStore((state) => state.term); 

  const currentPage = usePostStore((state) => state.currentPage);
  const limit = usePostStore((state) => state.limit);

  useEffect(() => {
    const controller = new AbortController();

    const fetchPosts = async () => {
      try {
        setIsLoading(true);
        const { data } = await axiosInstance.get(
          `/posts?_page=${currentPage}&_limit=${limit}&q=${encodeURIComponent(term)}`, { signal: controller.signal }
        );
        setPosts(data);
      } catch (e) {
        if (e instanceof Error && e.name !== "CanceledError") setError(e.message);
      } finally {
        if (!controller.signal.aborted) setIsLoading(false);
      }
    };
    fetchPosts();

    return () => controller.abort();
  }, [currentPage, limit, term]); // 의존성 배열에 term 추가 

  return (
    <div className="mb-8">
      {/* 데이터가 없을 때 */}
      {/* <NoData /> */}
      {/* 로딩 중일 때 */}
      {/* <LoadingState /> */}
      {/* 에러가 발생했을 때 */}
      {/* <ErrorState /> */}

      {/* 데이터가 있을 때 */}
      {isLoading ? (
        <LoadingState />
      ) : error ? (
        <ErrorState />
      ) : (
        posts.length > 0 ? <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
          {posts.map((post) => (
            <PostCard key={post.id} {...post} />
          ))}
        </div> : <NoData />
      )}
    </div>
  );
}

 

디바운스(Debounce) : 웹 개발에서 자주 발생하는 이벤트(키보드 타이핑, 스크롤, 마우스 움직임 등)가 연속해서 게릴라성으로 터질 때, 이를 매번 처리하지 않고 가장 마지막에 터진 이벤트만 딱 한 번 실행하도록 통제하는 기법 

ㄴ 새로운 이벤트가 유입될 때마다 기존 setTimeout 타이머를 초기화(clearTimeout)하고 새 타이머를 설정하는 매커니즘