이벤트 루프

·2022년 2월 2일
2

JavaScript

목록 보기
3/4

머리말

이 글은 뻘짓과 구글링의 결합이다.

  • 멀티 스레드는 여러 작업을 동시에 한다.
  • 싱글 스레드는 한 번에 한 작업만 한다.

JavaScript는 싱글 스레드 언어다.
싱글 스레드가 우수한 방식이어서 그렇게 만들었을까?

언뜻 봐도 비효율적이다. 자바스크립트는 왜 이런 방식을 채택했을까?

JavaScript is an inherently single-threaded language. It was designed in an era in which this was a positive choice; there were few multi-processor computers available to the general public, and the expected amount of code that would be handled by JavaScript was relatively low at that time.
자바스크립트는 싱글 스레드 언어입니다. 당시에는 이 설계가 좋은 선택이었는데요. 그 시절에 멀티 프로세서 컴퓨터는 보편적이지 않았을 뿐더러 자바스크립트가 처리할 코드 양도 적었거든요.

이렇게 될 줄 몰랐으니까!

자바스크립트는 열흘 만에 만들어진 언어다.
당시 1995년은 Java의 시대였고 JavaScript는 그냥 Java를 잘 보조하고 Java와 잘 호환되는 접착 언어(glue language)였다. 그래서 이름도 모카(Mocha)였다가 라이브 스크립트였다가 자바스크립트로 바꿨다.

아무튼 시대가 지나면서 자바스크립트의 지분이 상당히 커졌다.
이제는 자바스크립트로 작업을 빠르게 처리할 수 있는 비법이 있기 때문이다. 그건 바로...

개발자를 괴롭히면 된다

농담이다.
빠르게 돌리는 방법이 하나 뿐은 아니지만 이번 포스팅에서는 이벤트 루프를 다뤄볼까 한다.

이벤트 루프는 브라우저에 어떤 작업들이 있는지 주시한다.
작업들의 우선 순위를 구분하기 위해서인데, 다음과 같이 작업을 분류한다.

작업 순위종류
1순위Call Stack
2순위Microtask
3순위Animation Frame
4순위Macrotask
기타WebAPI

【 Call Stack 】

우리가 짠 코드는 먼저 Call Stack으로 이동한다. 이곳의 코드가 작업 최우선 순위다. 스택이란 이름답게 후입선출(LIFO, Last In First Out) 구조다.

console.log(1);
console.log(2);
console.log(3);
// 출력 : 1, 2, 3 

이런 코드는 문제 없는데, 아래 코드가 입문자를 혼란스럽게 한다.

console.log(1);

setTimeout(() => {
	console.log(2);
},0);

console.log(3);
// 출력 : 1, 3, 2

이유 : setTimeoutCall Stack에서 바로 처리되지 않는다.

  • 여담
    ECMAScript 공식 문서에서는 Call stack이란 말을 안 쓴다.
    Execution Context Stack이라고 표현한다. 그런데 누가 이렇게 표현할진 모르겠다.

【 Macrotask Queue】

큐라는 이름답게 선입선출(FIFO, First In First Out)구조다.
이 구간에 들어가는 대표적인 예시는 setTimeout
이곳에 들어가는 코드는 Call Stack보다 후순위로 밀려난다.

console.log가 바로 실행되지 않는다. WebAPI로 전달되고, WebAPI는 0초를 센다. (실제로 0초는 아니다. 대략 몇 ms ~ 몇 십 ms의 시간을 센다.)

WebAPI에서 시간을 다 세면 Queue로 전달된다.
그리고 작업 최우선 순위인 Call Stack에 있는 작업들이 다 끝나길 기다린다.
Call Stack에 아무 작업이 없으면 그제야 Queue는 작업을 Call Stack으로 넘긴다. Call StackQueue에서 전달된 작업을 처리한다.

// call stack
console.log(1);

// task queue
setTimeout(()=>{
	console.log(2);
},0);

// call stack
console.log(3);

