프로젝트/BookStore 사이트

1203 모킹 서버 / 리뷰 api / UI 구현 - 드롭다운, 탭, 토스트, 모달, 무한스크롤

thinktank911 2025. 12. 3. 17:51

모킹 서버 작성 (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


무한스크롤

리액트 쿼리의 인피니티 쿼리 응용

스크롤 감지 기능 구현

훅으로 분리