-
2023.07.16 ~ 2023.07.19
-
원티드 프리온보딩 인턴십 최종 과제, 개인 프로젝트
-
한국임상정보의 검색영역 클론
-
프로젝트 테스트를 위해선 server 폴더에 있는 json server를 구동해야합니다.
cd server npm install npm start
- 질환명 검색시 API 호출 통해서 검색어 추천 기능 구현
- API 호출별로 로컬 캐싱 구현 (캐싱 기능을 제공하는 라이브러리 사용 금지 !)
- API 호출 횟수를 줄이는 전략 수립 및 실행
- 콘솔창에서 API 호출 횟수 확인이 가능하도록 설정
- 키보드만으로 추천 검색어들로 이동 가능하도록 구현
-
개발 조건
- 전역 상태 관리 툴(필수 사용 X, 필요 시 사용)
- 스타일 관련 라이브러리(styled-components, emotion, UI kit, tailwind, antd 등)
- HTTP Client(axios 등)
- 라우팅 관련 라이브러리(react-router-dom)
-
사용 기술
-
요구사항을 보고 떠오른 컨셉은 다음과 같았습니다.
- 로컬에서 캐싱할 수 있는 객체 생성
- fetch 요청을 할 수 있는 custom hook을 구현
- custom hook이 cache들을 공유할 수 있도록 구현
- cache time(expire time)을 data와 함께 저장
-
핵심 컨셉 4가지를 바탕으로 설계를 진행하였습니다.
-
우선 cache는 key-value를 안전하게 관리할 수 있는
Map
객체를 선택했습니다. -
key는 단 하나만 존재해야 하며, Map 객체를 컨트롤 할 수 있는 직관적인 프로토타입 메서드들을 제공(ex:
has()
,set()
등) 하므로 cache를 관리하기 적합했습니다.const cacheStore = new Map();
-
그리고 서버데이터 캐싱을 통해 조건부로 GET 요청하는 로직을 캡슐화하여
useCacheQuery
를 구현하였습니다.import { useCallback, useEffect, useState } from 'react'; const initialCacheTime = 5 * 60 * 1000; const cacheStore = new Map(); const useCacheQuery = ({ queryKey, queryFn, initialData, cacheTime = initialCacheTime, select, }) => { const [data, setData] = useState(initialData); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); // ... return { data, isLoading, error }; }; export default useCacheQuery;
-
캐시를 관리할
cacheStore
은 이 커스텀 훅이 쓰이는 모든 컴포넌트에서 접근할 수 있도록 모듈 스코프에 선언하였습니다. -
데이터 캐시를 판별할 key 문자열
queryKey
와 데이터를 요청하는 비동기 함수queryFn
는 외부에서 전달 받습니다.(의존성 주입)// useCacheQuery 사용 예 const useRecommendedSearchTerms = input => { const { data: recommendations, isLoading, error, } = useCacheQuery({ queryKey: `@Suggestion ${input}`, queryFn: useCallback(() => getSearchTerms(input), [input]), initialData: [], select: useCallback(data => data.slice(0, 20), []), }); // ... };
-
fetchWithCache
라는 함수에서 조건문을 통해 캐시를 확인하고 조건부로 데이터를 받도록 설정해주었습니다.// ... const fetchWithCache = useCallback(async () => { if (cacheStore.has(queryKey)) { const cache = cacheStore.get(queryKey); if (Date.now() - cache.createAt < cacheTime) { setData(cache.data); cacheStore.delete(queryKey); cacheStore.set(queryKey, cache); return; } else { cacheStore.delete(queryKey); } } // ...
- 사용자의 요청이 들어오면
cacheStore
에queryKey
가 있는지 확인합니다. - 캐시에 해당 key가 존재한다면 value의 생성시간(ms)과 현재시간(ms)의 차이를
cacheTime
과 비교합니다. - 만약 조건이 모두 만족된다면 캐시를 사용하여 상태를 업데이트하고 early
return
처리합니다.
- 사용자의 요청이 들어오면
-
유효한 캐시가 없을 경우 서버에 데이터 요청을 수행합니다.
// ... try { setIsLoading(true); const { data } = await queryFn(); cacheStore.set(queryKey, { data, createAt: Date.now() }); setData(data); } catch (e) { setError(e); } finally { setIsLoading(false); } }, [cacheTime, queryFn, queryKey] ); //...
- 사용자가 전달한
queryFn
로 데이터를 받아오고 캐시에는 데이터와 생성시간을 담은 객체로 저장합니다.
- 사용자가 전달한
-
만약
select
콜백함수가 있다면data
를select
로 가공하여 사용자가 필요한 데이터만 반환하도록 하였습니다.return { data: select ? select(data) : data, isLoading, error };
-
메모리 누수 방지를 위한 로직을 추가적으로 구현하였습니다.
try { // ... if (cacheStore.size > maxCacheSize) { const oldestCacheKey = cacheStore.keys().next().value; cacheStore.delete(oldestCacheKey); } // ... }
-
캐시의 max size를 설정하고 데이터를 가져올때마다 확인하고 오래 사용되지 않은 캐시부터 제거합니다.
-
Map
객체는 순서가 보장되므로 이를 활용할 수 있었습니다.if (cacheStore.has(queryKey)) { const cache = cacheStore.get(queryKey); if (Date.now() - cache.createAt < cacheTime) { setData(cache.data); cacheStore.delete(queryKey); cacheStore.set(queryKey, cache); return; } else { cacheStore.delete(queryKey); } }
-
오래된 순서대로 캐시를 지워야하기 때문에 캐시를 사용할 때 캐시 시간이 연장되도록 기존의 캐시를 delete하고 새로운 캐시를 set 해주게끔 변경하였습니다.
-
👀 전체 로직
import { useCallback, useEffect, useState } from 'react';
const initialCacheTime = 5 * 60 * 1000;
const cacheStore = new Map();
const maxCacheSize = 100;
const useCacheQuery = ({
queryKey,
queryFn,
initialData,
cacheTime = initialCacheTime,
select,
}) => {
const [data, setData] = useState(initialData);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const fetchWithCache = useCallback(async () => {
if (cacheStore.has(queryKey)) {
const cache = cacheStore.get(queryKey);
if (Date.now() - cache.createAt < cacheTime) {
setData(cache.data);
cacheStore.delete(queryKey);
cacheStore.set(queryKey, cache);
return;
} else {
cacheStore.delete(queryKey);
}
}
try {
setIsLoading(true);
const { data } = await queryFn();
cacheStore.set(queryKey, { data, createAt: Date.now() });
if (cacheStore.size > maxCacheSize) {
const oldestCacheKey = cacheStore.keys().next().value;
cacheStore.delete(oldestCacheKey);
}
setData(data);
} catch (e) {
setError(e);
} finally {
setIsLoading(false);
}
}, [cacheTime, queryFn, queryKey]);
useEffect(() => {
fetchWithCache();
}, [fetchWithCache]);
return { data: select ? select(data) : data, isLoading, error };
};
export default useCacheQuery;
-
요약하자면
useCacheQuery
를 호출하면initialState
를 초기 상태로 저장하고 비동기 함수queryFn
을 래핑한useCacheQuery
를useEffect
에서 호출합니다.useCacheQuery
에서는 로컬 캐시를 확인하고 관리하며 필요시 서버에 데이터를 요청해data
와 캐시를 업데이트합니다.data
를select
여부에 따라 가공해서 반환하거나 그대로 반환합니다. -
추후 보완하면 좋을 아이디어도 몇가지
- 다른 컴포넌트에게 상태 변경을 알리는 로직이 부족합니다. 이를 보완하기 위해 recoil의
atomFamily
로 전역적으로 구현할 수 있었으나, 현재 프로젝트에서는 굳이 필요하지 않아 라이브러리를 추가하지 않았습니다. 후에 옵저버 패턴을 구현해서 상태 변경을 컴포넌트에게 알리는 로직을 추가 구현해보고자 합니다. - 선언적으로 에러, 로딩처리를 할 수 있도록
ErrorBoundary
와Suspence
를 지원하는 기능 추가하면 좋을것 같습니다.
- 다른 컴포넌트에게 상태 변경을 알리는 로직이 부족합니다. 이를 보완하기 위해 recoil의
-
UI를 그리는 상태를 그대로 사용하게 되면 과도한 API 호출이 되기 때문에 지연된 상태를 만들기 위해
useDebounceValue
훅을 구현했습니다. -
내장 함수 setTimeout을 사용하여 delay 만큼
debouncedValue
상태의 set 함수의 타이머를 설정합니다. -
설정한 delay 시간보다 먼저 value가 변경된다면
useEffect
의 cleanup 함수로 타이머가 제거가 됩니다. -
즉, 사용자의 입력이 delay 만큼 없다면
debouncedValue
가 반환되어 사용할 수 있도록 구현하였습니다. -
반환된
debouncedValue
는 하위 컴포넌트의 조건부 랜더링과, 하위 컴포넌트에 props로 전달되어 데이터 요청을 보내는데에 사용됩니다.const useDebounceValue = (value, delay = 300) => { const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { const handler = setTimeout(() => { setDebouncedValue(value); }, delay); return () => { clearTimeout(handler); }; }, [value, delay]); return debouncedValue; };
-
먼저,
focusRef
를 자식 컴포넌트에 전달하여 input에서 아래 화살표 keydown 이벤트가 발생할 때 에도 추천검색어로 옮겨가도록 구현하였습니다. -
ref
로 참조하고 있는 요소에tabIndex
옵션을 주어focus()
가 작동하도록 구현했습니다.(도움주신 팀원분들 감사합니다 🥰)// 부모컴포넌트 내부 const focusRef = useRef(); const focusSuggestion = e => { if (e.key === 'ArrowDown') { e.preventDefault(); focusRef.current?.focus(); } };
-
자식컴포넌트
Suggestion
에서는selectedIndex
상태와 keydown 이벤트로 각각 추천검색어의index
와selectedIndex
를 비교하여 요소의 배경색을 변경했습니다.const handleKeyDown = e => { if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex(prevIndex => prevIndex > 0 ? prevIndex - 1 : prevIndex ); } else if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIndex(prevIndex => prevIndex < recommendations.length - 1 ? prevIndex + 1 : prevIndex ); } };
-
추천 검색어 목록을 스크롤이 허용되도록 구현했기 때문에 keydown 이벤트 발생시 스크롤이 현재
selectIndex
와 다르게 움직이는 이슈가 있었습니다. -
useRef
로 배열 형태의ref
를 만들어 index로ref
에 인덱스마다 각각 추천검색어 요소를 참조할 수 있게하였고,scrollIntoView
메서드로 스크롤 오작동 문제를 해결할 수 있었습니다. -
ex)
<List ref={el => (listItemRefs.current[i] = el)>
const listItemRefs = useRef([]); const updateScroll = useCallback(() => { if (listItemRefs.current[selectedIndex]) { listItemRefs.current[selectedIndex].scrollIntoView({ behavior: 'smooth', block: 'nearest', }); } }, [selectedIndex]); useEffect(() => { updateScroll(); }, [selectedIndex, updateScroll]);
-
서버 요청시에 불 필요한 데이터가 캐시에 저장되거나 중복된 데이터가 저장되는 것을 줄이기 위해서
textProcessing
유틸 함수로 사용자 input을 전처리 해주었습니다.const textProcessing = text => { const regex = /[ㄱ-ㅎㅏ-ㅣ]|[^\w\s가-힣A-Za-z0-9]/g; return text.replace(regex, '').trim(); }; export default textProcessing;
-
추천검색어 mouseenter 이벤트에 아래 핸들러를 적용하여
selectIndex
를 초기화하여 css hover와 중복 방지 처리를 해주었습니다.const handleMouseIn = () => { setSelectedIndex(-1); };
-
추천 검색어 창이 닫히기 쉽도록
useClickOutside
훅을 구현했습니다.const useClickOutside = handler => { const ref = useRef(); useEffect(() => { const listener = e => { if (ref.current && !ref.current.contains(e.target)) { handler(); } }; document.addEventListener('mousedown', listener); return () => { document.removeEventListener('mousedown', listener); }; }, [handler]); return ref; };
-
캐시에 빈배열
[]
이 들어가는 현상이 발견되었습니다. 원인을 조사한 결과select
를 급히 구현하다splice
메서드로 구현한 콜백함수를useCacheQuery
에 전달하면서 일어난 버그였습니다.const { data: recommendations, isLoading, error, } = useCacheQuery({ queryKey: `@Suggestion ${input}`, queryFn: useCallback(() => getSearchTerms(input), [input]), initialData: [], cacheTime: 2 * 60 * 1000, select: useCallback(data => data.splice(0, 20), []), });
-
splice의 부수효과(side effect)로 인해 발생한 문제이기 때문에
slice
메서드로 대체하여 이를 해결하였습니다.select: useCallback(data => data.slice(0, 20), []),