Skip to content

๐Ÿ ์›ํ‹ฐ๋“œ ํ”„๋ฆฌ์˜จ๋ณด๋”ฉ ์ธํ„ด์‹ญ 4์ฃผ์ฐจ ๊ณผ์ œ - ๐Ÿ–ฑ ์ถ”์ฒœ ๊ฒ€์ƒ‰์–ด ๋ฌดํ•œ ์Šคํฌ๋กค

Notifications You must be signed in to change notification settings

WANTED-TEAM03/pre-onboarding-10th-4-3

Folders and files

NameName
Last commit message
Last commit date

Latest commit

ย 

History

62 Commits
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 

Repository files navigation

๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘งโ€๐Ÿ‘ฆ TEAM ๋ณด๋žŒ์‚ผ์กฐ

์ธํ„ด์‹ญ ๊ธฐ๊ฐ„๋™์•ˆ ๋ณด๋žŒ์ฐฌ 3์กฐ๊ฐ€ ๋˜์ž!

Name ํ™ฉ์ˆ˜ํ˜„ ์ด์ค€ํ˜ธ ๋ฐ•์ˆ˜ํ˜„ ์ด์ƒ๋ฏผ ์œ ๋™ํ˜
Profile
GitHub @rjsej12 @wujuno @pySoo @sangminlee98 @robin14dev
Name ๊ฐ•๋ช…์ฃผ ๋ฐ•๊ฒธ์˜ ์ •์ •์ˆ˜ ๊ณ ์˜์šฑ ์ถ”ํ—Œ์žฌ
Profile
GitHub @myungju030 @seoltang @wjdwjdtn92 @free-ko @Chuhj

์›ํ‹ฐ๋“œ ํ”„๋ฆฌ์˜จ๋ณด๋”ฉ ํ”„๋ก ํŠธ์—”๋“œ ์ธํ„ด์‹ญ 4์ฃผ์ฐจ ๊ณผ์ œ

๊ณผ์ œ ์ฝ”๋“œ ๋ฆฌํŒฉํ† ๋ง + ์ถ”์ฒœ ๊ฒ€์ƒ‰์ฐฝ ๊ตฌํ˜„ + ๋ฌดํ•œ ์Šคํฌ๋กค

์ง„ํ–‰ ๊ธฐ๊ฐ„: 2023-05-14 ~ 2023-05-17

๋ชฉ์ฐจ


๋ฐฐํฌ ๋งํฌ

https://pre-onboarding-10th-4-3.netlify.app/


๋™์ž‘ ํ™”๋ฉด

demo


์‚ฌ์šฉํ•œ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ

Area Tech Stack
Frontend

ํ”„๋กœ์ ํŠธ ๊ตฌ์กฐ

๐Ÿ“ฆsrc
โ”œโ”€โ”€ ๐Ÿ“‚__mocks__
โ”œโ”€โ”€ ๐Ÿ“‚__tests__
โ”œโ”€โ”€ ๐Ÿ“‚api
โ”œโ”€โ”€ ๐Ÿ“‚components
โ”‚   โ””โ”€โ”€ ๐Ÿ“‚Dropdown
|   โ””โ”€โ”€ ๐Ÿ“‚DropdownItem
|   โ””โ”€โ”€ ๐Ÿ“‚Header
|   โ””โ”€โ”€ ๐Ÿ“‚InputTodo
|   โ””โ”€โ”€ ๐Ÿ“‚TodoItem
|   โ””โ”€โ”€ ๐Ÿ“‚TodoList
|   โ””โ”€โ”€ ๐Ÿ“‚shared
โ”‚       โ”œโ”€โ”€ ๐Ÿ“‚LoadingSpinner
โ”‚       โ”œโ”€โ”€ ๐Ÿ“‚PlusButton
โ”‚       โ””โ”€โ”€ ๐Ÿ“‚TrashButton
โ”œโ”€โ”€ ๐Ÿ“‚constants
โ”œโ”€โ”€ ๐Ÿ“‚hooks
โ”œโ”€โ”€ ๐Ÿ“‚pages
โ”œโ”€โ”€ ๐Ÿ“‚types
โ””โ”€โ”€ ๐Ÿ“‚utils

