๋ฐ˜์‘ํ˜•
๊ธ€ ์ˆ˜์ •์‚ฌํ•ญ์€ ๋…ธ์…˜ ํŽ˜์ด์ง€์— ๊ฐ€์žฅ ๋น ๋ฅด๊ฒŒ ๋ฐ˜์˜๋ฉ๋‹ˆ๋‹ค. ๋งํฌ๋ฅผ ์ฐธ๊ณ ํ•ด ์ฃผ์„ธ์š”

 

 

Debounce๋Š” ์ด๋ฒคํŠธ๊ฐ€ ์—ฐ์†์ ์œผ๋กœ ๋ฐœ์ƒํ•ด๋„ ํ•ญ์ƒ ๋งˆ์ง€๋ง‰ ์ด๋ฒคํŠธ๋งŒ ์ฒ˜๋ฆฌํ•˜๋Š” ๊ฒƒ์„ ๋งํ•˜๋ฉฐ, Memoize๋Š” ์ด์ „ ์—ฐ์‚ฐ ๊ฒฐ๊ณผ๋ฅผ ์žฌ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์„ ๋งํ•œ๋‹ค. Debounce์™€ Memoize๋ฅผ ํ™œ์šฉํ•ด ๋ถˆํ•„์š”ํ•œ API ์š”์ฒญ์„ ๋ฐฉ์ง€ํ•  ์ˆ˜ ์žˆ๋‹ค.

 

Animichan์€ ์ผ๋ณธ ์• ๋‹ˆ๋ฉ”์ด์…˜์— ๋“ฑ์žฅํ•œ ์ธ์šฉ๋ฌธ(Quotes)์„ ์ œ๊ณตํ•˜๋Š” OpenAPI๋‹ค. title ๋งค๊ฐœ๋ณ€์ˆ˜์— ์• ๋‹ˆ๋ฉ”์ด์…˜ ์ œ๋ชฉ์„ ์ฟผ๋ฆฌ์ŠคํŠธ๋ง ๋ณด๋‚ด์„œ ์š”์ฒญํ•˜๋ฉด, ํ•ด๋‹น ์• ๋‹ˆ๋ฉ”์ด์…˜์˜ ์ธ์šฉ๋ฌธ ์„ธํŠธ๋ฅผ ๋ฐ›์•„์˜ฌ ์ˆ˜ ์žˆ๋‹ค. ์ด API๋ฅผ ์ด์šฉํ•ด ๊ฐ„๋‹จํ•œ ๊ฒ€์ƒ‰ ์–ดํ”Œ๋ฆฌ์ผ€์ด์…˜์„ ๊ตฌํ˜„ํ•  ์ˆ˜ ์žˆ๋‹ค. 

// Request
'https://animechan.vercel.app/api/quotes/anime?title=naruto'

// Output
[{ anime: "Naruto", character: "...", quote: "..." }, ...]

 

HOW TO WORK โญ๏ธ


  1. App : quotes(์ธ์šฉ๋ฌธ ์„ธํŠธ) ์ƒํƒœ ๊ด€๋ฆฌ
    • ๊ฒ€์ƒ‰์ฐฝ(SearchBar)์—์„œ ์ž…๋ ฅ์„ ๋งˆ์นœ ๊ฒ€์ƒ‰์–ด(term)๋ฅผ ๋ฐ›์•„
    • ํ•ด๋‹น ๊ฒ€์ƒ‰์–ด๋กœ API ์š”์ฒญ(onSearchSubmit ํ•ธ๋“ค๋Ÿฌ ์‹คํ–‰)
    • ์ธ์šฉ๋ฌธ ์„ธํŠธ๋ฅผ ๋ฐ›์•„์˜จ ํ›„, ์ธ์šฉ๋ฌธ ๋ฆฌ์ŠคํŠธ(quotes) ๋ Œ๋”
  2. SearchBar : term(๊ฒ€์ƒ‰์–ด) ์ƒํƒœ ๊ด€๋ฆฌ
    • ๊ฒ€์ƒ‰์–ด(debouncedTerm; ์ž„์‹œ ๊ฒ€์ƒ‰์–ด) ์ž…๋ ฅ ํ›„ 1์ดˆ๊ฐ„ ์ถ”๊ฐ€ ์ž…๋ ฅ์ด ์—†์„ ๊ฒฝ์šฐ
    • ํ•ด๋‹น ๊ฒ€์ƒ‰์–ด๋ฅผ ์š”์ฒญ ๊ฒ€์ƒ‰์–ด(term)๋กœ ์ €์žฅํ•œ ํ›„
    • App ์ปดํฌ๋„ŒํŠธ์— ์žˆ๋Š” API ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ ์‹คํ–‰
  3. Quote : 1๊ฐœ ์ธ์šฉ๋ฌธ์— ๋Œ€ํ•œ ์ปจํ…Œ์ด๋„ˆ
  4. animeChan : API ์š”์ฒญ (Lodash ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ์˜ Memoize ํ™œ์šฉ)
    • ์ด๋ฏธ ๊ฒ€์ƒ‰ํ–ˆ๋˜ ํ‚ค์›Œ๋“œ๋ผ๋ฉด ์บ์‹œ ๋ฐ์ดํ„ฐ(Map ๊ฐ์ฒด) ์žฌ์‚ฌ์šฉ
    • ์ด์ „์— ๊ฒ€์ƒ‰ํ•˜์ง€ ์•Š์•˜๋˜ ํ‚ค์›Œ๋“œ๋ผ๋ฉด API ์š”์ฒญ์ง„ํ–‰

