Trie 자료구조로 추천 검색(자동완성) + 로컬 캐싱 구현하기

💡 구현 내용 및 목표

원티드 프리온보딩 3주차 과제는 추천 검색어 목데이터를 활용하여 자동완성 검색창을 구현하는 것이었습니다. 핵심 요구사항은 로컬캐싱으로 api 호출 횟수를 줄이는 것입니다.

추천 검색어를 받아오는 것은 api 호출 시 쿼리스트링에 검색어를 입력하면 간단하게 추천 검색어를 받을 수 있습니다. 간단하게 캐싱을 구현하자면 api 호출 시 key로 검색어를, 대응하는 value 값으로는 response 값들을 배열을 사용하여 저장하면 간단하게 캐싱하고 캐싱한 값을 찾을 수 있습니다.

[
  {담낭:
   [
      {
          "sickCd": "C23",
          "sickNm": "담낭의 악성 신생물"
      },
      {
          "sickCd": "K81",
          "sickNm": "담낭염"
      },
      {
          "sickCd": "K82",
          "sickNm": "담낭의 기타 질환"
      },
      ...
    ]
   }
   ...
]

하지만 이번 과제의 목표는 api 호출 횟수를 줄이는 것이기 때문에, 이보다 더 효과적으로 캐싱하고 호출 횟수를 줄일 수 있는 방법이 있을지 고민해 보았습니다.

응답받은 추천 검색어들은 검색한 문자열이 무조건 포함되어있었습니다. 위의 방법은 사용자가 타이핑한 문자열이 이전에 타이핑한 문자열과 정확히 일치해야 캐싱 된 값을 사용할 수 있었습니다.

예를들어 첫 검색 시 [담낭]이라고 검색하면 [담낭]의 쿼리키로 api를 호출하고, 응답 결과를 캐싱하면 이 후 [담낭]이라고 똑같이 검색해야만 키 값으로 캐싱된 값을 찾을 수 습니다. [담낭의]이라고 한글자만 다시 추가해서 검색한다면 [담낭의]이라는 키 값이 캐시 배열에 존재하지 않기 때문에 다시 api를 호출하게 되고, [담낭의]의 추천 검색결과를 다시 캐싱하게 됩니다.

저는 검색한 문자열이 이전 캐싱한 키 값에 문자열이 추가되는 형태라면 이전 캐싱된 값을 현재 검색한 문자열을 기준으로 필터링해서 재사용하고 싶었습니다. 이렇게 되면 위의 예시에서는 [담낭의], [담낭염], [담석의 악성 신생물] 등등 [담낭]이라는 문자열이 포함된 검색어의 추천 검색어를 단 한번의 api 호출만 가지고 커버할 수 있게됩니다. 이를 구현하기 위해 Trie 자료구조를 활용해 보기로 했습니다.


💡 Trie 자료구조란?

Trie 자료 구조란 문자열을 저장하고 효율적으로 탐색하기 위한 트리 형태의 자료 구조를 말합니다.

트라이 자료구조 출처 - 위키피디아

각 간선은 이전 정점으로 부터 새롭게 추가되는 문자 정보를 갖고 있고 각 정점은 이전 정점으로 부터 더해진 문자열 정보를 갖고 있습니다.

문자열을 탐색할 땐 문자열의 갯수 * 각 문자열의 길이 만큼 시간 복잡도를 가지지만 트라이를 이용하면 찾는 문자열의 길이만큼만 시간 복잡도를 가지게 됩니다.

보편적으로 Trie 자료구조를 활용해 추천 검색어 기능을 구현하게 되면 트리의 루트부터 동일한 문자열을 파악해서 내려가기 때문에 접두사가 동일한 검색어를 찾습니다. 하지만 이번 과제의 추천검색어 목록에는 접두사가 동일한 추천 검색어 뿐만 아니라 단어 혹은 문장 중간에 검색어가 포함되고 있습니다.

따라서 일반적으로는 추천 검색어 목록 자체트라이 자료구조로 저장해놓고 추천 검색어를 찾아 사용하지만 이 과제의 경우에는 서버에 이미 저장된 추천 검색어 목록을 호출한 후 캐싱해서 사용하는 것이기도 하고, 추천 검색어 중간검색어가 포함되어있기도 하여 다른 방식을 생각해 보아야했습니다.

