
๐ฏ ๋ชจํน ์๋ฒ๋ฅผ ํตํด ๋ฆฌ๋ทฐ ๊ธฐ๋ฅ์ ์ ์ํ๊ณ , ๋๋๋ค์ด, ํญ, ํ ์คํธ, ๋ชจ๋ฌ, ๋ฌดํ ์คํฌ๋กค๊น์ง ๋ค์ํ UI๋ฅผ ์ ์ฉ์ํต๋๋ค.
์ค์ API ์๋ฒ ์์ด๋ ๊ฐ์ง(Mock) ์๋ต์ ๋ง๋ค์ด์ฃผ๋ ํด์ ๋๋ค. MSW๋ฅผ ์ฌ์ฉํ๋ ์ด์ ๋ ๋ค์๊ณผ ๊ฐ์ต๋๋ค.
์๋ฒ ์์ง ์ ๋ง๋ค์ด์ก๋๋ฐ, ํ๋ก ํธ์๋ ๊ฐ๋ฐ์ ๋ฏธ๋ฆฌ ํ๊ธฐ ์ํด์
๋ฐฑ์๋ ๋ค์ด๋๊ฑฐ๋ ๋๋ฆด ๋ ๊ฐ๋ฐ ยท ๋๋ฒ๊น ์๋๋ฅผ ๋น ๋ฅด๊ฒ ํ๊ธฐ ์ํด์
ํ ์คํธ ํ๊ฒฝ์์ ์ค์ ๋คํธ์ํฌ ์์ด ์๋ต ์๋ฎฌ๋ ์ด์ ํ๊ธฐ ์ํด์