App

๋”๋ณด๊ธฐ
import React, { useState } from 'react';

import '../styles/App.css';
import Quote from './Quote';
import SearchBar from './SearchBar';
import { requestQuotes } from '../apis/animeChan';

const App = function () {
  const [quotes, setQuotes] = useState([]);
  const [noResults, setNoResults] = useState(false);

  const onSearchSubmit = async (term) => {
    const quotesArray = await requestQuotes(term.toLowerCase());

    setNoResults(quotesArray.length === 0);
    setQuotes(quotesArray);
  };

  const clearResults = () => setQuotes([]);

  const renderedQuotes = quotes.map((quote, i) => {
    return <Quote quote={quote} key={i} />;
  });

  return (
    <div className="app">
      <h1 className="title">Search Quotes</h1>

      <div className="disclaimer-container">
        <p className="disclaimer">
          Get 10 quotes from your favorite{' '}
          <span className="highlight">anime</span>!
        </p>
      </div>

      <SearchBar onSearchSubmit={onSearchSubmit} clearResults={clearResults} />

      {noResults && <p className="no-results">No results found.</p>}
      <div className="main-content">{renderedQuotes}</div>
    </div>
  );
};

export default App;

 

State

const [quotes, setQuotes] = useState([]);
const [noResults, setNoResults] = useState(false);

 

  • quotes : ์ธ์šฉ๋ฌธ ์„ธํŠธ ๋ฐ์ดํ„ฐ
  • noResults : ๊ฒ€์ƒ‰์–ด ํ‚ค์›Œ๋“œ์— ๋Œ€ํ•œ ์ธ์šฉ๋ฌธ ์œ ๋ฌด ์—ฌ๋ถ€

 

Handler

const onSearchSubmit = async (term) => {
  const quotesArray = await requestQuotes(term.toLowerCase());
  setNoResults(quotesArray.length === 0);
  setQuotes(quotesArray);
};

const clearResults = () => setQuotes([]);

 

  • onSearchSubmit : ๊ฒ€์ƒ‰์–ด์— ๋Œ€ํ•œ API ์š”์ฒญ ํ›„ ๊ฒฐ๊ณผ๋ฅผ ๋ฐ›์•„ quotes noResults ์ƒํƒœ ์—…๋ฐ์ดํŠธ
  • clearResults : ๊ฒ€์ƒ‰์–ด๊ฐ€ ''๋นˆ ๋ฌธ์ž์—ด์ผ ๊ฒฝ์šฐ quotes ์ƒํƒœ๋ฅผ ๋นˆ ๋ฐฐ์—ด๋กœ ๋ณ€๊ฒฝํ•˜๋Š” ํ•ธ๋“ค๋Ÿฌ.

 

Render

SearchBar ์ปดํฌ๋„ŒํŠธ์— ์ •์˜ํ•œ ๋‘ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๋„˜๊ธฐ๊ณ , ๊ฒ€์ƒ‰์–ด์— ๋Œ€ํ•œ ์ธ์šฉ๋ฌธ ์„ธํŠธ๊ฐ€ 1๊ฐœ ์ด์ƒ์ด๋ผ๋ฉด Quote ์ปดํฌ๋„ŒํŠธ๋ฅผ ํ†ตํ•ด ๊ฐ ์ธ์šฉ๋ฌธ์„ ๋ Œ๋”ํ•œ๋‹ค.

// Quote, SearchBar ์ปดํฌ๋„ŒํŠธ import ๊ตฌ๋ฌธ ์ƒ๋žต

const renderedQuotes = quotes.map((quote, i) => {
  return <Quote quote={quote} key={i} />;
});

