프로젝트/BookStore 사이트
1127 비밀번호 초기화, 도서 목록 페이지 구현
thinktank911
2025. 11. 27. 15:17
비밀번호 초기화
- 회원가입과 유사해서 SignupStyle 재활용
- App.tsx에 라우트 등록
- resetRequested : 리셋 요청 여부에 따라 submit 시 분기처리
ResetPassword.tsx
import { useForm } from "react-hook-form";
import styled from "styled-components";
import Title from "../components/common/Title";
import InputText from "../components/common/InputText";
import Button from "../components/common/Button";
import { Link, useNavigate } from "react-router-dom";
import { use, useState } from "react";
import { resetPassword, resetRequest, signup } from "../api/auth.api";
import { useAlert } from "../hooks/useAlert";
import { SignupStyle } from "./Signup";
export interface SignupProps {
email: string;
password: string;
}
function ResetPassword() {
const navigate = useNavigate();
const showAlert = useAlert();
// 리셋 요청 여부
const [resetRequested, setResetRequested] = useState(false);
// react-hook-form 이용해 form 관리
const { register,
handleSubmit,
formState: { errors}
} = useForm<SignupProps>();
const onSubmit = (data: SignupProps) => {
if(resetRequested){
// 초기화
resetPassword(data).then(() => {
// 성공
showAlert('비밀번호 초기화되었습니다.');
navigate('/login');
}).catch((err) => {
// 실패
showAlert('비밀번호 초기화에 실패했습니다.');
})
} else {
// 초기화 요청
resetRequest(data).then(() => {
// 성공
setResetRequested(true);
}).catch((err) => {
// 실패
showAlert('비밀번호 초기화 요청에 실패했습니다.');
})
}
}
return (
<>
<Title size="large">비밀번호 초기화</Title>
<SignupStyle>
<form onSubmit={handleSubmit(onSubmit)}>
<fieldset>
<InputText placeholder="이메일"
// inputType="email" value={email}
// onChange={(e) => setEmail(e.target.value)}
inputType="email"
{...register("email", {required: true})} // "필드명", 필수 여부
/>
{errors.email && <p className="error-text">이메일을 입력해주세요.</p>}
</fieldset>
{/* 리셋 요청 있을 때 비밀번호 필드 노출 */}
{resetRequested && (
<fieldset>
<InputText placeholder="비밀번호"
inputType="password"
{...register("password", {required: true})} // "필드명", 필수 여부
/>
{errors.password && <p className="error-text">비밀번호를 입력해주세요.</p>}
</fieldset>
)}
<fieldset>
<Button type="submit" size="large" scheme="primary">
{resetRequested ? "비밀번호 초기화" : "비밀번호 초기화 요청"}
</Button>
</fieldset>
<div className="info">
<Link to="/reset">비밀번호 초기화</Link>
</div>
</form>
</SignupStyle>
</>
);
}
export default ResetPassword;
- 초기화 요청과 초기화 api 메소드 작성
auth.api.ts
// 비밀번호 리셋 요청
export const resetRequest = async (data:SignupProps) => {
const response = await httpClient.post("/users/reset",
data);
return response.data;
}
// 비밀번호 초기화
export const resetPassword = async (data:SignupProps) => {
const response = await httpClient.put("/users/reset", // put 메소드
data);
return response.data;
}
로그인과 전역 상태

