내가 보려고 만든 개발 공부 일지

자바스크립트 - debouncing과 throttling 그리고 AbortController 본문

Javascript

자바스크립트 - debouncing과 throttling 그리고 AbortController

kwangsunny 2022. 3. 16. 12:02

어떤 채용 공고에 제출한 프론트엔드 과제의 코드리뷰 중 이런 피드백을 받아본적이 있다.

"debounce와 throttle 처리가 없는게 아쉽습니다.. " (나 : 디바운스 뭐시기 그게 뭔데 😂)

 

사실 디바운싱과 스로틀링은 용어만 생소할 뿐이지 프론트엔드 개발자라면

한 번 쯤은 자기도 모르게 고민해봤거나, 직접 구현했던 적이 있을지도 모르는 프로그래밍 기법이다.

 

 

함수 실행 횟수 제한

프론트엔드 개발을 하다보면 이벤트 처리는 흔한 일이다.

페이지 내에 스크롤 위치에 따라 모양이 변하는 HTML 요소가 있다고 가정해 보자.

개발자는 먼저 scroll 이벤트를 등록할 것이고,

scroll 이벤트의 콜백함수에서는 스크롤의 현 위치값을 받아서 계산해주는 작업이 필요할 것이다.

그런데 이 콜백함수는 스크롤이 일어나는 순간마다 실행될것이고, 그만큼 계산 작업도 수없이 많이 발생하게 될것이다.

또 다른 예로, 엔터키 없이 검색어를 입력할 때마다 API 요청이 발생하는 검색 컴포넌트를 만들 수도 있다.

하지만 사용자가 입력한 글자 수만큼 불필요하게 너무 많은 API 요청이 일어날 수 있다.

 

위와 같이 코드가 불필요하게 많이 실행되면 자바스크립트 엔진이 바빠지고 브라우저를 버벅이게 만들 수 있다.

이런 문제를 개선하기 위해 여러번 호출되는 함수를 일정시간 동안 한 번만 실행하도록 만들어주는 조건을 걸어주는 것을 바로 디바운싱(debouncing) 과 스로틀링(throttling) 이라고 한다.

 

디바운싱 - 일정 시간내에 연이어 호출되는 함수들을 하나로 취급해 가장 마지막것만 실행시키는 것.  

스로틀링 - 함수를 실행한 순간부터 일정 시간동안 다시 호출되지 않도록 하는 것.

 

뭔가 설명이 비슷해 보이는데, 이 둘의 차이는 일정 시간동안 함수를 최대 한 번은 실행하냐 안하냐 이다. 

바로 아래 예제 코드를 살펴보자.

<input onkeyup="onKeyUp(event)">
<input onkeyup="debouncing(event)">
<input onkeyup="throttling(event)">

<script>
    function onKeyUp(e){ // (A)
        console.log('아무처리 X -> ' + e.target.value);
    }
    
    let timer1 = null;
    function debouncing(e){ // (B)
        if(timer1) clearTimeout(timer1);
        timer1 = setTimeout(()=>{
            console.log('디바운싱 -> ' + e.target.value);
        }, 500);
    }

    let timer2 = null;
    function throttling(e){ // (C)
        if(!timer2){
            timer2 = setTimeout(()=>{
                timer2 = null;
                console.log('스로틀링 -> ' + e.target.value);
            }, 500);
        }
    }
</script>

 

위 코드는 input 에 글자를 입력할 때마다 console.log 를 실행하는 간단한 예시이다.

(A) - 아무 처리도 안해준 input

(B) - 0.5초 간격으로 디바운싱 처리된 input

(C) - 0.5초 간격으로 스로틀링 처리된 input

 

각 input에 텍스트를 입력했을때 콘솔에 로그가 찍히는 빈도를 통해 디바운싱과 스로틀링이 어떻게 작용하는지

이해할 수 있을 것이다.

아무 처리 안했을때
좌 : 디바운싱 처리  /  우 : 스로틀링 처리

 

우선 아무처리도 하지 않은 예제는 input에 글자를 입력하는 족족 콘솔에 로그를 남긴다.

만약 console.log 가 아니라 API 호출을 하는 상황이었다면, 무수히 많은 쓸데없는 요청이 발생했을 것이다.

 

반면 디바운싱 처리를 해준 input은 0.5초 안에 또 함수가 호출되면 이전 스케줄을 취소하고(clearTimeout) 새로 타이머를 설정한다. 그래서 아무리 빠르게 글자를 입력하더라도 0.5초 동안 최대 한번 console.log를 실행하거나 아예 실행이 미뤄지는(0.5초 내에 또 함수가 호출되어 타이머를 새로 맞추는 상황) 상황이 발생한다.

그래서 디바운싱은 검색창에 입력이 다 끝났을때 API를 호출하는것과 같은 상황에 활용하면 좋다.  

    

스로틀링의 결과는 디바운싱과 비슷해 보이지만, 실행 빈도는 디바운싱보다 많다.

디바운싱은 연속으로 빠르게 함수가 실행될 경우 이전 스케줄을 취소하기 때문에 0.5초가 훨씬 지나서 실행될 수도 있지만, 스로틀링은 이전 스케줄을 취소하지 않고 단순히 실행을 무시하기때문에 0.5초 내에 반드시 한 번은 함수를 실행하게 된다.

스크롤을 위나 아래로 한 번만 슥~ 움직여도 수많은 스크롤 이벤트가 발생한다.

이때 스로틀링을 활용하면 스크롤 이벤트를 일정 시간 간격마다 한 번씩만 실행할 수 있으므로 스로틀링은