return (
  <div className="app">
    <SearchBar onSearchSubmit={onSearchSubmit} clearResults={clearResults} />
    {noResults && <p className="no-results">No results found.</p>}
    <div className="main-content">{renderedQuotes}</div>
  </div>
);

 

SearchBar โญ๏ธ


๋”๋ณด๊ธฐ
import React, { useEffect, useState } from 'react';

import '../styles/SearchBar.css';

const SearchBar = function ({ onSearchSubmit, clearResults }) {
  const [term, setTerm] = useState('');
  const [debouncedTerm, setDebouncedTerm] = useState(term);

  // update 'term' value after 1 second from the last update of 'debouncedTerm'
  useEffect(() => {
    const timer = setTimeout(() => setTerm(debouncedTerm), 1000);
    return () => clearTimeout(timer);
  }, [debouncedTerm]);

  // submit a new search
  useEffect(() => {
    if (term !== '') {
      onSearchSubmit(term);
    } else {
      clearResults();
    }
  }, [term]);

  return (
    <div className="searchbar">
      <input
        className="searchbar-input"
        type="text"
        placeholder="Search by title. . ."
        onChange={(e) => setDebouncedTerm(e.target.value)}
        value={debouncedTerm}
      />
    </div>
  );
};

export default SearchBar;

 

State

const [term, setTerm] = useState('');
const [debouncedTerm, setDebouncedTerm] = useState(term);

 

  • term : 1์ดˆ๊ฐ„ ์ถ”๊ฐ€์ ์ธ ๊ฒ€์ƒ‰์–ด ์ž…๋ ฅ์ด ์—†์„ ๋•Œ์˜ ๊ฒ€์ƒ‰์–ด ์ƒํƒœ. ์ด ๊ฒ€์ƒ‰์–ด๋กœ API ์š”์ฒญ์ด ์ง„ํ–‰๋œ๋‹ค
  • debouncedTerm : input ์ฐฝ์— ๋ณ€ํ™”๊ฐ€ ์ผ์–ด๋‚  ๋•Œ๋งˆ๋‹ค ์ €์žฅ๋  ์ž„์‹œ ๊ฒ€์ƒ‰์–ด ์ƒํƒœ

 

Effect

useEffect for debouncedTerm

useEffect(() => {
  const timer = setTimeout(() => setTerm(debouncedTerm), 1000);
  return () => clearTimeout(timer);
}, [debouncedTerm]);

 

  1. ๊ฒ€์ƒ‰์ฐฝ(input)์— ํ‚ค์›Œ๋“œ ์ž…๋ ฅ → onChange ์ด๋ฒคํŠธ ๋ฐœ์ƒ
  2. debouncedTerm ์ƒํƒœ ๋ณ€๊ฒฝ
  3. useEffect ์‹คํ–‰ (์ข…์†์„ฑ ๋ฐฐ์—ด์— debouncedTerm์„ ๋ช…์‹œํ–ˆ์œผ๋ฏ€๋กœ)
    1. ํด๋ฆฐ์—… ํ•จ์ˆ˜ ์‹คํ–‰ → ์ด์ „ timer ์ œ๊ฑฐ
    2. 1์ดˆ ํ›„ term ์ƒํƒœ๋ฅผ ๋ณ€๊ฒฝํ•˜๋Š” timer ๋“ฑ๋ก
  4. 1์ดˆ๊ฐ€ ์ง€๋‚˜๊ธฐ์ „ ๊ฒ€์ƒ‰์ฐฝ์— onChange ์ด๋ฒคํŠธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค๋ฉด 3๋ฒˆ ๋ฐ˜๋ณต. ๋“ฑ๋กํ•œ timer๋Š” ์‹คํ–‰๋˜์ง€ ์•Š์œผ๋ฏ€๋กœ term ์ƒํƒœ๋Š” ๊ทธ๋Œ€๋กœ ์œ ์ง€

 

useEffect for term

useEffect(() => {
  if (term !== '') {
    onSearchSubmit(term);
  } else {
    clearResults();
  }
}, [term]);

 

  1. 1์ดˆ ํ›„ ๋“ฑ๋กํ•œ timer๊ฐ€ ์‹คํ–‰๋ผ์„œ term ์ƒํƒœ ๋ณ€๊ฒฝ
  2. useEffect ์‹คํ–‰ (์ข…์†์„ฑ ๋ฐฐ์—ด์— term์„ ๋ช…์‹œํ–ˆ์œผ๋ฏ€๋กœ)
    • term ์ƒํƒœ๊ฐ€ ๋นˆ ๋ฌธ์ž์—ด์ด ์•„๋‹ˆ๋ผ๋ฉด onSearchSubmit ํ•ธ๋“ค๋Ÿฌ ์‹คํ–‰(API ์š”์ฒญ)
    • term ์ƒํƒœ๊ฐ€ ๋นˆ ๋ฌธ์ž์—ด์ด๋ผ๋ฉด clearResults ํ•ธ๋“ค๋Ÿฌ ์‹คํ–‰(App์˜ quotes ์ƒํƒœ๋ฅผ ๋นˆ ๋ฐฐ์—ด๋กœ ๋ณ€๊ฒฝ)

 