그래서 생각한 방식은 추천 검색어 목록 자체를 자료구조로 저장하는 것이 아니라 사용자가 입력한 검색어를 트라이 구조로 저장해 놓고, 노드의 data로 추천 검색어 목록을 저장하는 방식을 구현해보기로했습니다.

예를 들어 [담낭]이라는 검색어를 받게되면, [담] [낭]으로 문자열을 음절별로 쪼개어 트리 구조로 저장하고 [담석]이라는 검색어를 받게되면, [담]의 노드 자식 노드로 [낭]이 추가되고 결과적으로는 [낭] [석] 두 개의 자식 노드가 존재하게됩니다. 검색어로 입력한 마지막 문자열인 [낭] [석] 노드에는 [담낭] [담석]의 추천 검색어 목록이 저장됩니다.

이를 추천 검색어 캐싱에 적용해 본다면 다음과 같은 순서로 동작하게 됩니다.

  1. 검색어 기반 추천 목록 호출
  2. 검색어를 문자열로 쪼갠 후 노드를 순회하며 앞 문자열이 같은 노드가 있는지 확인 2-1. 앞 문자열이 같은 노드가 있다면 해당 노드의 데이터 중 검색어가 들어간 목록만 반환 및 재캐싱 2-1. 앞 문자열이 같은 노드와 검색어가 완전 일치한다면 해당 노드의 목록 전체 반환 2-2. 앞 문자열이 같은 노드가 없다면 false를 반환
  3. 2번에서 false를 반환했다면 api를 호출하고 서버로 부터 반환 받은 추천 검색어 목록을 캐싱

여기서 만료시간 체크, 가비지컬렉팅, 중복 순회 방지, 스토리지 초기화 등등 신경써야할 부분이 많지만 최대한 간단하게 정리해보았습니다. 이 내용은 아래 코드를 설명에서 더 자세히 다뤄보겠습니다.

이러한 구현을 Trie 자료 구조로 활용하지 않는다면 cacheKeys.filter((key)=>searchKey.includes(key)) 으로 간단하게 구현할 수 있을테지만 이렇게 되면 **O(cacheKeys.length * searchKey.length)**만큼의 시간 복잡도를 가지게 됩니다. 더불어 객체 형태의 배열의 키 값의 배열을 찾으려면 Object.keys(obj) 메서드를 써야하기 때문에 연산 비용이 증가하게됩니다.


💡 캐시 저장소 선택하기

Trie 자료구조를 구현하기 앞서 이번 과제의 중요한 조건 중 하나는 로컬캐싱이었습니다. 때문에 캐시를 저장할 저장소도 정해야합니다. 로컬캐싱은 말 그대로 로컬환경의 리소스를 이용하여 캐싱을 하는 것입니다. 하지만 로컬환경의 범위가 어디까지인지 정의가 살짝 헷갈렸습니다.

  1. JS 코드 내에서 메모리에 데이터를 저장하고 관리하는 방식
    • 클래스 인스턴스, Map 객체, 상태 관리 등등..
  2. 브라우저 저장소 사용하기
    • 로컬스토리지, 세션스토리지, 캐시스토리지 등등..

열심히 검색해 본 결과 사용하는 컨텍스트에 따라 의미가 달라지는 것 같았습니다. 따라서 명확한 캐시 스토리지 사용 조건이 없었기 때문에 api 호출 횟수를 최대한 줄이고자 하는 목표에 따라 페이지 리로드 간에도 캐시가 유지되는 브라우저 저장소를 사용하기로 했습니다.

이 중에서 캐시스토리지가 말 그대로 캐시를 저장하기 위한 저장소이기도하고, 용량제한도 다른 브라우저 스토리지보다 큰 편이라 사용해 보고 싶었지만 비동기로 스토리지에 접근해야하고 사용법도 익숙치 않아 가장 익숙하고 용량 외에 캐시스토리지와 큰 차이점이 없는 로컬스토리지를 사용하기로 했습니다.

브라우저를 껐다 켜도 캐시 데이터 남아있다는 점이 api 호출 횟수를 줄이는 것에 큰 메리트가 있으니 로컬스토리지를 사용해도 큰 무리가 없었습니다.

