지난 포스트에 이어서 검색 기능을 마저 구현해봤습니다. Debouncing과 Throttling을 비교해봤습니다.

검색바를 구현하자

이제 API도 준비됐겠다, 검색바만 구현하면 완성입니다.

검색바 스타일

검색바 프로토타입
검색바 프로토타입

프로토타입 스타일을 완성했습니다. 검색바가 열렸을 때 transition이 끝난 후 input 요소에 포커스가 갈 수 있도록 transition 시간만큼 setTimeout을 이용, 약간의 딜레이를 준 후 input 요소에 포커스하도록 했습니다.

검색바는 width값을 조절해 나왔다가 들어가도록 했는데, 이 과정이 Reflow를 일으킬 수 있으나 position: absolute를 줘 그 영향을 최소화했습니다.

검색 내용을 fetch하면 되는데...

이제 사용자가 입력할 때 입력한 값을 기반으로 API를 fetch하면 됩니다.

그런데, 단순히 사용자가 값을 입력할 때마다 요청을 날린다면 불필요한 API Call이 과도하게 많아질것입니다. 이럴 때 사용할 수 있는 기법이 바로 Throttling과 Debouncing입니다. 각각 어떤 기법인지에 대한 설명은 생략하고 테스트해본 결과를 보겠습니다.

기본 이벤트
useEffect(() => {
  inputRef.current.addEventListener("keydown", () => {
    console.log("이벤트!")
  })
}, [])
useEffect(() => {
  inputRef.current.addEventListener("keydown", () => {
    console.log("이벤트!")
  })
}, [])

기본 이벤트

키를 입력할때마다 이벤트가 트리거되고있습니다.

Throttling 테스트
useEffect(() => {
  let inputTimeout: NodeJS.Timeout | null
  inputRef.current.addEventListener("keydown", () => {
    if (!inputTimeout) {
      inputTimeout = setTimeout(() => {
        inputTimeout = null
        console.log("이벤트!")
      }, 500)
    }
  })
}, [])
useEffect(() => {
  let inputTimeout: NodeJS.Timeout | null
  inputRef.current.addEventListener("keydown", () => {
    if (!inputTimeout) {
      inputTimeout = setTimeout(() => {
        inputTimeout = null
        console.log("이벤트!")
      }, 500)
    }
  })
}, [])

Throttle 이벤트

일정 시간만큼 cool down 시간을 가지고 이벤트가 트리거됩니다.

Debouncing 테스트
useEffect(() => {
  const inputTimeout = setTimeout(() => {
    console.log("이벤트!")
  }, 500)
 
  return () => clearTimeout(inputTimeout)
}, [searchInput])
// input 요소에 입력되는 값은 searchInput이라는 State로 관리합니다.
useEffect(() => {
  const inputTimeout = setTimeout(() => {
    console.log("이벤트!")
  }, 500)
 
  return () => clearTimeout(inputTimeout)
}, [searchInput])
// input 요소에 입력되는 값은 searchInput이라는 State로 관리합니다.

Debouncing 이벤트

useEffect()를 활용해 debouncing을 구현했습니다. 입력이 끝나지 않으면 setTimeout()에 들어가는 콜백이 실행되지 않고, 입력이 끝난 후 일정 시간이 지나야 콜백이 실행됩니다.

저는 API Call을 최소화하고싶기 때문에 debouncing 기법을 사용하도록 하겠습니다.

쿼리를 넘기고 데이터를 받아오기

export const searchPosts = (query: string): Promise<SearchedPost[]> =>
  axios
    .get(`https://${process.env.NEXT_PUBLIC_HOST}/api/search`, {
      params: { q: query },
    })
    .then((res) => res.data)
 
// Searchbar.tsx
useEffect(() => {
  if (!searchInput) {
    return
  }
 
  const inputTimeout = setTimeout(async () => {
    const searchedData = await searchPosts(searchInput)
 
    setSearchResults(searchedData)
  }, 500)
 
  return () => clearTimeout(inputTimeout)
}, [searchInput])
export const searchPosts = (query: string): Promise<SearchedPost[]> =>
  axios
    .get(`https://${process.env.NEXT_PUBLIC_HOST}/api/search`, {
      params: { q: query },
    })
    .then((res) => res.data)
 
// Searchbar.tsx
useEffect(() => {
  if (!searchInput) {
    return
  }
 
  const inputTimeout = setTimeout(async () => {
    const searchedData = await searchPosts(searchInput)
 
    setSearchResults(searchedData)
  }, 500)
 
  return () => clearTimeout(inputTimeout)
}, [searchInput])

쿼리 param을 넘겨서 탐색 결과를 받아옵니다. 이후 이 데이터를 가지고 데이터를 갱신합니다.

