고양이 사진 검색기 만들기 (이벤트 디바운싱)

나혜수·2023년 3월 8일
0

자바스크립트 실전

목록 보기
18/19

고양이 사진 검색기

구현 요구사항

  1. 검색 키워드를 입력하면 추천 검색어 API를 이용해 추천 검색어를 보여준다.
    검색어 입력 후엔 엔터키 등 별도의 추가 액션이 없어도 검색어 목록을 보여줘야 한다.

  2. 키보드 혹은 마우스로 추천 검색어를 선택할 수 있게 한다.

    • esc 키를 누르면 검색창이 닫힌다.
    • 키보드의 위, 아래를 누르면 추천 검색어 하이라이트가 옮겨지고 엔터를 누르면 하이라이트 처리된 검색어가 반영된다.
    • 마우스로는 클릭한 검색어가 반영된다.
  3. 검색된 결과에 따라 고양이 사진이 화면에 렌더링 되어야한다.

API, CSS

API_END_POINT = "https://cat-search.edu-api.programmers.co.kr/"

  1. 검색 API : https://cat-search.edu-api.programmers.co.kr/keywords?q={keyword}
    URL : keywords?q={keyword}
    예시 : https://cat-search.edu-api.programmers.co.kr/keywords?q=bri

    ["British Longhair","British Shorthair"]
  2. 사진 검색 API : https://cat-search.edu-api.programmers.co.kr/search?q={keyword}
    URL : search?q={keyword}
    예시 : https://cat-search.edu-api.programmers.co.kr/search?q=브리

    {"data":
       [
         {
           "id":"MTc5NDU2MQ",
           "url":"https://cdn2.thecatapi.com/images/MTc5NDU2MQ.jpg",
           "name":"British Longhair / 브리티쉬 롱헤어"
         },
         {
           "id":"3tv",
           "url":"https://cdn2.thecatapi.com/images/3tv.jpg",
           "name":"British Shorthair / 브리티쉬 숏헤어"
         },
         {
           "id":"588",
           "url":"https://cdn2.thecatapi.com/images/588.jpg",
           "name":"British Shorthair / 브리티쉬 숏헤어"
         },
         {
           "id":"1u3",
           "url":"https://cdn2.thecatapi.com/images/1u3.jpg",
           "name":"British Shorthair / 브리티쉬 숏헤어"
         },
         {
           "id":"5cc",
           "url":"https://cdn2.thecatapi.com/images/5cc.jpg",
           "name":"British Longhair / 브리티쉬 롱헤어"}
       ]
    }
  3. CSS : https://cat-search-dev-serverlessdeploymentbucket-3288xrrz4slb.s3.ap-northeast-2.amazonaws.com/public/css/cat-search.css


이벤트 디바운싱 & 쓰로틀링

  • 디바운싱 : 연이어 호출되는 함수들 중 마지막 함수(또는 제일 처음)만 호출하도록 하는 것

  • 쓰로틀링 : 마지막 함수가 호출된 후 일정 시간이 지나기 전에 다시 호출되지 않도록 하는 것

  • setTimeout( ) - 함수의 실행을 예약하는 타이머 기능
    clearTimeout( ) - 타이머의 실행을 취소하는 기능

    const timerId = setTimeout(callbackFunction, timeout);
    // callbackFunction - 타이머가 만료되면 실행할 함수
    // timeout - 함수를 실행하기 전까지 기다릴 밀리초 단위의 시간

이벤트 디바운싱

기계식 스위치의 동작을 전기적 신호로 바꿀 때 생기는 진동 잡음을 제거하기 위하여 사용하는 하드웨어의 지연 회로, 소프트웨어의 적절한 지연 시간.

