javascript

EventLoop에 대한 완벽하지는 않지만 자세한 설명

하리하링웹 2024. 5. 3. 23:55

개요

 자바스크립트는 단일 스레드로 동작하며, 이는 한 번에 하나의 작업만을 처리할 수 있다는 의미를 갖는다. 그러나 자바스크립트가 이러한 단일 스레드 모델에도 불구하고 여러 이벤트를 동시에 처리할 수 있는 이유는 바로 **이벤트 루프**에 있다.

 이벤트 루프는 이름 그대로 끊임없이 루프하여 현재 큐에 태스크가 있는지 확인하며 큐에 태스크가 있을 경우 이벤트 루프의 내부 동작 알고리즘에 따라 태스크를 처리해주는 역할을 하며 이를 통해 I/O와 같은 이벤트들을 차단하지 않고 부드럽게 동작하게 해준다.

 

물론 정확히 말하면 차단이 되지만 우선순위를 높여 마치 차단되지 않는 것처럼 동작하게 해준다.

이벤트 루프의 역할

 정리하자면 이벤트 루프의 주요 역할은 다음과 같다:

  1. 이벤트 처리: 이벤트 루프는 비동기적으로 발생하는 이벤트들을 처리한다. 이러한 이벤트에는 사용자의 입력, 네트워크 요청, 타이머 등이 포함된다.
  2. 작업 큐 관리: 이벤트 루프는 여러 종류의 작업이 대기하는 큐(queue)를 관리한다. 주로 사용되는 큐는 태스크 큐(task queue)와 마이크로태스크 큐(microtask queue)이다.
  3. 우선순위 설정: 이벤트 루프는 작업을 처리하는 우선순위를 결정한다. 일반적으로 microtask queue가 Task Queue보다 더 높은 우선순위를 갖는다.

 이제부터 각 큐가 어떤 역할을 하고 어떻게 실행되는지 좀 더 자세히 설명하도록 하겠다.

Task Queue (macro task queue)

 첫 번째로 소개할 큐는 Task Queue이며 때로는 Macro Task Queue로도 불린다. 이벤트 루프는 한 개 이상의 Task Queue를 가질 수 있다. 물론 이는 브라우저에 따라 다를 수 있다. Promise가 아닌 대부분의 비동기 작업은 Task Queue로 들어가 처리된다.

브라우저 렌더링 파이프라인

 Task Queue를 설명하기 전에 알아야 할 정보가 있다. 브라우저는 모니터의 주사율에 따라 다르겠지만 일반적으로 1초에 60번의 렌더링 파이프라인을 거치도록 설계되어 있다. 즉 브라우저는 대략 16ms마다 렌더링이 발생하게 된다.

이 렌더링 파이프라인은 큐에 쌓일 수 있는 대부분의 태스크보다 우선순위를 갖는다. 이것은 이벤트 루프 내부의 Task Queue에 태스크가 많이 쌓여 있더라도 렌더링이 발생할 시간이 되면 Task Queue보다 우선적으로 렌더링이 일어난다는 것을 의미한다.

 주의해야 할 점은 단일 스레드는 어떤 태스크를 처리 중일 때 절대로 다른 태스크가 실행되지 않는다는 것이다. 브라우저 렌더링 파이프라인도 하나의 태스크로 볼 수 있다. 즉, Task Queue에 있는 작업이 10초가 걸리는 아주 긴 작업이라면 10초 동안 브라우저는 렌더링이 되지 않는다.

예를 들어 설명하자면 1초가 걸리는 작업이 10개가 Task Queue에 들어가 있다고 가정해보자. 콜 스택이 비어있다면 이벤트 루프는 1개의 Task Queue를 먼저 처리할 것이다. 그러면 1초 동안 브라우저는, 정확히는 자바스크립트는 그 어떤 작업도 처리하지 않는다.

 태스크의 실행이 완료되면 콜 스택은 비어 있고 Task Queue에는 9개의 작업이 남아 있게 된다. 하지만 브라우저의 렌더링 시간이 되었기에 Task Queue보다 우선적으로 브라우저 렌더링 파이프라인이 실행되게 된다.

