스케쥴링: setTimeout 과 setInterval

EJ·2020년 12월 13일
0

JavaScript 개념

목록 보기
10/13

함수를 당장 실행하지 않고 정확히 몇 초의 딜레이 후에 실행하고 싶을 때가 있다. 이것을 "호풀 스케쥴링하기(scheduling a call)"라고 한다.

이것을 구현하기 위해 두 가지 메소드가 존재한다.

  • setTimeout은 일정 시간 간격 이후에 함수가 한 번 실행된다.
  • setInterval은 일정 시간 간격으로 함수가 주기적으로 실행된다.

위의 두 메소드들은 자바스크립트 스펙의 일부가 아니다. 하지만 대부분의 환경은 내부적인 스케쥴러를 갖고 있다. 그리고 이러한 메소드들을 제공한다. 구체적으로는 Node.js와 모든 브라우저에서 제공된다.

setTimeout

문법:

let timerId = setTimeout(func|code, [delay], [arg1], [arg2], ...);

파라미터:
func|code
실행을 위한 함수나 문자열이다. 주로 함수를 받는다. 코드의 문자열도 넘겨질 수 있지만 히스토리적인 이유로 권장되지는 않는다.

delay
실행하기 전의 딜레이이다. ms단위로 이루어져있다. 1000ms = 1s이며 디폴트값은 0이다.

arg1, arg2...
함수에 대한 인자(Arguments)들이다. (IE9 미만 버전에서는 지원 불가)

예를 들어 1초 후에 sayHi()를 호출하는 코드를 작성해보자.

function sayHi() {
  alert('Hello');  
}

setTimeout(sayHi, 1000);

인자를 줘보자.

function sayHi(phrase, who) {
  alert( phrase + ', ' + who);  
}

setTimeout(sayHi, 1000, "Hello", "John"); // Hello, John

만일 첫 번째 인자에 문자열이 들어온다면, 자바스크립트는 그로부터 함수를 만들어 낼 것이다.
다음의 코드처럼 동작한다는 것이다.

setTimeout("alert('Hello')", 1000);

하지만 문자열을 사용하는 것은 권장되지 않는다. 문자열 대신 함수를 사용하자. 다음과 같이.

setTimeout(() => alert('Hello'), 1000);

함수를 넘겨야 하지만 실행하진 말아라.
함수 뒤에 ()괄호를 추가하지 않아야 한다.

// Wrong!
setTimeout(sayHi(), 1000);

위 코드는 작동하지 않는다. 그 이유는 setTimeout이 함수로의 참조를 받아올 것이라 예상했기 때문이다. 여기 sayHi()는 함수를 즉시 실행시킨다. 그리고 그 실행의 결과가 setTimeout으로 전달되게 된다. 위에서 작성한 코드들을 살펴보면 sayHi()의 결과는 undefined(아무것도 리턴하지 않는 함수)이다. 그러므로 아무것도 스케쥴되지 않는다.

clearTimeout으로 취소하기

setTimeout을 호출했을 때, 반환 값으로 실행을 취소하기 위해 사용할 수 있는 "timer identifier"인 timerId를 준다.

취소하기 위한 문법은 다음과 같다.

let timerId = setTimeout(...);
clearTimeout(timerId);

아래의 코드에서 함수를 스케쥴링하고 그 후에 취소해보자. 그 결과로는 아무것도 일어나지 않을 것이다.

let timerId = setTimeout(() => alert("never happens"), 1000);
alert(timerId); // timer identifier

clearTimeout(timerId);
alert(timerId); // same identifier (취소 이후에도 null이 되진 않는다.)

alert 출력을 통해 알 수 있듯이, 브라우저 내부에서 timer identifier는 숫자이다. 다른 환경에서는 timer identifier가 다른 것이 될 수 있다. 예를 들면, Node.js는 추가적인 메소드와 함께 timer object를 리턴한다.

다시 한 번 말하지만, 이러한 메소드에 대한 국제적인 스펙은 없다. 그래서 괜찮다.

브라우저에 대해서는 HTML5 표준의 timer section을 참조하자.

setInterval

setInterval 메소드는 setTimeout과 같은 문법을 갖고 있다.

let timerId = setInterval(func|code, [delay], [arg1], [arg2], ...);

