Logo

React로 검색 UI 구현하기 (+ Debounce)

많은 양의 데이터를 다루는 서비스에서 검색 기능은 필수적이죠? 이번 포스팅에서는 아래와 같은 웹에서 흔히 볼 수 있는 검색 UI를 React로 함께 구현해보겠습니다.

검색창 컴포넌트 구현

검색창(SearchBox) 컴포넌트에는 사용자가 검색어를 입력하므로 기본적으로 HTML의 <input> 요소를 사용합니다. 이때, 접근성을 위해 type 속성을 search로 설정해 주세요. 스크린 리더 사용자에게는 매우 중요한 정보이기 때문입니다.

export function SearchBox({ value, onChange }) {
  return (
    <input
      type="search"
      placeholder="국가 이름을 입력하세요"
      value={value}
      onChange={onChange}
    />
  );
}

부모 요소에서 상태 관리를 하기 위해서 <SearchBox /> 컴포넌트는 prop으로 valueonChange를 받게 하겠습니다.

검색 결과 컴포넌트 구현

검색 결과로는 사용자가 입력한 검색어를 포함하고 있는 국가들을 리스트의 형태로 보여줄 것입니다. 따라서, countries prop을 통해서 국가 목록을 받고 검색 중일 때는 로딩 표시를 해주기 위해 searching prop도 받겠습니다.

export function SearchResults({ countries, searching }) {
  return (
    <article aria-busy={searching}>
      {searching ? (
        "잠시만 기다려주세요. 국가를 검색하고 있습니다."
      ) : (
        <>
          <header>{countries.length}개의 국가가 검색되었습니다.</header>
          <ul>
            {countries.map(({ code, en, ko }) => (
              <li key={code}>
                {ko} ({en})
              </li>
            ))}
          </ul>
        </>
      )}
    </article>
  );
}

국가 데이터 검색 기능

실제 애플리케이션이었다면 DB 연동이나 API 호출을 통해서 국가 데이터를 검색하겠지만, 최대한 간단한 예제를 위해서 실습 프로젝트에서는 국가 데이터를 프론트엔드에 배열로 저장해놓겠습니다.

const countries = [
  { code: "AE", en: "United Arab Emirates", ko: "아랍에미리트" },
  { code: "AF", en: "Afghanistan", ko: "아프가니스탄" },
  // ... 생략 ...
  { code: "ZA", en: "South Africa", ko: "남아프리카 공화국" },
  { code: "ZW", en: "Zimbabwe", ko: "짐바브웨" },
];

export async function fetchCountries(query) {
  await new Promise((r) => setTimeout(r, 2_000)); // 2초 지연
  return countries.filter(
    (country) =>
      country.en.toLowerCase().includes(query.toLowerCase()) ||
      country.ko.toLowerCase().includes(query.toLowerCase())
  );
}

fetchCountries() 함수가 국가 데이터를 필터링해서 반환하기 전에 2초에 지연을 주었습니다. 검색 UI 구현을 완성 후 테스트할 때 실제로 발생할 법한 검색 지연을 시뮬레이션하기 위함입니다.

일반 검색 UI 구현

위에서 구현한 검색창(SearchBox)과 검색 결과(SearchResults)를 조합하여 검색 UI를 구현해 보겠습니다. 검색(Search) 컴포넌트는 부모 컴포넌트로서 두 개의 자식 컴포넌트를 위해 useState() 훅(hook)으로 다음 3개의 상태를 관리합니다.

  • query: 사용자가 입력하는 검색어
  • countries: 검색 결과로 나온 국가 목록
  • searching: 현재 검색 중인지 여부

사용자가 검색창을 통해 검색어를 입력할 때마다 사이드 이펙트(side effect)로 국가 데이터를 가져오기 위해 useEffect() 훅(hook) 함수를 사용하여 fetchCountries() 함수를 호출합니다. countries 상태와 searching 상태는 검색 진행 상황에 따라 비동기로 적절히 갱신되야 합니다.

import { useEffect, useState } from "react";
import { SearchBox } from "./SearchBox";
import { SearchResults } from "./SearchResults";
import { fetchCountries } from "./countries";

export function Search() {
  const [query, setQuery] = useState("");
  const [countries, setCountries] = useState([]);
  const [searching, setSearching] = useState(false);

  useEffect(() => {
    setSearching(true);
    fetchCountries(query).then((countries) => {
      setCountries(countries);
      setSearching(false);
    });
  }, [query]);

  return (
    <>
      <SearchBox value={query} onChange={(e) => setQuery(e.target.value)} />
      <SearchResults countries={countries} searching={searching} />
    </>
  );
}

React의 훅(hook) 함수에 대해서는 아래 포스팅에서 자세히 다루고 있으니 참고 바랍니다.

그럼 이제 완성된 검색 UI를 테스트해볼까요?

검색창에 ind라고 입력해보시면 “인도”와 “인도네시아”, 이렇게 2개의 국가가 검색될 것입니다. 그러나 검색 결과 부분을 자세히 관찰해보시면 알파벳을 하나씩 입력할 때마다 검색이 일어나는 것을 알 수 있습니다. 그래서 i일 때는 49개의 국가가 검색되었다가, in일 때 11개의 국가가 검색되고, ind일 때 최종적으로 2개의 국가가 검색될 것입니다. 위에서 검색하는데 2초의 지연을 주었기 때문에 육안으로도 충분히 중간 검색 결과를 잠깐이라도 보실 수 있으실 것입니다.