ํ”„๋กœ์ ํŠธ ์‹คํ–‰ ๋ฐฉ๋ฒ•

๋ ˆํŒŒ์ง€ํ† ๋ฆฌ ํด๋ก 

$ git clone https://github.com/WANTED-TEAM03/pre-onboarding-10th-4-3.git

ํŒจํ‚ค์ง€ ์„ค์น˜

$ yarn

ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ์ž…๋ ฅ

// .env

REACT_APP_API_URL=
REACT_APP_TOKEN=

์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์‹คํ–‰

$ yarn start

ํ…Œ์ŠคํŠธ ์‹คํ–‰

$ yarn test

๊ณผ์ œ ์ˆ˜ํ–‰ ๋‚ด์šฉ

Overview

  • ๊ธฐ์กด ์ฝ”๋“œ ๋ฆฌํŒฉํ† ๋ง

    • ๊ธฐ์กด ์ž๋ฐ”์Šคํฌ๋ฆฝํŠธ ํŒŒ์ผ์„ ํƒ€์ž…์Šคํฌ๋ฆฝํŠธ๋กœ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ํ–ˆ์Šต๋‹ˆ๋‹ค.
    • ๊ธฐ์กด ์ฝ”๋“œ๋“ค์˜ ํƒ€์ž…์„ ์ •์˜ํ•˜๊ณ  ๋ณ€์ˆ˜๋“ค์„ ์ƒ์ˆ˜ํ™”ํ•˜์—ฌ constants ํด๋”์—์„œ ๊ด€๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค.
    • CSS ๋ชจ๋“ˆํ™”๋ฅผ ์ ์šฉํ•˜์—ฌ ์ฝ”๋“œ์˜ ์žฌ์‚ฌ์šฉ์„ฑ์„ ๋†’์ด๊ณ  side effect ๋ฐœ์ƒ ํ™•๋ฅ ์„ ์ค„์˜€์Šต๋‹ˆ๋‹ค.
    • ๊ณตํ†ต์ ์œผ๋กœ ์‚ฌ์šฉํ•˜๋Š” Spinner์™€ Button์„ ๋ถ„๋ฆฌํ•˜์—ฌ ์žฌ์‚ฌ์šฉ์„ฑ์„ ๋†’์˜€์Šต๋‹ˆ๋‹ค.
  • ์ถ”์ฒœ ๊ฒ€์ƒ‰์ฐฝ ๊ตฌํ˜„

    • debounce์„ ํ†ตํ•ด API์˜ ํ˜ธ์ถœ์„ ์ค„์˜€์Šต๋‹ˆ๋‹ค.
    • useSearch hook์„ ์ด์šฉํ•˜์—ฌ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์„ UI ์ปดํฌ๋„ŒํŠธ์™€ ๋ถ„๋ฆฌํ•˜๊ณ  ๋ฐ์ดํ„ฐ ํŽ˜์นญ ๊ธฐ๋Šฅ์„ ๊ตฌํ˜„ํ–ˆ์Šต๋‹ˆ๋‹ค.
  • ๊ฒ€์ƒ‰์–ด ๋“œ๋กญ๋‹ค์šด ๊ตฌํ˜„

    • ๋‹จ์–ด๊ฐ€ ํ‚ค์›Œ๋“œ์™€ ๋™์ผํ•œ ๊ฒฝ์šฐ ์ƒ‰์ƒ์œผ๋กœ ํ™œ์„ฑํ™” ๊ธฐ๋Šฅ์„ ๊ตฌํ˜„ํ–ˆ์Šต๋‹ˆ๋‹ค.
    • ๋“œ๋กญ๋‹ค์šด ์•„์ดํ…œ ํด๋ฆญ ์‹œ Todo ๋ฆฌ์ŠคํŠธ์— ์ถ”๊ฐ€๋˜๋„๋ก ๊ตฌํ˜„ํ–ˆ์Šต๋‹ˆ๋‹ค.
    • useToggleModal hook์„ ์ด์šฉํ•˜์—ฌ ๋“œ๋กญ๋‹ค์šด ํ† ๊ธ€์„ ๊ตฌํ˜„ํ–ˆ์Šต๋‹ˆ๋‹ค.
  • ๋ฌดํ•œ ์Šคํฌ๋กค

    • Web API์˜ IntersectionObserver๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ๋งˆ์ง€๋ง‰ ์•„์ดํ…œ์ด ๊ฐ์ง€๋˜๋ฉด API๋ฅผ ํ˜ธ์ถœํ•˜๋Š” ๋ฌดํ•œ ์Šคํฌ๋กค์„ ๊ตฌํ˜„ํ–ˆ์Šต๋‹ˆ๋‹ค.
    • ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๋ฅผ ์ตœ๋Œ€ 10๊ฐœ์”ฉ ๋ฐ›์•„์˜ฌ ์ˆ˜ ์žˆ๋„๋ก ๊ตฌํ˜„ํ–ˆ์Šต๋‹ˆ๋‹ค.
  • jest๋ฅผ ์ด์šฉํ•œ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ

    • <InputTodo>, <Dropdown> ์ปดํฌ๋„ŒํŠธ์˜ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•˜์—ฌ ์ฃผ์š” ๊ธฐ๋Šฅ ๋ฐ ์ด๋ฒคํŠธ ๋ฐœ์ƒ์— ๋”ฐ๋ฅธ ๋กœ์ง์„ ํ…Œ์ŠคํŠธํ•˜๊ณ ์ž ํ–ˆ์Šต๋‹ˆ๋‹ค.