하나의 Task Queue

 우선 하나의 Task Queue를 가진 브라우저에서 이벤트 루프가 어떻게 동작하는지 살펴보자.

 비동기 작업인 setTimeout과 같은 작업이 발생하면 Task Queue에 태스크가 추가된다. 이벤트 루프는 계속해서 Task Queue가 비어있는지 확인하기 위해 루프를 돌며 대기한다.

 이제 Task Queue에 태스크가 존재한다고 가정해 보자. 먼저 콜 스택이 비워질 때까지 이벤트 루프는 Task Queue에 있는 태스크를 처리하지 않는다. 콜 스택이 비어지면, 이벤트 루프는 Task Queue에서 다음 태스크를 가져와 실행한다. 해당 태스크의 처리가 완료되면 다시 콜 스택이 비어있는 것을 확인하고 Task Queue에서 다음 태스크를 가져와 실행하는 작업을 반복한다.

 이러한 반복은 브라우저의 렌더링 시간이 되기 전까지 여러 번 발생할 수 있다. 만약 60프레임 주사율 모니터를 사용하고 있고, 태스크 처리에 3ms가 소요된다면 먼저 6개의 태스크를 처리할 수 있을 것이다. 이 때 6개의 태스크를 모두 처리하는 데에는 18ms가 소요되고, 브라우저의 렌더링은 16ms마다 발생하기 때문에 브라우저는 마지막 태스크를 처리할 때 렌더링 시간이 되었음에도 렌더링을 진행하지 않고 대기하며 이로 인해 브라우저에는 2ms의 렌더링 지연이 발생하게 된다.

다시 말하지만 꼭 기억해야 할 점은브라우저의 렌더링 시간이 되면 현재 태스크 처리가 완료된 후 우선적으로 브라우저는   렌더링 작업을 수행한다는 것이다.

 

이러한 Task Queue를 가진 이벤트 루프의 동작 의사코드는 아래와 같다.

while (true) {
	queue = getNewQueue();
	task = queue.pop();
	execute(task);

	if (isRepaintTime()) repaint();
}

 이제 Task Queue가 2개인 브라우저의 경우를 살펴보겠다. Task Queue가 2개 이상이더라도 처리 방식은 동일하므로 설명의 편의상 2개로 가정하겠다.

두 개의 Task Queue

 먼저 어떤 개발자가 I/O 이벤트 처리를 중요하게 여겨서 이를 우선적으로 처리하는 Task Queue를 가진 브라우저를 만들었다고 가정해보자. 이 개발자는 I/O 이벤트를 처리하는 Task Queue와 다른 모든 작업을 처리하는 Task Queue로 총 2개의 Task Queue를 사용하여 이벤트 루프를 구성했다. 또한, I/O 이벤트를 다른 작업보다 우선적으로 처리하기 위해 I/O Task Queue의 우선순위를 높였다.

 비동기 작업이 발생하면 해당 작업은 일반 Task Queue에 추가될 것이다. 많은 비동기 작업이 발생하여 Task Queue에 많은 작업이 쌓여 있다고 치며, 이때, 새로운 I/O 이벤트가 발생하여 I/O Task Queue에 새로운 작업이 추가되었다고 가정해보자.

 

 이 때 이벤트 루프는 어떤 순서로 동작할까?

 

 먼저 현재 실행 중인 작업을 마저 처리할 것이다.

 그리고 다음 작업을 선택할 때, 일반 Task Queue에 얼마나 많은 작업이 쌓여 있는지 신경 쓰지 않고 우선적으로 I/O Task Queue에 있는 작업을 선택하여 실행할 것이다. 이는 I/O Task Queue의 우선순위가 더 높기 때문에 발생하는 일이다. 만약 I/O Task Queue에 많은 작업이 있고, 일반 Task Queue에도 많은 작업이 있다면 어떻게 될까?

 

 여전히 모든 I/O Task Queue의 작업이 처리될 때까지, 일반 Task Queue의 작업은 처리되지 않을 것이다.

 

 그러나 여기서도 브라우저의 렌더링을 고려해야 한다. 두 개의 Task Queue에 모두 작업이 존재하고 있을 때, 브라우저의 렌더링 시간이 되면 어떻게 될까?

 

 여기서도 브라우저의 렌더링은 우선적으로 일어나게 된다. Task Queue는 단순히 Task Queue일 뿐이다. Task Queue가 어떤 작업을 우선적으로 처리하든지, 브라우저는 렌더링 시간이 되면 본인의 렌더링 작업을 우선적으로 처리하려고 한다.

