Javascript/React

[GitHub API] repository 검색창 만들기 with debounce

  • -
728x90

진행했던 프로젝트 중에 github api를 사용하요 이슈 목록을 가져오는 프로젝트가 있었다.

프로젝트의 목적은 api를 사용하여 이슈 목록을 가져오고 무한스크롤을 직접 만드는 프로젝트였다.

 

이 프로젝트의 목적을 좀 더 분명히 하기 위해서 리팩토링을 해보자.

facebook/react 리포지토리의 이슈만 가져오는 프로젝트였는데, 직접 검색한 리포지토리의 이슈를 가져오는 기능을 만들어보자.

 

검색바 만들기

내가 원하는 검색바의 기능은 다음과 같다.

  1. 리포지토리를 검색할 수 있는 검색바
  2. 검색바가 포커싱 되었을 때, 확인할 수 있는 검색 결과 영역
  3. 검색어 입력중일 때, 검색중 표시
  4. 검색 버튼을 누르지 않아도, 검색 결과를 확인할 수 있음
  5. 검색 결과에 원하는 리포지토리를 누르거나, 검색 결과 영역 밖을 눌렀을 때, 포커싱 취소
  6. 검색 결과를 누르면 해당 이슈 목록 페이지로 이동

 

위의 기능을 토대로 검색바를 만들려면 기억해야할 state값이 무엇이 있는지 생각해보자.

  • 검색어 input을 위한 값
    • 제어 컴포넌트냐 비제어 컴포넌트냐를 생각해야한다. 우선, 검색어에 유효성 검사는 딱히 필요 없을것같다. 하지만 원하는 기능은 검색어를 입력할 때마다 해당 검색어와 관련있는 리포지토리를 결과로 보여주어야 한다. 따라서 제어 컴포넌트를 사용하자.
  • 포커싱 상태를 저장할 값
  • 검색중일 때 검색중 표시를 위한 상태 값

기본적으로 생각해야할 상태들이 3개나 되고 그에 따른 핸들러들을 만들면 코드가 좀 지저분해질 것 같으니 커스텀 훅으로 관심사 분리를 해주자.

 

import { ChangeEvent, useEffect, useState } from 'react';
import { SearchParams, SearchRepo, getSearchList } from '../api/github';

const useRepoSearch = () => {
  const [isLoading, setIsLoading] = useState<boolean>(false);
  const [isFocus, setIsFocus] = useState<boolean>(false);

  const [searchInput, setSearchInput] = useState<string>('');
  const [searchParams, setSearchParams] = useState<SearchParams>({
    q: '',
    per_page: 5,
  });
  const [searchList, setSearchList] = useState<SearchRepo[]>([]);

  const inputChange = (e: ChangeEvent<HTMLInputElement>) => {
    setSearchInput(e.target.value);
  };

  const search = async () => {
    setIsLoading(true);
    try {
      const { data } = await getSearchList(searchParams);
      setSearchList(data.items);
      setIsLoading(false);
    } catch (error) {
      console.log(error);
    }
  };

  const handleFocus = (value: boolean) => {
    setIsFocus(value);
  };

  // debounce
  useEffect(() => {
    if (searchInput === '') {
      setSearchList([]);
      setSearchParams({ q: '', per_page: 5 });
      return;
    }
    const timer = setTimeout(() => {
      setSearchParams({ ...searchParams, q: searchInput });
    }, 500);

    return () => {
      clearTimeout(timer);
    };
  }, [searchInput, setSearchParams]);

  useEffect(() => {
    if (searchParams.q === '') return;

    search();
  }, [searchParams.q]);

  return {
    isLoading,
    isFocus,
    handleFocus,
    searchInput,
    searchList,
    inputChange,
  };
};

export default useRepoSearch;

 

useRepoSearch라는 훅으로 관심사 분리를 해주었지만, 음.. 데이터를 가져오는 부분과 디바운싱 처리하는 부분이 좀 더 분리되었으면한다. 그리고 useRepoSearch 처럼 특정 컴포넌트의 가독성과 상태를 관리하는 부분을 관심사 분리해주기도 하지만, useSearch라는 좀 더 범용적인 훅을 만들고싶다. 바꿔보자.

 

우선 debounce 기능을 처리할 useDebounce부터 만들어보자.

import { useEffect, useState } from 'react';

interface UseDebounceArg {
  value: string;
  delay?: number;
}

const useDebounce = ({ value, delay = 500 }: UseDebounceArg) => {
  const [isDebouncing, setIsDebouncing] = useState<boolean>(false);
  const [debouncedValue, setDebouncedValue] = useState<string>('');

  useEffect(() => {
    setIsDebouncing(true);
    const timer = setTimeout(() => {
      setDebouncedValue(value);
      setIsDebouncing(false);
    }, delay);

    return () => {
      clearTimeout(timer);
      setDebouncedValue('');
    };
  }, [delay, value, setDebouncedValue, setIsDebouncing]);

  return {
    isDebouncing,
    debouncedValue,
  };
};

export default useDebounce;

디바운스란 전자공학에서 사용되는 용어로 특정 신호가 발생했을 때 몇 초동안 다음 신호를 받지 않는 것을 말한다.

이 기술이 필요한 이유는, 검색어를 입력할 때마다 해당 검색어와 관련있는 리포지토리를 찾기위해서 GitHub API에 검색어에 대해 요청을 할 것이기 때문인데, 너무 빈번한 검색을 요청하면 성능상 좋지 않기도하고, 불필요한 검색어를 요청할 필요도 없기 때문이다.