모든 인자들은 같은 의미를 갖는다. 하지만 setTimeout과는 다르게 함수를 한 번만 실행하는 것이 아니라 부여된 시간 간격 이후로 주기적으로 실행한다.

더이상 호출하는 것을 중지하고 싶다면 clearInterval(timerId)를 호출해야 한다.

아래의 예제는 2초마다 메시지를 보여주고, 5초 후에 출력이 중지된다.

// 2초마다 반복
let timerId = setInterval(() => alert('tick'), 2000);

// 5초 후에 정지
setTimeout(() => { clearInterval(timerId); alert('stop'); }, 5000);

Chrome/Opera/Safari에서는 Modal windows가 시간을 freeze한다.
IE와 Firefox에서는 alert/confirm/prompt를 보여주면서 내부적인 타이머가 계속해서 "ticking"을 하지만 크롬, 오페라, 사파리에서는 내부적인 타이머가 "frozen"상태가 된다.

그래서 만일 위 코드를 돌리고 alert 윈도우를 일정 시간 도안 없애지 않았다면, 그 후에 Firefox/IE는 다음 alert가 즉시 보여진다.(지난 호출에서 2초 경과 후에)
그리고 Chrome/Opera/Safari에서는 2초가 더 지난 후에 보여지게 도리 것이다.(타이머는 alert동안에 시간이 가지 않는다.)

alert는 js엔진의 스레드를 멈추지 않는다. alert 동안에도 시간이 간다. 위의 취소표시한 내용은 아무래도 과거의 지식인 것 같다.

재귀적인 setTimeout

무언가 정기적으로 실행시키기 위해서는 두 가지 방법이 있다.

한가지가 setInterval이었고, 두 번째가 재귀적인 setTimeout이다. 구현은 다음과 같다.

let timerId = setInterval(() => alert('tick'), 2000);

// 5초 후에 정지
setTimeout(() => { clearInterval(timerId); alert('stop'); }, 5000);

위의 setTimeout은 현재 실행중인 것이 끝날 때((*)) 다음 호출을 바로 스케쥴한다.

재귀적인 setTimeoutsetInterval보다 더욱 유연하다. 이 방법에서는 다음 호출은 아마 때에 따라 다르게 스케쥴 될 것이다. 현재 실행하던 것의 결과에 따라 달라질 것이다.

이를테면, 서버에 5초마다 데이터를 물어보는 요청을 보내는 서비스를 작성할 필요가 있는데, 서버에 요청이 너무 많을 때는 계속해서 요청을 보내기 보다는 우리가 주기를 10초, 20초, 40초 정도로 늘리는 것이 바람직하다.

다음은 의사코드이다.

let delay = 5000;

let timerId = setTimeout(function request() {
  ...요청 전송...
  
  if(서버 과부하 때문에 요청이 실패한다면...) {
    // 다음 실행까지 인터벌을 좀 늘리자..
    delay *= 2;
  }
  
  timerId = setTimeout(request, delay);
  
}, delay);

그리고 주기적으로 CPU 사용량이 많은 작업(CPU-hungry tasks)이 있다면, 실행에 걸린 시간을 측정하고 다음 호출을 더 일찍할지 늦게할지 계획할 수 있다.

재귀적인 setTiemoutsetInterval이 보장하지 못하는 실행간 딜레이를 보장할 수 있다.

밑의 두 코드를 비교해보자. 첫 번째 예제에서는 setInterval을 사용한다.

let i = 1;
setInterval(function () {
  func(i);
}, 100);

두 번째 예제는 재귀적인 setTimeout을 사용한다.

let i = 1;
setTimeout(function run() {
  func(i);
  setTimeout(run, 100);
}, 100);

setInterval에서는 내부적인 스케쥴러가 junc(i)를 매 100ms마다 실행할 것이다.

위 그림을 보면 func 호출 사이의 진짜 딜레이는 코드에 기재된 것보다 적음을 알 수 있다.

이것이 일반적인 경우이다. 그 이유는 func의 실행에 의해 소비되는 일부 interval때문이다.

func가 우리가 예상한 것보다 더 길게 실행되어 100ms의 시간보다 더 걸리는 것도 가능하다.

