검색창을 구현하면서 겪었던 API 호출에 관한 문제점과, 이를 해결하기 위해 적용했던 디바운스, 로컬 캐싱에 대해 학습하고 프로젝트에 적용했던 것들을 공유하고자 한다.
한국임상정보의 검색창을 클론코딩 하는 과제를 받아, 이를 구현하면서 API 호출과 관련된 두 가지의 문제점을 만났다.
검색창의 Input
에 검색어를 입력할 때 매 입력시 마다 API가 호출되는 문제가 발생했다. 예를 들어, '암' 이라는 질병을 검색할 경우에 'ㅇ', 'ㅏ', 'ㅁ', '암' 이런식으로 하나의 키워드에 총 4번의 API를 호출했다. 이 얼마나 비효율적인가 ? '암' 이라는 한 글자 키워드에만 4번의 비용이 발생하는데, 만약 사용자가 10글자 정도 되는 검색어를 입력한다고 했을 땐 ? 비용상의 문제는 굉장히 커질 것이고, 잦은 API 호출로 인해 서버에 부하를 줄 위험이 있다.
위에서 언급한 문제를 해결하기 위해 생각해낸 방법으로는 디바운싱
, 쓰로틀링
이 있었고, 나는 그 중 디바운싱
을 적용해 문제를 해결했다. 디바운싱과 쓰로틀링에 대해서는 제로초님 블로그에서 쉽게 설명하고 있다.
블로그의 설명을 요약하자면, 디바운스 기법은 '연속된 이벤트를 그룹화하여 마지막 이벤트가 발생한 뒤 일정 시간이 경과했을 때 처리하는 방법' 을 말한다. 빠르게 반복되는 이벤트에 대한 처리를 최적화하여 성능을 향상시키고 비용을 절감하는데 있어 큰 도움을 주는 기법으로 이는 위에서 언급한 사용자가 Input
에 값을 입력할 때 마다 API가 호출되는 것을 방지하는데 있어 최적의 방법이라고 생각했다.
디바운스 기법을 활용한 간단한 예시 코드를 살펴보자.
// 디바운스 함수 작성
function debounce(func, delay) {
let timeoutId;
return function(...args) {
// 이전에 설정된 타이머 clear
if (timeoutId) {
clearTimeout(timeoutId);
}
// 새로운 타이머 생성
timeoutId = setTimeout(() => {
func.apply(this, args);
}, delay);
};
}
// 사용 예시
const logText = (text) => {
console.log(text);
};
const debouncedLogText = debounce(logText, 500);
// 마지막 입력부터 500ms 이후에 logText 함수가 호출된다.
document.querySelector('input').addEventListener('input', (e) => {
debouncedLogText(e.target.value);
});
setTimeout을 이용해 500ms 라는 타이머를 설정했다. (이는 유동적으로 변경할 수 있다.) 500ms 동안 추가적인 입력이 없으면 입력이 끝난 것으로 간주하여 API를 호출하고, 500ms 이전에 입력이 발생하면 이전 타이머는 취소되고 (clearTimeout을 통해) 새로운 타이머를 설정하는 원리이다.
그럼, 예시 코드를 참고해서 React 프로젝트에 이를 적용해보자. 우선 나는 useDebounce
라는 커스텀 훅을 만들어 디바운스 로직을 분리 작성했다.
// useDebounce.ts
import { useEffect, useState } from 'react';
const useDebounce = <T>(value: T, delay?: number): T => {
// state의 초기값으로 value (입력 값) 를 받음
const [debouncedValue, setDebouncedValue] = useState<T>(value);
// useEffect로 value의 값이 변경될 때 디바운싱 로직이 실행되도록 함
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay || 500);
// delay time을 따로 지정해주지 않으면 500ms 이후에 API 호출
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
};
export default useDebounce;
그리고 Modal
컴포넌트에서 아래와 같이 useDebounce
훅에 입력 값 (query) 과 delay time을 전달한 값을 'debouncedValue' 라는 변수로 선언하고, 이를 data fetcing 훅으로 전달한다.
// Modal.tsx
const Modal = ({ query, useCache, setQuery }: ModalProps) => {
const debouncedValue = useDebounce(query, DELAY_TIME);
const { keywordData, isLoading } = useKeywordData(debouncedValue, useCache);
// 이 외 코드 생략
디바운스 로직을 적용하니 기존 매 입력시 마다 API가 호출되었던 것이, 마지막 입력을 마치고 한 번만 호출되는 방식으로 정상 작동하는것을 확인할 수 있었다.
디바운스를 통해 API 호출을 최적화 하는데 성공하고 신이 난것도 잠시, 바로 다음 문제를 맞이했는데, 바로 '같은 검색어의 연속적인 API 호출' 이었다. 사용자가 '당뇨' 라는 키워드를 검색한다고 가정했을 때, 이를 지웠다가 다시 '당뇨'를 검색할 경우 똑같은 검색어로 API 호출이 반복되는 것이었다.
'같은 검색어를 사용했을 땐 이전의 검색했던 값을 기억해서 API 호출 빈도를 낮출 수 없을까?' 라는 생각이 들었고, 이와 같은 고민끝에 알게된 기능이 바로 로컬 캐싱
이었다.
캐싱이란, 임시 캐싱 저장소와 사용되었던 데이터는 다시 사용되어질 가능성이 높다는 개념을 이용하여, 다시 사용될 확률이 있는 데이터는 저장소에 저장해두었다가 API 호출 없이 해당 데이터를 불러오는 기법을 말한다.
캐싱 기능을 사용했을 때 장점은 다음과 같다.
- 캐시 저장소에 저장된 데이터는 API 호출을 하지 않고 직접 불러오기 때문에, 훨씬 빠르게 데이터를 가져올 수 있다.
- 캐싱된 데이터를 이용하면 API 호출 빈도를 줄일 수 있으므로 데이터 사용 비용을 절감할 수 있다.
- 2번과 같은 이유로 서버 요청을 줄여 서버 부하를 감소시킨다.
이 처럼 로컬 캐싱
기능을 이용했을 때 얻을 수 있는 이점은 상당하다. 이를 구현하기 위한 방법도 여러가지가 있는데 대표적으로 (storage, libarary, cache api, react-context, indexed DB) 등이 있다.
react-query 와 같은 라이브러리를 이용하면 캐싱 기능이 내장되어 있기 때문에 쉽게 캐싱을 구현할 수 있다. 하지만 제공된 과제에서는 기능 구현과 관련된 라이브러리의 사용이 제한되었기에, 나는 위 방법 중 Local Storage
를 이용하기로 했다.
위에서 언급했듯이 캐싱 기능을 구현하는 데에는 다양한 방법이 존재한다. 그 중 내가 Local Storage
를 이용한 이유는 다음과 같다.
- 웹 스토리지 저장소는 단순한 구조로 데이터를 불러오거나 삭제할 수 있다. 때문에 학습하는 입장에서 캐싱의 기본 개념을 빠르게 익히고 구현할 수 있다.
- 거의 모든 웹 브라우저에서 웹 스토리지를 지원하기에 호환성 문제를 걱정할 필요가 없다.
- 스토리지 저장소의 경우 개발자 도구에서 안의 내용을 쉽게 확인하고 필요에 따라 수정할 수 있다. 따라서, 개발 단계에서 캐싱 데이터가 어떻게 저장되고 삭제되는지 직관적인 확인이 가능하다.
이와 같은 이유로 직접적인 구현이 처음인 로컬 캐싱의 기본적인 개념과 패턴을 빠르게 학습하여 사용해보고자 비교적 간단한 방법인 Web Storage
를 이용한 방식을 채택했으며, 탭을 닫거나 브라우저를 종료했을 때 캐싱 데이터가 유실되지 않도록 하기 위해 Local Storage
를 선택했다.
자 이제 로컬 캐싱이 뭔지도 알았고, 구현할 방식도 정했으니 한 번 코드로 구현해보자. 우선 localCache
라는 util 함수를 만들어 캐싱 로직을 작성하기로 했다. 커스텀 훅의 경우 리액트 컴포넌트 내에서만 사용되어야 한다는 규칙을 가진다. 때문에, API 호출 등에서 사용될 캐싱 로직은 커스텀 훅 보다 util 함수로 작성하는것이 더 적합하다고 판단했다.
// localCache.ts
import { KeywordDataTypes } from '../constants/types';
const localCache = (() => {
// 특정 키 값에 해당하는 데이터를 로컬 스토리지에 캐싱
const writeToCache = (key: string, data: KeywordDataTypes[]) => {
// 저장할 데이터와 현재 시간을 객체에 담아서
const storageValue = {
data,
timestamp: new Date().getTime(),
};
// 로컬 스토리지에 JSON 형태로 저장
localStorage.setItem(key, JSON.stringify(storageValue));
};
const readFromCache = (key: string) => {
// 특정 키 값에 해당하는 데이터를 스토리지에서 가져옴
const storageValueString = localStorage.getItem(key);
// 해당 데이터가 없으면 빈 배열 return
if (!storageValueString) return [];
// 문자열을 객체 형태로 변환
const storageValue = JSON.parse(storageValueString);
// 데이터의 expire time을 체크하고 지났으면 스토리지에서 삭제 후 빈 배열 return
if (new Date().getTime() - storageValue.timestamp > EXPIRE_TIME) {
localStorage.removeItem(key);
return [];
}
// expire time이 유효하면 캐싱 데이터 return
return storageValue.data;
};
return {
writeToCache,
readFromCache,
};
})();
export default localCache;
// 캐싱 데이터 유효시간 5분으로 설정
const EXPIRE_TIME = 5 * 60 * 1000;
그리고 작성된 캐싱 로직을 API 요청 로직에 적용했다.
// useKeywordData.ts
const fetchKeywordData = useCallback(async () => {
if (debouncedValue && debouncedValue.length) {
setIsLoading(true);
let data = useCache ? await localCache.readFromCache(debouncedValue) : null;
if (!data || !data.length) {
data = await getKeywordData(debouncedValue, useCache);
}
setKeywordData(data);
setIsLoading(false);
}
}, [debouncedValue, useCache]);
// getData.ts
export const getKeywordData = async (
query: string,
cacheResponse: boolean,
): Promise<KeywordDataTypes[]> => {
const data = await httpClient.search(query);
cacheResponse && localCache.writeToCache(query, data);
return data;
};
위 로직을 살펴보면, 사용자가 검색어를 입력했을 때 데이터를 조회하여 storage
내의 key
값을 확인하고, 캐싱된 데이터가 존재하면 API를 호출하지 않고 해당 값을 불러와 사용한다. 만약 캐싱된 데이터가 없다면, API 호출을 통해 데이터를 가져오고 이를 storage
에 캐싱한다.
또한, EXPIRE_TIME
을 설정하여 해당 시간이 경과하면 스토리지에 캐싱된 데이터가 삭제되도록 하여 계속해서 스토리지에 데이터가 남아있는것을 방지했다. 개발 단계에서 삭제되는 것을 확인하기 위해 유효 시간을 5분으로 설정했지만, 이는 필요에 따라 개발자가 유동적으로 변경할 수 있다.
캐싱을 적용하고 검색 결과를 살펴보면, 검색시에 해당 검색값으로 반환된 데이터가 스토리지에 담기게 되고
첫 번째 검색에 대해서는 API를 호출하지만 그 후에는 API를 호출하지 않는걸 확인할 수 있다.
로컬 캐싱 기능을 구현하면서 직관성과 편리함을 장점으로 꼽아 Local Storage
를 이용했는데, 다른 방법도 이용해서 구현해보고 싶다는 생각이 들었다. 그 중 나는 Cache API
를 사용해보기로 했다.
Cache API와 로컬 스토리지를 비교했을 때 Cache API의 장점은 다음과 같다.
- 로컬 스토리지의 단점을 뽑자면 5 - 10MB 정도의 용량 제한이다. 반면 Cache API는 큰 데이터를 저장할 수 있기 때문에 이미지나 스크립트와 같은 큰 리소스를 저장하는데 적합하다.
- 공식문서에 따르면 Cache API는 Request와 Response 객체를 직접 저장하므로, 네트워크 리소스의 요청과 응답을 캐싱하는데 더 적합하다.
- 로컬 스토리지는 문자열 데이터만 저장할 수 있는데 비해 Cache API는 다양한 데이터 타입을 저장할 수 있다.
- Cache API는 비동기적으로 동작하므로 앱의 성능에 영향을 주지 않는다.
- Cache API는 HTTPS를 통해서만 접근 가능하기 때문에, 웹의 보안을 강화하는데 도움을 준다.
사실 간단한 프로젝트였기에 로컬 스토리지를 그대로 유지해도 괜찮았지만, 개발자 도구를 통해 쉽게 데이터가 노출된다는 점과 다양한 방식을 통해 기능을 구현하며 학습해보고자 하는 욕심(?)으로 인해 리팩토링을 진행해보기로 했다.
기존의 localCache
함수를 Cache API
를 이용한 로직으로 수정하였고, class 구문을 사용했다.
// localCache.ts
import { KeywordDataTypes } from '../constants/types';
const cacheVersion = 'v1';
const cacheName = `sick-cache-${cacheVersion}`;
class LocalCache {
static EXPIRE_TIME = 5 * 60 * 1000;
async writeToCache(
key: string,
data: KeywordDataTypes[],
expireTime: number = LocalCache.EXPIRE_TIME,
) {
try {
const cache = await caches.open(cacheName);
const expired = new Date().getTime() + expireTime;
const request = new Request(key);
const responseData = {
data,
expired,
};
const response = new Response(JSON.stringify(responseData));
cache.put(request, response);
} catch (error) {
console.error('데이터 캐싱 중 오류가 발생했습니다:', error);
}
}
async readFromCache(key: string) {
try {
const cache = await caches.open(cacheName);
const response = await cache.match(key);
if (!response) return [];
const responseData = await response.json();
const now = new Date().getTime();
if (now > responseData.expired) {
cache.delete(key);
return [];
}
return responseData.data || [];
} catch (error) {
console.error('캐싱 데이터를 읽는 도중 오류가 발생했습니다:', error);
return [];
}
}
}
const localCache = new LocalCache();
export default localCache;
또한, Cache API는 비동기적으로 작동하기에 이를 사용하는 함수에서 await 키워드를 추가해줬다.
// useKeywordData.ts
const fetchKeywordData = useCallback(async () => {
if (debouncedValue && debouncedValue.length) {
setIsLoading(true);
// awiat 키워드 추가
let data = useCache ? await localCache.readFromCache(debouncedValue) : null;
if (!data || !data.length) {
data = await getKeywordData(debouncedValue, useCache);
}
setKeywordData(data);
setIsLoading(false);
}
}, [debouncedValue, useCache]);
Cache API
를 적용하고 개발자 도구를 열어 확인해보면 더이상 캐싱 데이터가 스토리지에 담기지 않고,
캐시 스토리지에 검색 값에 따라 캐싱 데이터가 저장되는 모습을 확인할 수 있다.
이번 과제를 수행하면서 평소 신경쓰지 못했던 API 호출의 최적화에 대해 학습하고 이를 효율적으로 처리하는 방법을 직접 구현해볼 수 있는 귀한 경험을 하게되어 뜻깊은 시간이었다. 이번에 배운 기술을 가지고 앞으로의 개발에서 이를 더 잘 활용할 수 있는 방법을 찾아 응용해보고자 한다.