초성, 중성, 종성으로 이루어진 한글로 테스트해보시면 이 문제가 좀 더 두드러지게 나타나는데요. 예를 들어, 을 입력하려면 , , 이러한 단계를 거치게 되는데, 첫 번째 두 단계에서는 0개의 국가가 검색됩니다. 즉, 우리는 불필요하게 2번의 검색을 더 하고 있는 것입니다.

검색어를 한 글자 한 글자 입력할 때 마다 검색 수행하는 것은 사용자 경험에도 악영향을 줄 뿐만 아니라, 대규모 서비스에서는 백엔드 API에 부담을 가중시키거나 데이터베이스의 과부하로 이어질 수 있습니다. 즉, 결국 서비스 운영 비용을 상승시킬 수 있는 것이지요.

향상된 검색 UI 구현

디바운스(Debounce) 기법을 적용하여 사용자가 입력에 즉각 반응하지 않고 마지막 입력 후 1초 동안 대기한 후에 검색이 일어나도록 구현을 개선해보겠습니다.

이를 위해서는 우선 지연된(debounced) 검색어를 저장해두기 위한 추가 상태가 필요하며, 이 지연된 검색어의 상태를 변경하기 위해서 사이드 이펙트도 하나 더 필요합니다.

사용자가 검색어를 입력할 때마다 setTimeout() 함수를 사용하여 1초의 지연을 두고 지연된 검색어에 반영되도록 예약을 겁니다. 그리고 clearTimeout() 함수를 사용하여 1초가 경과하기 전에 다시 사용자가 검색어를 입력하면 이전에 호출한 예약을 취소합니다. 이를 통해 1초 미만의 간격으로 발생하는 검색어 입력을 한꺼번에 모아 지연된 검색어에 반영할 수 있습니다.

마지막으로 데이터 검색 작업을 사용자가 입력한 검색어가 아닌 우리가 지연시킨 검색어에 의존되도록 바꿔주면 됩니다.

import { useEffect, useState } from "react";
import { SearchBox } from "./SearchBox";
import { SearchResults } from "./SearchResults";
import { fetchCountries } from "./countries";

export function DebouncedSearch() {
  const [query, setQuery] = useState("");
  const [debouncedQuery, setDebouncedQuery] = useState(query);  const [countries, setCountries] = useState([]);
  const [searching, setSearching] = useState(false);

  useEffect(() => {    const timeout = setTimeout(() => setDebouncedQuery(query), 1_000);    return () => clearTimeout(timeout);  }, [query]);
  useEffect(() => {
    setSearching(true);
    fetchCountries(debouncedQuery).then((countries) => {      setCountries(countries);
      setSearching(false);
    });
  }, [debouncedQuery]);
  return (
    <>
      <SearchBox value={query} onChange={(e) => setQuery(e.target.value)} />
      <SearchResults countries={countries} searching={searching} />
    </>
  );
}

커스텀 훅 추출

불필요한 검색을 줄이게 되어 좋긴한데 컴포넌트 코드가 좀 복잡해져서 아쉽죠?

우리 한 걸음 더 나아가 지연된 값에 대한 상태 관리와 사이드 이펙트를 커스텀 훅로 추출해보면 어떨까요?

import { useEffect, useState } from "react";

export function useDebouncedState(value, delay = 500) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timeout = setTimeout(() => setDebouncedValue(value), delay);
    return () => clearTimeout(timeout);
  }, [value, delay]);

  return debouncedValue;
}

이렇게 커스텀 훅으로 잡다구리한 로직을 빼니 컴포넌트 코드가 다시 깔끔해졌습니다. ✨

import { useEffect, useState } from "react";
import { SearchBox } from "./SearchBox";
import { SearchResults } from "./SearchResults";
import { fetchCountries } from "./countries";
import { useDebouncedState } from "./useDebouncedState";
export function DebouncedSearch() {
  const [query, setQuery] = useState("");
  const debouncedQuery = useDebouncedState(query, 1_000);  const [countries, setCountries] = useState([]);
  const [searching, setSearching] = useState(false);

  useEffect(() => {
    setSearching(true);
    fetchCountries(debouncedQuery).then((countries) => {
      setCountries(countries);
      setSearching(false);
    });
  }, [debouncedQuery]);

  return (
    <>
      <SearchBox value={query} onChange={(e) => setQuery(e.target.value)} />
      <SearchResults countries={countries} searching={searching} />
    </>
  );
}

게다가 이 커스텀 훅은 애플리케이션의 다른 검색 컴포넌트에서도 활용이 가능할 것입니다.

전체 코드

본 포스팅에서 작성한 코드는 아래에서 직접 확인하고 실행해보실 수 있습니다.

마치면서

지금까지 실습을 통해 React로 검색 UI를 어떻게 구현하고 Debounce를 통해 어떻게 최적화를 할 수 있는지 알아보았습니다. 불필요한 서비스 부하를 줄이고 좋은 검색 경험을 제공하는데 본 포스팅이 도움이 되었으면 좋겠습니다.