이 경우에는 엔진은 func의 실행 완료까지 기다리고 스케쥴러를 체크하고 시간이 됐다면, 다시 즉시 실행할 것이다.

극단적인 경우에는 만일 함수가 항상 delayms보다 더 길게 실행된다면, 잠깐이 정지도 하지 않고 즉시 실행될 것이다.

아래는 재귀적인 setTimeout의 그림이다.

재귀적인 setTimeout은 고정된 딜레이를 보장한다.

새로운 호출이 이전 호출의 끝에 계획되기 때문이다.

Garbage Collection
함수가 setInterval 혹은 setTimeout에 넘겨졌을 때, 그것을 가리키는 내부적인 레퍼런스가 만들어지고 스케쥴러에 저장된다. 이것은 만일, 함수에 별다른 참조가 없더라도 함수가 garbage collect되는 것을 막아준다.

// 스케쥴러 호출 시까지, 함수는 메모리에 머무른다. 
setTimeout(function() {...}, 100);

setInterval의 경우에는 clearInterval이 호출될 때까지 함수는 메모리에 머문다.
side-effect도 있다. 한 함수가 🔜lexical 환경 바깥을 참조한다. 그래서 이 함수가 살아있는 동안, 바깥의 변수들도 마찬가지로 살아있게 된다. 변수들은 함수 자체보다 더 많은 메모리를 소비할 것이다. 그래서 스케쥴된 함수가 더이상 필요하지 않을 때는 아주 작은 함수이더라도 cancel시켜주는 것이 좋다.

🔜 렉시컬 환경(Lexical Environment)
스크립트 전체, 실행중인 함수, 코드블록 등은 자신만의 렉시컬 환경을 갖는다. 렉시컬 환경은 환경레코드, 외부렉시컬 환경으로 구성된다.

setTimeout(..., 0)

setTimeout(func, 0) 또는 setTimeout(func) 등의 특별한 용례가 있다.

앞의 코드는 func의 실행을 가능한 빠르게 스케쥴한다. 하지만 스케쥴러는 현재의 코드가 끝난 뒤에 호출할 것이다.

그래서 함수가 현재의 코드가 끝난 직후에 실행하도록 스케쥴되는 것이다. 즉, 비동기적으로 실행된다.

예를 들면, 다음 코드는 Hello를 출력한 후 즉시 Wordl를 출력한다.

setTimeout(() => alert("World"));

alert("Hello");

첫 번째 줄은 달력에 0초 후에 함수를 호출하라는 명령을 넣는다. 하지만 스케쥴러는 현재의 코드가 끝난 뒤에만 달력을 확인한다. 그래서 Hello가 첫 번째고, World가 뒤따라 나온다.

CPU 소비가 많은 작업을 Splitting하기

setTimeout을 이용해 CPU 사용량이 많은 작업들을 나누는 트릭이 있다.

예를 들면, syntax-highlighting(코드 컬러링하는 작업)을 하는 스크립트는 CPU를 꽤 많이 잡아먹는다. 코드에 하이라이팅을 하기 위해 엔진은 코드를 분석하고 많은 색칠된 엘리먼트들을 만들어내고 문서(document)에 추가한다. 큰 텍스트의 경우에는 정말 많은 시간이 소요된다. 심지어 브라우저를 잠시 먹통이 되게 만들기도 한다.

그래서 setTimeout(..., 0)을 이용해 긴 텍스트를 조각조각 나눌 수 있다. 처음 100줄 이후 또 다른 100줄 이러한 형식으로 말이다.

명쾌하게 이해하기 위해, 간단한 예제를 갖고 고려해보자. 1부터 1000000000까지 숫자를 세는 함수를 갖고있다.

만일 이 함수를 실행한다면 CPU는 잠시 멈출 것이다. 서버사이드에서 이용하는 자바스크립트의 경우, 이런 일은 상당히 크게 느껴진다. 그리고 만일 브라우저에서 실행시키고 다른 버튼을 클릭하려 한다면, 모든 자바스크립트가 일시정지하는 현상을 볼 수 있을 것이다. 작업이 끝날 때까지 어떠한 액션도 작동하지 않을 것이다.

let i = 0;

let start = Date.now();