스크롤 이벤트 처리에 가장 많이 활용된다. 

 

 

비동기 작업 취소하기 - AbortController

지금까지 디바운싱과 스로틀링에 대해 살펴보았고, 한 걸음 더 나아간 케이스를 살펴보자.

디바운싱을 적용하여 일정 시간내에 또 함수가 호출되면 이전 스케줄을 취소하고 타이머를 새로 설정한다고 했다.

그런데 이전 스케줄에 fetch 를 통해 API를 호출하는 코드가 있다면?  fetch 요청도 취소해주는게 당연할 것이다.

그럼 이미 호출한 fetch는 어떻게 취소시킬 수 있을까?

 

fetch를 실행시키면 프로미스를 반환한다.

이 프로미스는 응답이 오기 전까지 pending(대기) 상태이고 이 pending 상태를 취소하는 기능은 프로미스에

따로 존재하지 않는다.

하지만 사용자 행위에 따라 fetch 같은 비동기 작업을 취소해야만 하는 경우는 얼마든지 있을 수 있다.

이런 경우에 AbortController가 그 해답이다.    

 

AbortController는 생성자 함수로써 new 를 통해 인스턴스를 생성한다.

let controller = new AbortController();

 

이렇게 만들어진 controller 객체는

abort 메서드와 signal 이라는 프로퍼티를 가지고 있다.

controller 내부 구조

abort 메서드를 호출하면 signal 객체 내부 상태가 바뀌게 되고, 이를 이용해 비동기 작업을 취소할 수 있다.

 

사용 방법은 매우 간단하다.

1. 요청을 취소하고 싶은 순간에 controller.abort 메서드를 실행한다.

2. controller.signal 은 abort 이벤트를 수신한다.

3. controller.signal.aborted 가 true 로 바뀐다. (초기값은 false)

 

간단한 예제를 통해 확인해보자.

let controller = new AbortController();
let signal = controller.signal;

// abort 이벤트 등록
signal.addEventListener('abort', ()=> console.log('abort 이벤트 발생!')) // (A)

// aborted 초기값 == false
console.log(signal.aborted); 

// 취소 요청
controller.abort(); // (B)

console.log(signal.aborted);

 

예제 실행 결과

(B) 에서 abort 메서드를 실행하고 있다.

그럼 abort 이벤트가 발생하게되고, (A) 에서 signal에 abort 이벤트 핸들러를 등록해주었기 때문에

abort 이벤트를 수신하여 console.log가 실행되는 것이다.

그런데 signal 객체가 갑자기 어떻게 addEventListener를 쓰는거지.? 라고 생각이 들수있다.

signal 객체 해부

짠! signal 객체를 만든 생성자를 추적해보면

Object --> EventTarget --> AbortSignal --> signal 인걸 알 수 있다.

여기서 EventTarget은 자신의 프로토타입에 addEventListener 메서드를 가지고 있다.

그래서 signal 객체도 addEventListener를 상속받듯이 그대로 가져와 사용할 수 있는것이다.

 

 

AbortController 적용

지금까지 AbortController 를 어떻게 사용하는지, 내부 구조는 어떻게 생겼는지 살펴보았고,

이제 AbortController 로 실제 비동기 작업을 취소해보자.

<button onclick="request()">fetch 요청</button>
<button onclick="job()">비동기 작업</button>
<button onclick="cancel()">취소</button>

<script>
    let controller = new AbortController();
    
    async function request(){
        try{
            let res = await fetch('뭔가-오래걸리는-요청', {
                signal: controller.signal // (A)
            });
        }catch(err){
            console.log(err); // (B)   
        }
    }
    
    function job(){
        new Promise((res, rej)=> {
            setTimeout(()=> res('done!'), 3000);
            controller.signal.addEventListener('abort', ()=> rej('작업 취소')); // (C)
        })
        .then(msg => console.log(msg))
        .catch(err => console.log(err));
    }
    
    function cancel(){
    	controller.abort(); // (D)
    }
</script>

 

위 예시에는 버튼이 3개가 있고, 클릭 시 각각

fetch 로 요청  /  비동기 작업  /  작업 취소 역할을 수행한다.

 

먼저 (A) 를 보면 fetch 옵션으로 signal이 존재하고 이 값으로 contorller.signal 객체를 전달 해주고 있다.

사실 fetch는 내부적으로 AbortController를 어떻게 활용해야 하는지 이미 알고 있다.

fetch는 전달받은 signal 객체의 abort 이벤트를 계속 지켜보고 있다가 abort 이벤트가 발생하면 ( (D)를 실행하면 )

fetch는 프로미스를 중단하고 에러를 발생시킨다. (reject 실행)

그럼 제어의 흐름은 catch문으로 넘어가게 되고, (B) 에서 'DOMException: The user aborted a request.' 를 볼 수 있다.

이런 식으로 우리는 fetch 요청을 간단히 취소할 수 있다.

 

job 함수에서는 프로미스로 커스텀한 비동기 작업을 수행중이다.

그리고 executor (프로미스의 첫번째 인자로 넘겨진 함수) 에서 signal 에 abort 이벤트 핸들러를 등록해주었다.

3초가 지나기 전에 (D)를 실행하면, executor 에서 등록했던 abort 이벤트 핸들러가 실행되고 reject를 호출해서 에러를 발생 시킨다.

그럼 제어의 흐름은 catch 체인으로 이동하게 되고 '작업 취소' 가 출력될 것이다.

 

 

 

 

 

잘못된 내용이 있다면 댓글 주세요 :)

 

 

Comments