API ์š”์ฒญ ํšŸ์ˆ˜๋ฅผ ์ค„์ด๊ธฐ ์œ„ํ•ด Debounce ์ ์šฉ

  • ์ด๋ฒคํŠธ๋ฅผ ๊ทธ๋ฃนํ™”ํ•˜์—ฌ ํŠน์ • ์‹œ๊ฐ„์ด ์ง€๋‚œ ํ›„ ํ•˜๋‚˜์˜ ์ด๋ฒคํŠธ๋งŒ ๋ฐœ์ƒํ•˜๋„๋ก ํ•˜๋Š” Debounce ๊ธฐ์ˆ ์„ ์‚ฌ์šฉํ–ˆ์Šต๋‹ˆ๋‹ค.

๋„คํŠธ์›Œํฌ ๋น„์šฉ ์ค„์ด๊ธฐ

  1. ์ผ์ • ์‹œ๊ฐ„ ๋™์•ˆ ๊ฒ€์ƒ‰์–ด๋ฅผ ๋ชจ์€ debouncedInputText๋ฅผ search API๋ฅผ ํ˜ธ์ถœํ•˜๋Š” useSearch hook์— ์ „๋‹ฌํ•ฉ๋‹ˆ๋‹ค.

    // src/components/InputTodo/index.tsx
    
    const [inputText, setInputText] = useState('');
    ...
    const debouncedInputText = useDebounce(inputText);
    
    const { recommendList } = useSearch(debouncedInputText);
  2. delay ์‹œ๊ฐ„์„ 500ms๋กœ ์„ธํŒ…ํ•˜๊ณ , ๋งˆ์ง€๋ง‰ ์š”์ฒญ ์ดํ›„ API๋ฅผ ํ•œ ๋ฒˆ๋งŒ ํ˜ธ์ถœํ•˜๋„๋ก debounce๋ฅผ ์ ์šฉํ•˜์—ฌ ๋„คํŠธ์›Œํฌ ๋น„์šฉ์„ ์ค„์ด๋„๋ก ๊ตฌํ˜„ํ–ˆ์Šต๋‹ˆ๋‹ค.

    // src/hooks/useSearch.ts
    
    useEffect(() => {
       const fetchAutocompleteWords = async () => {
         try {
           const response = await getSearchedList();
         }
         ...
       };
       fetchAutocompleteWords();
    }, [inputText]);

