데브코스

[16주차 - DAY1] 도서 정보 사이트 - 리뷰

미안하다 강림이 좀 늦었다 2024. 6. 10. 14:11

 

 

모킹 서버 작성

MSW는 존재하지 않는 API에 대한 응답을 받을 수 있게 해준다.

개발 환경에서만 사용하므로 --save-dev 옵션을 넣어준다.

npm i msw --save-dev

아래 명령어를 실행하면 public 폴더에 mockServiceWorker.js 파일이 생성된다.

npx msw init public/ --save

가짜 리뷰 데이터 생성을 위해 faker를 설치한다.

npm i @faker-js/faker --save-dev

 

models/book.model.ts

리뷰의 인터페이스를 정의한다.

export interface BookReviewItem {
    id: number;
    userName: string;
    content: string;
    createdAt: string;
    score: number;
}

 

mock/review.ts

리뷰 조회와 리뷰 추가 API를 정의한다. 리뷰는 faker를 사용해서 가짜로 생성해둔다.

import { BookReviewItem } from '@/models/book.model';
import { http, HttpResponse } 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.helpers.rangeToNumber({ min: 1, max: 5 })
}));

export const reviewsById = http.get('http://localhost:3000/reviews/:bookId', () => {
    return HttpResponse.json(mockReviewData, {
        status: 200
    });
});

export const addReview = http.post('http://localhost:3000/reviews/:bookId', () => {
    return HttpResponse.json(
        { message: '리뷰가 등록되었습니다.' },
        { status: 200 }
    )
})

 

mock/browser.ts

아까 만든 API를 등록한다.

import { setupWorker } from 'msw/browser';
import { addReview, reviewsById } from './review';

const handlers = [reviewsById, addReview];

export const worker = setupWorker(...handlers);

 

main.tsx

msw의 시작 후에 돔이 마운트 되는 게 아니라 둘은 별도로 동작하기 때문에 async 함수로 아래와 같이 작성하여 msw가 시작되기 전에 마운트 되는 것을 막아준다. 이 작업을 하지 않으면 타이밍 때문에 콘솔에서 에러가 발생할 수 있다.

...
async function mountApp() {
  if (process.env.NODE_ENV === 'development') {
    const { worker } = await import('./mock/browser');
    await worker.start();
  }

  ReactDOM.createRoot(document.getElementById('root')!).render(
    <React.StrictMode>
      <App />
    </React.StrictMode>,
  )
}

mountApp();

 

 

리뷰

models/book.model.ts

리뷰를 작성하기 위해 사용자로부터 입력받아야 하는 정보는 리뷰 content와 별점인 score이다.

export type BookReviewItemWrite = Pick<BookReviewItem, 'content' | 'score'>;

 

review.api.ts

리뷰 목록을 조회 api로 요청을 보내는 함수와 리뷰를 추가하는 api로 요청을 보내는 함수를 정의한다.

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}`, data);
}

 

hooks/useBook.ts

앞서 정의한 함수를 useEffect의 콜백 함수에서 호출하여 reviews에 저장하고, 리뷰 컴포넌트에서 렌더링 할 수 있도록 export 해준다. 리뷰가 추가되면 alert 창으로 리뷰가 등록되었다는 메시지를 보여준다.

export const useBook = (bookId: string | undefined) => {
    ...
    const [reviews, setReviews] = useState<BookReviewItem[]>([]);

    ...

    useEffect(() => {
        if (!bookId) return;

        fetchBook(bookId).then((book) => {
            setBook(book);
        });

        fetchBookReview(bookId).then((reviews) => {
            setReviews(reviews)
        })
    }, [bookId]);

    const addReview = (data: BookReviewItemWrite) => {
        if (!book) return;

        addBookReview(book.id.toString(), data).then((res) => {
            showAlert(res?.message);
        })
    }

    return {..., reviews, addReview };
}

 

pages/BookDetail.tsx

리뷰가 목차 후에 나타나게 한다.

reviews는 faker로 만들었던 리뷰 배열이고, addReview는 alert 창을 띄우는 함수이다.

function BookDetail() {
    ...
    const { book, likeToggle, reviews, addReview } = useBook(bookId);
    ...
    return (
        <BookDetailStyle>
            <header className="header">
                ...
            </header>
            <div className="content">
                ...

                <Title size='medium'>목차</Title>
                <p className="index">{book.contents}</p>

                <Title size="medium">리뷰</Title>
                <BookReview reviews={reviews} onAdd={addReview} />
            </div>
        </BookDetailStyle>
    );
}

 

components/book/BookReview.tsx

리뷰 등록 폼 컴포넌트(BookReviewAdd)와 리뷰 하나에 대한 컴포넌트(BookReviewItem)를 map을 이용하여 모두 렌더링 한다.

interface Props {
    reviews: IBookReviewItem[];
    onAdd: (data: BookReviewItemWrite) => void;
}

function BookReview({ reviews, onAdd }: Props) {
    return (
        <BookReviewStyle>
            <BookReviewAdd onAdd={onAdd} />
            {
                reviews.map((review) => (
                    <BookReviewItem review={review} />
                ))
            }
        </BookReviewStyle>
    );
}

 

components/book/BookReviewAdd.tsx

리뷰를 등록하는 폼을 가지는 컴포넌트이다.

function BookReviewAdd({ onAdd }: Props) {
    const { register, handleSubmit, formState: { errors } } = useForm<BookReviewItemWrite>();

    return (
        <BookReviewAddStyle>
            <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 })}>
                            <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>
        </BookReviewAddStyle>
    );
}

 

components/book/BookReviewItem.tsx

별점(Star)은 이 컴포넌트 안에서만 사용되므로 별도로 분리하지 않았다.

상위 컴포넌트에서 받은 리뷰를 렌더링 한다.

interface Props {
    review: IBookReviewItem
}

const Star = (props: Pick<IBookReviewItem, 'score'>) => {
    return (
        <span className="star">
            {
                Array.from({ length: props.score }, () => (
                    <FaStar />
                ))
            }
        </span>
    )
}

function BookReviewItem({ review }: Props) {
    return (
        <BookReviewItemStyle>
            <header className="header">
                <div>
                    <span>{review.userName}</span>
                    <Star score={review.score} />
                </div>
                <div>
                    {formatDate(review.createdAt)}
                </div>
            </header>
            <div className="content">
                <p>
                    {review.content}
                </p>
            </div>
        </BookReviewItemStyle>
    );
}

 

 

 

배운 점

  • 백엔드 API가 개발되지 않아도 msw 같은 API 모킹 라이브러리를 사용하면 요청을 보내고, 응답을 받을 수 있다.
  • PICK을 사용하면 객체에서 특정 타입만 골라서 사용할 수 있다.
  • faker를 사용하면 가짜 데이터를 생성할 수 있고, faker에는 한국어용 데이터도 있다.