1203 모킹 서버 / 리뷰 api / UI 구현 - 드롭다운, 탭, 토스트, 모달, 무한스크롤
모킹 서버 작성 (MSW)
참고 링크 : https://mswjs.io/
mocking : 실제로 존재하지 않는 것을 가상으로 존재하게 만든다.
mocking responses

구현하지 않았던 API 요청을 추가하게 되었을 때 사용
➡️ 리뷰 추가
도서 상세 / 리뷰
모델 작성
books.model.ts
api 작성
review.api.ts
hooks 작성
useBook.ts
- reviews 상태
BookDetail.tsx 에서 rewiews 데이터 사용
현재 모킹 서버 셋팅 안했으므로 에러가 난다.
MSW 셋팅
- 설치 : npm i msw --save-dev
- mock 서비스워커 설정 : npx msw init public/ --save
➡️ public 폴더에 mockServiceWorker.js 파일 생성 - index.tsx에 적용
// 개발 환경으로 제한
if(process.env.NODE_ENV === 'development'){
const { worker } = require('./mock/browser');
worker.start();
}
src/mock 에 review.ts 만들기
- msw에서 http와 HttpResponse를 가져와서 셋팅
import { BookReviewItem } from '@/models/book.model';
import { http, HttpResponse } from 'msw';
export const reviewsById = http.get('http://localhost:9999/reviews/:bookId',
() => {
const data:BookReviewItem[] = [];
return HttpResponse.json(data, {
status: 200
})
});
- src/mock/browser ts 파일 만들기
import { setupWorker } from "msw/browser";
import { reviewsById } from "./review";
const handlers = [reviewsById];
export const worker = setupWorker(...handlers);
이 상태에서 서버 재실행 시 모킹서버 실행되는 타이밍 이슈로 에러 발생
- index.tsx에서 비동기 처리
async function mountApp() {
// 개발 환경으로 제한
if(process.env.NODE_ENV === 'development'){
const { worker } = require('./mock/browser');
await worker.start(); // MSW 시작
}
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
{/* <GlobalStyle /> */}
<ThemeContext.Provider value={state}>
<App />
</ThemeContext.Provider>
</React.StrictMode>
);
}
mountApp();
가짜 데이터 만들기
faker.js 도입
참고링크 : https://fakerjs.dev/
설치 : npm install @faker-js/faker --save-dev
더미데이터 대신 faker 사용
- 한국어 데이터 만들려면 import 시 fakerKO
import { fakerKO as faker } from '@faker-js/faker';
// const mockReviewsData:BookReviewItem[] = [
// {
// id: 1,
// userName: "홍길동",
// content: "좋아요",
// createdAt: "2025-01-01",
// score: 5,
// },
// {
// id: 2,
// userName: "김길동",
// content: "좋아요2",
// createdAt: "2025-01-01",
// score: 3,
// },
// ];
const mockReviewsData:BookReviewItem[] = Array.from({length:8}).map((_, index) => (
{
id: index,
userName: faker.person.firstName(),
content: faker.lorem.paragraph(),
createdAt: faker.date.past().toISOString(),
score: faker.number.int({min:1, max:5}),
}
));
리뷰 목록
위치 : components>book>BookReview.tsx
위치 : components>book>BookReviewItem.tsx
별점 표시
BookReviewItem.tsx
const Star = (props: Pick<IBookReviewItem, "score">) => {
return (
<span className="star">
{
Array.from({length: props.score}, (_, index) => (
<FaStar key={index} />
))
}
</span>
);
};
리뷰 작성
mock 서버에 http 요청 만들기
review.ts
api 만들기
review.api.ts
useBook.ts에서 addReview 만들기
BookReviewAdd.tsx 컴포넌트에서 리뷰작성 컴포넌트
- useForm으로 작성
- select option value는 string이므로 number로 받고 싶을 땐
register의 옵션 프로퍼티인 valueAsNumber를 true로 설정
UI 경험
드롭다운

조건에 따라 style 다르게 주기
- style에 타입 선언 후 적용
- boolean 타입 선언 시 $ 붙이기
- style컴포넌트에 해당 props 넘겨주기
- style 프로퍼티 선언부에 변수 넘겨주기
<DropdownStyle $open={open}>
<button className="toggle" onClick={()=> setOpen(!open)}>
{toggleButton}
</button>
{open && <div className="panel">{children}</div>}
</DropdownStyle>
interface DropdownStyleProps {
$open?: boolean;
}
const DropdownStyle = styled.div<DropdownStyleProps>`
svg {
width: 30px;
height: 30px;
fill: ${({theme, $open}) => $open ?
theme.color.primary: theme.color.text};
}
`
클릭 아웃사이드 구현
- useRef 사용
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);
// useEffect 안에서 리턴 시 mount 해제 시 작동 선언
return () => {
document.removeEventListener("mousedown", handleOutSideClick);
}
}, [dropdownRef]);
탭

import React, { useState } from "react";
import styled from "styled-components";
interface TabProps {
title: string;
children: React.ReactNode;
}
function Tab({children, title}: 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 (
<TabsStyle>
<div className="tab-header">
{tabs.map((tab, index) => (
<button
key={index}
onClick={() => setActiveIndex(index)}
className={index === activeIndex ? "active" : ""}
{...tab.props}
>
{tab.props.title}
</button>
))}
</div>
<div className="tab-content">
{tabs[activeIndex]}
</div>
</TabsStyle>
);
}
const TabsStyle = 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-weigt: bold;
color: ${({theme}) => theme.color.text};
border-radius: ${({theme}) => theme.borderRadius.default} ${({theme}) => theme.borderRadius.default} 0 0;
padding: 12px 24px;
&.active {
background: ${({theme}) => theme.color.primary};
color: #fff;
}
}
}
.tab-content {
padding: 24px 0;
}
`;
export {Tabs, Tab };
토스트

store>toastStore.ts 만들기
- zustand 사용
import { create } from "zustand";
export type toastType = 'info' | 'error';
interface Toast {
id: number;
message: string;
type: toastType;
}
interface ToastStoreState {
toasts: Toast[];
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, {
id: Date.now(),
message,
type
}]
}))
},
removeToast: (id) => {
set((state) => ({
toasts: state.toasts.filter((toast) => toast.id !== id)
}))
}
}));
export default useToastStore;
Toast.tsx 와 ToastContainer.tsx 만들어서 배치
토스트 사라지기
- 상수 TOAST_REMOVE_DELAY 만들기
- setTimeOut
SetTimeOut 훅으로 분리
// useEffect(() => {
// const timer = setTimeout(() => {
// // 삭제
// handleRemoveToast();
// }, TOAST_REMOVE_DELAY);
// return () => clearTimeout(timer);
// }, []);
useTimeout(() => {
handleRemoveToast();
}, TOAST_REMOVE_DELAY);
- useTimeout.ts
import { useEffect } from "react"; export const useTimeout = (callback: () => void, delay: number) => { useEffect(() => { const timer = setTimeout(callback, delay); return () => clearTimeout(timer); }, [callback, delay]); } export default useTimeout;
모달

createPortal
무한스크롤
리액트 쿼리의 인피니티 쿼리 응용
스크롤 감지 기능 구현
훅으로 분리