데이터 패칭 기초 개념
데이터 통신 : 컴포넌트가 서버에 외부 데이터를 요청하고, 그 응답을 받아 상태로 반영하여 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 추가 기능
- 페이징(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개 출력
- 전체 텍스트 검색(Full-text Search) 기능
- q 파라미터를 사용하면 데이터 객체 내의 모든 필드(id, title, content 등)를 대상으로 키워드 포함 여부를 검사
- 기본 검색
- GET) http://localhost:3000/posts?q=Post #22
- --> 데이터 중 어느 필드에든 "Post #22"라는 문자열이 포함된 데이터를 모두 찾아 출력
- 응답 지연(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: 정상 작동
- setIsLoading(true) --> 화면에 Loading... 출력
- fetch 성공 --> response.ok가 true이므로 if문 통과
- .json() 포장 뜯기 성공 --> setPosts(data)로 데이터 저장
- .finally() 발동 --> setIsLoading(false)
- 화면에 정상적인 포스트 리스트 출력
상황 B: API 주소가 오타났을 때 (404 에러)
- setIsLoading(true) --> 화면에 Loading... 출력
- 서버 컴퓨터엔 갔는데 주소가 존재 X --> response.ok가 false가 됨
- throw new Error(...) 실행 --> 강제 에러
- .then()은 패스, .catch()로 이동
- .catch((e) => { ... }) 내부가 실행되면서 콘솔창에 에러 기록
- .finally()는 무조건 실행되므로 setIsLoading(false)
- 화면 로딩은 멈추고 빈 리스트 출력
상황 C: json-server를 아예 안 켰을 때 (failed Network 에러)
- setIsLoading(true) --> 화면에 Loading... 출력
- 서버 컴퓨터를 못 찾아서 fetch가 시작하자마자 에러
- .then()은 패스, .catch()로 이동
- .catch((e) => { ... }) 내부가 실행되면서 콘솔창에 에러 기록
- .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 요청을 연결해줌
작동 메커니즘
- new AbortController()를 통해 인스턴스 객체 생성 (signal 세트)
- fetch() 매개변수에 signal을 넘겨주어, 해당 요청이 controller의 명령을 받도록 연결
- 원하는 시점에 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)하고 새 타이머를 설정하는 매커니즘
'React > 타입스크립트로 배우는 리액트(React.js) : 기초부터 최신 기술까지' 카테고리의 다른 글
| 섹션 17. 데이터 통신 심화 (0) | 2026.06.22 |
|---|---|
| 섹션 15. 전역 상태 관리 - Zustand (0) | 2026.06.12 |
| 섹션 13. 전역 상태 관리 - Context API (0) | 2026.06.09 |
| 섹션 12. 사이드 이펙트와 컴포넌트 최적화 (0) | 2026.05.22 |
| 섹션 11. 할 일 관리 앱 (TODO LIST) (0) | 2026.05.14 |