Render

term์ด ์•„๋‹Œ debouncedTerm์„ input ํƒœ๊ทธ์— ๋ฐ”์ธ๋”ฉํ•œ๋‹ค.

return (
  <div className="searchbar">
    <input
      className="searchbar-input"
      type="text"
      placeholder="Search by title. . ."
      onChange={(e) => setDebouncedTerm(e.target.value)}
      value={debouncedTerm}
    />
  </div>
);

 

API โญ๏ธ

์‚ฌ์šฉ์ž๊ฐ€ abab ์ˆœ์„œ๋กœ ๊ฒ€์ƒ‰์–ด๋ฅผ ์ž…๋ ฅํ–ˆ๋‹ค๋ฉด ์ด 4๋ฒˆ์˜ API ํ˜ธ์ถœ์ด ์ด๋ค„์ง„๋‹ค.

1) a ๊ฒ€์ƒ‰์–ด ์ž…๋ ฅ : a ๊ฒ€์ƒ‰์–ด์— ๋Œ€ํ•œ API ์š”์ฒญ
2) b ๊ฒ€์ƒ‰์–ด ์ž…๋ ฅ : b ๊ฒ€์ƒ‰์–ด์— ๋Œ€ํ•œ API ์š”์ฒญ
3) a ๊ฒ€์ƒ‰์–ด ์ž…๋ ฅ : a ๊ฒ€์ƒ‰์–ด์— ๋Œ€ํ•œ API ์š”์ฒญ
4) b ๊ฒ€์ƒ‰์–ด ์ž…๋ ฅ : b ๊ฒ€์ƒ‰์–ด์— ๋Œ€ํ•œ API ์š”์ฒญ

 

Lodash์˜ Memoize ๋ฉ”์„œ๋“œ๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด API ํ˜ธ์ถœ์„ 2๋ฒˆ์œผ๋กœ ์ค„์ผ ์ˆ˜ ์žˆ๋‹ค.

1) a ๊ฒ€์ƒ‰์–ด ์ž…๋ ฅ : a ๊ฒ€์ƒ‰์–ด์— ๋Œ€ํ•œ API ์š”์ฒญ
2) b ๊ฒ€์ƒ‰์–ด ์ž…๋ ฅ : b ๊ฒ€์ƒ‰์–ด์— ๋Œ€ํ•œ API ์š”์ฒญ
3) a ๊ฒ€์ƒ‰์–ด ์ž…๋ ฅ : 1๋ฒˆ ๊ฒฐ๊ณผ ์žฌ์‚ฌ์šฉ
4) b ๊ฒ€์ƒ‰์–ด ์ž…๋ ฅ : 2๋ฒˆ ๊ฒฐ๊ณผ ์žฌ์‚ฌ์šฉ

 

_.memoize ๋ฉ”์„œ๋“œ์˜ ์ฒซ๋ฒˆ์งธ ์ธ์ž๋Š” ์—ฐ์‚ฐ์„ ์ˆ˜ํ–‰ํ•  ๋กœ์ง์ด ๋‹ด๊ธด ์ฝœ๋ฐฑ ํ•จ์ˆ˜๋ฅผ ๋ฐ›๋Š”๋‹ค. ๋งŒ์•ฝ ํ•ด๋‹น ์ฝœ๋ฐฑ ํ•จ์ˆ˜๊ฐ€ ๋ฐ›์€ ํŒŒ๋ผ๋ฏธํ„ฐ์˜ ๊ฐ’์œผ๋กœ ์—ฐ์‚ฐ์„ ์ˆ˜ํ–‰ํ•œ์  ์žˆ๋‹ค๋ฉด ํ•ด๋‹น ์—ฐ์‚ฐ ๊ฒฐ๊ณผ๋ฅผ ์žฌ์‚ฌ์šฉํ•œ๋‹ค. ์ด์ „์— ๋ฐ›์€์ ์ด ์—†์—ˆ๋˜ ํŒŒ๋ผ๋ฏธํ„ฐ ๊ฐ’์ด๋ผ๋ฉด ์—ฐ์‚ฐ์„ ์ˆ˜ํ–‰ํ•˜๊ณ  ์บ์‹œ์— ์ €์žฅ๋œ๋‹ค. Memoize ๊ด€๋ จ ํฌ์ŠคํŒ…์€ ๋งํฌ ์ฐธ๊ณ .

