이번에는 한국 임상정보 사이트의 검색 기능을 클론하는 작업을 해보았습니다. 이번 과제는 이전과는 다르게 개인과제로 진행되었습니다.
이번 과제의 핵심적인 구현 목표는 임상정보사이트
의 기능을 따르되, 검색어에 대한 캐싱을 제공해야 하는 점이었습니다. 즉, api요청의 횟수를 최소화 하는 것이 목표였습니다.
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를 활용한 검색창 구현 및 검색어 추천 기능
❓설명
input
에 focus
가 되면 검색 결과창이 표시되고, blur
가 되면 검색 결과창이 사라져야 합니다.input
이 제공하는 onBlur
기능을 활용했으나, 이 경우 input
외부의 검색 결과창을 클릭할 경우에도, 결과창이 꺼져버리는 결과가 발생했습니다.blur
처리를 input
에 해서는 안되고 검색창과 결과창을 모두 포함하는 컨테이너에 해주어야 했습니다.inputRef
와 sectionRef
를 생성하여 마우스 이벤트를 추적하였고, 이를 통해 onFocus
상태 관리를 할 수 있었습니다.input
에 debounce
(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에 불과한 최대 용량이 문제였습니다.cacheStorage
와 indexedDB
를 생각하게 되었고, 이 중에서 네트워크 리소스를 저장하기 적합한 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
커스텀훅으로 추상화하였습니다.searchRes
와 recentSearchArr
을 모두 인자로 받았습니다. 또한 이둘의 스위칭 과정에선 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;
}