이렇게 작업 순서를 나눠 처리하는 걸 비동기라 부른다.

  • 겉보기에 큐처럼 행동하지만 실제 구조는 set이다.

  • Message Queue, Task Queue, Callback Queue, Macrotask Queue 등등 부르는 호칭이 많이 나뉜다. 나는 Task Queue로 부르겠다.


【 WebAPI 】

사실 자바스크립트 엔진에는 비동기나 이벤트 루프라는 개념이 없다.

“뭔 소리여? 지금 JS랑 이벤트 루프 얘기중 아니었어?”

일단 API가 뭔지 다시 짚어보자. Application Programming Interface다. 사용자가 내부 구조 잘 몰라도 기능을 편리하게 이용하도록 만든 인터페이스다. 예컨대 배열의 push, pop 메소드도 API다. 그런데 여기선 왜 굳이 “Web”API라고 부를까?

  • 사실 setTimeout 메소드는 자바스크립트 엔진에 없다. runtime에 있다.
  • 사실 Task queue, Microtask queueruntime에 있다. 자바스크립트 엔진은 콜 스택 외길이다.

vscodeJavaScript를 실행하려고 할 때 Node.js를 설치하라는 가이드를 많이 봤을 텐데, Node.jsV8 + runtime을 제공하기 때문이다.

V8 : 자바스크립트를 실행하는 엔진
runtime : 자바스크립트를 실행하게 돕는 환경

WebAPIQueue로 가기 전에 고유의 처리 과정이 선행된다. 예를 들면 fetch( ) 메소드는 WebAPI이고 Call Stack, Task Queue, Microtask Queue 어디에도 속하지 않고 네트워크 처리로서 따로 행동한다. 이제 아래가 어떤 순서로 출력될지 맞혀보자.

function a() {
  console.log('a');
}

function b() {
  console.log('b');
}

function c() {
    setTimeout(b, 0);
    a();
    console.log('c');
}

c();


function d() {
    setTimeout(b, 100);
    setTimeout(a, 0);
    console.log('d');
}

d();

// 코드 출처 : 나

그림으로 보자

이래서 비동기다. 다만 한 가지 환기할 점이 있다. JavaScript는 싱글 스레드 언어라는 점인데, 결국엔 한 번에 하나의 작업만 한다. 처리 속도가 매우 빨라서 동시에 하는 것처럼 보일 뿐이다. 위의 비동기 처리 그림을 확대해보겠다.

좀 더 구체적으로 들여다보자.

이벤트 루프는 최종적으로 실행할 순서를 정해줄 뿐, 동시 작업을 시키진 않는다.
아래 코드 실행 순서를 그림으로 이해해보자.

console.log(1);

setTimeout(()=>{
	console.log(2);
},0);

console.log(3);


【 Animation Frame 】

브라우저에서 렌더링은 다음과 같은 단계를 거친다.

requestAnimationFrame => styling => Layout => Painting

CSS를 계산하기 전에 Animation Frame을 계산한다. 이 단계에서 처리하고 싶은 게 있으면 requestAnimationFrame 메소드를 쓴다. 이 때, 렌더링 때마다 처리할 것이므로 작성한 함수는 콜백으로 계속 넘겨주도록 한다. 특정 주기동안만 하고 멈출 거면 Date() 등 조건을 써서 제한하도록 한다.

const Animation = () => {
	let x = 1;
	const box = document.querySelector("div");
	function moveBox() {
		box.style.transform = `translateX(${x}px)`;
		x += 5;
	}
	function callback() {
		moveBox();
		// requestAnimationFrame(callback);
		// setTimeout(callback, 1000 / 60);
	}
	callback();
	return (
		<>
			<div id="box" className={styles.box}></div>
		</>
	);
};

// 코드 출처 : 나

위와 같은 코드를 짰을 때, setTimeoutrequestAnimationFrame은 60fps 간격으로 움직인다. 그러면 둘이 뭐가 다를까?

우린 보통 setTimeout이 위와 같이 작동할 거라고 기대한다.

