[회고] search-clinical-trials

초코침·2023년 10월 25일
0

회고

목록 보기
2/2
post-thumbnail

과제 소개

이번 과제는 연관 검색어 제공 및 캐싱 기능을 제공하는 웹 사이트를 만드는 것이었다.

(레포 바로가기)

요구사항

  • 질환명 검색 시 api 호출하여 연관 검색어 제공 기능 구현
  • api 호출 별 로컬 캐싱 구현 및 expire time 설정
  • 입력마다 api 호출하지 않도록 api 호출 횟수 줄이기
  • 키보드로 연관 검색어 이동하기

과제 회고

로직 분리 리팩토링

검색어 입력과 입력에 따른 연관 검색어 제공 로직을 다루는 SearchBar 컴포넌트를 만들었다.

const SearchBar = () => {
  // 인풋 value 다룰 상태
  const [inputText, setInputText] = useState('');
  // 연관 검색어 다룰 상태
  const [suggestedKeywords, setSuggestedKeywords] = useState<SuggestedKeyword[]>([]);
  // 키보드로 포커싱된 연관 검색어 인덱스 다룰 상태
  const [selectedIndex, setSelectedIndex] = useState(-1);

  /*
	* 키보드로 포커싱 조작하는 로직
		- 화살표 키가 눌렸는지 검사
		- 화살표 업 다운에 따른 selectedIndex 조작
	*/

  /*
	* 연관 검색어 가져오는 로직
			- 캐시 저장소에 입력한 검색어가 있는지 검사
			- 있으면 캐시에서 꺼내와 suggestedKeywords 상태 업데이트하고
			- 없으면 api 요청하고 응답을 캐시에 저장한 다음, 상태 업데이트 
	*/

  /*
   * 인풋의 onChange 핸들러
  */

  return (
    <>
      <인풋 />
      <키워드 리스트 />
    </>
  );
};

키보드 조작 로직과 연관 검색어 가져오는 로직의 코드 길이가 좀 길기도 했고 상당히 명령적인 느낌이었다.

그리고 키보드 조작 로직이나 이미 캐싱되어 있는지 확인하는 과정들을 SearchBar라는 컴포넌트가 꼭 알아야 할 필요가 있을까라는 의문이 들어 과감하게 분리해 버리기로 결정했다.

  1. 키보드 포커싱 조작 로직 분리하기

    키보드로 포커싱 조작하는 로직을 hook으로 분리했다. (코드)

    SearchBar의 관심사는 “어떤 검색어가 포커싱됐는가”라고 생각했다. 따라서 검색어를 어떻게 포커싱하는 것인지는 hook 내부로 숨기고 (1) 현재 포커싱된 검색어의 인덱스, (2) 인덱스를 변경하는 키보드 이벤트 핸들러, (3) 포커싱을 해제하는 함수 이렇게 3가지를 리턴하도록 작성했다.

    결론적으로 키보드로 포커싱 조작하는 로직을 SearchBar 컴포넌트에서 분리하여 SearchBar는 어떤 검색어에 포커싱 됐는지만 알고 있다.

    const [selectedKeywordIndex, changeSelectedKeywordIndex, resetSelectedKeywordIndex] =
        useIndexByArrowKey(suggestedKeywords.length - 1);
    <Input {...props} onKeyDown={changeSelectedKeywordIndex} />
    <List selectedKeywordIndex={selectedKeywordIndex} />
  2. 연관 검색어 로직 분리하기

    연관 검색어 가져오는 로직도 hook으로 분리했다. (코드)

    이 로직을 분리해내는 데에 많은 고민을 했다. SearchBar에서만 사용할 로직이라면 그대로 가지고 있어도 될 것 같았기 때문이다. 즉, 재사용 가능한 hook이 될 순 없을 것 같았다.

    그럼에도 분리하기로 결정한 이유는 SearchBar에게 중요한 것은 연관 검색어 목록 그 자체라고 생각했기 때문이다. (키보드 조작을 분리한 이유와 비슷하게) 연관 검색어를 캐시에서 꺼내왔든 api 요청으로 받아왔든 어떻게 가져오는지에 대한 관심은 필요하지 않다고 생각했다.

    따라서 연관 검색어를 가져오는 과정은 hook 내부로 숨기고, 새로운 키워드를 호출해야 하는 상황(사용자의 입력 변화)에서 (1) 사용할 update 함수와 (2) 현재 입력값과 연관된 키워드 목록을 리턴하도록 작성했다.

    const [suggestedKeywords, updateSuggestedKeywords] = useSearchSuggestions();