โจ chrome ๊ธฐ์ค DevTools โ Application โ Service Workers โ โBypass for networkโ ์ฒดํฌ๊ฐ ํด์ ๋์ด ์์ด์ผ MSW๊ฐ ์๋ํฉ๋๋ค.
msw ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ์ค์นํฉ๋๋ค.
npm install msw@latest --save-dev
์๋น์ค ์์ปค ํ์ผ( mockServiceWorker.js )์ ์์ฑํฉ๋๋ค.
npx msw init ./public --save
reviews.ts์์ ๊ฐ์ง ๋ฆฌ๋ทฐ ์๋ต(Mock Response)์ ์ ์ํฉ๋๋ค.// reviews.ts
import { BookReviewItem } from '@/models/book.model';
import { HttpResponse, http } from 'msw';
import { fakerKO as faker } from '@faker-js/faker';
const mockReviewData: BookReviewItem[] = Array.from({ length: 8 }).map(
(_, index) => ({
id: index,
userName: `${faker.person.lastName()}${faker.person.firstName()}`,
content: faker.lorem.paragraph(),
createdAt: faker.date.past().toISOString(),
score: faker.number.int({ min: 1, max: 5 }),
})
);
export const reviewsById = http.get(
'http://localhost:9999/reviews/:bookId',
() => {
return HttpResponse.json(mockReviewData, {
status: 200,
});
}
);
export const addReview = http.post(
'http://localhost:9999/reviews/:bookId',
() => {
return HttpResponse.json(
{
message: '๋ฆฌ๋ทฐ๊ฐ ๋ฑ๋ก๋์์ต๋๋ค.',
},
{
status: 200,
}
);
}
);
์ค์ API๊ฐ ์์ ๋ MSW๋ก ์์ฒญ์ ๊ฐ๋ก์ฑ์ ๊ฐ์ง ๋ฐ์ดํฐ๋ฅผ ๋๋ ค์ค๋๋ค.
(/reviews/:bookId์ ๋ํ ๋ฐฑ์๋ API๊ฐ ์๊ธฐ ๋๋ฌธ์ mock ์๋ฒ๋ฅผ ์ด์ฉํฉ๋๋ค.)
Faker.js ๋ผ์ด๋ธ๋ฌ๋ฆฌ์ ๋๋ค ์ด๋ฆ, ๋ด์ฉ, ๋ ์ง, ํ์ ์ผ๋ก ๊ฐ์ง ๋ฆฌ๋ทฐ ๋ชฉ๋ก์ ์์ฑํ์์ต๋๋ค.
๐ค
Array.from({ length: n })์ ์ด๋ค ๊ฐ์ ์ถ๋ ฅํ ๊น?
Array.from({ length: 3 })์[undefined, undefined, undefined]์ธ ๊ธธ์ด๊ฐ 3์ด๊ณ , ๊ฐ ์นธ์undefined์ธ ๋ฐฐ์ด๋ก ๋ง๋ค์ด์ง๋๋ค.๊ทธ๋ฌ๋ฏ๋ก
Array.from({ length: 3 }).map((_, index) => ({ id: index, name: 'Item',}));์
[ { id: 0, name: 'Item' }, { id: 1, name: 'Item' }, { id: 2, name: 'Item' } ]์ผ๋ก ๋ํ๋ฉ๋๋ค.
browser.ts ์์ setupWorker(...handlers)์ ํธ์ถํ์ฌ Mock ์๋ฒ ํธ๋ค๋ฌ๋ฅผ ๋ฑ๋กํฉ๋๋ค.// browser.ts
import { setupWorker } from 'msw/browser';
import { addReview, reviewsById } from './review';
const handlers = [reviewsById, addReview];
export const worker = setupWorker(...handlers);
setupWorker๋ ๋ธ๋ผ์ฐ์ ์์ Service Worker๋ฅผ ํตํด ์์ฒญ์ ๊ฐ๋ก์ฑ๋ ์ญํ ์ ํฉ๋๋ค.
๋ฐฐ์ด๋ก ๋๊ธด ํธ๋ค๋ฌ๋ค์ GET, POST ๋ฑ HTTP ๋ฉ์๋๋ณ๋ก ๋์ํฉ๋๋ค.
worker.start()๋ฅผ ํธ์ถํด Mock ์๋ฒ๋ฅผ ํ์ฑํํฉ๋๋ค.// index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
async function mountApp() {
if (process.env.NODE_ENV === 'development') {
const { worker } = require('./mock/browser');
await worker.start();
}
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
}
mountApp();
๋ฐฐํฌํ๊ฒฝ์์๋ development์ผ ๋๋ง mock ์๋ฒ๋ฅผ ํ์ฑํํฉ๋๋ค.
Mock ์๋ฒ๊ฐ ์ค๋น๋๊ธฐ ์ ์ App์ด ๋จผ์ ๋ ๋๋ง๋๋ฉด, API ์์ฒญ์ด ์๋ฒ๋ก ๋ณด๋ด์ง ์ ์์ด์
Mock ์๋ฒ๊ฐ ์์ ํ ์ค๋น๋ ๋๊น์ง await์ผ๋ก worker๊ฐ ์ค๋น๋ ๋ค์ App์ ๋ง์ดํธํฉ๋๋ค.
๐ค
development๋ ์ด๋ป๊ฒ ํ๋จํ ๊น?Node.js์์๋ ๋ณดํต ํ๊ฒฝ ๋ณ์
NODE_ENV๋ฅผ ๊ธฐ์ค์ผ๋ก ํฉ๋๋ค.NODE_ENV=development # ๊ฐ๋ฐ ํ๊ฒฝ NODE_ENV=production # ๋ฐฐํฌ ํ๊ฒฝ
review.api.ts์์ ๋ฆฌ๋ทฐ API ์์ฒญ ํจ์๋ฅผ ์ ์ํฉ๋๋ค. (์ค์ API์ ๋์ผํ ์ธํฐํ์ด์ค)import { BookReviewItem, BookReviewItemWrite } from '@/models/book.model';
import { requestHandler } from './http';
export const fetchBookReview = async (bookId: string) => {
return await requestHandler<BookReviewItem[]>('get', `/reviews/${bookId}`);
};
interface AddBookReviewResponse {
message: string;
}
export const addBookReview = async (
bookId: string,
data: BookReviewItemWrite
) => {
return await requestHandler<AddBookReviewResponse>(
'post',
`/reviews/${bookId}`
);
};
useBook.ts ํ
์์ ๋ฆฌ๋ทฐ ๋ฐ์ดํฐ๋ฅผ ์์ฒญํ๋ฉด, ์ค์ ์์ฒญ ๋์ Mock ์๋ต์ด ๋ฐํ๋ฉ๋๋ค.import { useEffect, useState } from 'react';
import {
BookDetail,
BookReviewItem,
BookReviewItemWrite,
} from '../models/book.model';
import { useAlert } from './useAlert';
import { addBookReview, fetchBookReview } from '@/api/review.api';
export const useBook = (bookId: string | undefined) => {
const [book, setBook] = useState<BookDetail | null>(null);
const { showAlert } = useAlert();
const [reviews, setReviews] = useState<BookReviewItem[]>([]);
...
const addReview = (data: BookReviewItemWrite) => {
if (!book) return;
addBookReview(book.id.toString(), data).then((res) => {
// fetchBookReview(book.id.toString()).then((reviews) => {
// setReviews(reviews);
// });
showAlert(res?.message);
});
};
return { book, likeToggle, addToCart, cartAdded, reviews, addReview };
};
export interface BookReviewItem {
id: number;
userName: string;
content: string;
createdAt: string;
score: number;
}
export type BookReviewItemWrite = Pick<BookReviewItem, 'content' | 'score'>;
export default function BookDetail() {
const { bookId } = useParams();
const { book, likeToggle, reviews, addReview } = useBook(bookId);
if (!book) return null;
return (
<StyledBookDetail>
....
<div className='content'>
<Title size='medium'>์์ธ ์ค๋ช
</Title>
<EllipsisBox lineLimit={2}>{book.detail}</EllipsisBox>
<Title size='medium'>๋ชฉ์ฐจ</Title>
<p className='index'>{book.contents}</p>
<Title size='medium'>๋ฆฌ๋ทฐ</Title>
<BookReview reviews={reviews} onAdd={addReview} />
</div>
</StyledBookDetail>
);
}
<BookReview> ์ปดํฌ๋ํธ๋ฅผ ์ถ๊ฐํ์์ต๋๋ค.import {
BookReviewItemWrite,
BookReviewItem as IBookReviewItem,
} from '@/models/book.model';
import styled from 'styled-components';
import BookReviewItem from './BookReviewItem';
import BookAddReview from './BookAddReview';
interface Props {
reviews: IBookReviewItem[];
onAdd: (data: BookReviewItemWrite) => void;
}
export default function BookReview({ reviews, onAdd }: Props) {
return (
<StyledBookReview>
<BookAddReview onAdd={onAdd} />
{reviews.map((review) => (
<BookReviewItem key={review.id} review={review} />
))}
</StyledBookReview>
);
}
const StyledBookReview = styled.div`
display: flex;
flex-direction: column;
gap: 16px;
`;
<BookAddReview>๊ณผ ๋ฆฌ์คํธ<BookReviewItem>๋ฅผ ํ๋์ UI๋ก ๊ตฌ์ฑํ์์ต๋๋ค.import { BookReviewItem as IBookReviewItem } from '@/models/book.model';
import { Theme } from '@/style/theme';
import { FaStar } from 'react-icons/fa';
import styled from 'styled-components';
interface Props {
review: IBookReviewItem;
}
const Star = (props: Pick<IBookReviewItem, 'score'>) => {
return (
<span className='star'>
{Array.from({ length: props.score }, (_, index) => (
<FaStar key={index} />
))}
</span>
);
};
export default function BookReviewItem({ review }: Props) {
return (
<StyledBookReviewItem>
<header className='header'>
<div>
<span>{review.userName}</span>
<Star score={review.score} />
</div>
</header>
<div className='content'>
<p>{review.content}</p>
</div>
</StyledBookReviewItem>
);
}
const StyledBookReviewItem = styled.div`
display: flex;
flex-direction: column;
gap: 12px;
box-shadow: 0 0 4px rgba(0, 0, 0, 0.2);
padding: 12px;
border-radius: ${({ theme }) => (theme as Theme).borderRadius.default};
.header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.875rem;
color: ${({ theme }) => (theme as Theme).color.secondary};
padding: 0;
.star {
padding: 0 0 0 8px;
svg {
fill: ${({ theme }) => (theme as Theme).color.primary};
}
}
}
.content {
p {
font-size: 1rem;
line-height: 1.5;
margin: 0;
}
}
`;
import { BookReviewItemWrite } from '@/models/book.model';
import { useForm } from 'react-hook-form';
import styled from 'styled-components';
import Button from '../common/Button';
import { Theme } from '@/style/theme';
interface Props {
onAdd: (data: BookReviewItemWrite) => void;
}
export default function BookAddReview({ onAdd }: Props) {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<BookReviewItemWrite>();
return (
<StyledBookAddReview>
<form onSubmit={handleSubmit(onAdd)}>
<fieldset>
<textarea {...register('content', { required: true })}></textarea>
{errors.content && (
<p className='error-text'>๋ฆฌ๋ทฐ ๋ด์ฉ์ ์
๋ ฅํด์ฃผ์ธ์.</p>
)}
</fieldset>
<div className='submit'>
<fieldset>
<select
{...register('score', { required: true, valueAsNumber: true })}
>
<option value='1'>1์ </option>
<option value='2'>2์ </option>
<option value='3'>3์ </option>
<option value='4'>4์ </option>
<option value='5'>5์ </option>
</select>
</fieldset>
<Button size='medium' scheme='primary'>
์์ฑํ๊ธฐ
</Button>
</div>
</form>
</StyledBookAddReview>
);
}
const StyledBookAddReview = styled.div`
form {
display: flex;
flex-direction: column;
gap: 6px;
fieldset {
border: 0;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 12px;
justify-content: end;
.error-text {
color: red;
padding: 0;
margin: 0;
}
}
textarea {
width: 100%;
height: 100px;
border: 1px solid ${({ theme }) => (theme as Theme).color.border};
border-radius: ${({ theme }) => (theme as Theme).borderRadius.default};
padding: 12px;
}
.submit {
display: flex;
justify-content: end;
gap: 12px;
}
}
`;
react-hook-form ํผ ์ํ ๊ด๋ฆฌ์ฉ ํ
์ ์ด์ฉํด์ HTML <form> ์์์ ์
๋ ฅ๊ฐ๋ค์ ํธํ๊ฒ ๋ค๋ฃจ๊ณ , ๊ฒ์ฆ๋ ์ฝ๊ฒ ํ ์ ์๋๋ก ๋์์ค๋๋ค.
๋๋๋ค์ด(dropdown)์ ํด๋ฆญํ๊ฑฐ๋ ํธ๋ฒํ์ ๋ ์๋๋ก ํผ์ณ์ง๋ ๋ฉ๋ด๋ ๋ฆฌ์คํธ๋ฅผ ๋งํฉ๋๋ค.
import { Theme } from '@/style/theme';
import { useEffect, useRef, useState } from 'react';
import styled from 'styled-components';
interface Props {
children: React.ReactNode;
toggleButton: React.ReactNode;
isOpen?: boolean;
}
export default function Dropdown({
children,
toggleButton,
isOpen = false,
}: Props) {
const [open, setOpen] = useState(isOpen);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleOutsideClick(event: MouseEvent) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setOpen(false);
}
}
document.addEventListener('mousedown', handleOutsideClick);
return () => {
document.removeEventListener('mousedown', handleOutsideClick);
};
}, []);
return (
<StyledDropdown $open={open} ref={dropdownRef}>
<button className='toggle' onClick={() => setOpen(!open)}>
{toggleButton}
</button>
{open && <div className='panel'>{children}</div>}
</StyledDropdown>
);
}
interface StyledDropdownProps {
$open: boolean;
}
const StyledDropdown = styled.div<StyledDropdownProps>`
position: relative;
button {
background: none;
border: none;
cursor: pointer;
outline: none;
svg {
width: 30px;
height: 30px;
fill: ${({ theme, $open }) =>
$open ? (theme as Theme).color.primary : (theme as Theme).color.text};
}
}
.panel {
position: absolute;
top: 40px;
right: 0;
padding: 16px;
background: #fff;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
border-radius: ${({ theme }) => (theme as Theme).borderRadius.default};
z-index: 100;
}
`;
ref.current.contains(e.target)์ด ์๋๋ฉด ๋ซ์์ง๋๋ก ํฉ๋๋ค. ๐ค useEffect ์์กด์ฑ ๋ฐฐ์ด์
[]์ผ๋ก ์ ๋ ์ด์ ๋ ๋ฌด์์ผ๊น?
ref.current๋ ๊ทธ๋ฅ ๋ฆฌ์กํธ๊ฐ ์๋ ์ผ๋ฐ JS ๊ฐ์ฒด์ฒ๋ผ ์๋ํ๋ ์ ์ฅ์์ด๊ธฐ ๋๋ฌธ์ DOM์ ์ง์ ๊ฑด๋๋ฆด ์ ์๊ณcurrent๊ฐ ๋ฐ๋์ด๋ ๋ฆฌ์กํธ์์ ๋ ๋๊ฐ ์ ๋๊ธฐ ๋๋ฌธ์ ์์กด์ฑ ๋ฐฐ์ด์ ๋ฃ์ ํ์๊ฐ ์์ต๋๋ค.
๐ค ์คํ์ผ
props์$์ ๋ถ์ด๋ ์ด์ ๋ ๋ฌด์์ผ๊น?
styled-components์์๋ ์ปดํฌ๋ํธ์ props๋ฅผ ๋๊ธฐ๋ฉด
๊ธฐ๋ณธ์ ์ผ๋ก ๊ทธ props๊ฐ HTML DOM์๋ ์ ๋ฌ๋๊ธฐ ๋๋ฌธ์$์ ๋ถ์ด๊ฒ ๋ฉ๋๋ค.<StyledDropdown $open={open}> ๋ด์ฉ </StyledDropdown>์ค์ ๋ ๋๋ง๋ HTML์ ์ด๋ ๊ฒ ํ์๋ฉ๋๋ค.
<div> ๋ฉ๋ด ๋ด์ฉ </div>
import styled from 'styled-components';
import { light, Theme } from '../../style/theme';
import logo from '../../assets/images/logo.png';
import { FaSignInAlt, FaRegUser, FaUserCircle } from 'react-icons/fa';
import { Link } from 'react-router-dom';
import { useCategory } from '../../hooks/useCategory';
import { useAuthStore } from '../../store/authStore';
import Dropdown from './Dropdown';
import ThemeSwitcher from '../header/ThemeSwitcher';
export default function Header() {
const { category } = useCategory();
const { isLoggedIn, storeLogout } = useAuthStore();
return (
<HeaderStyle>
...
<nav className='auth'>
<Dropdown toggleButton={<FaUserCircle />}>
{isLoggedIn && (
<ul>
<li>
<Link to='/cart'>์ฅ๋ฐ๊ตฌ๋</Link>
</li>
<li>
<Link to='/orderlist'>์ฃผ๋ฌธ๋ด์ญ</Link>
</li>
<li>
<button onClick={storeLogout}>๋ก๊ทธ์์</button>
</li>
</ul>
)}
{!isLoggedIn && (
<ul>
<li>
<Link to='/login'>
<FaSignInAlt />
๋ก๊ทธ์ธ
</Link>
</li>
<li>
<Link to='/signup'>
<FaRegUser />
ํ์๊ฐ์
</Link>
</li>
</ul>
)}
<ThemeSwitcher />
</Dropdown>
</nav>
</HeaderStyle>
);
}