function count() {
  
  // 무거운 작업을 하자
  for (let j = 0; j < 1e9; j++) {
    i++;
  }
  
  alert("Done in " + (Date.now() - start) + 'ms');
}

count();

브라우저는 "스크립트가 너무 깁니다."라는 경고 메시지를 보여줄 수도 있지만 그러지 않을 것이다. 왜냐하면 숫자가 너무 크지 않기 때문이다.

중첩된 setTimeout을 이용해 작업을 Split해보자.

let i = 0;
let start = Date.now();

function count() {
  // 약간의 무거운 작업을 해보자. (*)
  do {
    i++;
  } while (i % 1e6 != 0);
  
  if (i == 1e9) {
    alert("Done in " + (Date.now() - start) + 'ms');
  } else {
    setTimeout(count); // 호출을 스케쥴링한다. (**) 
  }
}

count();

"counting" 작업을 하는 동안, 브라우저 UI가 완전히 동작한다.

우리는 작업의 부분을 수행한다.

  1. 첫 번째 실행에서: i=1...1000000
  2. 두 번째 실행에서: `i=1000001..2000000
  3. 그리고 계속...while에서 i1000000에 의해 나눠지는기 검사한다.

그 후에 아직 작업이 완료되지 않았다면, 다음 호출은 (**)에서 스케쥴된다.

count 실행 중 일시정지는 자바스크립트 엔진에게 숨 쉬며 다른 작업을 할 시간을 준다. 다른 사용자의 액션에 반응할 시간을 말이다.

주목해야 할 것은 작업을 setTimeout으로 나누든 나누지 않든, 속도는 거의 비슷하다는 것이다. 전체 카운팅 타임에는 큰 차이가 없다는 것이다.

이 개념들에 더 익숙해지도록 소스코드를 개선해보자.

스케쥴링을 count()의 시작 부분으로 옮길 것이다.

let i = 0;
let start = Date.now();

function count() {
  // move the scheduling at the beginning
  if ( i < 1e9 - 1e6) {
    setTimeout(count);  
  }
  
  do {
    i++;
  } while (i % 1e6 != 0);
  
  if ( i == 1e9) {
    alert("Done in " + (Date.now() - start) + 'ms');
  }

}

count();

이제, count()부터 시작하고 count()를 더 할 필요가 있다는 것을 안다. 작업을 하기 전에 즉시 스케쥴을 걸어놓자.

실행해보면, 이게 훨씬 적은 시간이 든다는 것을 알 수 있다.

브라우저에서 중첩된 타이머의 딜레의 최소화

브라우저에서는 중첩된 타이머를 얼마나 자주 동작할 수 있는지에 대한 제한이 있다. HTML5 표준은 "5개의 중첩된 타이머 이후에는 간격이 적어도 강제로 4ms만큼 있을 것이다."라고 말한다.

이게 무엇을 의미하는지 아래 예제에서 말해준다. 코드에서 0ms 후에 setTimeout 호출을 하여 자신을 재스케쥴한다. 각 호출은 times배열에서 이전 호출로부터의 정확한 시간을 기억한다. 진짜 딜레이는 어떻게 보일까? 테스트해보자.

let start = Date.now();
let times = [];

setTimeout(function run() {
  times.push(Date.now() - start); // remember delay from the previous call

  if (start + 100 < Date.now()) alert(times); // show the delays after 100ms
  else setTimeout(run); // else re-schedule
});

// an example of the output:
// 1,1,1,1,9,15,20,24,30,35,40,45,50,55,59,64,70,75,80,85,90,95,100

첫 타이머는 (스팩에 기재된대로) 즉시 실행된다. 그리고 그 후에는 딜레이가 일어나기 시작해서 숫자값이 9, 15, 20, 24... 이러한 형식으로 들어오게 된다.

이런 제한은 예정부터 있어왔고 많은 스크립트들이 의존하고 있다. 그래서 아직까지 그러한 히스토리적인 이유로 존재한다.

서버사이드 자바스크립트의 경우 이러한 제한이 없다. 그리고 즉각적인 비동기 작업을 스케쥴하기 위해 다른 방법이 존재한다. 이를테면 노드js에 process.nextTicksetimmediate와 같은 것이 있다. 이런 개념은 브라우저에 한해 적용된다.

브라우저가 렌더링하도록 허락하기

브라우저 내부에서 실행되는 스크립트의 또다른 이점은 유저에게 프로그래스바와 같은 것을 보여줄 수 있다는 것이다. 왜냐하면 브라우저는 주로 스크립트가 완료된 이후에 모든 "repainting" 작업을 하기 때문이다.

그래서 만일 하나의 큰 함수를 수행한다면, 만일 이것이 무엇을 변화시키더라도 변화는 그 작업이 끝날 때까지 반영되지 않는다.

아래 코드를 살펴보자.

<div id="progress"></div>

<script>
let i = 0;

function count() {
  for (let j = 0; j < 1e6; j++) {
    i++;
    // 현재의 i 값을 progress div에 넣습니다.
    // innerHTML에 대해 더 알아봅시다.
    progress.innerHTML = i;
  }
}

count();
</script>

실행하기 되면, 모든 카운팅 작업이 끝난 뒤에 i의 변화가 반영된다.

만일 위의 작업을 조각조각 나누기 위해 setTimeout을 사용한다면, 변화가 각 작업 도중에 반영될 것이다. 아마 더욱 보기 좋을 것이다.

<div id="progress"></div>

<script>
  let i = 0;

  function count() {

    // do a piece of the heavy job (*)
    do {
      i++;
      progress.innerHTML = i;
    } while (i % 1e3 != 0);

    if (i < 1e9) {
      setTimeout(count);
    }

  }

  count();
</script>

이제 <div>i의 증가를 보여준다.

요약

  • setInterval(func, delay, ...args)setTimeout(func, delay, ...args) 2개의 메소드는 funcdelayms 이후에 주기적으로 혹은 한 번 실행하도록 허용해준다.
  • 실행을 취소하기 위해, setInterval 또는 setTimeout에서 반환되는 값을 이용해 clearInterval 또는 clearTimeout을 호출해야 한다.
  • 중첩된 setTimeout 호출은 setInterval을 이용하는 것보다 더욱 유연하다. 그리고 각 실행 사이에 최소한의 딜레이를 보장해준다.
  • 타임아웃이 0인 스케쥴링 setTimeout(..., 0)은 "현재 코드가 끝난 이후에 바로 호출"을 스케쥴링하고 싶을 때 사용된다.

setTimeout(..., 0)의 유즈케이스는 다음과 같다.

  • CPU가 많이 소모되는 작업들을 조각조각 나누기 위해 스크립트는 더이상 그 작업에 매달려있지(hang) 않을 것이다.
  • 프로세스가 진행되는 도중에 브라우저가 다른 것을 할 수 있도록 만들기 위해.(이를테면 progress bar의 진행)

모든 스케쥴링 메소드는 정확한 딜레이를 보장하지 못하는 것을 알아두자. 스케쥴된 코드에서 그것에 의존하지 않는 것이 좋다.

예를 들면, 브라우저 내무의 타이머는 많은 이유에 의해 지연될 수 있다.

  • CPU가 오버로드 됨
  • 브라우저 탭이 백그라운드 모드에 있음
  • 랩탑이 배터리를 사용 중임

모든 것이 최소한의 타이머 딜레이를 300ms 심하면 1000ms까지 증가시킬 수 있다. 어떤 브라우저를 쓰는지와 셋팅에 따라 달라진다.

연습문제

  • 매 초마다 증가하는 숫자를 출력하는 printNumbers(from, to) 함수를 작성해보자.
    - setInterval() 메소드를 이용해 작성해 본 뒤
    - setTimeout() 메소드를 이용해 재귀적으로 작성해보자.
// setInterval()
function printNumbers_setInterval(from, to){
	var timerId = setInterval(
		()=>{ 

			if(from>=to){
                clearInterval(timerId);
            }

            console.log(from); 			
			from ++;
        }, 1000);
}

// setTimeout()
function printNumbers_setTimeout(from, to){

    setTimeout(
        function run(from, to) {
            console.log(from);

            if(from<to){
                setTimeout(run, 1000, ++from, to);
            }
			
			
        }
    , 1000, 0, 5);

}

profile
주니어 프론트엔드 개발자 👼🏻

0개의 댓글