기본으로 돌아가자_디바운스

안승찬·2026년 5월 31일

기본기

목록 보기
1/1

요새 너무 AI 의존해 뇌와 생각이 멈춘 것 같다는 느낌을 받아, 초심으로 돌아가 기능들을 하나씩 직접 만들어 보기 위해 이 글을 시작 했습니다.

그래서 이번에 만들어 볼 건 기본적이며, 실제 구현에 많이 쓰는 검색 기능을 만들어 볼려고 합니다.

기본적인 요구사항은 아래와 같이 잡고 시작 하였습니다.


//요구사항
1) 데이터 구조

items = [
  { id, title, tags[] }
]

2) search 기능
search(query, options)

요구사항:
title에서 query 검색
tags에서도 query 검색
대소문자 무시

3) filterTag 옵션
search("apple", { filterTag: "watch" })

4) debounce 옵션
useDebounce: true,
delay: number

5) cache 기능
같은 query + filterTag 조합이면 재계산 X

6) race condition 방지

기본 구조

 class SearchEngine {
    constructor(items) {    
      this.items = [...items] // 검색 리스트
      this.timer = null; // 디바운스를 위한 타이머
    }
  
    search(query, options = {}) {
      
    }


    _searchCore(query,filterTag) {
       

    }
    
  }

먼저 요구 사항을 충족하기 위해 검색의 기본이 되는 _searchCore 함수를 먼저 구현

_searchCore(query,filterTag) {
        if (!query || typeof query !== "string") return []; 
        const q = query.toLowerCase(); // 대소문자 무시


        return this.items.filter((item)=>{     
            const titleMatch = item.title.toLowerCase().includes(q); // 타이틀 검사
            const tagMatch = item.tags.some((tag) =>
                tag.toLowerCase().includes(q)
              ); // 태그 검사

            const matchesQuery = titleMatch || tagMatch; //태그 존재 검사

       
            if (filterTag) // 필터 태그 검사 조건
            {
                return item.tags.includes(filterTag) && matchesQuery;
            }
        
              return matchesQuery;
        })
    }

search() 함수 구현

search(query, options = {}) {
  const { useDebounce = false, delay = 300, filterTag = null } = options;

  if (useDebounce) { // 디바운스 조건 분기 처리
    if(this.timer) { 
    clearTimeout(this.timer);// 이미 예약된 타이머 있으면 종료
    }

    this.timer = setTimeout(() => {
      const result = this._searchCore(query, filterTag); 
    }, delay);// 디바운스 구현

    return;
  }

  return this._searchCore(query, filterTag); 
}

그 다음 구현 조건인 cache 기능을 구현

  constructor(items) {    
      this.items = [...items] // 검색 리스트
      this.timer = null; // 디바운스를 위한 타이머
      this.cache = new Map(); // query + filter 조건 캐시
    }

search(query, options = {}) {
  const {
    useDebounce = false,
    delay = 300,
    filterTag = null,
  } = options;

  const key = `${query}-${filterTag || ""}`; // 고유 키 값 생성

  // cache hit
  if (this.cache.has(key)) {
    return this.cache.get(key);
  }

  const runSearch = () => {
    const result = this._searchCore(query, filterTag);
    this.cache.set(key, result);// cache set
    return result;
  };

  if (useDebounce) {
    if(this.timer){
    clearTimeout(this.timer);
    }

    this.timer = setTimeout(() => {
      runSearch();
    }, delay);

    return;
  }

  return runSearch();
}

* 캐시를 Object가 아닌 Map으로 선택한 이유는, 단순 데이터 저장이 아니라 “동적 key 기반의 검색 결과 캐시”였기 때문이였습니다. Map은 key 타입의 자유로움, 순서 보장, 쉬운 삭제/순회, 그리고 Map은 insertion order이기 때문에 LRU(Least Recently Used) 확장성까지 고려하여 캐시 구조에 더 적합하다고 판단 했습니다.

마지막으로 Race Condition 방지하는 조건을 살표 보았습니다.

Race Condition은 여러 작업이 동시에 실행될 때, 완료되는 순서에 따라 최종 결과가 달라지는 문제를 의미합니다.

Search 기능에서 debounce를 적용하면 setTimeout을 통해 작업이 지연 실행되는데, 이때 해당 작업들은 매크로테스크 큐(macrotask queue)에 등록되어 이벤트 루프를 통해 순차적으로 실행됩니다.

문제는 여러 입력이 빠르게 발생하면 여러 setTimeout이 동시에 큐에 쌓이게 되고, 각 작업의 실행 시점이 달라질 수 있다는 점입니다.

그 결과, 나중에 발생한 요청이 아니라 이전 요청의 결과가 뒤늦게 반영되면서 최신 상태를 덮어쓰는 문제가 발생할 수 있습니다.

예를 들어 입력이 a → ap → app 순서로 빠르게 들어왔을 때,
각 요청의 실행 시간이 달라져 결과가 app 기준이 아니라 이전 요청 결과가 뒤늦게 반영되는 상황이 생길 수 있습니다.

  constructor(items) {    
      this.items = [...items] // 검색 리스트
      this.timer = null; // 디바운스를 위한 타이머
      this.cache = new Map(); // query + filter 조건 캐시
      this.requestId = 0; // race 순서
    }

search(query, options = {}) {
  const {
    useDebounce = false,
    delay = 300,
    filterTag = null,
  } = options;

  const key = `${query}-${filterTag || ""}`; // 고유 키 값 생성

  // cache hit
  if (this.cache.has(key)) {
    return this.cache.get(key);
  }

  const runSearch = (id) => {
    const result = this._searchCore(query, filterTag);
    // 최신 요청만 cache 반영
    if (id !== this.requestId) return null;
    this.cache.set(key, result);// cache set
    return result;
  };

  if (useDebounce) {
    if(this.timer){
    const id = ++this.requestId;
    clearTimeout(this.timer);
    }

    this.timer = setTimeout(() => {
        runSearch(id);
    }, delay);

    return;
  }
  
  const id = ++this.requestId;
  return runSearch(id);
}

requestId를 증가시키며 각 요청을 식별하고, 실행 시점에 최신 requestId와 비교하여 이전 요청의 결과를 무시함으로써 race condition을 제어하고 runSearch를 최신 요청 기준으로 실행하도록 설계했습니다.

실행 결과

searchEngine.search("a", { useDebounce: true })
searchEngine.search("ab", { useDebounce: true })
searchEngine.search("abc", { useDebounce: true })
searchEngine.search("abc", { useDebounce: false })
searchEngine.search("abc", { useDebounce: false })


---Log---
[SEARCH] q=a, key=a-, id=0
[DEBOUNCE] schedule id=1
[SEARCH] q=ab, key=ab-, id=1
[DEBOUNCE] schedule id=2
[SEARCH] q=abc, key=abc-, id=2
[DEBOUNCE] schedule id=3
[SEARCH] q=abc, key=abc-, id=3
[EXECUTE] id=4, q=abc
[CACHE SET] abc-
[SEARCH] q=abc, key=abc-, id=4
[CACHE HIT] abc-
[EXECUTE] id=3, q=abc
[DROP] stale id=3, latest=4

실행 결과를 보면 requestId를 통해 최신 요청만 유효하게 처리되도록 제어되었으며, 이전 요청은 DROP 처리되어 race condition이 방지되는 것을 확인할 수 있습니다. 또한 동일한 검색 키에 대해서는 cache hit가 발생하여 불필요한 재계산 없이 결과가 재사용되는 것도 확인할 수 있습니다.

0개의 댓글