요즘 서비스들은 검색어를 치자 마자 엔터 없이도 결과가 바로바로 나온다. 이렇게 결과를 즉시 보여주려면 항상 input 이벤트에 대기하고 있어야 한다. 예를 들어 '고양이'를 검색했을 때이다. 실제 ajax 요청을 보내기 힘들어 콘솔 로그로 대체했다. 로그가 콘솔에 찍힐 때마다 ajax 요청이 실행된다고 생각하면 된다.
문제는 한 글자 칠 때마다 ajax 요청이 실행된다는 것이다. → 'ㄱ', '고', '고야', '고양', '고양이' (5번 요청)
이와 같은 낭비는 유료 API를 사용했을 때 비용적으로 큰 문제가 된다. 따라서 우리는 마지막 '고양이'를 다 쳤을 때 ajax 요청을 보내고 싶다.

어떻게 구현할지 생각해보자. 대부분 사람들은 한번에 검색어를 입력한다. 따라서 검색어 입력이 다 끝난 후에 요청을 보내면 된다. 즉, 타자를 칠 때( input 이벤트 발생 )마다 타이머를 설정한다. 예를 들어 200ms 동안 입력이 없으면 입력이 끝난 것으로 간주한다. 200ms 이전에 타자 입력이 발생하면 이전 타이머는 취소하고 새로운 타이머를 다시 설정한다.

var timer;
document.querySelector('#input').addEventListener('input', (e) => {
  if (timer) {
    clearTimeout(timer)
  }
  timer = setTimeout(() => {
    console.log(e.target.value)
  }, 200)
})

// var timer 부분이 addEventListener 안쪽에 들어와 있으면 제대로 작동을 안하는데 이유
// 변수의 스코프 문제 : 안쪽에 변수를 선언하면 addEventListener가 끝나면 timer 변수가 사라진다. 

이제 더는 여러 번 호출되지 않는다. 이게 바로 디바운싱이다. 참고로 한글 특성상 마지막에 두 번 호출되는 경우도 있다.

이벤트 쓰로틀링

영어 단어에서 유래한 말로, 원어는 목을 조르는 행위를 말한다. 이 점에서 유래하여 무언가의 출력을 조절하는 뜻도 가지고 있다.

쓰로틀링은 보통 성능 문제 때문에 많이 사용한다. 스크롤을 올리거나 내릴 때 scroll 이벤트가 매우 많이 발생한다. scroll 이벤트가 발생할 때 복잡한 작업을 하도록 설정했다면, 매우 빈번하게 실행되기 때문에 렉이 걸릴 것이다. 그럴 때 쓰로틀링을 걸어준다. 몇 초에 한 번, 또는 몇 밀리초에 한 번씩만 실행되게 제한을 두는 것이다.

디바운싱으로 구현했던 ajax 검색을 쓰로틀링으로도 구현해보자. 똑같이 200ms 제한을 걸어다. 타이머가 설정되어 있으면 아무 동작도 하지 않고, 타이머가 없다면 타이머를 설정한다. 타이머는 일정 시간 후에 스스로를 해제하고, ajax 요청을 보내게 하면 된다.

var timer;
document.querySelector('#input').addEventListener('input', (e) => {
  if (!timer) {
    timer = setTimeout(() => {
      timer = null;
      console.log(e.target.value)
    }, 200)
  }
})

이제 최소 200ms 마다 요청을 보낸다. 물론 ajax 검색은 디바운싱으로 처리하는 게 더 나아보인다. 하지만 중간 중간 검색 결과를 보여주고 싶다면 쓰로틀링도 괜찮은 옵션인 것 같다.


코드

이번에는 로컬 스토리지가 아닌 세션 스토리지를 이용했다.
디바운싱, 세션스토리지를 이용해 지나친 api 호출을 줄인다.

index.html

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>고양이 사진 검색기</title>
    <link rel="stylesheet" href="https://cat-search-dev-serverlessdeploymentbucket-3288xrrz4slb.s3.ap-northeast-2.amazonaws.com/public/css/cat-search.css">
</head>
<body>
    <main class="App"></main>
    <script src="./src/main.js" type="module"></script>   
</body>
</html>

main.js

import App from "./App.js"

const $target = document.querySelector(".App")


new App({$target})

Api.js

const API_END_POINT = "https://cat-search.edu-api.programmers.co.kr/"

export const request = async (url) => {
    try{
        const res = await fetch(`${API_END_POINT}${url}`)
        if(!res.ok){
            throw new Error("api 호출 오류")
        }
        return res.json()

    }catch(e){alert(e.message)} 
}