하지만 정확하게 작동하지 않는다. 루틴이 깨질 수도 있고, 작업 처리에 필요한 시간 때문에 렌더링이 늦어질 수 있다.

requestAnimationFrame은 실행 주기 = 렌더 주기라서 깔끔하게 처리된다. 실제로 위의 코드를 각각 실행해봐도 그렇다. setTimeout은 도중에 박스의 움직임이 뚝뚝 끊기는 반면, requestAnimationFrame은 부드럽게 움직인다.

【 Microtask Queue】

JavaScript promises and the Mutation Observer API both use the microtask queue to run their callbacks
자바스크립트에서 프로미스와 Mutation Observer API는 콜백 함수를 실행할 때 마이크로태스크 큐를 사용합니다.

프로미스, async, await에 대한 내용은 이 포스팅으로 옮겼다.

마이크로태스크는 우선 순위뿐 아니라 처리되는 과정도 일반 태스크와 조금 다르다.

  • Task QueueCall Stack에 하나 넣고, Call Stack이 비워지길 기다리고, 중간에 Microtask나 렌더링이 있는지 확인한다. (새로이 추가되는 task는 상황에 따라서 계속 실행할 수도 있고, 다른 작업에게 우선 순위를 양보할 수도 있다.)

  • Animation Callback QueueCall Stack에 연속으로 넣지만, 새로이 추가되는 작업들은 다음 렌더링에서 처리한다.

  • Microtask Queue는 이벤트가 새로 생기더라도 Queue가 완전히 다 비워질 때까지 계속 Call Stack에 넣는다.

위 코드를 웹에서 마우스로 클릭했다고 치면 Task queue에 클릭 메소드 2개가 들어갈 것이고,
첫 번째 클릭 이벤트 처리로 Listener 1 => Microtask 1이 출력될 것이다.
그 다음으로 task queue에 남아있던 2번째 이벤트가 stack에 들어와서 처리될 것이다.

∴ Listener 1 => Microtask 1 => Listener 2 => Microtask 2

이건 콘솔 결과가 어떻게 뜰까?
웹에서 버튼을 클릭하지 않고 JS 코드를 이용해서 버튼을 클릭했기 때문에 실행 문맥은 button.clickCall Stack 기저로 깔게 된다. 따라서 웹에서 마우스 클릭했을 때와 완전히 다른 결과가 나온다. 첫 번째 클릭 이벤트를 처리해도 scriptCall Stack에 남아있다. 따라서 Microtask queue는 가만히 있고, 다음 클릭 이벤트를 처리한다.

∴ Listener 1 => Listener 2 => Microtask 1 => Microtask 2

Promise의 then은 항상 Microtask로 처리될까?

setTimeout(()=>{
  console.log("setTimeouts");
})

queueMicrotask(() => {
  console.log("queueMicrotask");
});

const p0 = new Promise((resolve, reject) => {
  resolve(1);
});

p0.then(console.log("this then works in call stack"));
p0.then(() => {
  console.log("this then works in microtask queue");
});
console.log("call stack");

// 출처 : 나
// 위 코드를 실행해보자.

프로미스 문서를 '유심히' 본 사람만 이해할 수 있는 코드다.
프로미스는 실행자(executor) 함수를 써서 프로미스 객체를 생성하고, 실행자는 resolve, reject 메소드를 매개변수로 쓴다.
resolve를 쓰면 then으로 전달되고, reject를 쓰면 catch로 전달된다. 참고로 resolve, reject를 안 써도 프로미스 객체는 pending 상태로 생성된다.

'그 다음'부터 '콜백 함수'들이 Microtask로 처리된다.

문서에서는 꼭 '콜백'이라고 명시하는데, 콜백 함수가 아니면 콜 스택으로 돌아간다. (이런 코드를 작성할 일이 평생에 한 번이라도 있을까? 이런 쓸데없는 실험 좀 그만)

microtask는 어디에 쓸 수 있을까?

먼저 microtask로 처리되는 메소드를 소개한다. queueMicrotask()다.