์œ ์ง€ ๋ณด์ˆ˜๋ฅผ ์œ„ํ•œ ์ปค์Šคํ…€ ํ›… ์ƒ์„ฑ

  • ๋””๋ฐ”์šด์Šค ์ž‘์—…์˜ ์œ ์ง€ ๋ณด์ˆ˜๊ฐ€ ์šฉ์ดํ•˜๋„๋ก ์ปค์Šคํ…€ ํ›…์œผ๋กœ ๋ถ„๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค.

  • ์ถ”ํ›„ ์žฌ์‚ฌ์šฉ์„ ๊ณ ๋ คํ•ด value์˜ ํƒ€์ž…์„ generic <T> ๋กœ ์„ ์–ธํ–ˆ์Šต๋‹ˆ๋‹ค.

    // src/hooks/useDebounce.ts
    
    export default function useDebounce<T>(value: T, delay = 500) {
      const [debouncedValue, setDebouncedValue] = useState(value);
    
      useEffect(() => {
        const timerId = setTimeout(() => {
          setDebouncedValue(value);
        }, delay);
    
        return () => {
          clearTimeout(timerId);
        };
      }, [value, delay]);
    
      return debouncedValue;
    }

useSearch hook์„ ํ†ตํ•œ ๋ฐ์ดํ„ฐ ํŽ˜์นญ ๊ตฌํ˜„

  • ์ถ”์ฒœ ๊ฒ€์ƒ‰ ๊ธฐ๋Šฅ์„ ๊ตฌํ˜„ํ•˜๊ธฐ ์œ„ํ•ด ๋‹ค์–‘ํ•œ ์ƒํƒœ๋ฅผ ์‚ฌ์šฉํ•˜๊ณ  ์žˆ์–ด์„œ ๋ชจ๋“ˆํ™”์˜ ํ•„์š”์„ฑ์„ ๋Š๊ผˆ์Šต๋‹ˆ๋‹ค.
  • ๊ฒ€์ƒ‰๊ณผ ๊ด€๋ จ๋œ ์ƒํƒœ๋“ค๊ณผ ๊ฒ€์ƒ‰์–ด ๋ณ€๊ฒฝ ์‹œ์— ์Šคํฌ๋กค์„ ๋ณ€๊ฒฝํ•˜๊ณ  ๋ฐ์ดํ„ฐ๋ฅผ ์ดˆ๊ธฐํ™” ํ•˜๋Š” ์ดˆ๊ธฐํ™” ํ•จ์ˆ˜๋“ค์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค.
  • debounced๋œ inputText๊ฐ€ ๋“ค์–ด์™”์„ ๋•Œ, ๋นˆ ๊ฐ’์ด ์•„๋‹ˆ๋ผ๋ฉด API๋ฅผ ํ˜ธ์ถœํ•ฉ๋‹ˆ๋‹ค.
// src/hooks/useSearch.ts

const useSearch = (inputText: string) => {
  ...
  const scrollToTop = () => {
    scrollRef.current?.scrollTo(0, 0);
  };

  const getMoreItem = async () => {
    ...
  };

  useEffect(() => {
    ...
    fetchAutocompleteWords();
  }, [inputText]);

  return {
    isSearching,
    recommendList,
    hasNextPage,
    isFirstSearch,
    scrollRef,
    getMoreItem,
  };
};

export default useSearch;

๊ฒ€์ƒ‰์–ด ๋“œ๋กญ๋‹ค์šด

๊ฒ€์ƒ‰ ํ‚ค์›Œ๋“œ ์ƒ‰์ƒ ํ™œ์„ฑํ™”

  • ํ•ด๋‹น ์•„์ดํ…œ์— ๊ฒ€์ƒ‰ํ•œ ํ‚ค์›Œ๋“œ๋ฅผ ๋ฝ‘์•„๋‚ด๋Š” ์œ ํ‹ธ ํ•จ์ˆ˜ splitTextWithKeyword ๋ฅผ ๋งŒ๋“ค์—ˆ์Šต๋‹ˆ๋‹ค.

    // src/utils/splitTextWithKeyword.ts
    
    export const splitTextWithKeyword = (text: string, keyword: string) => {
      const splitKey = '@#$%^';
      return text
        .replaceAll(keyword, `${splitKey}${keyword}${splitKey}`)
        .split(splitKey);
    };
  • ํ•ด๋‹น ์œ ํ‹ธํ•จ์ˆ˜๋ฅผ ํ†ตํ•ด, api๋ฅผ ํ†ตํ•ด ๋ฐ›์•„์˜จ ์•„์ดํ…œ๋“ค์ด ๊ฒ€์ƒ‰ ํ‚ค์›Œ๋“œ์™€ ๋™์ผํ•œ ๊ฒฝ์šฐ ์Šคํƒ€์ผ๋ง์„ ํ•ด์ฃผ๋Š” ๊ธฐ๋Šฅ์„ ๊ตฌํ˜„ํ–ˆ์Šต๋‹ˆ๋‹ค.

    // src/components/DropdownItem/index.tsx
    
    <li>
      {splitTextWithKeyword(searchWord, keyword).map((text, index) => (
        <span key={index} className={text === keyword ? styles.accent_text : ''}>
          {text}
        </span>
      ))}
    </li>