키보드 입력에 따른 잦은 api 요청 줄이기

연관 검색어 제공 기능은 사용자의 입력에 따른 api 호출이 바탕이 된다. 따라서 타이핑을 할 때마다 api 요청이 발생하기 때문에 최적화를 하지 않으면 단 시간에 많은 양의 api 호출을 발생시킬 수 있다.

사용자가 입력할 때마다 onChange 핸들러 내부에서는 다음과 같이 2가지 동작을 하게 되는데

  1. 입력한 값을 보여주기
  2. 입력한 값으로 연관 검색어 찾기

둘 중 입력한 값을 보여주는 건 즉각적으로 처리돼야 하는 부분(즉각적으로 처리되지 않으면 사용자는 렉이 걸렸다고 생각할 수 있다)이지만 연관 검색어를 찾는 것은 즉각적으로 처리할 필요성이 강하지 않기 때문에 2번 과정에 디바운스를 적용했다.

디바운스는 콜백이 연속으로 호출될 경우 마지막 호출만 실행시키는 useDebounce 커스텀 훅을 만들어 구현했다. updateSuggestedKeywords 함수를 콜백으로 넘기고 리턴받은 함수를 onChange 내부에서 호출함으로써 api 호출을 최적화했다.

const debouncedUpdateSuggestedKeywords = useDebounce(updateSuggestedKeywords, 300);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
	const userInput = e.target.value;
	setInputText(userInput); // 1번 동작 - 입력한 값을 보여주기
	debouncedUpdateSuggestedKeywords(userInput); // 2번 동작 - [디바운스] 입력한 값으로 연관 검색어 찾기
	if (userInput.length === 0) {
		resetSelectedKeywordIndex();
	}
};

어느 저장소에 캐싱할 것인가

검색한 적 있는 검색어를 캐싱하여 짧은 시간 내에 다시 검색할 때 발생하는 요청을 줄여야 한다는 요구사항이 있었다. 이를 구현하기 위해 어느 저장소를 사용할 것인지가 중요한 문제였다.

크게 5가지의 후보를 두고 각 저장소의 장단점을 고민해봤다.

  • 로컬 스토리지
    • 장: 사용하기 쉬움
    • 단: 영구적으로 저장되기 때문에 주기적으로 저장소를 비워주는 로직이 필요할 것
  • 세션 스토리지
    • 장: 사용하기 쉬움, 탭 닫으면 알아서 지워지기 때문에 데이터의 삭제를 크게 신경쓰지 않아도 됨
    • 단: 딱히 없음
  • 캐시 스토리지
    • 장: 빠른 속도로 가져올 수 있음
    • 단: 응답 자체를 캐싱해야 함, 비동기적으로 작동함
  • 로컬 state
    • 장: 간편함
    • 단: 새로고침하면 캐싱한 데이터가 삭제됨 → 오히려 장점일수도?
  • 전역 state
    • 장: 간편함
    • 단: 라이브러리 설치 필요, 새로고침하면 캐싱한 데이터가 삭제됨, 검색 결과를 전역에서 관리해야 하는가에 대한 의문

처음에는 캐시 스토리지를 사용해 보고 싶어서 캐시 스토리지 기반으로 코드를 작성했는데, 레퍼런스도 적고 비동기적으로 동작하기 때문에 사용하기 어렵다는 느낌이 들었다. (캐시 스토리지로 작성한 코드)

그래서 세션 스토리지와 로컬 상태 중 고민하게 되었고, 둘 다 사용해서 구현해 봤다. (세션 스토리지로 작성한 코드)

두 방법 모두 구현해본 결과 내가 느낀 세션 스토리지와 로컬 state의 차이는 새로고침 시 데이터 유지 여부인 것 같다. 따라서 둘 중 고민된다면 이 기준으로 선택하면 될 것 같다. (생각해보니 새로고침하면 일반적으로 새로운 데이터를 받아오는 걸 생각하니까 로컬 state로 구현하는 게 더 좋을 것 같기도 하다..)