// 1번 코드
customElement.prototype.getData = url => {
  if (this.cache[url]) {
    this.data = this.cache[url];
    this.dispatchEvent(new Event("load"));
  } else {
    fetch(url).then(result => result.arrayBuffer()).then(data => {
      this.cache[url] = data;
      this.data = data;
      this.dispatchEvent(new Event("load"));
    });
  }
};

// 2번 코드
customElement.prototype.getData = url => {
  if (this.cache[url]) {
    queueMicrotask(() => {
      this.data = this.cache[url];
      this.dispatchEvent(new Event("load"));
    });
  } else {
    fetch(url).then(result => result.arrayBuffer()).then(data => {
      this.cache[url] = data;
      this.data = data;
      this.dispatchEvent(new Event("load"));
    });
  }
};


element.addEventListener("load", () => console.log("Loaded data"));
// call stack 단에서 처리되는 코드
console.log("Fetching data...");
// 문제의 코드
element.getData();
// call stack 단에서 처리되는 코드
console.log("Data fetched");

1번 코드에서 if문은 작업이 stack단에서 처리되고, else문은 작업이 microtask단에서 처리된다.

// 1. getData()가 stack단에서 처리되면?
	// 출력 결과
	Fetching data
	Loaded data
	Data fetched

// 2. getData()가 microtask 단에서 처리되면?
	// 출력 결과
	Fetching data
	Data fetched
	Loaded data

2번 코드는 if, else문 둘 다 microtask단에서 처리하니 실행 순서가 보장된다.
백문이 불여일견, 아래의 개판 코드를 실행해보자.

const num = Math.random();
function func(num) {
	if (num < 0.4) {
		fetch("https://velog.io/")
          .then((response) => console.log(`if success and result is ${response}`));
	} else if (num < 0.6) {
		queueMicrotask(() => {
			console.log("queueMicrotask works");
		});
	} else {
		console.log("stack works!!!!!!!!!!!!!");
	}
}

console.log("start");
func(num);
console.log("end");


// 출처 : 나
// 개발자 도구를 켜고 위 코드를 콘솔창에 '여러 번' 실행해보자.

Quiz

function a() {
	queueMicrotask(() => {
		setTimeout(b, 0);
		console.log("a");
	});
}

function b() {
	console.log("b");
}

function b2() {
	console.log("b2");
}

function c() {
	console.log("c");
	setTimeout(b2, 0);
	a();
}

c();

// 출처 : 나
// 출력 순서를 예상해보고 실행해보자.

하나 더

재밌는 MDN 코드를 하나 더 소개한다.(내가 원문을 조금 변경했다.)

const messageQueue = [];

let sendMessage = (message) => {
	messageQueue.push(message);

	if (messageQueue.length === 1) {
		queueMicrotask(() => {
			const json = JSON.stringify(messageQueue);
			messageQueue.length = 0;
			// 예제용 microtask 코드. 원래는 fetch 구문임.
			queueMicrotask(() => console.log(json));
		});
	}
};

for (let i = 0; i < 4; i++) {
	sendMessage("testMessage");
}

console.log("foo");

/*  간단한 요청 몇 개가 아니라 
	여기저기서 수많은 요청이 들어왔다고 생각해보자.
	queueMicrotask(() => console.log(json))는 
	Promise처럼 microtask단으로 처리하기 위해 쓴 코드다. */

※ 일반 함수는 Call Stack에 들어가서 처리된다.
microtaskCall Stack이 비워져있을 때 움직인다.

위 사항을 유념하고 어떻게 처리되는지 살펴보자.

  1. messageQueue 배열에 testMessage가 들어간다.
  2. if문에 의해 queueMicrotask()의 내용은 microtask queue에 등록.

※ 주의!
아직 실행 안 된다. '등록'만 했다.

  1. for문, console.log가 다 실행되면 그제야 microtask에서 대기를 타던 작업이 실행된다.

※ 이 과정은 microtask를 '딱 한 번만' 써서 처리했다.
call stack이 남아있을 땐 microtask가 작업할 수 없는 점을 이용했다.