๊ฒ€์ƒ‰์–ด ๋ณ€๊ฒฝ ์‹œ ๋“œ๋กญ๋‹ค์šด ์œ ์ง€

  • ๊ฒ€์ƒ‰์–ด๊ฐ€ ๋ณ€๊ฒฝ์‹œ api ์‘๋‹ต ๊ฒฐ๊ณผ๋ฅผ ์ดˆ๊ธฐ ์ƒํƒœ๋กœ ์„ค์ •ํ•˜๊ณ  ๊ฒ€์ƒ‰์–ด ๋ฆฌ์ŠคํŠธ๋ฅผ ๋ณ€๊ฒฝํ•˜๋Š” getMoreItem ํ•จ์ˆ˜๋ฅผ ๋งŒ๋“ค์–ด ์‚ฌ์šฉํ•จ์œผ๋กœ์จ ๋“œ๋กญ๋‹ค์šด์ด ์œ ์ง€๋  ์ˆ˜ ์žˆ๋„๋ก ๋ณ€๊ฒฝํ–ˆ์Šต๋‹ˆ๋‹ค.
// src/hooks/useSearch.ts

const getMoreItem = async () => {
  if (!hasNextPage) return;

  setIsSearching(true);

  try {
    const trimmedText = inputText.trim().toLowerCase();
    const response = await getSearchedList(trimmedText, nextPage);
    const { limit, page, qty, total, result } = response;

    setRecommendList((prev) => [...prev, ...result]);

    if (limit * (page - DEFAULT_PAGE) + qty >= total) setHasNextPage(false);
    setNextPage((prev) => prev + 1);
  } catch (error) {
    console.error(error);
    alert('Something went wrong.');
  } finally {
    setIsSearching(false);
  }
};

์Šคํฌ๋กค ์œ„์น˜ ์ดˆ๊ธฐํ™”

  • ๊ฒ€์ƒ‰์–ด ๋ฆฌ์ŠคํŠธ๊ฐ€ ๋ณ€๊ฒฝ ๋  ๋•Œ ๊ธฐ์กด ์Šคํฌ๋กค ์œ„์น˜๊ฐ€ ์œ ์ง€๋˜๋Š” ํ˜„์ƒ์ด ์žˆ์–ด ์Šคํฌ๋กค ์œ„์น˜๋ฅผ ์ดˆ๊ธฐํ™” ํ•  ์ˆ˜ ์žˆ๋„๋ก ๋ณ€๊ฒฝํ–ˆ์Šต๋‹ˆ๋‹ค.
  • useSearch hook์—์„œ returnํ•œ scrollRef๋ฅผ ๋“œ๋กญ๋‹ค์šด์˜ ref๊ฐ’์œผ๋กœ ๋„ฃ์–ด์ฃผ์—ˆ์Šต๋‹ˆ๋‹ค.
  • APIํ˜ธ์ถœ ์‹œ scrollToTop๋ฅผ ์‹คํ–‰ ํ•ด ์Šคํฌ๋กค์„ ์ตœ์ƒ๋‹จ์œผ๋กœ ์˜ฎ๊ฒจ์ฃผ์—ˆ์Šต๋‹ˆ๋‹ค.
// src/components/Dropdown/index.tsx

<ul className={styles.dropdown} ref={scrollRef}>
  ~
</ul>
// src/hooks/useSearch.ts

const scrollRef = useRef<HTMLUListElement>(null);

const scrollToTop = () => {
  scrollRef.current?.scrollTo(0, 0);
};