// apids/animeChan.js
import _ from 'lodash';

export const requestQuotes = _.memoize(async (title) => {
  const res = await fetch(
    `https://animechan.vercel.app/api/quotes/anime?title=${title}`,
  );
  if (res.status !== 200) return [];

  const quotesArray = await res.json();
  return quotesArray;
});

 

์ €์žฅ๋œ ์บ์‹œ๋Š” requestQuotes.cache์—์„œ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋‹ค. world ๊ฒ€์ƒ‰์–ด๋กœ ์ž…๋ ฅํ•ด๋ณด๋ฉด ์•„๋ž˜์ฒ˜๋Ÿผ ๋‚˜์˜จ๋‹ค.

 

 

PAGINATION


๋”๋ณด๊ธฐ ๋ฒ„ํŠผ์ด๋‚˜ ๋ฌดํ•œ ์Šคํฌ๋กค ๋“ฑ์„ ๊ตฌํ˜„ํ•˜๋ ค๋ฉด term ์ƒํƒœ๋ฅผ SearchBar๊ฐ€ ์•„๋‹Œ App์—์„œ ๊ด€๋ฆฌํ•˜๋Š”๊ฒŒ ์œ ์ง€๋ณด์ˆ˜ํ•˜๊ธฐ ๋” ํŽธํ•˜๋‹ค. term ์ƒํƒœ ์™ธ์— ํ˜„์žฌ ํŽ˜์ด์ง€์— ๋Œ€ํ•œ ์ •๋ณด๋ฅผ ๋‹ด๋Š” currentPage ์ƒํƒœ๋„ ํ•„์š”ํ•˜๋‹ค.

// App.js
const [term, setTerm] = useState('');
const [currentPage, setCurrentPage] = useState(0); // ํŽ˜์ด์ง€ ์ •๋ณด
// ...์ƒ๋žต

useEffect(() => {
  if (term === '') {
    setRenderData([]);
    setNoResults(false);
  }
}, [term]);

return (
  // ...์ƒ๋žต
  <SearchBar term={term} setTerm={setTerm} setCurrentPage={setCurrentPage} />
);

 

SearchBar ์ปดํฌ๋„ŒํŠธ์˜ useEffect์—์„  debouncedTerm(ํ˜„์žฌ ์ž…๋ ฅ๋˜์–ด ์žˆ๋Š” ๊ฒ€์ƒ‰์–ด)์™€ term์ด ๊ฐ™์ง€ ์•Š์„ ๋•Œ๋งŒ ์‹คํ–‰๋˜๋„๋ก ํ•œ๋‹ค. ๊ฒ€์ƒ‰์–ด๊ฐ€ ๋‹ฌ๋ผ์กŒ๋‹ค๋ฉด ํŽ˜์ด์ง€(currentPage)๋„ 0(ํ˜น์€ 1)๋ถ€ํ„ฐ ๋‹ค์‹œ ์‹œ์ž‘ํ•ด์•ผ ํ•œ๋‹ค.

// SearchBar.js
const [debouncedTerm, setDebouncedTerm] = useState(term);

useEffect(() => {
  // isChanged๋Š” term๊ณผ debouncedTerm์ด ๊ฐ™์€์ง€ ํ™•์ธํ•˜๋Š” ์œ ํ‹ธ ํ•จ์ˆ˜(trim ์ ์šฉ ํ›„ ๊ฐ™์€์ง€ ํ™•์ธ)
  const isChanged = checkIsTermChanged(term, debouncedTerm);
  let timer;

  if (isChanged) {
    timer = setTimeout(() => {
      setTerm(debouncedTerm);
      setCurrentPage(0); // ๊ฒ€์ƒ‰์–ด(debouncedTerm)๊ฐ€ ๋ฐ”๋€Œ์—ˆ์œผ๋ฉด 0ํŽ˜์ด์ง€ ๋ถ€ํ„ฐ ๋‹ค์‹œ ์‹œ์ž‘
    }, 1000);
  }

  return () => clearTimeout(timer);
}, [debouncedTerm, term]);

 

REFERENCE


 

How to Create an Optimized Real-Time Search with React

Learn how to create an optimized real-time search feature with React Hooks using debouncing and memoization techniques.

javascript.plainenglish.io

 

๋ฐ˜์‘ํ˜•