원티드 프리온보딩 개인 과제 후기(한국 임상정보 검색 기능 클론)

JYROH·2023년 7월 21일
0
post-thumbnail

이번에는 한국 임상정보 사이트의 검색 기능을 클론하는 작업을 해보았습니다. 이번 과제는 이전과는 다르게 개인과제로 진행되었습니다.

이번 과제의 핵심적인 구현 목표는 임상정보사이트의 기능을 따르되, 검색어에 대한 캐싱을 제공해야 하는 점이었습니다. 즉, api요청의 횟수를 최소화 하는 것이 목표였습니다.

Git Repo

구현 내용 및 설명


📌 최근 검색어 제공 기능

session Storage를 활용한 최근 검색어 저장 및 제공

❓설명

  • 필수 구현 사항은 아니지만, 임상시험 사이트에서 기본적으로 최근 검색어를 저장하는 기능을 제공하고 있었기에 구현을 하였습니다.
  • input Form에서 submit이벤트가 발생하거나, 유저가 특정 검색어를 클릭하였을때, 해당 경우를 "검색"으로 생각하고 작업하였습니다.
  • recentsearchArr이라는 배열에 최대 7개의 최근 검색어가 들어가며, 중복방지기능과, 오래된 검색어부터 제거되게 구현하였습니다.
  • 최근 검색어가 제거전까지는 영구히 보존되는 localStorage보다 해당 세션에만 종속되는 sessionStorage를 사용하는 것이 더 알맞다고 생각하여 sessionStorage를 활용하였습니다.
// Home.tsx
// sessionStorage
const addRecentSearch = (
  event?: React.FormEvent<HTMLFormElement> | React.MouseEvent<HTMLElement>,
  value?: string,
) => {
  event?.preventDefault();
  const arr = sessionStorage.getItem('recentSearch');
  const tmpSearch = value || search;
  if (arr) {
    const recentArr = JSON.parse(arr);
    const index = recentArr.indexOf(tmpSearch);
    if (index > -1) {
      recentArr.splice(index, 1);
    }
    if (recentArr.length === MAX_SHOW_NUM) recentArr.splice(-1, 1);
    sessionStorage.setItem('recentSearch', JSON.stringify([tmpSearch, ...recentArr]));
  } else sessionStorage.setItem('recentSearch', JSON.stringify([tmpSearch]));
};

useEffect(() => {
  arr = sessionStorage.getItem('recentSearch');
  recentSearchArr = arr ? JSON.parse(arr) : [];
}, []);

📌 검색창 구현 및 검색어 추천 기능

debounce와 ref를 활용한 검색창 구현 및 검색어 추천 기능

❓설명

  • 우선 검색 결과창을 구현하면서, 먼저 생각해본 것은 검색 결과창이 표시되는 조건입니다.
  • 기본적으로는 inputfocus가 되면 검색 결과창이 표시되고, blur가 되면 검색 결과창이 사라져야 합니다.
  • 그래서 최초에는 input이 제공하는 onBlur기능을 활용했으나, 이 경우 input외부의 검색 결과창을 클릭할 경우에도, 결과창이 꺼져버리는 결과가 발생했습니다.
  • blur처리를 input에 해서는 안되고 검색창과 결과창을 모두 포함하는 컨테이너에 해주어야 했습니다.
  • 따라서 inputRefsectionRef를 생성하여 마우스 이벤트를 추적하였고, 이를 통해 onFocus상태 관리를 할 수 있었습니다.
  • 또한 api호출 횟수를 줄이기 위해, 검색 기능 수행 시, inputdebounce(500ms)를 걸어서 관리하였습니다.
  • 해당 debounce기능은 useDebounce커스텀 훅을 생성하여 사용하였습니다.
  • 최종적으로 debounce된 검색어를 바탕으로 getSickList api 함수를 호출하여 사용하였습니다.
// hooks/useDebounce.ts
import { useState, useEffect } from 'react';

function useDebounce(value: string) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, 500);

    return () => {
      clearTimeout(handler);
    };
  }, [value]);

  return { debouncedValue, setDebouncedValue };
}

export default useDebounce;

// Home.tsx
// debounce
const [search, setSearch] = useState('');
const [searchRes, setSearchRes] = useState<Sick[]>([]);
const [onFocus, setOnFocus] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const sectionRef = useRef<HTMLDivElement>(null);

const { debouncedValue, setDebouncedValue } = useDebounce(search);

useEffect(() => {
  if (search === '' || debouncedValue === '') return;

  const getSick = async () => {
    const res = await getSickList(debouncedValue);
    if (res.length > MAX_SHOW_NUM) {
      const tmpArr = res.slice(0, MAX_SHOW_NUM);
      setSearchRes(tmpArr);
    } else {
      setSearchRes(res);
    }
    setLoading(false);
  };
  getSick();
}, [debouncedValue, onFocus]);

useEffect(() => {
  if (search.length === 0) {
    setSearchRes([]);
    setDebouncedValue('');
    setLoading(false);
  } else setLoading(true);
}, [search]);

const changeInputValue = (e: React.ChangeEvent<HTMLInputElement>) => {
  setLoading(true);
  setSearch(e.target.value);
};

const handleSearchValue = (value: string) => {
  setSearch(value);
  addRecentSearch(undefined, value);
};

//ref
const clickInputFocus = () => {
  setOnFocus(true);
};