Task Queue는 한 개 이상 존재할 수 있으며, 이론적으로 100개도 가능할것이다. 하지만 모든 Task Queue가 작업으로 가득 차 있더라도, 브라우저의 렌더링 시간이 되었다면 현재 처리 중인 작업이 완료된 후 다음 작업으로 즉시 브라우저가 렌더링 된다. 그리고 그 후 우선순위에 따라 남은 작업이 처리된다.

 

 그러면 브라우저 렌더링은 무조건 최우선 순위를 가지고 실행될까?

 

 정답은 아니오 이다. Microtask Queue라는 것이 있기 때문이다. 이제 Microtask Queue에 대해 설명하겠다.

MicroTask Queue

 Microtask Queue는 1순위로 동작하는것을 보장해주는 큐이다. 이는 Task Queue와는 별도의 큐로 존재하며 완전히 다른 방식으로 동작한다.

 Task Queue와 Microtask Queue 두 개의 큐가 있다고 가정하면, 이벤트 루프는 Microtask Queue의 작업을 항상 먼저 실행한다.

 Microtask Queue에 작업이 10개 있고 Task Queue에 작업이 100개 있다고 하더라도, 이벤트 루프는 무조건 Microtask Queue의 작업 10개를 모두 완료한 후에야 Task Queue의 작업을 실행한다.

 Microtask Queue의 작업이 밀리는 경우는 단 한 번뿐 이다. 현재 처리 중인 작업이 오랜 시간이 걸리는 경우, 다음 Microtask가 실행되지 않고 현재 작업이 완료된 후 실행된다.

 Task Queue가 가득 차 있고 Microtask Queue가 비어있는 상태에서 새로운 작업이 Microtask Queue에 추가된다면, Task Queue를 모두 처리한 후에 Microtask가 실행될 것 같지만 실제로는 그렇지 않다. 이벤트 루프는 매번 작업을 처리할 때마다 Microtask Queue가 비어있는지 확인하고, 비어있지 않다면 모든 Microtask를 실행한다. 따라서 아래와 같이 동작하게 된다.

for (let i = 0; i < 3; i += 1) {
  setTimeout(() => {
    console.log('@@ setTimeout @@');
    queueMicrotask(() => {
      console.log('** micro **');
    });
  });
}

// 결과
@@ setTimeout  @@
** micro **
@@ setTimeout  @@
** micro **
@@ setTimeout  @@
** micro **

 위의 결과에서 볼 수 있듯이 Microtask Queue의 작업은 매 태스크의 실행 사이사이에서 매번 1순위로 처리된다.

Microtask는 다른 모든 작업보다 우선순위가 높기 때문에, 브라우저 렌더링이나 네트워크 요청 등의 모든 작업도 후순위로 밀릴 수 있다.

 즉 Macro Task Queue처럼 브라우저의 렌더링 시간이 되었다면 이를 우선적으로 실행해주지 않는다는 것이다.

 만약 1초짜리 태스크 100개가 Microtask Queue에 존재한다면 브라우저는 100초동안 렌더링되지 않게된다.

 만약 Microtask Queue에 무한 루프를 포함한 Promise가 있으면, 브라우저는 동작하지 않게 될 것이다.

 

 아래는 Microtask Queue를 포함한 이벤트 루프의 의사코드이다.

while (true) {
	queue = getNewQueue();
	task = queue.pop();
	execute(task);

	while(microtaskQueue.hasTasks())
		doMicrotask();

	if (isRepaintTime()) repaint();
}

Animation Frame Queue

 Animation Frame Queue는 **requestAnimationFrame**을 통해 생성된 태스크가 들어오는 큐 공간이다. 이 큐는 매 브라우저 렌더링 시점에서 실행되어야 하는 작업을 관리한다.

[참고링크]

 

RequestAnimationFrame이란

개요프론트엔드에서 애니메이션을 구현할 때 CSS를 사용하는 방법을 주로 사용하지만 Javascript를 사용하는 방법도 있다. 물론 CSS에 비해 Javascript는 성능이 떨어지지만 모든것을 CSS로 해결할수

jjongsk.tistory.com

 

 requestAnimationFrame의 특성상 매 렌더링 시점마다 실행되는 것을 보장해줘야 하기에 Animation Frame Queue는 조금 특이하게 동작한다.

 Animation Frame Queue는 브라우저의 렌더링 시간이 되었을 때 현재 콜스택과 Microtask Queue가 비어있다면 우선적으로 실행되게 된다.

 만약 Task Queue에 작업이 있더라도 Animation Frame Queue의 태스크는 이보다 우선적으로 처리되며 브라우저 렌더링 시간이 되었을 때 처리되기 때문에 직후 브라우저 렌더링이 일어나게 된다.