Header.js

import Keyword from "./KeyWord.js"


export default function Header({ $target, initialState, onKeywordInput, onEnter }){
    const $header = document.createElement('header')
    $header.className = 'Header'
    $target.appendChild($header)

    this.state = initialState // keyword: "British Longhair"

    this.setState = nextState => {
        this.state = nextState 
        keyword.setState({
        value: this.state.keyword
       })
    }

    const $title = document.createElement('h1')
    $title.innerHTML = '😺고양이 사진 검색기🔎'
    $title.style.textAlign = 'center'
    $header.appendChild($title)

    /* App.js에서 header에게 내려준 onKeywordInput이 
       header에서는 다시 keyword에서 내려줬기 때문에 
       keyword에서 호출하면 거꾸로 keyword -> header -> App으로 오게된다 */

    const keyword = new Keyword({ 
        $target: $header, 
        initialState: {
            value: this.state.keyword // keyword: "British Longhair"
        }, 
        onKeywordInput,
        onEnter 
    })
}

Keyword.js

export default function Keyword({$target, initialState, onKeywordInput, onEnter}){
    const $keyword = document.createElement('input')
    $keyword.className = "Keyword"
    $target.appendChild($keyword)

    this.state = initialState // value: "British Longhair"

    this.setState = nextState => {
        this.state = nextState
        $keyword.value = this.state.value
        // suggest 중 키워드를 선택하면 input 값이  선택한 키워드 값으로 바뀜
    }

    $keyword.addEventListener('keyup', e => {
        if(e.key == "Enter"){
            onEnter()
        } else{
            onKeywordInput(e.target.value)
        }
    })
}

SuggestKeywords.js

검색어에 기반한 추천 키워드들을 보여준다.
여러 개의 키워드가 뜨면 화살표를 눌러 위 아래로 이동이 가능하고 엔터 입력시 해당 글자가 선택된다.

export default function SuggestKeywords({$target, initialState, onKeywordSelect}){
    const $suggest = document.createElement('div')
    $suggest.className = "Keywords"
    $target.appendChild($suggest)

    this.state = initialState 
    // keywords: ["British Longhair","British Shorthair"]
    // cursor: -1

    this.setState = nextState => {
        this.state = {...this.state,...nextState}
        this.render()
    }

    this.render = () => {
        const {keywords, cursor} = this.state
        $suggest.innerHTML = `
            <ul>
            ${keywords.map((keyword,i) => `
                <li class="${cursor === i ? 'active' : '' }">${keyword}</li>`).join('')}
            </ul>`
        $suggest.style.display = keywords.length > 0 ? 'block' : 'none'    
    }
    
    this.render()

    $suggest.addEventListener('click', e =>{
        const $li = e.target.closest('li')
        if($li){
            onKeywordSelect($li.textContent)
        }
    })

    // 키보드 위,아래 키로도 키워드 이동이 가능하게 하는 이벤트 
    window.addEventListener('keydown', e => {
        if($suggest.style.display !== 'none'){
            const {key} = e
            // arrow down
            if (key === 'ArrowUp'){
                const nextCursor = this.state.cursor - 1
                this.setState({
                    ...this.state,
                    cursor: nextCursor < 0 ? this.state.keywords.length -1 : nextCursor
                })
            }
            // arrow up
            else if (key === 'ArrowDown'){
                const nextCursor = this.state.cursor + 1
                this.setState({
                    ...this.state,
                    cursor: nextCursor > this.state.keywords.length -1 ? 0 : nextCursor
                })
            }
            // enter 
            else if (key === 'Enter'){
                onKeywordSelect(this.state.keywords[this.state.cursor])
            }
        }
    })

}

searchResult.js

검색한 결과를 검색창 하단에 보여준다.