ํญ(Tab)์ ํ๋์ ํ๋ฉด ์์์ ์ฌ๋ฌ ๋ด์ฉ์ ๊ตฌ๋ถ๋ ์์ญ์ผ๋ก ๋๋ ์, ๋ฒํผ์ฒ๋ผ ํด๋ฆญํ๋ฉด ๋ด์ฉ์ด ๋ฐ๋๋ UI๋ฅผ ๋งํฉ๋๋ค.
import { Theme } from '@/style/theme';
import React, { useState } from 'react';
import styled from 'styled-components';
interface TabProps {
title: string;
children: React.ReactNode;
}
function Tab({ children }: TabProps) {
return <>{children}</>;
}
interface TabsProps {
children: React.ReactNode;
}
function Tabs({ children }: TabsProps) {
const [activeIndex, setActiveIndex] = useState(0);
const tabs = React.Children.toArray(
children
) as React.ReactElement<TabProps>[];
return (
<StyledTabs>
<div className='tab-header'>
{tabs.map((tab, index) => (
<button
key={index}
onClick={() => setActiveIndex(index)}
className={activeIndex === index ? 'active' : ''}
>
{tab.props.title}
</button>
))}
</div>
<div className='tab-content'>{tabs[activeIndex]}</div>
</StyledTabs>
);
}
const StyledTabs = styled.div`
.tab-header {
display: flex;
gap: 2px;
border-bottom: 1px solid #ddd;
button {
border: none;
background: #ddd;
cursor: pointer;
font-size: 1.25rem;
font-weight: bold;
color: ${({ theme }) => (theme as Theme).color.primary};
border-radius: ${({ theme }) => (theme as Theme).borderRadius.default}
${({ theme }) => (theme as Theme).borderRadius.default} 0 0;
padding: 12px 24px;
&.active {
color: #fff;
background: ${({ theme }) => (theme as Theme).color.primary};
}
}
}
.tab-content {
padding: 24px 0;
}
`;
export { Tabs, Tab };
activeIndex๋ฅผ ํตํด ๋์ ์ธ ํญ ๊ตฌ์กฐ๋ฅผ ๋ง๋ค๊ณ , ํ์ฌ ํ์ฑํ๋ ํญ์ ๋ฐ๋ผ ์ปจํ
์ธ ๋ฅผ ์กฐ๊ฑด๋ถ ๋ ๋๋งํ๊ฒ ํ์์ต๋๋ค.....
export default function BookDetail() {
const { bookId } = useParams();
const { book, likeToggle, reviews, addReview } = useBook(bookId);
if (!book) return null;
return (
<StyledBookDetail>
...
<div className='content'>
<Tabs>
<Tab title='์์ธ ์ค๋ช
'>
<Title size='medium'>์์ธ ์ค๋ช
</Title>
<EllipsisBox lineLimit={2}>{book.detail}</EllipsisBox>
</Tab>
<Tab title='๋ชฉ์ฐจ'>
<Title size='medium'>๋ชฉ์ฐจ</Title>
<p className='index'>{book.contents}</p>
</Tab>
<Tab title='๋ฆฌ๋ทฐ'>
<Title size='medium'>๋ฆฌ๋ทฐ</Title>
<BookReview reviews={reviews} onAdd={addReview} />
</Tab>
</Tabs>
</div>
</StyledBookDetail>
);
}