다만 브라우저 저장소 + Trie 자료 구조를 사용한다면 고려해야할 사항이 있습니다.

  1. string 형식으로 저장하고 파싱해서 사용해야한다.
  • 이는 Map이나 Node 형식의 객체를 사용할 수 없다는 말입니다. Trie 자료구조는 Map의 메서드를 활용하면 보다 직관적이고 편하게 구현할 수 있습니다. 하지만 Map객체나 메서드가 포함된 Class 객체를 JSON.stringify()로 직렬화 하면 의도대로 자료구조 전체가 저장되지 않았습니다. (정말이지 알고싶지 않았습니다..)
  1. 브라우저 스토리지를 사용자가 임의 조작할 경우를 대비해야한다.
  • Trie 자료 구조로 파싱이 불가능한 범위로 조작하거나 파싱은 되었지만 구조상 다르게 조작해놨으면 에러를 캐치하여 스토리지를 초기화 해야합니다.
  1. 브라우저 메모리가 가득 찬 경우를 대비해야한다.
  • 캐시 만료 기능을 구현해야 한다.
  • 만료시점에 캐시를 교체하는 것 뿐만아니라 자료구조를 순회할 때 가비지 컬렉팅을 시도해보자.
  • 캐시 만료 기능을 구현하였더라도 Trie 자료구조는 시간복잡도에 이점이 있지만 메모리를 많이 차지한다는 단점이 있다. 때문에 메모리가 가득찬 경우에도 에러를 캐치하여 스토리지를 초기화 해야한다.

💡 Trie 자료구조 구현

저장소를 선택하고 고려해야하는 사항을 정리했다면 본격적으로 Trie 자료 구조를 구현해봅시다.

저는 몇 번의 시행착오를 맞닥뜨리며 그리고 api 호출 횟수를 줄이기 위해 아래 세가지 방식 모두 구현해 보는 아주 매운 경험을 했습니다.

1. Map객체 + 노드 인스턴스 + Trie 인스턴스(메소드 포함) 활용 
2. 노드 인스턴스 + 함수형으로 자료구조 조작
3. 노드 인스턴스 + TrieCache 인스턴스(메소드로 자료구조 조작) 

위에서 언급했지만 일반적으로 브라우저 스토리지를 사용하지 않는다면 Map 객체와 insert, get 등의 메소드가 포함된 클래스 인스턴스를 사용하는 것이 훨씬 간편합니다. 하지만 1번 방법은 노드를 조작하기에 아주 간편하나 Map 객체가 완전히 보장된채로 직렬화가 되지 않아 pass.

2, 3번 방법은 브라우저로 부터 반환 받은 캐시 객체를 복사해서 가변적인 객체의 특징을 가지고 노드를 순회하고 조작하는 방식입니다.

다만 2번 처럼 함수형으로 구현했을 때 모듈화를 하고 관심사를 분리하기 위해 인자로 객체를 전달하게 되면 객체가 불변하게 될 수 있고, 이에따라 자료구조를 중복해서 순회하는 경우도 생기게 될 수 있습니다. 또한 함수형으로 프로그래밍을 할 때 객체의 불변성을 보장하며 코드를 짜는 것이 중요 개념이기 때문에 함수로 객체를 가변하게 조작하는 것이 영 찝찝했습니다. (불가능하진 않습니다.)

3번은 최종 구현 버전입니다. 클래스를 활용해서 캐싱기능과 브라우저 저장소 관리에 필요한 메소드들을 모듈화하고 인스턴스의 root에 저장소로 부터 받아온 캐시들을 저장하고 메소드로 해당 루트와 노드들을 조작하는 방법입니다. 브라우저에 캐싱할 때에는 객체 형태의 root만 stringify해서 저장하면 됩니다. 이 방법으로 구현했을 때 순회중이였던 노드를 파악하기 쉬워 중복 순회를 방지할 수 있고, 해당 인스턴스에도 완전한 형태로 자료구조가 저장되어 에러가 발생하여 저장소를 초기화 할 때도 완전 reset 하지 않고 기존 root에 저장된 자료구조를 가져와 초기화하면 됩니다.


💡 코드

1. 저장소 활용 모듈

먼저 브라우저 저장소를 간편하게 활용할 수 있는 모듈을 만듭니다.

export class LocalStorage<T> {
    key: string;
    constructor(key: string) {
        this.key = key;
    }
 