const messageQueue = [];

let sendMessage = (message) => {
	messageQueue.push(message);

	if (messageQueue.length === 1) {
      		// queueMicrotask(() => {
			const json = JSON.stringify(messageQueue);
			messageQueue.length = 0;
			queueMicrotask(() => console.log(json));
      // }
	}
};

for (let i = 0; i < 4; i++) {
	sendMessage("testMessage");
}

console.log("foo");

microtask 처리를 빼고 실행해보자.

매번 다 동기적으로 처리하고, 호출 횟수도 그만큼 늘어난다.
MDN 설명에 따르면 이런 식으로 처리했을 땐 호출횟수가 늘어날수록 부하가 심해진다고 한다.

의문

스택 오버 플로우에 질문이 올라왔는데(이 고민을 작성할 때에는 WebAPI에 대해 잘 몰랐다.) 나처럼 microtask, macrotask에 대해 공부해서 실행 순서를 배우다가 연습으로 코드를 썼더니 이해가 안 가게 출력이 된다는 것이다. 나도 의문이 생겨서 바로 아래에 적용을 해보았다.

const num = Math.random();
setTimeout(() => {
	console.log("setTimeout");
}, 0);
function func(num) {
	if (num < 0.4) {
		fetch("https://velog.io/").then((response) => {
			const data = response;
			console.log(`fetch works and result is ${response}`);
		});
	} else if (num < 0.6) {
		queueMicrotask(() => {
			console.log("queueMicrotask works");
		});
	} else {
		console.log("stack works!!!!!!!!!!!!!");
	}
}

console.log("start");
func(num);
console.log("end");

// 출처: 나

나는 fetch, thenmicrotask단에서 처리될 테니 setTimeout보다 먼저 출력이 될 것이라고 예상했다. 하지만 setTimeoutfetch, then보다 먼저 출력됐다.

microtasksetTimeout보다 선행되는 건 맞다. 그렇다면 axios.get(), fetch, then 구문이 내 예상과 다른 구조로 돌아간다는 얘기다. 마침 답변이 하나 있었다.

Understand that tasks of any type only run when they're actually in a queue. The Axios task won't be in the queue until the HTTP request completes
task는 queue에 들어있을 때에만 돌아갑니다. axios task는 HTTP 요청이 완료되기 전까지는 queue에 들어가지 않습니다.

위에서도 썼듯이 비동기는 결국 WebAPI를 거쳐서 처리된다.

정리

fetch()와 queueMicrotask()는 ‘똑같은 처리 과정’이라고 할 수 있나?
∴ 아니다.

fetch(), axios.get() 자체는 네트워크 처리 과정이다. webAPImicrotask는 성격 자체가 달라서 비교 대상이 아니다. 따라서 비교를 하겠다면 then의 콜백 함수와 queueMicrotask()를 해야할 것이다.

의문

then의 콜백 함수는 정말 microtask로 처리될까? microtask는 다 비워질 때까지 task보다 우선적으로 call stack에 작업을 넘길까?

직접 확인하자

  • 가정
    1. thenmicrotask단계로 처리된다.
    2. microtask Queue는 다 비워질 때까지 task보다 우선적으로 작업을 준다.
  • 상황
    microtask 요청들이 처리되는 중일 때 then, setTimeout을 넣자.

  • 예상 결과
    가정이 맞다면 thensetTimeout보다 선행될 것이다.

const num = Math.random();
async function func(num) {
  queueMicrotask(() => {
    for (let i = 0; i < 10000; i++) {
      console.log(i);
    }
  });
  setTimeout(() => {
    console.log("setTimeout");
  }, 0);
  if (num < 0.4) {
    console.log("call stack으로 처리됩니다.");
    //		const data = await fetch("https://velog.io/");
    //		console.log(`fetch works and result is ${data}`);
    fetch("https://velog.io/").then((data) => {
      console.log(`fetch works and result is ${data}`);
    });
  } else if (num < 0.6) {
    queueMicrotask(() => {
      console.log("queueMicrotask works");
    });
  } else {
    console.log("stack works!!!!!!!!!!!!!");
  }
}