이상 저장소를 고른 배경은 이러하고, 로컬 state를 활용해 캐싱하는 코드를 가져와 봤다.

import { useState } from 'react';

interface CacheValue<T> {
  expireTime: number;
  value: T;
}

const useCacheState = <T,>(expireSeconds: number) => {
  const [cache, setCache] = useState<Map<string, CacheValue<T>>>(new Map());

  const set = (key: string, value: T) => {
    setCache(
      (prevCache) =>
        new Map([...prevCache, [key, { expireTime: Date.now() + expireSeconds, value }]])
    );
  };

  const get = (key: string) => {
    const cachedData = cache.get(key);
    if (cachedData && cachedData?.expireTime > Date.now()) return cachedData?.value;
    return undefined;
  };

  const remove = (key: string) => {
    setCache((prevCache) => {
      const newCache = new Map(prevCache);
      newCache.delete(key);
      return newCache;
    });
  };

  const clear = () => {
    setCache(new Map());
  };

  return [get, set, remove, clear] as const;
};

export default useCacheState;

Map 자료구조를 상태로 활용하여 로컬 캐싱을 할 수 있는 커스텀 훅을 만들었다.

지정한 현재 시간이 expireTime을 초과했거나 캐싱된 적이 없는 키워드라면 새로 api를 호출하기 위해 undefined를 반환하고, 유효한 expireTime이라면 캐싱된 값을 사용하여 api 호출을 줄였다.

const [getCachedSuggestions, setCachedSuggestions] = useCacheState<SuggestedKeywordType[]>(
	1000 * 60 * 5
);

state로 Map 자료구조를 사용할 때

입력(key)에 해당하는 연관 검색어(value)를 캐싱하기 위해 Map 자료구조를 사용했다.

Map은 참조형이기 때문에 state로 다룰 때 주의할 점이 있다.

리액트는 얕은 비교를 통해 state에 변경이 있는지 확인한다. 따라서 Map 자료구조의 상태를 업데이트하려면 다른 참조를 갖는 새로운 Map 객체를 만들어야 한다.

// 새로운 데이터 추가
setMap((prev) => new Map([...prev, 'new data']));

// 데이터 삭제
setMap((prev) => {
	const newMap = new Map(prev);
	newMap.delete('key');
	return newMap;
}

// 데이터 전체 삭제 (비우기)
setMap(new Map());

추가로, 타입스크립트를 사용한다면 타입을 어떻게 지정해야 하는지 좀 헷갈리는데 이렇게 지정하면 된다.

const [map, setMap] = useState<Map<string, KeyType>>(new Map());

내 프로젝트에서는 캐시를 구현하기 위해 만든 state이므로 캐싱할 데이터를 타입으로 가질 수 있게(연관 검색어가 string일 것은 확실하지만) 다음과 같이 제네릭을 활용했다.

interface CacheValue<T> {
  expireTime: number;
  value: T;
}

const useCacheState = <T,>(expireSeconds: number) => {
  const [cache, setCache] = useState<Map<string, CacheValue<T>>>(new Map());

키보드로 검색어 목록 포커싱 다루기

키보드의 arrow up 키와 arrow down 키를 눌러 추천 검색어 포커싱을 이동할 수 있게 구현했다.

input 컴포넌트에 포커스된 상태에서 키를 눌렀을 때 연관 검색어로 이동할 수 있도록 input의 onKeyDown 핸들러에 관련 로직을 연결해 주었다.

그런데 검색어를 입력한 뒤 바로 아래 화살표를 누르면 이벤트가 두 번 트리거되어 첫 번째 연관 검색어가 아닌 두 번째 연관 검색어로 포커싱하는 문제가 발생했는데, 이는 한글같은 조합 문자에서 발생하는 문제라고 한다.

따라서 native event의 isComposing이 true인 경우 이벤트를 처리하지 않도록 하여 문제를 해결했다.

if (event.nativeEvent.isComposing) return;
profile
블로그 이사중 🚚 (https://sungjihyun.vercel.app)

0개의 댓글