    getItem() {
        try {
            return JSON.parse(localStorage.getItem(this.key)!);
        } catch (e) {
            console.error('파싱될 수 없는 value 값입니다. 스토리지를 리셋합니다.');
            this.setItem('');
        }
    }
 
    setItem(value: T | string) {
        try {
            return localStorage.setItem(this.key, JSON.stringify(value));
        } catch (e) {
            console.error('용량을 초과했습니다. 스토리지를 리셋합니다.');
            this.setItem('');
        }
    }
}
 
export const searchCacheStorage = new LocalStorage('searchCache');
 

해당 클래스로 인스턴스를 생성하면 인스턴스를 생성할 때 주입한 키로 저장소에 간편하게 접근할 수 있습니다. 더불어 ** 파싱 에러나 용량 초과 에러가 나면 저장소를 초기화하는 코드를 추가했습니다. **

2. Node 클래스

각 노드에는 다음과 같은 데이터가 들어가야합니다.

  • value : 이전 정점으로 부터 새롭게 추가되는 문자열
  • data : 추천 검색어 목록
  • expireTime : 캐시 만료 시간
  • createdAt : 노드 생성일시간 (만료시간 체크를 위함)
  • children : 자식 노드들
class Node {
    value: string;
    data: TypeCacheData;
    expireTime: TypeExpireTime;
    createdAt: TypeCreatedAt;
    children: InterfaceChild;
    constructor(
        value: string,
        data: TypeCacheData = null,
        expireTime: TypeExpireTime = null,
        createdAt: TypeCreatedAt = null
    ) {
        this.value = value;
        this.data = data;
        this.expireTime = expireTime;
        this.createdAt = createdAt;
        this.children = {};
    }
}

api 호출횟수를 줄이기 위해 디바운싱 기능도 사용하였기 때문에 쿼리키가 음절 하나하나 순서대로 들어가지 않습니다. 따라서 데이터는 검색어의 마지막 문자열에 저장되면 됩니다. 때문에 중간에 거쳐가는 문자열들은 value 값만 추가됩니다. 데이터가 들어오지 않으면 나머지 생성자는 null으로 초기화하고 children은 빈 객체로 초기화합니다.

3. TrieCache 클래스

TrieCache 클래스는 다음과 같이 구성했습니다. 먼저 생성자를 확인해 봅시다.

생성자

  • cacheStorage : 저장된 캐시를 불러오고 조작한 자료구조를 저장하게 될 저장소 모듈입니다. 위에서 생성한 인스턴스를 생성할 때searchCacheStorage를 넣어주면 됩니다.
  • root : 순회하게 될 객체 형태의 Trie 자료구조이다. 캐시 된 값을 브라우저에서 불러와서 사용할 수 있다. 초기 값은 빈 value의 Node 인스턴스를 넣어줍니다.
  • currentNode : 현재 탐색 중인 노드를 가리킵니다. 문자열을 순회할 때마다 변경 될 값이며 초기 값은 this.root로 지정해 해당 노드를 조작할 때마다 root 객체도 동시에 변경되게 합니다.
export class TrieCache {
    private root: InterfaceNode;
    private currentNode: InterfaceNode;
    private cacheStorage: InterfaceLocalStorage;
    constructor(cacheStorage: InterfaceLocalStorage) {
        this.cacheStorage = cacheStorage;
        this.root = new Node('');
        this.currentNode = this.root;
    }
  ...
}

메서드

캐시스토리지 조작 메서드

다음은 캐시 스토리지 조작에 사용될 메서드들입니다.

  • openCache() : 추천 검색어 캐시 데이터를 받아오고 확인하는 로직에서 가장 먼저 사용될 메서드입니다. 저장소에 저장 된 캐시를 가져와서 root에 초기화합니다.
  • resetCacheStorage() : 오픈한 저장소에 저장 된 값이 Trie 자료구조가 아닐 경우 해당 메서드를 사용해서 제대로 된 형태로 초기화합니다.
  • initCacheStorage() : 인스턴스에 저장 된 root 값으로 캐시 저장소를 초기화 할 수 있습니다.
export class TrieCache {
    // ... constructor
 
