TIL

[JavaScript] 비동기 작업과 이벤트 루프

기절초뿡 2022. 7. 21. 15:46

1. 동기와 비동기


자바스크립트는 '단일 스레드' 기반의 언어이다. 이는 한 번에 하나의 작업 흐름만 가질 수 있다는 뜻이다. 그래서 자바스크립트는 기본적으로 '동기적' 방식으로 작업을 처리한다.

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

Hello();
console.log("Bye");

위의 예제를 실행하면, Hello가 먼저 출력된 후 Bye가 출력될 것이다. 이와같이 순차적으로 코드를 수행하며 하나의 작업이 끝난 뒤에 다음 작업을 수행하는 방식이 동기적 방식이다. 동기적 방식은 코드가 적힌 순서대로 실행되기때문에 실행 순서와 결과를 예측하기 쉽다는 장점이 있지만, 하나의 작업이 끝날 때까지 다른 작업을 수행할 수 없으므로, 하나의 작업 시간이 길어지게 될 때 전체적인 흐름까지 느려진다는 문제점이 있다.

fetch('url')
  .then((response) => response.json())
  .then((data) => console.log(data));

대표적으로 ajax 같은 서버와의 통신은 시간이 많이 걸리는 느린 작업에 속한다. 이런 작업이 동기적으로 수행된다 생각해보자. 우리는 서버에 요청을 보낸 뒤 응답이 올 때까지 몇 초간 가만히 기다릴 수밖에 없고, 그렇다면 우리의 사용자는.. 브라우저를 꺼버릴지도 모른다. 

이러한 문제점을 해결할 수 있는 방법이 있다. 바로 '멀티 스레드 방식'을 사용하는 것이다. '스레드'는 작업을 수행하는 주체인데, 이 스레드가 둘 이상인 것을 멀티 스레드라고 한다. 즉, 여러명에서 동시에 같이 일하는 것이다. 하지만 안타깝게도 자바스크립트는 싱글 스레드 기반의 언어이다. 그래서 자바스크립트는 일하는 사람을 한 명밖에 둘 수 없다. 대신 한 사람한테 여러 일을 동시에 시킬 수는 있다. 이것이 바로 비동기적 처리 방식이다. 먼저 작성된 코드의 결과를 기다리지 않고 다음 코드를 바로 실행하여, 하나의 스레드에서 여러 개의 작업을 동시에 수행하는 것이 비동기적 처리 방식이다. 자바스크립트에서 비동기 함수는 대표적으로 setTimeout 함수, DOM API, HTTP요청(ajax) 이 있다. 자바스크립트의 비동기적 처리를 이해하기 위해서는 자바스크립트 엔진이 코드를 실행하는 방식과 이벤트 루프의 개념에 대해서 알아야 한다.

2. 이벤트 루프


2-1. JavaScript 엔진

먼저 자바스크립트 엔진부터 간단하게 알아보자. 자바스크립트 엔진에는 메모리 힙(Memory Heap)과 호출 스택(Call stack)이 있다. 메모리 힙은 메모리 할당이 이루어지는 곳이고, 호출 스택은 실행 컨텍스트가 추가되고 제거되는 실행 컨텍스트 스택이다. 즉, 코드는 순서대로 이 호출 스택에 쌓여 실행이 되고, 실행된 후 호출 스택에서 사라진다.

다음 예제와 그림을 보자.

function foo() {
  boo();
  console.log("foo");
}

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

foo();

전역 실행 컨텍스트를 뜻하는 main 함수 위에 foo 함수가 호출 되면서 호출 스택에 푸시된다. foo 함수는 boo 함수를 호출하고, boo 함수도 호출 스택에 푸시된다. boo 함수 안에서 console.log('boo')가 실행되고, boo 함수는 종료되므로 boo 함수는 호출 스택에서 팝하여 제거된다. 그 뒤 foo 함수 내부의 console.log('foo')가 실행이 되고, foo 함수도 종료되면서 foo 함수도 팝하여 호출 스택에서 제거된다. 모든 코드가 실행되었으므로 전역 실행 컨텍스트 main 함수도 팝하여 제거되고 호출 스택에는 아무것도 남아있지 않게 된다.