이후 검색 결과를 나타낼 마크업과 스타일을 완료하면 됩니다.

Troubleshooting

하지만 한번에 다 잘되면 불안한 것이 개발자죠... 계속 여러 검색어로 테스트를 해본 결과 특정 검색어를 입력하면 탐색 로직이 완료되는데까지 비정상적으로 긴 시간이 걸리는 것을 확인할 수 있었습니다.

정확한 이유는 알 수 없었지만 블로그 글 내용에서 탐색하는 부분을 제외했을 때는 해당 이슈가 발생하지 않는 것으로 보았을 때, 긴 글들을 대상으로 매치되는 부분을 탐색하는 데 시간이 오래 걸리는 조합이 있는 것 같았습니다.

이런 경우 content에 대한 탐색을 중단하고 title에 대한 탐색 결과만 리턴하길 원했는데 그렇게 만드는 과정이 쉽지 않았습니다.

JS 동기 함수의 시간이 너무 오래 걸릴 경우 error를 throw하기

Fuzzy Search를 진행하는 함수는 동기 함수인데, 어떻게 하면 실행 시간을 측정하고 timeout error를 띄울 수 있을까요?

원래는 이런 방법으로 처리해보고자 했습니다.

export default async function getFuzzyPostDataRace(query: string) {
  let timer
  let result = await Promise.race([
    getFuzzyPostData(query, false),
    new Promise((resolve) => {
      timer = setTimeout(() => resolve("timeout"), 200)
    }),
  ])
 
  if (result === "timeout") {
    console.log("타임아웃!")
 
    clearTimeout(timer)
    return await getFuzzyPostData(query, true)
  }
 
  return result as SearchedPost[]
}
export default async function getFuzzyPostDataRace(query: string) {
  let timer
  let result = await Promise.race([
    getFuzzyPostData(query, false),
    new Promise((resolve) => {
      timer = setTimeout(() => resolve("timeout"), 200)
    }),
  ])
 
  if (result === "timeout") {
    console.log("타임아웃!")
 
    clearTimeout(timer)
    return await getFuzzyPostData(query, true)
  }
 
  return result as SearchedPost[]
}

getFuzzyPostData()의 두번째 인자로 title만 확인할지 여부를 결정하는 boolean을 받도록 수정하고 위의 코드를 작성했습니다. Promise.race()를 써서 타임아웃을 확인하는 PromisegetFuzzyPostData()중 어느쪽이 먼저 resolve되는지 확인하고, 그 결과에 따라 필요한 처리를 하도록 분기한 것입니다.

getFuzzyPostData()를 async로 만들면 위 코드가 제대로 동작할줄 알았습니다.

그러나, 동기적 동작을 하는 코드는 결국 동기적으로 실행됐고, 동기적 코드가 실행되는 중에는 비동기가 실행되고 있는 queue system에서 내뱉은 handler에 절대로 도달하지 못하기에 문제가 해결되지 않았습니다.

따라서, 탐색 중간 중간에 누적 동작 시간을 확인해 Timeout인지 여부를 직접 확인하는 방향으로 바꾸기로 했습니다.

// fuzzy.ts
 
// 해결한 코드 (간략하게 적음)
const TIMEOUT = 400
 
const findFuzzyPostData = (option: "title" | "content", regex: RegExp) => {
  const searchStarted = Date.now() // 시작 시간
 
  let results = []
  for (const postData of CacheDB) {
    const match = postData[option].match(regex) // 시간이 오래 걸리는 line
 
    if (Date.now() - searchStarted > TIMEOUT) {
      // TIMEOUT보다 더 오래 걸렸다면 Error를 throw
      throw Error("Timeout!")
    }
 
    results.push("필요한 데이터, 여기에서는 생략")
  }
 
  return results
}
 
export default function getFuzzyPostData(query: string): SearchedPost[] {
  const regex = createFuzzyMatcher(query)
  const fuzzyByTitle = findFuzzyPostData("title", regex)
 
  try {
    const fuzzyByContent = findFuzzyPostData("content", regex)
 
    return [...fuzzyByTitle, ...fuzzyByContent].sort(
      (post1, post2) => post1.matchLength - post2.matchLength,
    )
  } catch (error) {
    console.log("Timeout! title 탐색 결과만 리턴합니다.")
 
    return fuzzyByTitle.sort((post1, post2) => post1.matchLength - post2.matchLength)
  }
}
// fuzzy.ts
 
// 해결한 코드 (간략하게 적음)
const TIMEOUT = 400
 
