티스토리 뷰

프로그래머스에서 'roto'님이 진행하는 '[스터디/9기] 프론트엔드 개발을 위한 자바스크립트(feat. VanillaJS)'를 수강하며 배운 내용을 정리한 글입니다.

 

3주 차 미션

  1. API를 이용하여 움짤 검색기 만들기
  2. async ~ await 사용하기
  3. 검색창에 debounce 적용
  4. 검색 히스토리(중복 X, 히스토리를 선택하여 검색)

 

구현 하기

1. 움짤 검색기 만들기

처음엔 아래와 같은 boilerplate가 주어진다. 간결하지만 검색을 할 수 있는, 실제 작동하는 코드이다. 함수 내 모든 코드가 연결되어 있는데, 한 뭉치지만 하는 일이 많다.

;(function() {
  document
    .querySelector('#search-keyword')
    .addEventListener('keyup', function(e) {
      fetch(`url?text=${e.target.value}`)
        .then(x => x.json())
        .then(data => {
          const htmlString = data
            .map(d => `<img src="${d.imageUrl}">`)
            .join('')
          document.querySelector('#search-result').innerHTML = htmlString
        })
    })
})()
  1. 이벤트 연결
  2. 데이터 가져오기
  3. 화면에 그리기

 

하나의 함수는 한 가지 일만 해야 하니 위의 코드를 분리한다. 먼저 2주 차 미션과 같은 방법으로 App, SearchInput, SearchResult 컴포넌트를 나눠준다.

  1. SearchInput 컴포넌트에서 검색어를 받는다
  2. App 컴포넌트에서 데이터를 가져와서 상태를 업데이트한다
  3. App 컴포넌트의 setState에서 업데이트된 상태로 SearchResult의 상태도 업데이트한다
  4. SearchResult에서 업데이트된 상태로 화면에 render한다
/* SearchInput.js */

$input.addEventListener('keyup', (e) => {
  const inputKeyword = e.target.value
  if (inputKeyword !== '') this.onSearch(inputKeyword)
})
/* App.js */

this.onSearch = (keyword) => {
  fetch(`url?text=${keyword}`)
    .then((res) => res.json())
    .then((searchResult) => this.setState({ nextMemeData: searchResult }))
    .catch((error) => console.error(error))
}
/* SearchResult.js */

this.render = () => {
  this.$target.innerHTML = this.memeData
    .map((meme) =>`<img src="${meme.imageUrl}" alt="${meme.title}">`)
    .join('')
}

 

2. async / await 사용하기

then으로 작성된 코드를 async - await로 변경한다. catch를 이용해 에러를 처리한 이전과 달리 async - awaittry - catch로 감싸준다.

/* App.js */

async onSearch(keyword) {
  try {
    const response = await fetch(`url?text=${keyword}`)
    const searchResult = await response.json()

    this.setState({ nextMemeData: searchResult })
  } catch (error) {
    console.error(error)
  }
}

 

구현 후 fetch부분은 api.js 같은 파일을 만들어 따로 관리하는 것이 좋다는 리뷰를 받았다. 그리고 세션을 진행하며 fetch가 잘 되었는지(response가 200번대 인지) 확인해주어야 한다는 말을 듣고 코드를 수정하였다.

/* App.js */

async onSearch(keyword) {
  try {
    const searchResult = await fetchMemes(keyword)
    this.setState({ nextMemeData: searchResult })
  } catch (error) {
    console.error(error)
  }
}
/* api.js */

export async function fetchMemes(keyword) {
  const response = await fetch(`url?text=${keyword}`)

  if (!response.ok) { throw new Error('api 요청에 문제가 있습니다.') }

  return await response.json()
}

 

3. debounce 적용

debounce 이번 미션을 진행하면서 처음 접해본 개념이었다. debounce는 연속적인 이벤트 발생 시 이벤트 발생 시간 간격이 정해진 시간을 넘어야 작업을 수행하는 것이다.

예를 들어 현재 진행 중인 움짤 검색기는 검색창에서 키보드 입력이 들어올 때 마다 api요청을 보낸다. 검색창에 '고양이'를 입력하면 'ㄱ', '고', '공', '고야', '고양', '고양ㅇ', '고양이'라는 7번의 요청을 보내는 것이다. 이것은 비효율적일뿐만 아니라 유료 api를 이용하는 경우 비용의 문제도 발생한다. debounce를 이용하면 이런 문제를 완화시킬 수 있다.

/* util.js */

let timer    // 타이머
export function debounce({ time, callBack }) {
  if (timer) { clearTimeout(timer) }      // 타이머 초기화
  timer = setTimeout(callBack, time)      // time만큼 이후에 callBack함수 실행
}
/* SearchInput.js */

this.$input.addEventListener('keyup', (e) => {
  debounce({
    time: 300,
    callBack: () => {
      const inputKeyword = e.target.value
      if (inputKeyword !== '') { this.onSearch(inputKeyword) }
    },
  })
})