하지만 주의해야 할 점이 있다. Animation Frame Queue역시 Microtask Queue와 동일하게 내부에 작업이 존재하는 동안에는 브라우저 렌더링이 이루어지지 않는다. 따라서 Animation Frame Queue에 10개의 태스크가 존재한다면, 브라우저는 이 모든 태스크를 처리할 때까지 렌더링되지 않는다.

여기서 한번 더 착각할 수 있는 부분이 있다.

 브라우저 렌더링 시간이 되었을 때 Animation Frame Queue에 10개의 태스크가 존재한다고 가정해보자. 이는 브라우저 렌더링을 뒤로 미루더라도 우선적으로 처리되게 될 것이다. 하지만 처리 도중 Animation Frame Queue에 또 다시 10개의 태스크가 추가된다면 어떻게 될까?

 정답은 기존에 존재하던 10개의 태스크를 처리한 뒤 브라우저가 렌더링 되고 이후 다음 브라우저의 렌더링 시간에 추가된 나머지 태스크를 처리하게 된다이다.

 Animation Frame Queue내부의 태스크는 브라우저의 렌더링 시간 기준으로 그 때 존재하는 만큼만 태스크를 처리해주며 이후 몇 개의 태스크가 Animation Frame Queue에 추가되더라도 이는 다음 브라우저 렌더링 시간에 처리해주는 방식으로 동작한다.

 Animation Frame Queue는 브라우저 렌더링 시간에는 Task Queue보다 우선순위가 높지만 브라우저 렌더링 시간이 아니라면 Task Queue보다 우선순위가 낮아지게 된다. 예를 들어 Task Queue에 5개의 5ms짜리 작업, Animation Frame Queue에 5개의 5ms작업이 존재하며 브라우저의 렌더링 주기는 16ms라고 가정해보자

 이는 Task Queue에 있는 4개의 태스크가 먼저 실행되고 Animation Frame Queue에 존재하는 5개의 작업이 처리되고 이후 브라우저가 렌더링이 일어나고 남은 1개의 태스크가 처리되는 순서로 동작하게 된다.

Task Queue의 태스크 3개를 처리해도 15ms의 시간밖에 걸리지 않기 떄문에 브라우저 렌더링 시간인 16ms가 되지 않아 Animation Frame Queue의 우선순위는 Task Queue보다 낮은 상태로 존재하게 된다. 따라서 Task Queue의 태스크 1개를 더 처리하여 20ms의 시간이 되고나서야 Animation Frame Queue의 우선순위가 더 높아지게 되고 이후 위의 순서대로 동작하게 되는것이다.

 

 Animation Frame Queue역시 매 태스크의 실행 사이사이 Microtask Queue의 작업을 처리해줘야한다. 즉 아래와 같이 동작한다.

for (let i = 0; i < 3; i += 1) {
  requestAnimationFrame(() => {
    console.log('@@ animation callback @@');
    queueMicrotask(() => {
      console.log('** micro **');
    });
  });
}

@@ animation callback @@
** micro **
@@ animation callback @@
** micro **
@@ animation callback @@
** micro **

 Microtask Queue는 항상 1순위 인 것을 기억하자!

 

 최종적으로 이벤트 루프의 의사 코드는 아래와 같다고 말할 수 있다.

while (true) {
	queue = getNewQueue();
	task = queue.pop();
	execute(task);

	while (microtaskQueue.hasTasks()) doMicrotask();

	if (isRepaintTime()) {
		animationTasks = animationQueue.copyTasks();
		for (task of animationTasks) {
			doAnimationTask(task);
		}
		repaint();
	}
}

결론

 자바스크립트의 이벤트 루프는 위에서 설명한 방식으로 동작한다. 물론 의사 코드가 정확하지 않고(microtask의 실행 시점) animation frame queue의 태스크 처리 시점에 대한 설명이 명확하지 않다는 생각도 들 수 있다. 하지만 이 정도만 이해하고 있더라도 대부분의 자바스크립트 코드를 작성할 때 문제 없이 충분이 작성할 수 있을 것이라 생각한다.

 

[참고 영상]

https://www.youtube.com/watch?v=8aGhZQkoFbQ

https://www.youtube.com/watch?v=u1kqx6AenYw