const findFuzzyPostData = (option: "title" | "content", regex: RegExp) => {
  const searchStarted = Date.now() // 시작 시간
 
  let results = []
  for (const postData of CacheDB) {
    const match = postData[option].match(regex) // 시간이 오래 걸리는 line
 
    if (Date.now() - searchStarted > TIMEOUT) {
      // TIMEOUT보다 더 오래 걸렸다면 Error를 throw
      throw Error("Timeout!")
    }
 
    results.push("필요한 데이터, 여기에서는 생략")
  }
 
  return results
}
 
export default function getFuzzyPostData(query: string): SearchedPost[] {
  const regex = createFuzzyMatcher(query)
  const fuzzyByTitle = findFuzzyPostData("title", regex)
 
  try {
    const fuzzyByContent = findFuzzyPostData("content", regex)
 
    return [...fuzzyByTitle, ...fuzzyByContent].sort(
      (post1, post2) => post1.matchLength - post2.matchLength,
    )
  } catch (error) {
    console.log("Timeout! title 탐색 결과만 리턴합니다.")
 
    return fuzzyByTitle.sort((post1, post2) => post1.matchLength - post2.matchLength)
  }
}

실제 탐색을 진행하는 findFuzzyPostData()에 각 탐색마다 시작 시간과 현재 시간의 차가 TIMEOUT보다 큰지 여부를 확인하고, 크다면 Error를 throw하도록 했습니다.

Troubleshooting 성공

원하던대로 시간이 너무 오래 걸리면 title만 가지고 탐색한 결과를 리턴하고 있습니다! 🙌🙌

Vercel Serverless Function Timeout Error

그렇게 행복한 상상을 하며 deploy를 했지만 역시나 배포는 호락호락 하지 않았습니다 😂

Serverless Function Timeout Error

'ㅂㄹㄱ에 검색 기능을 구현해보자'라는 검색어를 넣었을 때 생긴 문제입니다.

Hobby 계정(무료 계정)에 제공하는 최대 Timeout은 10초라고 하니, 제가 잡지 못한 예외가 또 있는 것이 분명했습니다.

다시 고민해보니 생각하지 못한 케이스가 두가지 있었습니다.

위 로직대로라면 최소한 하나의 Post의 content에 대해서는 일단 match를 찾아야 합니다. 즉, 그 하나의 Post에 대해 match를 찾을 때 시간이 10초 이상 걸린다면 Timeout 에러를 뒤늦게 throw하게 되는겁니다.

또한, A, B, C 의 세 문서가 있다고 가정했을 때 A, B까지는 TIMEOUT보다 덜 걸렸으나 C를 검사할 때 TIMEOUT을 한참 뛰어넘는 시간이 걸리는 경우도 있었을겁니다.

이 문제를 해결하기 위해 저는 두가지 해결책을 고민했습니다.

  1. CacheDB의 순서를 content가 적은 순으로 나열합니다. 그러면 길이가 짧아 검사에 시간이 많이 걸리지 않는 포스트부터 검사하므로 edge case에 대응할 여지가 더 많이 생길 것입니다. (위의 두번째 문제에 대한 해결책)
  2. TIMEOUT을 줄입니다.
Sol1. CacheDB의 순서를 content가 적은 순으로 나열하기
// cache.ts
 
postsCache.sort((post1, post2) => post1.content.length - post2.content.length)
// cache.ts
 
postsCache.sort((post1, post2) => post1.content.length - post2.content.length)

이후 npm run cache로 새 캐시를 생성해 다시 테스트해보니 일단 문제가 됐던 'ㅂㄹㄱ에 검색 기능을 구현해보자'라는 문구에 대한 문제는 해결됐습니다.

그런데, 그 과정에서 새로 알게된 문제가 또 있습니다. 제가 사용한 Fuzzy 로직으로는 초성이 세 개 이상이면 무조건 Timeout이 되고 있었습니다. 포스트가 많아질수록 이 문제는 더 심해질텐데 추후 content에 대해 match를 어떻게 하면 더 효율적으로 찾을 수 있을지에 대해 공부해봐야 할 것 같습니다.

Sol2. TIMEOUT 줄이기

Sol1을 통해 문제를 해결했으므로 Sol2는 사용하지 않기로 했습니다. TIMEOUT을 줄이면 줄일수록 content에 대한 검색을 수행할 가능성이 더 줄어들어 제가 원하는 퀄리티 높은 검색을 구현할 수 없기 때문입니다.

그렇지만 앞으로 추가적인 문제가 발생한다면 최후의 방법으로 이걸 써야 할 것 같습니다.

결과물

결과물

우선은 원하던대로의 결과물이 잘 나왔습니다. 매칭된 부분을 하이라이트도 해주고 있어서 만족스러워요! 왕뿌듯