const clickSection = <T extends Event>(event: T) => {
  const targetNode = event.target as Node;
  if (document.activeElement !== inputRef.current && !sectionRef.current?.contains(targetNode)) {
    setOnFocus(false);
    setSearchRes([]);
  }
};

const clickSectionWrapper: EventListener = (event) => {
  clickSection(event);
};

useEffect(() => {
  document.addEventListener('click', clickSectionWrapper);
  return () => {
    document.removeEventListener('click', clickSectionWrapper);
  };
}, []);

📌 검색 결과 캐싱 기능

cache Storage를 활용한 검색 결과 로컬 캐싱 및 expire time 설정

❓설명

  • 검색 결과를 로컬 캐싱을 해야했기에 localStorage, sessionStorage와 같은 브라우저 저장소를 생각해 보았으나, 수명의 문제와 5mb에 불과한 최대 용량이 문제였습니다.
  • 따라서 용량의 제한이 적은 cacheStorageindexedDB를 생각하게 되었고, 이 중에서 네트워크 리소스를 저장하기 적합한 cacheStorage를 선택하여 작업하였습니다.
  • api와 결합을 하였습니다. 따라서 api호출 전 getCache를 통해 해당 검색어에 대한 캐시가 있는지를 확인합니다. 있으면 해당 캐시를 리턴하고, 없으면 api호출을 한 뒤 setCache를 통해 캐시에 저장합니다.
  • 만료시간을 구현하였습니다. 해당 캐시의 header에 캐시 생성 시간을 저장해둡니다. 추후에 해당 캐시에 접근했을때, 지금으로부터 지난 시간을 EXPIRE_TIME과 비교하여 만료되었으면 삭제해줍니다.
// api/search.ts
export async function getSickList(search: string): Promise<Sick[]> {
  try {
    const cachedResponse = await getCache(search);
    if (cachedResponse) return cachedResponse.json();
    console.info('calling api');
    const response = await instance.get(`sick?q=${search}`);
    await setCache(search, response.data);
    return response.data;
  } catch (error) {
    console.log(error);
    throw error;
  }
}

// utils/cacheStorage.ts
const isExpired = (cacheResponse?: Response) => {
  const cachedDate = cacheResponse?.headers.get('SET_DATE');

  if (!cachedDate) return;
  const fetchDate = new Date(cachedDate).getTime();
  const now = new Date().getTime();

  return now - fetchDate > EXPIRE_TIME;
};

export const getCache = async (value: string) => {
  const cache = await caches.open('clinical-cache');
  const response = await cache.match(value);
  if (response) {
    if (isExpired(response)) {
      const request = new Request(value);
      await cache.delete(request);
      return null;
    } else {
      return response;
    }
  }
  return null;
};

export const setCache = async (value: string, data: Sick[]) => {
  const cache = await caches.open('clinical-cache');
  const header = new Headers();
  header.append('SET_DATE', new Date().toISOString());
  const response = new Response(JSON.stringify(data), { headers: header });
  cache.put(value, response);
};

📌 키보드를 활용한 검색어 이동 기능

useKeyboard 커스텀 훅을 활용하여 키보드로 검색 결과에 접근

❓설명

  • 키보드를 통한 결과창에서 서칭이 가능해야 했습니다. 기본적으로 index를 다루는 상태로 관리를 하였고 로직또한 복잡했기에 따로 useKeyboard커스텀훅으로 추상화하였습니다.
  • 제 서비스에서는 두가지의 결과창이 존재합니다. 첫째는 최근 검색어 이고, 둘째는 검색 결과 입니다.
  • 따라서 이 두개의 결과창에 모두 키보드로 대응 해야 했기에 searchResrecentSearchArr을 모두 인자로 받았습니다. 또한 이둘의 스위칭 과정에선 index를 초기화해주는 과정을 구현하였습니다.
  • 키보드는 위와 아래, 엔터키가 구현되어있습니다. 위,아래 키로 검색창에서 이동가능하며, 엔터키를 통해 해당 검색어에 대한 본격적인 검색이 가능합니다.
  • 화살표로 이동가능한 index를 동적으로 제어하면서 에러를 방지하였습니다.
// hooks/useKeyboard
function useKeyboard(
  value: string,
  setSearch: React.Dispatch<React.SetStateAction<string>>,
  searchRes: Sick[],
  recentSearchArr: string[],
) {
  const [index, setIndex] = useState(-1);

  useEffect(() => {
    const handleKey = (event: KeyboardEvent) => {
      if (event.key === 'ArrowDown') {
        if (value.length === 0) {
          if (recentSearchArr.length - 1 === index) return;
          setIndex((prevIndex) => prevIndex + 1);
        } else {
          if (searchRes.length - 1 === index) return;
          setIndex((prevIndex) => prevIndex + 1);
        }
      } else if (event.key === 'ArrowUp') {
        if (index === 0) return;
        setIndex((prevIndex) => prevIndex - 1);
      } else if (event.key === 'Enter') {
        if (index === -1) return;
        if (value.length === 0) setSearch(recentSearchArr[index]);
        else setSearch(searchRes[index].sickNm);
      }
    };
    window.addEventListener('keydown', handleKey);

    return () => {
      window.removeEventListener('keydown', handleKey);
    };
  }, [searchRes, index]);

  useEffect(() => {
    setIndex(-1);
  }, [value]);

  return index;
}
profile
안녕하세요 노준영입니다.

0개의 댓글