[jsdp] Throttling Pattern

개요

엔터프라이즈 패턴에서 흔하게 발견되는 쓰로틀링 패턴은 원래 유체역학이나 기타 공학의 용어에서 빌려온 단어입니다.
보통 어떤 흐름이 있을때 특정 부분을 좁히면 압력이 변화하여 자연스럽게 힘이 줄어들게 하거나 속도를 줄일 수 있는데 각 공학적인 접근에 따라 교축, 컨덕턴트나 콘덴서도 마찬가지 개념에 해당되는 것들입니다. 일반적으로 프로그래밍에서는 버퍼구조등을 이용하거나 별도의 완화처리기를 두는 방식으로 처리되죠.

..여기까지 걍 용어설명이었을뿐이고 js입장에서 이 패턴이 필요한 이유는 함수를 너무 자주 호출하면 UI의 사용성이 안좋아지고 블록킹이 일어나는 문제를 해결하기 위해서 입니다.

함수 호출의 빈도를 조정할 수 있는 경우

예를 들어 다음과 같은 함수가 있다고 해볼까요.

var start = 1, //left의 시작지점
    end = 500, //left의 최종지점
    startTime, //애니메이션 시작시점
    endTime;   //애니메이션 종료시점

var process = function(){
  var rate = (Date.now() - startTime) / endTime; //경과시간의 비율을 구한다.
  document.getElementById('a').style.left = start + (end - start) * rate + 'px'; //비율만큼 전진시킴
};

startTime = Date.now(); //시작시간
endTime = startTime * 3000; //종료시간은 3초후
setInterval(process, 1); //인터벌시작

이 코드에서 process는 interval을 통해 반복되면서 내부에서는 호출될때마다 현재 시간을 구해 경과시간에 대한 비율로 left값을 증가시키도록 되어있습니다.
간단한 보간 애니메이션입니다.
하지만 실제 화면에 갱신되는 비율은 60fps를 넘지 않으므로 사실 이 함수는 1ms마다 실행될 필요가 없습니다. 따라서 setInterval의 인자를 좀 증가시켜주면 됩니다.

setInterval(process, 1000/60);

이렇게 함수 호출에 대한 빈도를 직접 결정할 수 있는 경우는 쓰로틀링이 필요없습니다. 직접 조정하면 되니까요.

함수 호출의 빈도를 조정할 수 없는 경우

하지만 위와 같은 경우만 존재하는 것은 아닙니다. 함수호출을 개발자가 하는 것이 아니라 시스템이 해주는 경우 그 빈도는 매우 잦아질 수 있습니다.
예를 들어 resize이벤트 경우 pc환경에서는 브라우저의 크기를 바꿀 때만 일어나지만, 모바일 환경에서는 많은 모바일브라우저가 구현하고 있는 주소창이 자동으로 작아지는 기능이 발동될 때, 작아지는 애니메이션에 맞춰 매프레임마다 resize가 일어나게 됩니다.
뿐만 아니라 touchmove나 mousemove 이벤트의 경우도 유저가 계속 이동하는 이상 굉장히 잦은 비율로 호출됩니다.
scroll을 비롯하여 많은 DOM이벤트와 심지어 click이라 할지라도 너무 빠르게 누르면 잦은 함수호출을 유발하게 됩니다(한마디로 흔합니다^^)

이러한 경우 이벤트 리스너에서 하드한 DOM작업등을 하는 경우 성능이 매우 낮아지고 경험이 나빠지게 됩니다. 이론적으로 브라우저는 60ftp이상으로 빨리 레이아웃 갱신작업을 할 필요가 없고 경우에 따라서는 더욱 빈도를 낮춰 실행해도 괜찮습니다.

우선 일반적인 move이벤트를 보죠.

document.getElementById('a').addEventListener('mousemove', function(e){
  //이미지 여러 장을 슬라이드시키고
  //패럴랙스 효과를 받는 다양한 div의 속성을 갱신한다.
});

이 경우 유저가 마우스를 마구마구 움직이고 있으면 이벤트 큐에 해소되지 않은 리스너 호출이 어마무지 쌓이면서 브라우저가 매우 느려지게 됩니다.
특히 안의 내용이 제법 무겁다면 더욱 상황이 악화되고 성능이 약한 머신에서는 브라우저가 다운되는 경우도 생깁니다.

setTimeout과 clearTimeout을 이용한 지연호출