console.log("start");
func(num);
console.log("end");


// 출처: 나
// microtask Queue : [console.log(0), console.log(1), ...]
// task queue : [console.log("setTimeout")]
// webAPI : [fetch....]

// fetch가 완료되기 전에 microtask queue가 다 비워진다면, 
// call stack은 task queue에 있는 애를 먼저 부를 것이다.

// 하지만 microtask queue가 다 비워지기 전에 fetch가 완료되면,
// then이 microtask queue에 들어갈 것이고,
// 이벤트 루프는 then까지 microtask를 다 처리한 다음에,
// task queue에 있는 console.log("setTimeout")을 실행할 것이다.

가설대로 동작한다.

데이터를 받아오는 시간이 길면 다른 작업부터 먼저 끝낸다.

리액트 발문

react에서는 어떤 걸 생각할 수 있을까?

function App() {
	const [val, setVal] = useState("value");
	const onClick = () => {
		queueMicrotask(() => {
			for (let i = 0; i < 10000; i++) {
				console.log("loading...");
			}
		});
		setVal("changed value");
		console.log("stack works");
	};
	return (
		<>
			<Button onClick={onClick} />
			{val}
		</>
	);
}

setStatemicrotask단에서 처리되는 듯하다. 그런데 왜 microtask들이 처리되는 중간에 바뀌었을까? state 값을 바꾸기만 하는 게 아니라, 화면에 다시 띄우는 과정도 있음을 생각해보면, state값은 일단 먼저 바꾸고, 그러다 도중에 화면을 바꾼 걸까?

function App() {
	const [val, setVal] = useState("value");
	useEffect(() => {
		console.log(`side effect works and val is ${val}`);
	}, [val]);

	function raf() {
		console.log("rAF");
		requestAnimationFrame(raf);
	}
	const onClick = () => {
		raf();
		setTimeout(() => {
			console.log("setTimeout");
		}, 0);

		queueMicrotask(() => {
			for (let i = 0; i < 10000; i++) {
				console.log(`loading... val is ${val}`);
			}
		});

		setVal("changed value");
		console.log("stack works");
	};
	return (
		<>
			<Button onClick={onClick} />
			{val}
		</>
	);
}

화면에선 이미 state변경이 적용됐는데, console에선 변경이 안 된 상태다.

모든 microtask가 끝난 뒤에야 val 값이 changed value로 변경되어 출력된다. 하지만 화면상에선 아까부터 changed value로 나왔다. 이건 리액트의 실행 순서 구조를 이해해야 왜 이렇게 되는지 알 수 있다. 이 포스팅은 자바스크립트니까 리액트 포스팅을 쓰게 되면 거기에 작성해야겠다.

여담

이벤트 루프에서 감당하기 무거운 비용, UI에 직접 영향을 미치지 않는 작업이면 Web Worker도 좋은 선택이다.
예컨대 fetch를 써서 수많은 데이터를 가져오는 작업을 떠올려보자. 굳이 메인 스레드가 감당해야만 할 필요성은 없다. 이럴 때 웹 워커를 쓰면 다른 스레드로 작업을 넘긴다. 즉, 멀티 스레드 작업이 가능하다.

JavaScript Engine은 어쨌든 멀티 스레드로 돌아간다. 인터프리터가 싱글 스레드로 돌아갈 뿐. 백그라운드 스레드는 Top-level code 컴파일 등을 해서 메인 스레드가 JS를 빠르게 실행할 수 있도록 후방 지원한다. 그리고 웹 워커는 백그라운드 스레드에서 작업한다. 웹 워커는 또 나름대로 별도의 이벤트 루프를 가지는데, 이 부분은 필요한 사람만 공부하면 될 듯하다.

결론

비동기에선 반드시 네트워크 처리를 동반하기 때문에 순서가 흔들리기 쉽다. 이 점에 주의해야 할 것이다.

참조

profile
모르는 것 투성이

0개의 댓글