export default function SearchResults({ $target,initialState }){
    const $serchResults = document.createElement('div')
    $serchResults.className = "SearchResults"
    $target.appendChild($serchResults)

    this.state = initialState

    this.setState = nextState => {
        this.state = nextState
        this.render()
    }

    this.render = () => {
        $serchResults.innerHTML = `
        ${this.state.map(result => `
            <div>
                <img src="${result.url}">
            </div>`).join('')}`
    }

    this.render()

}

debounce.js

이 코드는 매우 유명해서 VSCode copilot 확장에서 자동으로 만들어준다. 잘 알아두도록 하자.

export default function debounce(fn,delay){
    let timer = null
    return function (){
        const context = this
        const args = arguments
        clearTimeout(timer)
        timer = setTimeout(()=>{
            fn.apply(context,args)
        },delay)
    }
}


// this를 특정 객체에 명시적으로 바인딩하는 방법 => apply
// fn 함수 실행시 this는 context를 가리키며, args는 fn함수에 전달될 인수 집합이다. 

/*  함수는 일반 객체와 다른 함수만의 프로퍼티를 가진다. 바로 arguments 객체이다. 
    arguments 객체는 함수 호출 시 전달된 인수들의 정보를 담고 있는 순회가능한 유사 배열 객체이며 
    함수 내부에서 지역 변수처럼 사용된다. 즉, 함수 외부에서는 사용할 수 없다. */

storage.js

검색어를 세션스토리지에 저장한다. 저장되지 않은 것만을 api 호출한다.

const storage = window.sessionStorage

export const getItem = (key,defaultValue) => {
    try{
        const storagedValue = storage.getItem(key)
        if (storagedValue){
            return JSON.parse(storagedValue)
        }
        return defaultValue
    }catch {
        return defaultValue
    }   
}

export const setItem = (key, value) =>{
    storage.setItem(key, JSON.stringify(value))
}

App.js

import Header from "./Header.js"
import { request } from "./Api.js"
import SuggestKeywords from "./SuggestKeywords.js"
import SearchResults from "./searchResult.js"
import debounce from "./debounce.js"
import { getItem, setItem } from "./storage.js"

export default function App({$target}){
    this.state = {
        keyword: '',
        keywords: [], // ["British Longhair","British Shorthair"]
        catImages: []
    }


    this.cache = getItem('keywords_cache', {})

    this.setState = nextState => {
        this.state = nextState

        if (this.state.keyword !== nextState.keyword){
            header.setState({
                keyword: this.state.keyword
            })
        }  
        suggestKeywords.setState({
            ...this.state,
            keywords: this.state.keywords
        })  
       
        if (this.state.catImages.length > 0){
            searchResults.setState(this.state.catImages)  
        }
    }

    const header = new Header({
        $target,
        initialState: {
            keyword: this.state.keyword
        },

        // debounce로 통째로 감싼다. 
        onKeywordInput: debounce(async (keyword) => {
            if(keyword.trim().length > 1){
                // 한 번 조회한 것은 캐시에 넣고, 캐시에 없는 것만 api를 불러옴 
                let keywords = null

                if(this.cache[keyword]){
                    keywords = this.cache[keyword]
                } else{
                    keywords = await request(`keywords?q=${keyword}`)
                    this.cache[keyword] = keywords
                    setItem('keywords_cache', this.cache)
                }

                this.setState({
                    ...this.state,
                    keyword,
                    keywords
                })
            }       
        }, 300),
        onEnter: () => {
            fetchImages()
        }
    }) 

    const suggestKeywords = new SuggestKeywords({
        $target,
        initialState: {
            keywords: this.state.keywords,
            cursor: -1
            // -1은 아무것도 아닌 상태, 아래로 +1, 위로 -1 
        },
        onKeywordSelect: (keyword) => {
            this.setState({
                ...this.state,
                keyword,
                keywords: []
            })

            fetchImages()
        }
    })

    const searchResults = new SearchResults({
        $target, 
        initialState: this.state.catImages
    })

    const fetchImages = async() => {
        const {data} = await request(`search?q=${this.state.keyword}`)
        // object 형태이기 때문에 꺼내옴 

        this.setState({
            ...this.state,
            catImages: data,
            keywords: []
        })
    }
}


profile
오늘도 신나개 🐶

0개의 댓글