๐ŸŽฏ ๋ชจํ‚น ์„œ๋ฒ„๋ฅผ ํ†ตํ•ด ๋ฆฌ๋ทฐ ๊ธฐ๋Šฅ์„ ์ œ์ž‘ํ•˜๊ณ , ๋“œ๋ž๋‹ค์šด, ํƒญ, ํ† ์ŠคํŠธ, ๋ชจ๋‹ฌ, ๋ฌดํ•œ ์Šคํฌ๋กค๊นŒ์ง€ ๋‹ค์–‘ํ•œ UI๋ฅผ ์ ์šฉ์‹œํ‚ต๋‹ˆ๋‹ค.


๐Ÿ“— Today I Learned

Mock Service Worker

์‹ค์ œ 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

์‚ฌ์šฉ ๋ฐฉ๋ฒ•

1๏ธโƒฃ 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' } ] ์œผ๋กœ ๋‚˜ํƒ€๋‚ฉ๋‹ˆ๋‹ค.


2๏ธโƒฃ 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 ๋ฉ”์„œ๋“œ๋ณ„๋กœ ๋™์ž‘ํ•ฉ๋‹ˆ๋‹ค.


3๏ธโƒฃ ๊ฐœ๋ฐœ ํ™˜๊ฒฝ์—์„œ 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    # ๋ฐฐํฌ ํ™˜๊ฒฝ

4๏ธโƒฃ 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}`
  );
};

5๏ธโƒฃ 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 };
};
  • ์ง€๊ธˆ์€ ๋‹จ์ˆœํžˆ ๋ชจ์–‘๋งŒ ํ…Œ์ŠคํŠธํ•˜๋Š” ๋ชฉ์ ์ด๋ผ, ๋ฆฌ๋ทฐ๋ฅผ ์‹ค์ œ๋กœ ์ถ”๊ฐ€ํ•˜์ง€ ์•Š๊ณ  "๋ฆฌ๋ทฐ ๋“ฑ๋ก๋˜์—ˆ์Šต๋‹ˆ๋‹ค!" ์•Œ๋ฆผ๋งŒ ๋ณด์—ฌ์ฃผ๋Š” ๋ฐฉ์‹์ž…๋‹ˆ๋‹ค.



๋„์„œ ๋ฆฌ๋ทฐ ์ปดํฌ๋„ŒํŠธ

book.model.ts

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

export type BookReviewItemWrite = Pick<BookReviewItem, 'content' | 'score'>;
  • ๋„์„œ ๋ฆฌ๋ทฐ ์•„์ดํ…œ์— ๊ด€ํ•œ ํƒ€์ž…์„ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค.

BookDetail.tsx

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> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ถ”๊ฐ€ํ•˜์˜€์Šต๋‹ˆ๋‹ค.

BookReview.tsx

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๋กœ ๊ตฌ์„ฑํ•˜์˜€์Šต๋‹ˆ๋‹ค.

BookReviewItem.tsx

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;
    }
  }
`;
  • ํ‰์ ์— ๋”ฐ๋ผ ๋ณ„ ๊ฐœ์ˆ˜๋ฅผ ๋‚˜ํƒ€๋‚ด๋Š” ์ปดํฌ๋„ŒํŠธ๋ฅผ ๋งŒ๋“ค๊ณ  ๋ฆฌ๋ทฐํ•˜๋Š” ์ž‘์„ฑ์ž, ์ ์ˆ˜, ๋‚ด์šฉ๋“ค์„ ํ‘œ๊ธฐํ•˜๊ฒŒ ํ•˜์˜€์Šต๋‹ˆ๋‹ค.

BookAddReview.tsx

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;
  }
`;
  • ํด๋ฆญ ์‹œ์— dropdown์„ ๋ณด์—ฌ์ฃผ๊ณ , dropdown ๋ฐ”๊นฅ์„ ํด๋ฆญํ•ด์„œ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>

Header.tsx

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>
  );
}
  • Header์— Dropdown ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.

๊ฒฐ๊ณผ ํ™”๋ฉด




ํƒญ

ํƒญ(Tab)์€ ํ•˜๋‚˜์˜ ํ™”๋ฉด ์•ˆ์—์„œ ์—ฌ๋Ÿฌ ๋‚ด์šฉ์„ ๊ตฌ๋ถ„๋œ ์˜์—ญ์œผ๋กœ ๋‚˜๋ˆ ์„œ, ๋ฒ„ํŠผ์ฒ˜๋Ÿผ ํด๋ฆญํ•˜๋ฉด ๋‚ด์šฉ์ด ๋ฐ”๋€Œ๋Š” UI๋ฅผ ๋งํ•ฉ๋‹ˆ๋‹ค.

Tabs.tsx

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๋ฅผ ํ†ตํ•ด ๋™์ ์ธ ํƒญ ๊ตฌ์กฐ๋ฅผ ๋งŒ๋“ค๊ณ , ํ˜„์žฌ ํ™œ์„ฑํ™”๋œ ํƒญ์— ๋”ฐ๋ผ ์ปจํ…์ธ ๋ฅผ ์กฐ๊ฑด๋ถ€ ๋ Œ๋”๋งํ•˜๊ฒŒ ํ•˜์˜€์Šต๋‹ˆ๋‹ค.

BookDetail.tsx

....

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๋Š” ํ™”๋ฉด ํ•œ์ชฝ์— ์ž ๊น ๋œจ๋Š” ์•Œ๋ฆผ ๋ฉ”์‹œ์ง€๋ฅผ ๋งํ•ฉ๋‹ˆ๋‹ค.

toastStore.ts

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๋กœ ์ „์—ญ ์ƒํƒœ๋กœ ์ €์žฅํ–ˆ์„๊นŒ?

์—ฌ๋Ÿฌ ์ปดํฌ๋„ŒํŠธ์—์„œ ์ž์œ ๋กญ๊ฒŒ ํ† ์ŠคํŠธ ์ถ”๊ฐ€ ๊ฐ€๋Šฅํ•˜๋„๋ก ํ•˜๊ธฐ ์œ„ํ•จ์ž…๋‹ˆ๋‹ค.


useToast.ts

import useToastStore from '@/store/toastStore';

export const useToast = () => {
  const showToast = useToastStore((state) => state.addToast);

  return { showToast };
};
  • store ์ ‘๊ทผ์„ ์‰ฝ๊ฒŒ ๋งŒ๋“œ๋Š” custom hook์œผ๋กœ ์ปดํฌ๋„ŒํŠธ์—์„œ ๊น”๋”ํ•˜๊ฒŒ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

useBook.ts

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๋ฅผ ์ถ”๊ฐ€ํ•˜์—ฌ ์ค๋‹ˆ๋‹ค.

ToastContainer.tsx

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;
`;
  • ์ „์ฒด ํ† ์ŠคํŠธ UI ๋ฌถ์Œ์„ map()์œผ๋กœ ๋Œ๋ ค ํ•˜๋‚˜์”ฉ ๋ Œ๋”๋งํ•ฉ๋‹ˆ๋‹ค.