    private openCache() {
        try {
            const cachedData = this.cacheStorage.getItem();
            const cachedDataIsIterable =
                'value' in cachedData &&
                'data' in cachedData &&
                'expireTime' in cachedData &&
                'createdAt' in cachedData &&
                'children' in cachedData;
 
            if (cachedData) {
                if (!cachedDataIsIterable) {
                    throw new Error('순회할 수 없는 자료구조입니다.');
                }
                this.root = cachedData;
              // 루트를 가져온 캐시 데이터로 재할당
                this.root = cachedData;
            } else {
              // 캐시 데이터가 없을 경우 현재 인스턴스의 루트로 스토리지 초기화
                this.initCacheStorage();
            }
        } catch (e) {
            console.error('초기화합니다.');
          // 저장소에서 캐시를 못가져오는 경우 스토리지 리셋
            this.resetCacheStorage();
        }
    }
 
    private initCacheStorage() {
        const initData = this.root;
        if (initData) {
            this.cacheStorage.setItem(initData);
        } else {
            this.resetCacheStorage();
        }
    }
 
    private resetCacheStorage() {
      // new Node를 직접 넣는 경우 Node형태로 Stringify 되기 때문에 객체 형태로 변환해주어야함
        this.cacheStorage.setItem({...new Node('')});
        const resetData = this.cacheStorage.getItem();
        this.root = resetData;
    }
 
    ...
}

캐시 만료 체크를 위한 메서드

  • getCurrentTime() : 현재 시간을 리턴하는 메서드, 만료시간 체크와 생성시간을 넣을 때 사용합니다.
  • isExpired(cacheInfo: InterfaceNode) : 현재 노드가 expired 되었는지 체크하는 메서드, 만료 되었다면 캐시 데이터를 초기화합니다.
export class TrieCache {
    // ... constructor
    // ... 스토리지 조작 메서드
    private getCurrentTime() {
        return new Date().getTime();
    }
 
    private isExpired(cacheInfo: InterfaceNode) {
        try {
            const {createdAt, expireTime} = cacheInfo;
            const currentTime = this.getCurrentTime();
            if (createdAt && expireTime && currentTime - createdAt > expireTime) {
                return true;
            }
            return false;
        } catch (e) {
            console.error('만료 시간을 체크할 수 없습니다. 스토리지를 다시 오픈합니다.');
            this.initCacheStorage();
        }
    }
   //...
}

자료구조 조작 메서드

자료구조 조작 메서드입니다. 코드가 길어서 우선 메서드 동작 흐름만 먼저 살펴보겠습니다.

export class TrieCache {
    // ... constructor
    // ... 스토리지 조작 메서드    
  	getCacheData(searchKey: string) {
      ...
    }
 
    private getCommonPrefixCache(searchKey: string) {
        ...
    }
 
    insertCache(searchKey: string, cacheInfo: TypeCacheInfo) {
        ...
    }
 
    // 간선이 될 단일 문자열 key와 정보를 담은 node를 인자로 받아 현재 노드의 child로 추가하는 메서드이다.
   // 이 역시 node를 인자로 받으면 Node 형태로 stringify 되기 때문에 일반 객체로 변환해 주어야한다.
    private addChild(key: string, node: Node) {
        this.currentNode.children[key] = {...node};
    }
    
}

실행 순서대로 메서드를 간단하게 정리해보았습니다. (실제로는 반대 순서로 작성하였습니다.)

  1. 먼저 getCacheData(searchKey) 이 호출
  2. getCommonPrefixCache(searchKey) 를 통해 공통 접두사 혹은 동일 키 서치. 이 것으로 반환받은 캐시 데이터를 반환
  3. 만일 캐싱 된 공통 접두사 데이터 혹은 동일 키 값이 없다면 api를 호출하고 insertCache(searchKey, cacheInfo) 메서드를 직접 사용하여 캐싱.
  4. 2번에서 공통 접두사를 키 값으로 찾았다면 searchKey로 유사 데이터 목록을 필터링 한 후 재캐싱하기 위해 insertCache(searchKey, cacheInfo) 를 사용.