ํ ์คํธ(Toast) UI๋ ํ๋ฉด ํ์ชฝ์ ์ ๊น ๋จ๋ ์๋ฆผ ๋ฉ์์ง๋ฅผ ๋งํฉ๋๋ค.

import { create } from 'zustand';
export type ToastType = 'info' | 'error';
export interface ToastItem {
id: number;
message: string;
type: ToastType;
}
interface ToastStoreState {
toasts: ToastItem[];
addToast: (message: string, type?: ToastType) => void;
removeToast: (id: number) => void;
}
const useToastStore = create<ToastStoreState>((set) => ({
toasts: [],
addToast: (message, type = 'info') => {
set((state) => ({
toasts: [...state.toasts, { message, type, id: Date.now() }],
}));
},
removeToast: (id) => {
set((state) => ({
toasts: state.toasts.filter((toast) => toast.id !== id),
}));
},
}));
export default useToastStore;
addToast() : ํ ์คํธ๋ฅผ ์ถ๊ฐํฉ๋๋ค.
removeToast() : ํ ์คํธ๋ฅผ ์ ๊ฑฐํฉ๋๋ค.
๐ค ์ zustand๋ก ์ ์ญ ์ํ๋ก ์ ์ฅํ์๊น?
์ฌ๋ฌ ์ปดํฌ๋ํธ์์ ์์ ๋กญ๊ฒ ํ ์คํธ ์ถ๊ฐ ๊ฐ๋ฅํ๋๋ก ํ๊ธฐ ์ํจ์ ๋๋ค.
import useToastStore from '@/store/toastStore';
export const useToast = () => {
const showToast = useToastStore((state) => state.addToast);
return { showToast };
};
store ์ ๊ทผ์ ์ฝ๊ฒ ๋ง๋๋ custom hook์ผ๋ก ์ปดํฌ๋ํธ์์ ๊น๋ํ๊ฒ ์ฌ์ฉํ ์ ์๊ฒ ๋ฉ๋๋ค. import { useEffect, useState } from 'react';
import {
BookDetail,
BookReviewItem,
BookReviewItemWrite,
} from '../models/book.model';
import { fetchBook, likeBook, unlikeBook } from '../api/books.api';
import { useAuthStore } from '../store/authStore';
import { useAlert } from './useAlert';
import { addCart } from '../api/carts.api';
import { addBookReview, fetchBookReview } from '@/api/review.api';
import { useToast } from './useToast';
export const useBook = (bookId: string | undefined) => {
const [book, setBook] = useState<BookDetail | null>(null);
const { isLoggedIn } = useAuthStore();
const { showAlert } = useAlert();
const { showToast } = useToast();
const [cartAdded, setCartAdded] = useState(false);
const [reviews, setReviews] = useState<BookReviewItem[]>([]);
const likeToggle = () => {
if (!isLoggedIn) {
showAlert('๋ก๊ทธ์ธ์ด ํ์ํฉ๋๋ค.');
return;
}
if (!book) return;
if (book.liked) {
unlikeBook(book.id).then(() => {
setBook({
...book,
liked: false,
likes: book.likes - 1,
});
showToast('์ข์์๊ฐ ์ทจ์๋์์ต๋๋ค.');
});
} else {
likeBook(book.id).then(() => {
setBook({
...book,
liked: true,
likes: book.likes + 1,
});
showToast('์ข์์๊ฐ ์ถ๊ฐ๋์์ต๋๋ค.');
});
}
};
...
return { book, likeToggle, addToCart, cartAdded, reviews, addReview };
};
useToast๋ฅผ ์ถ๊ฐํ์ฌ ์ค๋๋ค.import useToastStore from '@/store/toastStore';
import styled from 'styled-components';
import Toast from './Toast';
export default function ToastContainer() {
const toasts = useToastStore((state) => state.toasts);
return (
<StyledToastContainer>
{toasts.map((toast) => (
<Toast
key={toast.id}
id={toast.id}
message={toast.message}
type={toast.type}
/>
))}
</StyledToastContainer>
);
}
const StyledToastContainer = styled.div`
position: fixed;
top: 32px;
right: 24px;
z-index: 100;
display: flex;
flex-direction: column;
gap: 12px;
`;
map()์ผ๋ก ๋๋ ค ํ๋์ฉ ๋ ๋๋งํฉ๋๋ค.import { useTimeout } from '@/hooks/useTimeout';
import useToastStore, { ToastItem } from '@/store/toastStore';
import { Theme } from '@/style/theme';
import { useEffect, useState } from 'react';
import { FaBan, FaInfoCircle, FaPlus } from 'react-icons/fa';
import styled from 'styled-components';
export const TOAST_REMOVE_DELAY = 3000;
export default function Toast({ id, message, type }: ToastItem) {
const removeToast = useToastStore((state) => state.removeToast);
const [isFadingOut, setIsFadingOut] = useState(false);
const handleRemoveToast = () => {
setIsFadingOut(true);
};
const handleAnimation = () => {
if (isFadingOut) {
removeToast(id);
}
};
useTimeout(() => {
setIsFadingOut(true);
}, TOAST_REMOVE_DELAY);
return (
<StyledToast
className={isFadingOut ? 'fade-out' : 'fade-in'}
onAnimationEnd={handleAnimation}
>
{type === 'info' && <FaInfoCircle />}
{type === 'error' && <FaBan />}
<p>{message}</p>
<button onClick={handleRemoveToast}>
<FaPlus />
</button>
</StyledToast>
);
}
const StyledToast = styled.div<{ theme: Theme }>`
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
&.fade-in {
animation: fade-in 0.3s ease-in-out forwards;
}
&.fade-out {
animation: fade-out 0.3s ease-in-out forwards;
}
background-color: ${({ theme }) => theme.color.secondary};
padding: 12px;
border-radius: ${({ theme }) => theme.borderRadius.default};
display: flex;
justify-content: space-between;
align-items: start;
gap: 24px;
p {
color: ${({ theme }) => theme.color.text};
line-height: 1;
margin: 0;
flex: 1;
display: flex;
align-items: end;
gap: 4px;
}
button {
background-color: transparent;
border: none;
cursor: pointer;
padding: 0;
margin: 0;
svg {
transform: rotate(45deg);
}
}
`;
useTimeout ํ
์ ์ฌ์ฉํด์ 3์ด ํ์ ์๋์ผ๋ก fade-out ์์ํฉ๋๋ค.
animationend ์ด๋ฒคํธ๊ฐ ๋๋๋ฉด ์ํ์์ ์ ๊ฑฐํฉ๋๋ค.
fade-in, fade-out : ์ํ๊ฐ isFadingOut์ ๋ฐ๋ผ ํด๋์ค๊ฐ ๋ฐ๋๊ณ , opacity๋ก fade ํจ๊ณผ๋ฅผ ์ค๋๋ค.
function App() {
return (
<QueryClientProvider client={queryClient}>
<BookStoreThemeProvider>
<RouterProvider router={router} />
<ToastContainer />
</BookStoreThemeProvider>
</QueryClientProvider>
);
}
import { useEffect } from 'react';
export const useTimeout = (callback: () => void, delay: number) => {
useEffect(() => {
const timer = setTimeout(callback, delay);
return () => {
clearTimeout(timer);
};
}, [callback, delay]);
};
setTimeout()์ React ์คํ์ผ๋ก ์์ ํ๊ฒ ์ฐ๋๋ก ๋ง๋ ์ปค์คํ
ํ
์
๋๋ค. ๐ค ์
useTimeout()์ด ํ์ํ ๊น?
setTimeout()์ ์ปดํฌ๋ํธ๊ฐ ์ธ๋ง์ดํธ ๋๊ฑฐ๋, ํ์ด๋จธ๊ฐ ์ค๋ณต๋ ๊ฒฝ์ฐ์๋ ๋ฉ๋ชจ๋ฆฌ ๋์ ์ํ์ด ์๊ธฐ ๋๋ฌธ์ ์ปดํฌ๋ํธ๊ฐ ์ฌ๋ผ์ง ๋clearTimeout()์ผ๋ก ์๋ ์ ๋ฆฌํ๋ ๋ฐฉ์์ด ํ์ํ ๊ฒ์ ๋๋ค.[์์ ์ฝ๋]
function App() { const [visible, setVisible] = useState(true); return ( <div> <button onClick={() => setVisible(false)}>์จ๊ธฐ๊ธฐ</button> {visible && <ToastMessage />} </div> ); } function ToastMessage() { useEffect(() => { const timer = setTimeout(() => { console.log('๐ ํ์ด๋จธ ์คํ!'); }, 3000); }, []); return <div>3์ด ํ ๋ฉ์์ง๋ฅผ ๋ณด์ฌ์ค๊ฒ!</div>; }
ToastMessage๋ง์ดํธ๋จ (setTimeout()์์๋จ) โ ์ฌ์ฉ์๊ฐ 1์ด ๋ค์ โ์จ๊ธฐ๊ธฐโ ๋ฒํผ ํด๋ฆญ โvisible์ดfalse๊ฐ ๋์ด ์ปดํฌ๋ํธ ์ธ๋ง์ดํธ โ ๊ทธ๋ฐ๋ฐ ํ์ด๋จธ๋ ์์ง ์ด์์์ โ 3์ด ์ง๋๋ฉด ์ฌ์ ํconsole.log()์คํํ๋ ค๊ณ ํจ โ React ๊ฒฝ๊ณ ๋ฐ์ํ๊ฑฐ๋, ์ก๊ณ ์๋ ๋ฆฌ์์ค ๋๋ฌธ์ ๋ฉ๋ชจ๋ฆฌ ๋์ ๋ฐ์