자바스크립트 엔진은 이런 방식으로 자바스크립트 코드를 실행한다. 자바스크립트에는 호출 스택이 하나뿐인데 어떻게 비동기적인 작업을 할 수 있는 걸까?

바로 브라우저 환경이 태스크 큐와 이벤트 루프를 제공하기 때문이다.

2-2. 이벤트 루프

이벤트 루프

이벤트 루프를 이야기에는 자바스크립트 엔진 외에도 Web API와 태스크 큐의 개념도 함께 필요하다.

비동기 함수들은 호출 직후 호출 스택에서 제거되고 이벤트 루프에 들어가게 된다. (이벤트 루프가 장소적인 개념인 것이 아니라, 루프의 흐름에 '빠진다'는 의미이다.) 예를 들어 setTimeout 함수는 브라우저에게 타이머 이벤트를 요청한 후에 바로 스택에서 제거된다. 그리고 setTimeout의 콜백 함수는 타이머가 만료되기를 기다리고, 시간이 만료 되면 태스크 큐에 푸시된다. 그리고 이벤트 루프는 계속 해서 콜스택이 비었는지 확인을 한다. 만약 콜스택이 비었다면, 태스크 큐에서 순서대로 꺼내어(FIFO) 콜스택에 푸시하고, 콜스택에서 실행된다. 즉, 비동기 함수들은 콜스택이 모두 비었을 때 비로소 실행될 수 있다. 이는 정확한 실행 타이밍을 보장하지 못한다는 뜻과도 같다. 다음 예제를 보자.

 

console.log("A");

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

console.log("B");

//출력
//A
//B
//callback

먼저, 콜스택에서 console.log("A")가 실행된 후에 팝된다. 그 후 setTimeout 함수는 0초 기다린 후에 (즉, 곧바로) 콜백 함수가 콜스택에 푸시된다. 그 뒤에 console.log("B")가 실행되고 팝되고, 더 이상 실행할 것이 없어 콜 스택이 비었을 때 비로소 setTimeout 의 콜백함수가 콜스택에 푸시되어 실행된다. 설정한 시간은 0초이지만 사실은 0+a초의 시간이 소요된다.  

 

3. MicroTask queue와 Promise

Promise도 마찬가지로 비동기로 동작한다. 그러나 다른 비동기 함수들과는 조금 다른 점이 있다. 다음 코드는 어떤 순서로 실행될까? 

setTimeout(()=>{
  console.log("A")
},0)

Promise.resolve()
  .then(() => console.log("B"))
  .then(() => console.log("C"));

A -> B -> C 라고 생각하는 사람들이 있을 거 같다. 나도 처음 봤을 땐 그렇게 생각했기 때문이다. 그러나 실제 콘솔에 찍히는 건 B -> C -> A 이다.  setTimoeout과 Promise 둘 다 비동기로 이벤트 루프에 의해서 실행되고, 태스크 큐에 먼저 들어 간 setTimeout이므로 A가 먼저 출력되는 것이 맞다고 생각할 수 있다. 그러나 Promise는 다른 비동기들에 비해서 우선순위를 가진다. 

Promise는 태스크 큐가 아닌 microTask queue 라는 별도의 공간에 푸시된다. 그리고 이벤트 루프는 콜 스택이 비었을 때, 마이크로태스크 큐를 먼저 확인하고 대기 하고 있는 함수를 실행시킨 후에 태스크 큐를 확인한다. 즉, 마이크로태스크 큐가 태스크 큐에 비해 우선순위를 가지기 때문에 Promise가 먼저 실행된다. 

 

 

 

참고 

책 '자바스크립트 딥다이브'

https://medium.com/@Rahulx1/understanding-event-loop-call-stack-event-job-queue-in-javascript-63dcd2c71ecd

https://vimeo.com/96425312