지연호출을 할 때의 아이디어는 만약 너무 빠르게 호출되면 지금 실행하지 않고 나중에 실행하겠다라는 것입니다.

  1. 나중에 호출하는 방법은 setTImeout입니다.
  2. 하지만 적당한 주기로 호출되면 물론 직접 실행 해야 합니다.
  3. 직접 실행하는 경우 중요한 점은 기존에 예약된 타이머가 있다면 해지해줘야한다는 점입니다.
  4. 타이머로 실행된 경우도 이미 타이머에 걸린 작업이 실행되었다는 것을 알려줘야 합니다.

이를 반영하여 코드를 1차적으로 개선해보죠.

//진짜 리스너는 따로 빼둔다.
var process = function(e){
  //처리할 작업들
  
  //실행이 완료되었으므로 타이머를 해지한다.
  timeOutId = -1;
};

var nextTime = 0, //다음에 실행가능한 시간
    timeOutId = -1, //타임아웃용 아이디(타임아웃을 절대 음수 id를 반환하지 않음)
    rate = 200; //200ms이내의 동일작업을 요청하면 지연한다.

document.getElementById('a').addEventListener('click', function(e){
  var currTime = Date.now(); //현재시간

  if(nexTime > currTime){ //아직 다음실행주기가 되지 않았는데 실행하려는 경우

    //타이머를 설정한적이 없다면 설정한다.
    if(timeOutId == -1) timeOutId = setTimeout(process, rate);

  }else{ //충분히 간격이 주어진 뒤 실행하려는 경우

    if(timeOutId != -1){//만약 예약된 타이머가 있다면 제거하고 초기화
      clearTimeout(timeOutId);
      timeOutId = -1;
    }

    process(); //직접 실행함
  }
  nextTime = currTime + rate; //다음실행가능시간을 설정한다.
});

우선 기존 함수를 process로 다시 작성하면서 젤 하단에 타임아웃에 대한 아이디를 해지하도록 코드를 추가합니다.

실제 리스너에서는 현재 시간을 보고 다음시간보다 작다라면 타임아웃으로 지연시키고 아니라면 직접 실행한 뒤

다음 시간을 현재 시간에서 일정 시간(rate)을 더한 값을 설정합니다.

위의 예제를 실행하면 200ms내에는 여러 번을 클릭해도 단한번만 실행되게 됩니다.

쓰로틀링의 일반화

매번 위의 코드처럼 작성할 수는 없는 노릇입니다(귀찮으니까) 간단히 인자를 받아 쓰로틀링을 일반화하여 반환하는 함수를 작성해보죠.

throttling = function(rate, process){
  var timeOutId = -1,
      next = 0,
      delay = function(){
        process();
        timeOutId = -1;
      };
  return function(){
    var curr = Date.now();
    if(next > curr){
      if(timeOutId == -1) timeOutId = setTimeout(delay, rate);
      }else{
        if(timeOutId != _NONE){
          clearTimeout(timeOutId);
          timeOutId = _NONE;
        }
        process();
      }
      next = curr + rate;
  };
};

코드는 이미 설명된 알고리즘과 거의 동일합니다. 이제는 매우 간단하게 사용할 수 있습니다.

document.getElementById('a').addEventListener('click', throttling(200, function(e){
  //원래소스
});

결론

함수 호출 쓰로트링은 자주 호출되는 함수실행을 완화시켜 부하를 낮추고 응답성을 높이는 효과가 있습니다.
더 나아가 자주 호출되지 않아도 한 번 호출하면 일정 시간 동안은 건들 수 없는 작업에도 효과가 있습니다.
하지만 위의 쓰로틀링 패턴은 스택이 1개뿐인 구조로 이해할 수 있으므로 여러 개의 클릭을 기억했다가 순차대로 해소해주는 기능은 없습니다.

만약 실시간으로 입력을 받고 실행이나 출력은 일정하게 쓰로틀링하고 싶다면 큐나 스택레이어가 중간에 위치해서 중재해야합니다.
이 경우는 보통 무한 큐를 허용하기보다는 써클큐 등을 이용해 일정 크기까지는 잡아주는 형식을 쓰게 됩니다. 대표적으로 이런 시스템에는 키보드 입력 큐가 있습니다.
뭐 이렇게 복잡하게 가지 않아도 이 쓰로트링 패턴을 이용해 보다 쾌적하고 반응성좋은 웹사이트를 만드는데 많이 기여할 수 있습니다.