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

이윤우·2023년 4월 26일
0

JavaScript

목록 보기
28/34

개발을 하다보면, 함수를 당장 실행하지 않고 정확히 몇 초의 딜레이 후에 실행하고 싶을 때가 있을 것입니다. 이것을 "호출 스케줄링하기(scheduling a call)"라고 합니다.

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

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

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

setTimeout

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

func|code: 실행을 위한 함수나 문자열입니다. 주로 함수를 받습니다. 실행하진 마세요.
delay: 실행하기 전의 딜레이 입니다. ms 단위로 이루어져 있습니다. (1000ms = 1s)
arg1, arg2: 함수에 대한 인자들입니다.

setTimeout(function (phrase, who) {
	alert(pharase + ', ' + who);
}, 1000, 'Hello', 'John');

clearTimeout으로 취소하기

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

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

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

setInterval

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

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

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

호출을 중지하고 싶다면, 우리는 clearInterval(timerId)를 호출해야 합니다.

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

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

재귀적인 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 사용량이 많은 작업이 있다면, 실행에 걸린 시간을 측정하고 다음 호출은 더 일찍할지 더 늦게 할지 계획할 수 있습니다.
재귀적인 setTimeout은 setInterval이 보장하지 못하는 실행간 딜레이를 보장할 수 있습니다.
밑의 두개의 코드를 비교해봅시다.

// 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 마다 실행할 것입니다.

func 호출 사이의 진짜 딜레이는 코드에 기재된 것 보다 적습니다.
그게 일반적인 경우입니다. 왜냐하면 func의 실행에 의해 소비되는 일부의 interval 때문입니다. func가 우리가 예상한것보다 더 길게 실행되어 100ms의 시간보다 더 걸리는 것도 가능합니다. 이 경우에는 엔진은 func의 실행 완료까지 기다리고 스케쥴러가 체크하고 시간이됐다면, 다시 즉시 실행할 것입니다.
극단적인 경우, 만일 함수가 항상 delay 보다 더 길게 실행된다면, 잠깐의 정지도 하지 않고 즉시 실행될 것입니다.


위의 그림은 setTimeout의 그림입니다. 재귀적인 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를 꽤 많이 잡아먹습니다. 코드에 하이라이팅을 하기 위해, 엔진은 코드를 분석하고 많은 색칠된 엘리먼트들을 만들어내고 문서(documnet)에 추가합니다. 큰 텍스트의 경우에는 정말 많은 시간이 소요됩니다. 심지어 브라우저를 잠시 먹통이 되게 만들기도 합니다.

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

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

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

function count() {
  	const start = Date.now();
  	let i = 0;
  	do {
    	i += 1
    } while (i % 1e9 !== 0)
  	
  	console.log(`Done in ${Date.now() - start} ms`);
}

count();

중첩된 setTimeout을 이용하여 작업을 Split 해봅시다.

function count() {
  	let start = Date.now();
  	
  	let i = 0;
  	do {
    	i += 1
    } while (i % 1e6 !== 0);
  
  	if (i == 1e9) {
      	console.log(`Done in ${Date.now() - start} ms`);
    } else {
    	setTimeout(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() {
	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()를 더 할 필요가 있다는 것을 압니다. 우리는 작업을 하기 전에 즉시 스케줄을 걸어놓습니다.

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

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

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

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

여기 데모가 있습니다.

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

<script>
	let i = 0
  	function count() {
  		i += 1;
  		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>

0개의 댓글