예를 들어 react를 검색할때, rac과 같은 오타라던지, r, re, rea, reac, 처럼 모든 글씨에 대해서 요청 할 필요가 없기 때문이다.

 

작동방식은 생각보다 간단하다. 디바운싱할 value를 setTimeout으로 delay를 준다음 delay가 지나면 해당 값을 setDebouncedValue 해준다.

 

 

다음으로 useSearch로 바꾼 코드이다.

import { ChangeEvent, useEffect, useState } from 'react';
import useDebounce from './useDebounce';

interface UseSearchArgs<T> {
  initialParams?: T;
  options?: {
    debouce: boolean;
  };
}

const useSearch = <T>({ initialParams, options }: UseSearchArgs<T>) => {
  const [isFocus, setIsFocus] = useState<boolean>(false);

  const [searchInput, setSearchInput] = useState<string>('');
  const [searchParams, setSearchParams] = useState<T>(
    (initialParams || {}) as T,
  );

  const { isDebouncing, debouncedValue } = useDebounce({ value: searchInput });

  const handleSearchInputChange = (e: ChangeEvent<HTMLInputElement>) => {
    setSearchInput(e.target.value);
  };

  const handleFocus = (value: boolean) => {
    setIsFocus(value);
  };

  const handleSearchParams = (params: T) => {
    setSearchParams((prev) => {
      return { ...prev, params };
    });
  };

  useEffect(() => {
    if (options?.debouce) {
      if (debouncedValue !== '') {
        setSearchParams((prev) => {
          return { ...prev, q: debouncedValue };
        });
      }
    }
  }, [options?.debouce, debouncedValue]);

  return {
    isFocus,
    isDebouncing,
    searchInput,
    searchParams,
    handleFocus,
    handleSearchParams,
    handleSearchInputChange,
  };
};

export default useSearch;

 

useDebouce를 검색바 컴포넌트에서 사용할지 useSearch 훅에서 사용할지 고민이 좀 됐다. 어쨋든 검색어와 관련있는 기능이기도 하고... 하지만 매번 디바운스 처리가 필요한 것도 아니고... 

그래서 생각해낸 방법이 useSearch에 option을 주어서 디바운스 기능이 필요할때에만 작동하도록 했다.

 

그리고 검색어 api에 요청할 때 대부분 query를 통해서 검색어 옵션이나 필터를 설정한다. 따라서 useSearch에 제네릭을 사용해서 api 요청시 필요한 query의 형태에따라 적용할 수 있도록 해주었다.

 

 

다음으로, SearchBar 컴포넌트이다.

import { useNavigate } from 'react-router-dom';
import styled from 'styled-components';
import useSearch from '../common/hook/useSearch';
import { SearchParams, SearchRepo, getSearchRepos } from '../common/api/github';
import { useQuery } from 'react-query';

const SearchBar = () => {
  const {
    isFocus,
    isDebouncing,
    searchInput,
    searchParams,
    handleFocus,
    handleSearchParams,
    handleSearchInputChange,
  } = useSearch<SearchParams>({
    initialParams: { q: '', per_page: 5 },
    options: { debouce: true },
  });

  const { status, data } = useQuery({
    queryKey: ['search_repo', searchParams.q],
    queryFn: () => getSearchRepos(searchParams),
    enabled: !!searchParams.q,
    refetchOnWindowFocus: false,
  });

  const navigate = useNavigate();

  const handleItemClick = (title: string) => {
    const [owner, repo] = title.split('/');

    navigate(`/issues?owner=${owner}&repo=${repo}`);
    handleFocus(false);
  };

  // console.log(data);

  return (
    <SearchBarStyle>
      <SearchTextArea>
        <SearchTextField>
          <SearchInput
            type="text"
            value={searchInput}
            placeholder="Repository 이름을 입력해주세요."
            onChange={handleSearchInputChange}
            onFocus={() => handleFocus(true)}
          />
        </SearchTextField>
        <SearchButton>검색</SearchButton>
      </SearchTextArea>

      {isFocus && (
        <SearchResultStyle>
          <SearchResultHeader>검색 결과</SearchResultHeader>
          <SearchResultMessage>
            {searchInput === ''
              ? '검색 결과 없음'
              : isDebouncing && '검색중...'}
          </SearchResultMessage>
          <ul>
            {data &&
              data.map((item) => (
                <SearchItem
                  key={item.id}
                  onClick={() => handleItemClick(item.full_name)}
                >
                  {item.full_name}
                </SearchItem>
              ))}
          </ul>
        </SearchResultStyle>
      )}
    </SearchBarStyle>
  );
};

export default SearchBar;

 

데이터 패칭은 react-query의 useQuery를 사용하였다. 

useQuery를 사용한 이유는 데이터 캐싱 기능을 사용하기 위해서이다. react-query의 useQuery와 비슷하게 직접 구현해본적도 있지만(나중에 블로그에 포스팅할 예정), 좀 더 react-query를 알아보고, 예전에 구현했던 useQuery와 다른 기능들을 리팩토링 하기 위해서 좀 더 심화해서 사용해보고 싶어 react-query를 사용했다.

 

원하는 기능이 제대로 잘 작동한다. 하지만 제어 컴포넌트로 구현한 검색어 입력 때문에 입력마다 리렌더링 된다. 

음 ... 해결 방법이 도무지 생각나지 않는다. 흠 ... 어떻게 해야하지 ?? 

 

 

혹시 알고 계신분이 있다면... 댓글 남겨주시면 감사하겠습니다.

728x90
300x250
Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.