๋ชจ๋ฌ(Modal)์ ํ๋ฉด ์์ ๊ฒน์ณ์ ๋จ๋ ์ฐฝ์ ๋๋ค.
import { Theme } from '@/style/theme';
import React, { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { FaPlus } from 'react-icons/fa';
import styled from 'styled-components';
interface Props {
children: React.ReactNode;
isOpen: boolean;
onClose: () => void;
}
export default function Modal({ children, isOpen, onClose }: Props) {
const modalRef = useRef<HTMLDivElement | null>(null);
const [isFadingOut, setIsFadingOut] = useState(false);
const handleClose = () => {
setIsFadingOut(true);
};
const handleOverlayClick = (e: React.MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
handleClose();
}
};
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
handleClose();
}
};
const handleAnimation = () => {
if (isFadingOut) {
onClose();
}
};
useEffect(() => {
if (isOpen) {
setIsFadingOut(false);
window.addEventListener('keydown', handleKeydown);
} else {
window.removeEventListener('keydown', handleKeydown);
}
return () => {
window.removeEventListener('keydown', handleKeydown);
};
}, [isOpen]);
if (!isOpen) return null;
return createPortal(
<StyledModal
className={isFadingOut ? 'fade-out' : 'fade-in'}
onClick={handleOverlayClick}
onAnimationEnd={handleAnimation}
>
<div className='modal-body' ref={modalRef}>
<div className='modal-contents'>
{children}
<div className='modal-close' onClick={handleClose}>
<FaPlus />
</div>
</div>
</div>
</StyledModal>,
document.body
);
}
const StyledModal = styled.div`
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
&.fade-in {
animation: fade-in 0.3s ease-in-out forwards;
}
&.fade-out {
animation: fade-out 0.3s ease-in-out forwards;
}
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 1000;
background-color: rgba(0, 0, 0, 0.6);
opacity: 0;
.modal-body {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 56px 32px 32px;
border-radius: ${({ theme }) => (theme as Theme).borderRadius.default};
box-shadow: 0 0 20px rgba(0, 0, 0, 0.3);
background-color: #fff;
max-width: 80%;
}
.modal-close {
border: none;
background-color: transparent;
cursor: pointer;
position: absolute;
top: 0;
right: 0;
padding: 12px;
svg {
width: 20px;
height: 20px;
transform: rotate(45deg);
}
}
`;
createPortal : DOM ํธ๋ฆฌ ๋ฐ๊นฅ์์ ๋ชจ๋ฌ์ ๋ ๋๋งํฉ๋๋ค. ๋ชจ๋ฌ์ ํ๋ฉด ์ต์๋จ์ ๋ ์ผ ํ๊ธฐ ๋๋ฌธ์ <body>์ ์ง์ ๋ถ์ฌ์ฃผ๋ ๊ฒ ์ข์ต๋๋ค.
useEffect : ๋ชจ๋ฌ์ด ์ด๋ฆฌ๋ฉด ESC ํค ์ด๋ฒคํธ ๋ฆฌ์ค๋๋ฅผ ์ถ๊ฐํ๊ณ , ๋ซํ๊ฑฐ๋ ์ปดํฌ๋ํธ๊ฐ ์ธ๋ง์ดํธ๋ ๋ ์ด๋ฒคํธ ๋ฆฌ์ค๋๋ฅผ ์ ๊ฑฐํฉ๋๋ค.
handleOverlayClick : ์ฌ์ฉ์๊ฐ ๋ชจ๋ฌ ๋ฐ๊นฅ์ ํด๋ฆญํ์ ๋ ๋ซํ๊ฒ ํด์ค๋๋ค.
๋ซ๊ธฐ ๋๋ฅด๋ฉด ๋ฐ๋ก isOpen = false ํ์ง ์๊ณ โ fade-out๋ฅผ ๋จผ์ ์คํํฉ๋๋ค. ์ ๋๋ฉ์ด์
์ด ๋๋ ๋ค(onAnimationEnd)์ onClose()๋ฅผ ํธ์ถํฉ๋๋ค.
return (
<StyledBookDetail>
<header className='header'>
<div className='content' onClick={() => setIsImgOpen(true)}>
<img src={getImgSrc(book.img)} alt={book.title} />
</div>
<Modal isOpen={isImgOpen} onClose={() => setIsImgOpen(false)}>
<img src={getImgSrc(book.img)} alt={book.title} />
</Modal>
...
</header>
...
</StyledBookDetail>
);
}