useEffect(() => {
  const fetchAutocompleteWords = async () => {
    ~
    try {
      setIsSearching(true);

      const response = await getSearchedList(trimmedText);
      const { limit, page, qty, total, result } = response;

      if (limit * (page - DEFAULT_PAGE) + qty < total) setHasNextPage(true);
      scrollToTop();
      setRecommendList(result);
    }
    ~
  };

  fetchAutocompleteWords();
}, [inputText]);

๋“œ๋กญ๋‹ค์šด ํ† ๊ธ€ ๊ตฌํ˜„

  • useToggleModal hook์„ ์ด์šฉํ•˜์—ฌ ๋“œ๋กญ๋‹ค์šด ํ† ๊ธ€์„ ๊ตฌํ˜„ํ–ˆ์Šต๋‹ˆ๋‹ค.
  • ํ† ๊ธ€ํ•  ์š”์†Œ๋ฅผ ๊ฐ€๋ฅดํ‚ค๋Š” target๊ณผ ๋ชจ๋‹ฌ์ด ํ™œ์„ฑํ™”๋œ ์ƒํƒœ์ธ์ง€ boolean๊ฐ’์„ ๋ฐ˜ํ™˜ํ•˜๋Š” isModalOpen์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค.
  • !target.contains(e.target as Node)์—์„œ ํ˜„์žฌ ํด๋ฆญํ•œ ์š”์†Œ๊ฐ€ ํ† ๊ธ€ํ•  ์š”์†Œ(target)์•ˆ์— ํฌํ•จ๋œ ๋…ธ๋“œ ๊ฐ์ฒด์ธ์ง€ ํ™•์ธํ•˜์—ฌ ๋ชจ๋‹ฌ ํ† ๊ธ€ ์ƒํƒœ ์—ฌ๋ถ€๋ฅผ ๊ฒฐ์ •ํ•ฉ๋‹ˆ๋‹ค.
// src/hooks/useToggleModal.ts

import { useEffect, useState } from 'react';

type ReturnType = {
  isModalOpen: boolean;
  setIsModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
  setTarget: React.Dispatch<
    React.SetStateAction<HTMLElement | null | undefined>
  >;
};

const useToggleModal = (): ReturnType => {
  const [target, setTarget] = useState<HTMLElement | null | undefined>(null);
  const [isModalOpen, setIsModalOpen] = useState(false);

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

    const handleCloseModal = (e: Event | React.MouseEvent) => {
      if (isModalOpen && (!target || !target.contains(e.target as Node)))
        setIsModalOpen(false);
    };
    window.addEventListener('mousedown', handleCloseModal);
    return () => {
      window.removeEventListener('mousedown', handleCloseModal);
    };
  }, [target, isModalOpen]);

export default useToggleModal;

๋ฌดํ•œ ์Šคํฌ๋กค

  • ๋ฌดํ•œ์Šคํฌ๋กค์„ ๊ตฌํ˜„ํ•˜๊ธฐ์œ„ํ•ด ์Šคํฌ๋กค ์ด๋ฒคํŠธ๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๊ฒฝ์šฐ, ๊ณผ๋„ํ•œ ์ด๋ฒคํŠธ์˜ ํ˜ธ์ถœ๋กœ ์„ฑ๋Šฅ ์ €ํ•˜์˜ ์šฐ๋ ค๊ฐ€ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค.
  • ๋”ฐ๋ผ์„œ Intersection Observer API๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ๋ถˆํ•„์š”ํ•œ ์ด๋ฒคํŠธ ๋ฐœ์ƒ ์—†์ด ๋ฌดํ•œ์Šคํฌ๋กค์„ ๊ตฌํ˜„ํ–ˆ์Šต๋‹ˆ๋‹ค.
  • IntersectionObserver API๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ์ปค์Šคํ…€ hook์„ ์ด์šฉํ•˜์—ฌ 10๋ฒˆ์งธ์ธ ๋งˆ์ง€๋ง‰ ์•„์ดํ…œ์ด ๊ฐ์ง€๋˜์—ˆ์„ ๋•Œ API๋ฅผ ํ˜ธ์ถœํ•ฉ๋‹ˆ๋‹ค.