이제 각 메서드 로직을 뜯어보겠습니다. 각 코드의 설명은 주석으로 달아놓겠습니다.

  • getCommonPrefixCache(searchKey: string)
    • 트라이 캐싱 기능의 가장 핵심이 될 메서드입니다. 루트 부터 노드를 순회하며 검색어를 쪼갠 문자열 앞 부터 공통되는 키 값을 찾아 데이터가 있는 노드 중 가장 유사한 노드를 반환합니다. 자료구조 조작 로직 중 실질적으로 가장 먼저 실행될 메서드입니다. 이 메서드로 자료구조를 순회할 때 캐시 만료 여부도 함께 체크합니다.
private getCommonPrefixCache(searchKey: string) {
   try {
     // 가장 먼저 동작하는 메소드이기 때문에 cache를 오픈함
        this.openCache();
     // currentNode를 캐시스토리지에서 가져온 루트로 초기화
        this.currentNode = this.root;
        let mostSimilarNode = this.currentNode;
 
     // 문자열을 순회하며 노드 체크
        for (let i = 0; i < searchKey.length; i++) {
            const char = searchKey[i];
 
    // 현재 노드의 children에 이미 문자열이 저장되어 있는 경우
            if (char in this.currentNode.children) {
                this.currentNode = this.currentNode.children[char];
 
    // 노드 순회 중 만료된 캐시를 본다면 삭제 (가비지 컬렉팅)
                const currentCacheIsExpired =
                   this.currentNode.expireTime !== null && this.isExpired(this.currentNode);
                if (currentCacheIsExpired) {
                  this.currentNode.data = null;
                  this.currentNode.expireTime = null;
                  this.currentNode.createdAt = null;
                  this.initCacheStorage();
                  this.initCacheStorage();
                }
 
   // 현재 노드에 데이터가 있다면 mostSimilarNode 업데이트
   // 현재 노드가 있지만 데이터가 없는 경우는 가비지 컬렉팅 당한 경우 혹은 중간 문자열으로 검색이 안된 경우임
   // 따라서 문자열 마지막까지 순회하며 mostSimilarNode를 찾아야함
               if (this.currentNode.data) {
                    mostSimilarNode = this.currentNode;
                    }
            } else {
   // currentNode.children에 현재 문자열이 없다면 현재 노드가 새로 검색한 string 하고 가장 비슷한 노드임
                return mostSimilarNode;
            }
        }
 
        return mostSimilarNode;
    } catch (e) {
        console.error('순회가 불가한 구조입니다. 캐시 스토리지를 초기화합니다.');
        this.resetCacheStorage();
    }
}
  • insertCache(searchKey: string, cacheInfo: TypeCacheInfo)
    • api로 호출한 데이터를 캐싱하고 공통 접두사 키 값의 데이터 목록을 필터링한 후 재캐싱할 때 사용하는 메서드입니다. getCommonPrefixCache에서 순회한 후 사용되기 때문에 가장 유사한 공통 접두사로 찾은 노드 다음부터 노드를 추가하게 됩니다.
insertCache(searchKey: string, cacheInfo: TypeCacheInfo) {
    const {data, expireTime} = cacheInfo;
 
   // 캐시 만료 시 현재 노드는 searchKey 노드이므로 바로 데이터 업데이트
    if (this.currentNode.value === searchKey) {
        this.currentNode.data = data;
        this.currentNode.expireTime = expireTime;
        this.currentNode.createdAt = this.getCurrentTime();
    }
  
  // currentNode는 getSimilar를 순회한 후 마지막 노드이기 때문에 startIdx는 비슷한 노드의 value 길이 다음 부터 체크하면 됨
    const startIdx = this.currentNode.value.length;
 
    for (let i = startIdx; i < searchKey.length; i++) {
        const char = searchKey[i];
        const newValue = this.currentNode.value + char;
 
        // 마지막 문자열에 데이터 추가
        const charNeedsAddData = i === searchKey.length - 1;
        if (charNeedsAddData) {
            this.addChild(char, new Node(newValue, data, expireTime, this.getCurrentTime()));
        } else {
                // 마지막 문자열이 아닌 경우 value 값만 있는 노드 추가
            this.addChild(char, new Node(newValue));
            this.currentNode = this.currentNode.children[char];
        }
    }
 
    // 업데이트 한 root 노드를 cacheStorage에 업데이트
    this.initCacheStorage();
}
 
  • getCacheData(searchKey: string)
    • TrieCache로 생성된 인스턴스를 직접적으로 사용할 때 처음으로 호출될 메서드입니다. **getCommonPrefixCache()**로 캐싱 된 유사 데이터 혹은 동일 데이터를 찾은 뒤 반환하거나 해당하지 않으면 false를 반환합니다. false를 반환하면 그 때 api를 호출해서 insertCache() 메서드를 사용하면 됩니다.