react-query, IntersectionObserver๋ฅผ ํ์ฉํ์ฌ ์์ฑํ์์ต๋๋ค.
import { useLocation } from 'react-router-dom';
import { fetchBooks } from '../api/books.api';
import { QUERYSTRING } from '../constants/querystring';
import { LIMIT } from '../constants/pagination';
import { useInfiniteQuery } from '@tanstack/react-query';
export const useBooks = () => {
const location = useLocation();
const getBooks = ({ pageParam }: { pageParam: number }) => {
const params = new URLSearchParams(location.search);
const category_id = params.get(QUERYSTRING.CATEGORY_ID)
? Number(params.get(QUERYSTRING.CATEGORY_ID))
: undefined;
const news = params.get(QUERYSTRING.NEWS) ? true : undefined;
const limit = LIMIT;
const currentPage = pageParam;
return fetchBooks({
category_id,
news,
limit,
currentPage,
});
};
const { data, fetchNextPage, hasNextPage, isFetching } = useInfiniteQuery({
queryKey: ['books', location.search],
queryFn: ({ pageParam = 1 }) => getBooks({ pageParam }),
initialPageParam: 1,
getNextPageParam: (lastPage) => {
const isLastPage =
Math.ceil(lastPage.pagination.totalCount / LIMIT) ===
lastPage.pagination.currentPage;
return isLastPage ? undefined : lastPage.pagination.currentPage + 1;
},
});
const books = data ? data.pages.flatMap((page) => page.books) : [];
const pagination = data ? data.pages[data.pages.length - 1].pagination : {};
const isEmpty = books.length === 0;
return {
books,
pagination,
isEmpty,
isBooksLoading: isFetching,
fetchNextPage,
hasNextPage,
};
};
ํ์ด์ง ๊ธฐ๋ฐ ๋ฐ์ดํฐ๋ฅผ ๋ฌดํํ ๋ถ๋ฌ์ค๋ ๋ก์ง์ ๊ด๋ฆฌํฉ๋๋ค.
useInfiniteQuery : ๋ด๋ถ์ ์ผ๋ก ๊ฐ ํ์ด์ง๋ฅผ ๋ฐ๋ก ๊ด๋ฆฌํ๋ฉด์ ์ด์ด ๋ถ์ด๋ ๊ตฌ์กฐ์
๋๋ค.
getNextPageParam : ๋ค์ ํ์ด์ง ์กด์ฌ ์ฌ๋ถ๋ฅผ ํ๋จํฉ๋๋ค.
fetchNextPage()๊ฐ ํธ์ถ๋๋ฉด pageParam์ด ์๋์ผ๋ก ์ฆ๊ฐํฉ๋๋ค.
data.pages : ๊ฐ ํ์ด์ง์ ์๋ต์ด ํฌํจ๋์ด ์์ต๋๋ค.
flatMap() : ๋ชจ๋ ํ์ด์ง์ ์ฑ
๋ค์ ํ ๋ฐฐ์ด๋ก ํฉ์นฉ๋๋ค.
import styled from 'styled-components';
import Title from '../components/common/Title';
import BooksFilter from '../components/books/BooksFilter';
import BooksList from '../components/books/BooksList';
import BooksEmpty from '../components/books/BooksEmpty';
import BooksViewSwitcher from '../components/books/BooksViewSwitcher';
import Loading from '@/components/common/Loading';
import { useBooksInfinite } from '@/hooks/useBooksInfinite';
import Button from '@/components/common/Button';
import { useIntersectionObserver } from '@/hooks/useIntersectionObserver';
export default function Books() {
const {
books,
pagination,
isEmpty,
isBooksLoading,
fetchNextPage,
hasNextPage,
} = useBooksInfinite();
const moreRef = useIntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
loadMore();
}
});
const loadMore = () => {
if (!hasNextPage) return;
fetchNextPage();
};
if (isEmpty) {
return <BooksEmpty />;
}
if (!books || !pagination || isBooksLoading) {
return <Loading />;
}
return (
<>
<Title size='large'>๋์ ๊ฒ์ ๊ฒฐ๊ณผ</Title>
<StyledBooks>
<div className='filter'>
<BooksFilter />
<BooksViewSwitcher />
</div>
<BooksList books={books} />
{/* <Pagination pagination={pagination} /> */}
<div className='more' ref={moreRef}>
<Button
size='medium'
scheme='normal'
onClick={() => fetchNextPage()}
disabled={!hasNextPage}
>
{hasNextPage ? '๋๋ณด๊ธฐ' : '๋ง์ง๋งํ์ด์ง'}
</Button>
</div>
</StyledBooks>
</>
);
}
const StyledBooks = styled.div`
display: flex;
justify-content: space-between;
flex-direction: column;
gap: 24px;
.filter {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 0;
}
`;
useBooks()๋ฅผ ๋์ ํด์ useBooksInfinite()๋ฅผ ์ฌ์ฉํ์์ต๋๋ค.
useIntersectionObserver() : moreRef๋ก ์ง์ ํ DOM ์์๊ฐ ํ๋ฉด์ ๋ค์ด์ค๋ฉด loadMore()๋ฅผ ์คํํฉ๋๋ค. (์คํฌ๋กค ๋์ ๋๋ฌํ๋ฉด ์๋์ผ๋ก ๋ค์ ํ์ด์ง ์์ฒญ)
import { useEffect, useRef } from 'react';
type Callback = (entries: IntersectionObserverEntry[]) => void;
interface ObserverOptions {
root?: Element | null;
rootMargin?: string;
threshold?: number | number[];
}
export const useIntersectionObserver = (
callback: Callback,
options?: ObserverOptions
) => {
const targetRef = useRef(null);
useEffect(() => {
const observer = new IntersectionObserver(callback, options);
if (targetRef.current) {
observer.observe(targetRef.current);
}
return () => {
if (targetRef.current) {
observer.unobserve(targetRef.current);
}
};
});
return targetRef;
};
observer : ํน์ ์์๊ฐ ๋ทฐํฌํธ์ ๋ค์ด์๋์ง ๊ฐ์งํฉ๋๋ค.
observer.observe : ๊ด์ฐฐ ๋์์ ๋ฑ๋กํฉ๋๋ค.
observer.unobserve : ์ปดํฌ๋ํธ๊ฐ ์ธ๋ง์ดํธ๋๋ฉด ๊ด์ฐฐ์ ์ค๋จํฉ๋๋ค. (๋ฉ๋ชจ๋ฆฌ ๋์ ๋ฐฉ์ง)

๋ชจํน ์๋ฒ์ ๋ค์ํ UI ์ ์ฉ์ ๋ง์ ๋ถ๋ ๋๋ฌธ์ ๊ฐ์๋ ๋ง์ด ๋ฐ๋ ธ์ง๋ง, ์ดํด ํ์ง ์๊ณ ๋์ด๊ฐ ์ ์์ด์ ์ ๋ฆฌ๊ฐ ์ค๋๊ฑธ๋ ค๋ ์ด์ฌํ ์ ๋ฆฌํ์๋ค.