들어가기 전에

자바스크립트 개발자라면 알아야 할 33가지 개념 #10 스케쥴링: setTimeout과 setInterval

개발을 하다보면, 함수를 당장 실행하지 않고 정확히 몇 초의 딜레이 후에 실행하고 싶을 때가 있을 것입니다. 이것을 "호출 스케쥴링하기(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동안에 시간이 가지 않습니다.)

역자 주 : 2019.04.23 현재일자 기준 테스트 결과 alert는 js 엔진의 스레드를 멈추지 않습니다. alert 동안에도 시간이 갑니다. 아무래도 과거의 지식인 것 같습니다.

재귀적인 setTimeout

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

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

let timerId = setTimeout(function tick() {
  alert('tick');
  timerId = setTimeout(tick, 2000); // (*)
}, 2000);

위의 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)이 있다면, 실행에 걸린 시간을 측정하고 다음 호출을 더 일찍할지 더 늦게 할지 계획할 수 있습니다.

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

밑의 두개의 코드를 비교해봅시다. 첫번째 예제에서는 setInterval을 사용합니다.

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

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

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

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

setinterval-interval.png

눈치 채셨나요?

func 호출 사이의 진짜 딜레이는 코드에 기재된 것 보다 적습니다.

그게 일반적인 경우입니다. 왜냐하면 func의 실행에 의해 소비되는 일부의 interval 때문입니다.

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

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

극단적인 경우에(In the edge case), 만일 함수가 항상 delayms 보다 더 길게 실행된다면, 잠깐의 정지도 하지 않고 즉시 실행될 것입니다.

그리고 아래에는 재귀적인 setTimeout의 그림입니다.

settimeout-interval.png

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

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

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

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

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

setTimeout(..., 0)

setTimeout(func, 0) 또는 setTimeout(func) 이러한 특별한 용례가 있습니다.

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

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

예를 들면, 다음 코드는 "Hello"를 출력한 후 즉시 "World"를 출력합니다.

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() 메소드를 이용하여 재귀적으로 작성해보세요.

제가(번역자) 작성한 답안은 다음과 같습니다.
여러분도 한번 재미로 만들어보세요!

function printNumbers_setInterval(from, to){
    var timerId = setInterval(
        ()=>{ 

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

            console.log(from);             
            from ++;
        }, 1000);
}
function printNumbers_setTimeout(from, to){

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

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


        }
    , 1000, 0, 5);

}

작성자가 작성한 답안은 링크에서 보실 수 있습니다!