키보드 입력이 들어올때 타이머를 초기화하고 300ms 후 callBack을 실행하도록 한다. 이때, 300ms가 지나기 전에 입력이 들어오면 다시 타이머가 초기화되기 때문에 이전 입력에 대한 callBack은 실행되지 않는다.

debounce는 검색에 자동완성이나 추천 검색어를 제시하는 기능 등에 사용할 수 있다. 이때, 시간을 얼마만큼으로 지정할지도 중요한데, 타자가 빠른 사람에게는 300ms 정도면 충분히 제 기능을 할 수 있겠지만 타자가 매우 느린 사람에게는 무용지물일 수 있다. 그렇다고 시간을 너무 길게 잡으면 입력을 다 끝나고도 지정된 시간만큼 기다려야 요청을 보내게 되니 서비스가 느리다고 느껴질 수 있다.

debounce에 대해 찾아보면 throttle이 늘 같이 언급된다. throttle은 정해진 시간 내에 연속적으로 발생한 이벤트 중에서 앞선 이벤트를 무시하고 마지막 이벤트만 수행하는 것이다. 주로 scroll이벤트에 많이 사용된다.

 

4. 검색 히스토리

검색 히스토리 리스트를 화면에 띄워주고 선택 시 그 단어로 검색되도록 하는 기능이다. 단, 중복된 검색어는 저장하지 않는다. SearchHistory 컴포넌트를 만들고 onSearch가 호출될 때마다 검색어가 기존 history배열에 있는지 검사하여 없다면 추가하고 SearchHistory 컴포넌트의 상태를 업데이트해주면 된다. 말은 간단하지만 실제 구현하다 보면 부가적으로 생각할 점이 있다.

 

입력된 검색어가 중복된 검색어인지 확인

처음에는 중복을 자연스럽게 제거하도록 Set를 사용하려 했다. setState에서 아래와 같이 중복을 제거하였다.

/* App.js */

this.searchHistoryData = Array.from(new Set(nextSearchHistoryData))

하지만 이 방법은 변환 과정에 어느 정도 오버헤드가 발생하고, 내가 구현한 방식은 입력된 검색어가 중복되어 실질적으로 searchHistoryData배열이 변경되지 않아도 상태를 업데이트하고 있었다.

/* App.js */

async onSearch(keyword) {
  try {
    const searchResult = await fetchMemes(keyword)

    if (searchResult.length > 0) { this.addSearchHistory(keyword)}

    this.setState({ nextMemeData: searchResult })
  } catch (error) {
    console.error(error)
  }
}

addSearchHistory(newKeyword) {
  if (!this.searchHistoryData.find((keyword) => keyword === newKeyword)) {
    this.setState({
      nextSearchHistoryData: [...this.searchHistoryData, newKeyword],
    })
  }
}

addSearchHistory라는 함수를 만들고 검색 결과가 존재는 검색어만 히스토리에 저장하기 위해 searchResult.length > 0인 경우에만 호출하였다. addSearchHistory에서는 find를 이용하여 중복된 검색어인지 확인하고, 중복된 경우가 아니라면 상태를 업데이트하도록 하였다.

 

검색 히스토리 저장

컴포넌트 내에 배열로 검색어를 저장하니 새로고침을 할 때마다 기존 히스토리가 사라졌다. 테스트하는 나도 화나고, (만약 있다면) 사용자도 화날 일... 검색어를 저장하기 위해 이전 2주 차에서 TodoList를 저장하기 위해 사용한 localStorage를 사용할까 생각했지만 사이트를 완전히 벗어난 후까지 검색 히스토리를 저장할 필요는 없다고 생각하여 sessionStorage에 저장하기로 하였다.

 

검색 히스토리를 통해 검색 검색어 표시

기존에 작성해둔 onSearch가 있기 때문에 화면에 표시된 히스토리에 이벤트를 걸어주면 검색 히스토리를 통해 움짤을 검색하는 기능은 쉽게 만들 수 있었다. 하지만 검색 결과 화면에서 내가 어떤 검색어로 검색하여 현재 화면을 보고 있는지 알 수 없는 문제가 있었다. 심지어 검색창에 '고양이'라는 검색어를 입력하여 검색한 후 검색 히스토리의 '강아지'를 누르면 검색창에는 '고양이'가 쓰여있지만 검색 결과는 '강아지'인 이상한 일이 벌어졌다.
따라서 SearchInputkeyword라는 상태를 추가하고 검색 히스토리로 검색 시 해당 키워드를 검색창에 띄워주도록 하였다.

/* App.js */

onSearchWithHistory(keyword) {
  this.searchInput.setState(keyword)
  this.onSearch(keyword)
}

 

구현 결과

Mission3. 움짤 검색기 구현 결과

 

 


 

참고사이트

 

 

댓글