- zustand 사용
로그인 완료 후 조치
store 만들기 위해 zustand 사용
설치 : npm i zustand --save
- 상태정보와 액션함수 같이 선언
- 로컬스토리지에 토큰 관리
authStore.ts
import { create } from "zustand";
// zustand는 상태정보와 액션 함수 같이 선언함
interface StoreState {
isloggedIn: boolean;
storeLogin: (token: string) => void;
storeLogout: () => void;
}
// 토큰 가져오기
const getToken = () => {
const token = localStorage.getItem("token");
return token;
};
// 로컬 스토리지에 토큰 저장
const setToken = (token: string) => {
localStorage.setItem("token", token);
};
// 토큰 클리어
const removeToken = () => {
localStorage.removeItem("token");
};
// set : isloggedIn과 같은 상태 정보를 변경할 수 있게 된다.
export const useAuthStore = create<StoreState>((set) => ({
isloggedIn: getToken() ? true : false, // 초기값
storeLogin: (token:string) => {
set(() => ({ isloggedIn: true }))
setToken(token);
}, // 액션함수
storeLogout: () => {
set(() => ({ isloggedIn: false }))
removeToken();
}
}));
Login.tsx
// zustand 상태관리 모듈
const { isloggedIn, storeLogin, storeLogout } = useAuthStore();
const onSubmit = (data: SignupProps) => {
login(data).then((res) => {
// 상태 변화
storeLogin(res.token);
로그아웃 처리
- Header.tsx에 useAuthStore() 셋팅
- isloggedIn으로 UI 분기처리
- 로그아웃 버튼 onClick 시 storeLogout 실행
headers에 Authorization 토큰 넣기
- http.ts에 headers에 Authorization 추가
headers: { "Content-Type": "application/json", Authorization: getToken() ? getToken() : "", },
로그인 만료 처리
http.ts
(error) => {
// 로그인 만료 처리
if(error.response.status === 401){
removeToken();
window.location.href = "/login";
return;
}
return Promise.reject(error);
});
도서 목록 페이지
도서 목록 화면 요구사항
- 도서(book) 목록을 fetch하여 화면에 렌더
- 페이지네이션 구현
- 검색 결과가 없을 때, 결과 없음 화면 노출
- 카테고리 및 신간 필터 기능 제공
- 목록의 view는 그리드 형태, 목록 형태로 변경 가능
도서 목록 페이지 구조

도서아이템
이미지 관리 위해 유틸 함수 생성
image.ts
export const getImgSrc = (id:number) => {
return `https://picsum.photos/id/${id}/info/600/600`;
}
BookItem.ts
interface Props {
book: Book;
}
function BookItem({book}: Props) {
return (
<BookItemStyle>
<div className="img">
<img src={getImgSrc(book.img)}
alt={book.title}/>
</div>
<div className="content">
<h2 className="title">
{book.title}
</h2>
<p className="summary">{book.summary}</p>
<p className="author">{book.author}</p>
<p className="price">{formatNumber(book.price)}원</p>
</div>
<div className="likes">
{/* <FaHeart /> */}
<span>{book.likes}</span>
</div>
</BookItemStyle>
);
}
도서아이템 테스트하기
- getByAltText 활용
BookItem.spec.tsx
import { render, screen } from "@testing-library/react";
import BookItem from "./BookItem";
import { BookStoreThemeProvider } from "../../context/ThemeContext";
const dumyBook = {
id: 1,
title: "어린왕자들",
img: 7,
category_id: 0,
form: "종이책",
isbn: "0",
summary: "어리다..",
detail: "많이 어리다..",
author: "김어림",
pages: 100,
contents: "목차입니다.",
price: 20000,
likes: 3,
pubDate: "2025-10-01",
};
describe("BookItem", () => {
it('렌더 여부', () => {
const { getByText, getByAltText } = render(
<BookStoreThemeProvider>
<BookItem book={dumyBook} />
</BookStoreThemeProvider>
);
expect(getByText(dumyBook.title)).toBeInTheDocument();
expect(getByText(dumyBook.summary)).toBeInTheDocument();
expect(getByText(dumyBook.author)).toBeInTheDocument();
expect(getByText("20,000원")).toBeInTheDocument();
expect(getByText(dumyBook.likes)).toBeInTheDocument();
expect(getByAltText(dumyBook.title)).toHaveAttribute("src",
`https://picsum.photos/id/${dumyBook.img}/info/600/600`
);
});
})
BooksEmpty.tsx
function BooksEmpty(props: Props) {
return (
<BooksEmptyStyle>
<div className="icon">
{/* <FaSmileWink /> */}
</div>
<Title size="large" color="secondary">
검색 결과가 없습니다.
</Title>
<p>
<Link to="/books">전체 검색 결과로 이동</Link>
</p>
</BooksEmptyStyle>
);
}
BooksFilter
쿼리스트링 업데이트

url 쿼리 변경 감지해서 화면 렌더링
- useCategory로 버튼 데이터 map 출력
- useSearchParams 로 쿼리 스트링 감지
- URLSearchParams : url에 접근하거나 수정할 수 있는 유틸리티
- button active
- 일일히 넣는 거 복잡
- useCategory 훅에 active 정보 넣기
- useLocation 활용 : location.search - 쿼리스트링
BooksFilter.tsx
function BooksFilter(props: Props) {
// 상태
// 1. 카테고리
// 2. 신간 여부 true, false
// 쿼리스트링 이용하기 : 상태 공유 가능, 재사용성, 검색엔진 최적화, 데이터 추적 분석에 용이
const { category} = useCategory();
const [searchParams, setSearchParams] = useSearchParams();
const handleCategory = (id: number | null) => {
const newSearchParams = new URLSearchParams(searchParams);
console.log(newSearchParams);
console.log(id);
if(id === null){
newSearchParams.delete("category_id");
} else {
newSearchParams.set("category_id", id.toString());
}
setSearchParams(newSearchParams); // 실제 업데이트
}
// 현재 선택된 카테고리 가져오기
// const currentCategory = searchParams.get("category_id");
// console.log(currentCategory);
const handeleNews = () => {
const newSearchParams = new URLSearchParams(searchParams);
if(newSearchParams.has("news")){
newSearchParams.delete("news");
} else {
newSearchParams.set("news", "true");
}
setSearchParams(newSearchParams); // 실제 업데이트
}
return (
<BooksFilterStyle>
<div className="category">
{
category.map((item) => (
<Button size="medium" scheme={item.isActive ? "primary" : "normal"}
key={item.category_id}
onClick={() => handleCategory(item.category_id)}
>
{item.category_name}
</Button>
))
}
</div>
<div className="new">
<Button size="medium" scheme={searchParams.has("news") ? "primary" : "normal"}
onClick={()=> handeleNews()}
>
신간
</Button>
</div>
</BooksFilterStyle>
);
}
useCategory.ts
export const useCategory = () =>{
const location = useLocation();
const [category, setCategory] = useState<Category[]>([]);
const setActive = () => {
const params = new URLSearchParams(location.search);
const categoryId = params.get("category_id");
if(categoryId){
setCategory((prev) => {
return prev.map((item) => {
return {
...item,
isActive: item.category_id === Number(categoryId)
};
});
});
} else {
// 전체일 경우 active 항상 false
setCategory((prev) => {
return prev.map((item) => {
return {
...item,
isActive: false
};
});
});
}
}
// 첫 렌더링 시 카테고리 데이터 가져오기
useEffect(() => {
fetchCategory().then((category) => {
if(!category) return;
const categoryWithAll = [
{ category_id: null, category_name: "전체" },
...category,
];
setCategory(categoryWithAll);
setActive();
});
}, []);
useEffect(() => {
setActive();
}, [location.search]);
return { category };
}
쿼리스트링 상수화
querystring.ts
// 쿼리스트링 키 상수화
export const QERYSTRING = {
CATEGORY_ID: "category_id",
NEWS: "news"
}
페이지네이션
pagination.tsx
<PaginationStyle>
{
pages > 0 && (
<ol>
{
Array(pages).fill(0).map((_, index) => (
<li>
<Button key={index} size="small"
scheme={index + 1 === currentPage ? "primary" : "normal"}
onClick={()=> handleClickPage(index+1)}
>
{index + 1}
</Button>
</li>
)
)
}
</ol>
)
}
</PaginationStyle>
BooksViewSwitcher
import styled from "styled-components";
import Button from "../common/Button";
import { FaList, FaTh } from "react-icons/fa";
import { useSearchParams } from "react-router-dom";
import { QUERYSTRING } from "../../constants/querystring";
import { useEffect } from "react";
// 컴포넌트 구조화
const viewOptions = [
{
value: "list",
// icon: <FaList/>,
label: "리스트",
},
{
value: "grid",
// icon: <FaTh/>,
label: "그리드",
},
]
function BooksViewSwitcher() {
const [searchParams, setSearchParams] = useSearchParams();
const handleSwitch = (value: string) => {
const newSearchParams = new URLSearchParams(searchParams);
newSearchParams.set(QUERYSTRING.VIEW, value);
setSearchParams(newSearchParams);
}
// 첫 화면 때 디폴트 값
useEffect(() => {
if(!searchParams.get(QUERYSTRING.VIEW)){
handleSwitch("grid"); // 그리드가 default
}
}, [])
return (
<BooksViewSwitcherStyle>
{
viewOptions.map((item) => (
<Button key={item.value} size="medium"
onClick={() => handleSwitch(item.value)}
scheme={ searchParams.get(QUERYSTRING.VIEW) === item.value ? "primary" : "normal"}>
{item.label}
{/* {item.icon} */}
</Button>
))
}
</BooksViewSwitcherStyle>
);
}
const BooksViewSwitcherStyle = styled.div`
display: flex;
gap: 8px;
svg {
fill: #fff;
}
`;
export default BooksViewSwitcher;
BookItem.tsx
import styled from "styled-components";
import { Book } from "../../models/book.model";
import { getImgSrc } from "../../utils/image";
import { formatNumber } from "../../utils/format";
import { FaHeart } from "react-icons/fa";
import { ViewMode } from "./BooksViewSwitcher";
interface Props {
book: Book;
view?: ViewMode;
}
function BookItem({book, view}: Props) {
return (
<BookItemStyle view={view}>
<div className="img">
<img src={getImgSrc(book.img)}
alt={book.title}/>
</div>
<div className="content">
<h2 className="title">
{book.title}
</h2>
<p className="summary">{book.summary}</p>
<p className="author">{book.author}</p>
<p className="price">{formatNumber(book.price)}원</p>
</div>
<div className="likes">
{/* <FaHeart /> */}
<span>{book.likes}</span>
</div>
</BookItemStyle>
);
}
const BookItemStyle = styled.div<Pick<Props, "view">>`
display: flex;
flex-direction: ${({view}) => (view === "grid" ? "column" : "row")};
position: relative;
box-shadow: 0 0 4px rgba(0, 0, 0, 0.2);
.img {
border-radius: ${({theme}) => theme.borderRadius.default};
overflow: hidden;
width: ${({view}) => (view === "grid" ? "auto" : "160px")};
img {
max-width: 100%;
}
}
.content {
padding: 16px;
position: relative;
flex: ${({view}) => (view === "grid" ? 0 : 1)};
.title {
font-size: 1.25rem;
font-weight: 700;
margin: 0 0 12px 0;
}
.summary {
font-size: 0.875rem;
color: ${({theme}) => theme.color.secondary};
margin: 0 0 4px 0;
}
.author {
font-size: 0.875rem;
color: ${({theme}) => theme.color.secondary};
margin: 0 0 4px 0;
}
.price {
font-size: 1rem;
font-weight: 700;
color: ${({theme}) => theme.color.secondary};
margin: 0 0 4px 0;
}
}
.likes {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.875rem;
color: ${({theme}) => theme.color.primary};
margin: 0;
font-weight: 700;
border: 1px solid ${({theme}) => theme.color.border};
border-radius: ${({theme}) => theme.borderRadius.default};
padding: 4px 12px;
position: absolute;
bottom: 16px;
right: 16px;
svg {
color: ${({theme}) => theme.color.primary};
}
}
`;
export default BookItem;