1. ํ˜ธ์ถœ ํŠธ๋ฆฌ๊ฑฐ

์Šคํฌ๋กค์„ ๊ฐ์ง€ํ•˜๋Š” ์˜์—ญ์„ useIntersectionObserver hook์„ ์ด์šฉํ•˜์—ฌ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค. ๋งŒ์•ฝ ๊ฒ€์ƒ‰ ์ค‘์ด ์•„๋‹ˆ๊ณ  ๋งˆ์ง€๋ง‰ ์•„์ดํ…œ์„ ์Šคํฌ๋กค ์ค‘์ด๋ผ๋ฉด search API๋ฅผ ํ˜ธ์ถœํ•ฉ๋‹ˆ๋‹ค.

// src/components/DropDown.ts

const onIntersect: IntersectionObserverCallback = ([{ isIntersecting }]) => {
  if (isSearching) return;
  if (isIntersecting) getMoreItem();
};

const { setTarget } = useIntersectionObserver({ onIntersect });

return(
...
{!isSearching && hasNextPage && (
  <span
    className={`${styles.align_center} ${styles.ellipsis}`}
      ref={setTarget}
  >
    ...
  </span>
)}
)

2. useIntersectionObserver hook

์ปค์Šคํ…€ hook์„ ์‚ฌ์šฉํ•˜์—ฌ ์Šคํฌ๋กค์„ ๊ฐ์ง€ํ•˜๋Š” ์ฝ”๋“œ๋ฅผ UI ์ปดํฌ๋„ŒํŠธ์™€ ๋ถ„๋ฆฌํ–ˆ๊ณ  options๋ฅผ ์ธ์ž๋กœ ๋ฐ›์•„์„œ ์žฌ์‚ฌ์šฉ์„ฑ์„ ๋†’์˜€์Šต๋‹ˆ๋‹ค.

// src/components/DropDown.ts

const useIntersectionObserver = ({
  root,
  rootMargin = '0px',
  threshold = 1,
  onIntersect,
}: useIntersectionObserverProps) => {
  const [target, setTarget] = useState<HTMLElement | null | undefined>(null);

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

    const observer: IntersectionObserver = new IntersectionObserver(
      onIntersect,
      { root, rootMargin, threshold },
    );
    observer.observe(target);

    return () => observer.unobserve(target);
  }, [onIntersect, root, rootMargin, target, threshold]);

  return { setTarget };
};

export default useIntersectionObserver;

3. ํ˜ธ์ถœ ์ œํ•œ

์„œ๋ฒ„์—์„œ ์ „๋‹ฌํ•˜๋Š” ๋ฐ์ดํ„ฐ total ๊ฐœ์ˆ˜์™€ ํ˜„์žฌ์˜ ๋ฐ์ดํ„ฐ ๊ฐœ์ˆ˜๋ฅผ ๋น„๊ตํ•˜์—ฌ ๋” ์ด์ƒ ๋ฐ›์•„์˜ฌ ๋ฐ์ดํ„ฐ๊ฐ€ ์—†๋‹ค๋ฉด API๋ฅผ ํ˜ธ์ถœํ•˜์ง€ ์•Š๋„๋ก ๊ตฌํ˜„ํ–ˆ์Šต๋‹ˆ๋‹ค.

// src/hooks/useSearch.ts

const { limit, page, qty, total, result } = response;

if (limit * (page - DEFAULT_PAGE) + qty >= total) setHasNextPage(false);
// src/components/DropDown.ts

return(
  ...
  {!isSearching && hasNextPage && (
    <span
      className={`${styles.align_center} ${styles.ellipsis}`}
        ref={setTarget}
    >
      ...
    </span>
  )}
)

Jest๋ฅผ ์ด์šฉํ•œ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ

InputTodo ์ปดํฌ๋„ŒํŠธ ํ…Œ์ŠคํŠธ

  • ๊ธฐ์กด์— ์‚ฌ์šฉ๋˜๊ณ  ์žˆ๋˜ useFocus ์ปค์Šคํ…€ ํ›…์„ ๋Œ€์ฒดํ•˜์—ฌ ์ถ”๊ฐ€ํ•œ autoFocus ์†์„ฑ์„ ๊ฒ€์ฆํ•˜๊ธฐ ์œ„ํ•ด ์ฒซ ๋ Œ๋”๋ง ์‹œ ์ž๋™ ํฌ์ปค์Šค ์—ฌ๋ถ€๋ฅผ ํ…Œ์ŠคํŠธํ–ˆ์Šต๋‹ˆ๋‹ค.
// src/__tests__/inputTodo.test.tsx

expect(screen.getByTestId('input-text')).toHaveFocus();
  • input์— ์ž…๋ ฅ๋œ ์ž…๋ ฅ๊ฐ’์„ ์ธ์ž๋กœ ํ•˜๋Š” ๋””๋ฐ”์šด์‹ฑ ์ปค์Šคํ…€ ํ›… ํ˜ธ์ถœ ์—ฌ๋ถ€๋ฅผ ํ…Œ์ŠคํŠธํ–ˆ์Šต๋‹ˆ๋‹ค.
// src/__tests__/inputTodo.test.tsx

test('input ์ž…๋ ฅ ์‹œ ์ž…๋ ฅ๊ฐ’์œผ๋กœ ๋””๋ฐ”์šด์‹ฑ ์‹คํ–‰๋จ', () => {
  const inputText = screen.getByTestId('input-text');
  const INPUT = 'lorem';

  userEvent.type(inputText, INPUT);

  expect(useDebounce).toHaveBeenCalledWith(INPUT);
});

Dropdown ์ปดํฌ๋„ŒํŠธ ํ…Œ์ŠคํŠธ

  • API ํ˜ธ์ถœ์„ ํ†ตํ•ด ๋ฐ›์•„์˜ค๋Š” ์ถ”์ฒœ ๊ฒ€์ƒ‰์–ด ๋ฐฐ์—ด๊ณผ ์‹ค์ œ ๋ Œ๋”๋ง๋˜๋Š” ๋“œ๋กญ๋‹ค์šด ๋ฆฌ์ŠคํŠธ์˜ ์ผ์น˜ ์—ฌ๋ถ€๋ฅผ ํ…Œ์ŠคํŠธํ–ˆ์Šต๋‹ˆ๋‹ค.
// src/__tests__/dropdown.test.tsx

expect(dropdownItems.length).toBe(DropdownProps.recommendList.length);
  • ๋ชจ๋“  ์ถ”์ฒœ ๊ฒ€์ƒ‰์–ด ๋ฆฌ์ŠคํŠธ ์ค‘ input ์ž…๋ ฅ๊ฐ’๊ณผ ์ผ์น˜ํ•˜๋Š” ๋ชจ๋“  ๋ฌธ์ž์—ด์˜ ๊ธ€์ž์ƒ‰ ๊ฐ•์กฐ ์Šคํƒ€์ผ๋ง ์ ์šฉ ์—ฌ๋ถ€๋ฅผ ํ…Œ์ŠคํŠธํ–ˆ์Šต๋‹ˆ๋‹ค.
// src/__tests__/dropdown.test.tsx

test('๋ชจ๋“  ์ถ”์ฒœ ๊ฒ€์ƒ‰์–ด ๋ฆฌ์ŠคํŠธ์—์„œ ์ž…๋ ฅ๊ฐ’๊ณผ ์ผ์น˜ํ•˜๋Š” ๋ฌธ์ž์—ด์€ ๊ธ€์ž์ƒ‰ ๊ฐ•์กฐ ์Šคํƒ€์ผ๋ง ์ ์šฉ', () => {
  const keywords = screen.getAllByText(DropdownProps.keyword);
  const accentTexts = screen.getAllByTestId('accent-text');

  expect(keywords).toMatchObject(accentTexts);
});

About

๐Ÿ ์›ํ‹ฐ๋“œ ํ”„๋ฆฌ์˜จ๋ณด๋”ฉ ์ธํ„ด์‹ญ 4์ฃผ์ฐจ ๊ณผ์ œ - ๐Ÿ–ฑ ์ถ”์ฒœ ๊ฒ€์ƒ‰์–ด ๋ฌดํ•œ ์Šคํฌ๋กค

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published