getCacheData(searchKey: string) {
    const lowerCaseString = searchKey.toLowerCase();
 
    // 검색한 문자열의 앞 문자열들로 캐싱된 데이터가 있는지 찾기
    const commonPrefixNode = this.getCommonPrefixCache(lowerCaseString);
    const commonPrefixData = commonPrefixNode?.data;
 
    // 앞 문자열이 같은 노드에 데이터가 있는 경우
    if (commonPrefixData) {
    // 앞 문자열이 같은 노드의 value 값이 정확히 일치한다면 바로 해당 데이터 반환
        const isPrefixEqual = commonPrefixNode?.value === lowerCaseString;
        if (isPrefixEqual) {
            return commonPrefixData;
        } else {
    // 아니라면 유사 데이터 중 현재 검색한 값과 일치하는 데이터만 찾아서 재캐싱
            const newData = commonPrefixData.filter(data =>
                data.toLowerCase().includes(lowerCaseString)
            );
            const cacheInfo = {data: newData, expireTime: commonPrefixNode.expireTime};
            this.insertCache(lowerCaseString, cacheInfo);
            return newData;
        }
    }
 
    // 검색 문자열이 아예 새로운 문자열이라면 false 반환
    // false인 경우 api를 호출하고 insert 메서드 사용하기
    return false;
}

드디어 TrieCache 모듈 로직을 다 정리했습니다! 사용은 다음과 같이 사용하면 됩니다.

 
// 먼저 컴포넌트 혹은 훅이 재렌더링 될 때마다 인스턴스가 재생성되지 않도록 
// 이렇게 캐시 데이터를 업데이트 할 컴포넌트 바깥에 선언한다.
const searchTrieCache = new TrieCache(searchCacheStorage);
 
const useSearch = () => {
    ...
    const getSearchRecs = useCallback(async (queryKey: string, expireTime: number) => {
      
      // 이렇게 먼저 캐시 데이터를 갖고온다.
    const cachedData = searchTrieCache.getCacheData(queryKey);
    if (cachedData) {
     // 캐시 데이터가 있다면 반환받은 캐시데이터로 상태를 업데이트하자
        dispatch({type: 'GET', payload: cachedData});
    } else {
        try {
     // 캐시 데이터가 없는 경우 api 호출하면 된다. 
            const res = await Fetcher.getSearchRecs(queryKey);
            const recs = res.data.map((data: Type.searchRec) => data.sickNm);
     // 서버에서 반환받은 데이터를 캐싱한다.      
            searchTrieCache.insertCache(queryKey, {
                data: recs,
                expireTime,
            });
            dispatch({type: 'GET', payload: recs});
        } catch (e) {...}
    }
  }, []);
    ...
};
 
export default useSearch;

끝!!!!


🔥 결과

📤 배포 사이트

캐싱 구조 및 api 호출 횟수 확인 영상에서는 10번 이상 검색했음에도 불구하고 api 호출은 단 두 번 밖에 되지 않습니다.

에러 핸들링 에러 핸들링 로컬스토리지를 임의로 조작한 뒤 검색을 하면 에러가 나타나고, 기존 캐싱 된 완전한 자료구조를 다시 가져와 초기화합니다.

expireTime은 console을 찍지 않아 영상으로 남기지 못했으나 테스트를 위해 3분으로 설정해 놓았습니다.

이 외에 디바운싱, 추천검색어 키보드 이동, 마우스 호버 후 키보드 이동 기능 등등도 신경써서 구현했으니 많이 살펴봐 주시면 너무 뿌듯할 것 같습니다ㅎㅎ

정말 디버깅하는데 시간을 많이쏟은 과제입니다🥹 그래서 더 뿌듯합니다. 저장소가 바뀌어도 같은 로직을 사용할 수 있도록 모듈화도 신경써서 했습니다.

이 외에 고민한 흔적들과 함수형으로 구현했을 때 코드는 아래 리드미에서 추가로 확인해 볼 수 있습니다.. ㅎㅎ

💌 리드미 확인하기