Toast.tsx

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 ํšจ๊ณผ๋ฅผ ์ค๋‹ˆ๋‹ค.


App.tsx

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <BookStoreThemeProvider>
        <RouterProvider router={router} />
        <ToastContainer />
      </BookStoreThemeProvider>
    </QueryClientProvider>
  );
}
  • ์–ด๋””์„œ๋“  Toast๋ฅผ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์žˆ๋„๋ก ์•ฑ์— ๋„ฃ์–ด์ค๋‹ˆ๋‹ค.

useTimeout.tsx

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)์€ ํ™”๋ฉด ์œ„์— ๊ฒน์ณ์„œ ๋œจ๋Š” ์ฐฝ์ž…๋‹ˆ๋‹ค.

Modal.tsx

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()๋ฅผ ํ˜ธ์ถœํ•ฉ๋‹ˆ๋‹ค.


bookDetail.tsx

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๋ฅผ ํ™œ์šฉํ•˜์—ฌ ์ž‘์„ฑํ•˜์˜€์Šต๋‹ˆ๋‹ค.

useBooksInfinite.ts

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() : ๋ชจ๋“  ํŽ˜์ด์ง€์˜ ์ฑ…๋“ค์„ ํ•œ ๋ฐฐ์—ด๋กœ ํ•ฉ์นฉ๋‹ˆ๋‹ค.


Books.tsx

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()๋ฅผ ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค. (์Šคํฌ๋กค ๋์— ๋„๋‹ฌํ•˜๋ฉด ์ž๋™์œผ๋กœ ๋‹ค์Œ ํŽ˜์ด์ง€ ์š”์ฒญ)


useIntersectionObserver.ts

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;
};
  • IntersectionObserver API๋ฅผ React์—์„œ ์‰ฝ๊ฒŒ ์“ฐ๋„๋ก ๋งŒ๋“  ์ปค์Šคํ…€ ํ›…์ž…๋‹ˆ๋‹ค.
  • observer : ํŠน์ • ์š”์†Œ๊ฐ€ ๋ทฐํฌํŠธ์— ๋“ค์–ด์™”๋Š”์ง€ ๊ฐ์ง€ํ•ฉ๋‹ˆ๋‹ค.

  • observer.observe : ๊ด€์ฐฐ ๋Œ€์ƒ์„ ๋“ฑ๋กํ•ฉ๋‹ˆ๋‹ค.

  • observer.unobserve : ์ปดํฌ๋„ŒํŠธ๊ฐ€ ์–ธ๋งˆ์šดํŠธ๋˜๋ฉด ๊ด€์ฐฐ์„ ์ค‘๋‹จํ•ฉ๋‹ˆ๋‹ค. (๋ฉ”๋ชจ๋ฆฌ ๋ˆ„์ˆ˜ ๋ฐฉ์ง€)


๊ฒฐ๊ณผํ™”๋ฉด




โœ๏ธ ํšŒ๊ณ 

๋ชจํ‚น ์„œ๋ฒ„์™€ ๋‹ค์–‘ํ•œ UI ์ ์šฉ์˜ ๋งŽ์€ ๋ถ„๋Ÿ‰ ๋•Œ๋ฌธ์— ๊ฐ•์˜๋„ ๋งŽ์ด ๋ฐ€๋ ธ์ง€๋งŒ, ์ดํ•ด ํ•˜์ง€ ์•Š๊ณ  ๋„˜์–ด๊ฐˆ ์ˆ˜ ์—†์–ด์„œ ์ •๋ฆฌ๊ฐ€ ์˜ค๋ž˜๊ฑธ๋ ค๋„ ์—ด์‹ฌํžˆ ์ •๋ฆฌํ•˜์˜€๋‹ค.

profile
๐ŸŒฑ๊ฐœ๋ฐœ ๊ธฐ๋ก์žฅ

0๊ฐœ์˜ ๋Œ“๊ธ€