개요

자바스크립트 런타임의 비동기 처리 방식을 깊이 이해하고 프로덕션 레벨에서 이를 제어하기 위한 기반 지식을 쌓기 위해 콜스택, WebAPIs, MicroTaskQueue, MacroTaskQueue(Callback Queue)간 상관관계를 깊이 살펴본다.

또한 Node.js 와 브라우저의 자바스크립트 런타임 환경의 차이점은 이벤트 루프의 내부 구현부 차이에 있다. Node.js는 I/O등의 운영체제 영역과 직접적으로 통신해야 하는 부분이 있기에 매크로 테스크 큐가 페이즈별로 세분화되어 구현되었다는 차이점을 가진다.

이를 다르게 말하면 이벤트 루프의 세부적인 동작 방식을 제외하고는 일반적으로 둘 환경에서의 자바스크립트 동작방식은 거의 같다라고 볼 수 있다.

특히 해당 글에서는 브라우저 환경에서 자바스크립트가 비동기 작업을 어떻게 처리하는지와 관련해 깊이있게 다루어 본다.

1. CallStack

일반적으로 자바스크립트가 실행된다는 말은, 내가 작성한 JS Script가 런타임 환경의 콜스택에 적재된다는 말이다. 관련하여 실행 컨텍스트나 클로저 등의 개념등의 출발점이 바로 콜스택이다.

자바스크립트는 기본적으로 인터프리터 방식을 기반으로 한다. 이는 코드의 바이너리 컴파일 과정 없이,코드라인의 상단에서 하단으로 순차적으로 읽어나가며 즉시 실행하는 방식을 말한다.

이때 특정 함수를 실행하는 코드를 만나는 경우, 해당 함수의 실행 컨텍스트는 콜스택에 적재된다는 기본 전제를 가진다.

그렇다면 해당 함수 내부에서, Fetch API와 같은 비동기 통신이나 브라우저 자체적으로 제공하는 여러 API 호출이 있다면 어떤식으로 동작하게 될까

2. WebAPIs

콜 스택에서 실행되는 JavaScript 코드가 비동기 API를 호출하면, 이러한 작업은 WebAPIs(Web APIs) 영역에서 처리된다. 여기서 중요한 건 JavaScript 자체는 싱글 스레드 기반이지만, 브라우저나 Node.js 환경에서 제공되는 WebAPIs는 백그라운드에서 별도의 스레드를 사용해 이러한 비동기 작업을 처리하게 된다.

이런 특성 때문에 자바스크립트가 싱글 스레드 언어라도, 비동기 작업을 효율적으로 처리할 수 있는 기반을 가질 수 있다.

2-1.WebAPIs에 이관되는 작업들

브라우저 혹은 Node.js 등 런타임에서 자체적으로 제공하는 API들의 대부분이 해당 영역에서 처리된다. 그 목록은 다음과 같다.

  1. XMLHttpRequest: 웹 서버와의 비동기적인 HTTP 통신을 가능하게 하는 API. AJAX(Ajax Asynchronous JavaScript and XML) 통신의 기반이 된다.

  2. 타이머 함수(setTimeout, setInterval 등): 지정된 시간이 지난 후에 함수를 실행하거나, 일정 간격으로 함수를 반복 실행하는 데 사용

  3. DOM 이벤트 리스너: 사용자의 상호작용(예: 클릭, 키보드 입력)을 감지하는 이벤트 리스너

  4. Fetch API, Promise API 등

  5. 기타 WebAPIs: FileReader, Canvas API, Geolocation API 등 다양한 기능을 제공하는 API들도 포함됩니다.

WebAPIs에 이관된 작업들이 완료된 경우 이에 대한 후속 작업인 콜백은 API 종류에 따라 달라진다. 전부 설명하기는 복잡하므로 여기서는 XML통신과 Promise를 기반으로하는 Fetch API의 차이를 살펴보자.

Promise가 공식 스펙으로 등장하기 이전에 비동기 통신을 처리하기 위해 XML을 주로 사용했고, 많이들 들어본 콜백 지옥의 기반이 된 친구이다.

그리고 개발자들이 콜백으로 비동기 통신의 후속 작업을 처리하려고 했던 이유는, XML의 콜백 함수는 WebAPIs내부에서 통신이 완료된 이후 매크로 태스크 큐에 적재되기 때문이다.

그리고 이벤트루프는 콜스택의 모든 컨텍스트가 완료되고 비워진 시점에서야 매크로 태스크 큐로 접근 가능하므로, 비동기 통신의 후속 작업을 콜백으로 제어하게 된것이다.

이는 Promise의 등장으로 비동기 통신 제어 방식의 기조가 조금 변경되었다.

Promise에 전달되는 콜백 함수는 기본적으로 마이크로테스크 큐에 적재된다. 즉 XML이 매크로테스크 큐 동작을 기반으로 콜백을 처리하던 방식과 다르게 동작하고, 프라미스 체이닝이나 동기적 코드 작성 방식과 같은 여러가지 개선점을 얻을 수 있게하는 기반이 되었다.

3.MicroTaskQueue, MacroTaskQueue

두 큐의 상관관계를 비교하기 이전에 주요한 대전제가 존재한다.

콜스택에 적재된 모든 컨텍스트가 완료되고 나서 콜스택이 빈 시점에 접근 가능하다는 점이다.

더 이상 실행할 컨텍스트가 콜스택에 존재하지 않는 경우, 이벤트 루프는 먼저 MicroTaskQueue에 접근해 해당 큐에 존재하는 모든 작업을 완료한다. 그 다음 매크로 테스크 큐의 작업을 하나 씩 처리하게 된다.

그리고 매크로 테스크 큐의 작업 하나가 처리될 때 마다 이벤트 루프는 마이크로 테스크 큐를 검사하여 작업이 존재하면 전부 수행한다.

즉 매크로 테스크를 처리하던 도중 마이크로 테스크 큐에 추가적인 테스크가 적재된다면, 다음 매크로 테스크를 처리하기 이전 모든 마이크로 테스크 큐의 테스크를 처리하고 난 후 다음 매크로 테스크클 처리하게 된다.

또한 브라우저는 렌더링 파이프라인을 통해 화면을 업데이트 하는 작업이 존재하는데, 마이크로 테스크 큐가 비어지고 다음 매크로 테스크 큐의 작업을 실행하는 중간에 이를 수행할 수 있는 제어권을 가지게 된다.

결국 비동기 작업을 처리하는 큐를 MicroTask와 MacroTask 로 분리하며 얻는 다양한 효용점들이 존재한다.

4.비동기 Queue 분리의 효용성

1.테스크 우선순위 관리: Promise의 콜백과 같은 마이크로 태스크는 비교적 우선순위가 높은 작업으로 간주되어 콜스택이 비어진 시점에 가장 먼저 실행되는 큐이다.

  1. 렌더링 성능 최적화 : 브라우저의 렌더링 작업은 매우 비싼 비용이 들어간다. 큐의 분리는 이를 효율적으로 수행할 수 있도록 렌더링 스케줄링을 관리하는데 적합하다.

  2. 스타베이션(Starvation) 완화 :매크로 테스크 큐만 사용했을 때 발생할 수 있는 starvation 위험성을 큐의 분리를 통해 어느정도 완화시킬 수 있다. 그러나
    마이크로 태스크 큐가 계속해서 새로운 마이크로 태스크를 추가할 수 있기 때문에, 매크로 태스크(예: setTimeout, UI 이벤트)가 영구적으로 지연될 위험이 여전히 존재한다. 이는 개발자가 신경써야 하는 영역이다.

  3. 일관된 비동기 처리 흐름 : 마이크로 태스크와 매크로 태스크를 분리함으로써, 개발자들은 비동기 코드의 실행 순서와 타이밍을 예측할 수 있도록 한다. 이는 디버깅과 오류 처리를 용이하게 만든다.

5.코드를 통해 검증해보기

다음 코드의 출력 결과를 예측하여 이벤트 루프 작동방식의 이해 척도를 가늠할 수 있다.

const promiseConstructor = () => {
    return new Promise((res)=>{
        res();
    })
}

setTimeout(()=>{
    console.log('macrotask queue element')
},0)

console.log('normal function callstack')

promiseConstructor().then(()=>{
    console.log('promise callback in microtask')
    setTimeout(()=>{

        console.log('promise callback in macrotask')
    })

})

결과는 다음과 같다.

normal function callstack
promise callback in microtask
macrotask queue element
promise callback in macrotask

해당 코드의 실행 과정을 구체적으로 살펴보자.

  1. 비동기 API인 setTimeout의 실행이 콜스택에 적재된다.
  2. 이는 WebAPIs로 이관되어 지연시간이 마무리될 때 까지 기다린다. 여기서의 지연시간은 최소지연시간으로 실제 지연시간은 0초보다 더 길다고 볼 수 있다.
    3.console.log가 실행되는 시점에 관계없이 WebAPIs는 최소 지연시간이 지났다고 판단하면 콜백함수를 매크로 태스크 큐에 적재한다. 이 타이밍은 console.log가 실행되기 전,후 둘 다 가능하다.
  3. console.log가 콜스택에 적재되고 동기적으로 실행된다. - normal function callstack 출력
  4. promiseConstructor가 실행되고 프라미스가 WebAPI로 이관되어 res();가 실행되는 시점에 이는 마이크로 테스크 큐에 적재된다.
  5. 콜스택이 전부 비었으므로 이벤트루프는 마이크로 테스크 큐에 접근한다.
  6. 프라미스 콜백을 콜스택에 적재하고 실행하여 'promise callback in microtask'을 출력하고, setTimeout이 콜스택에 다시 적재하고 실행하여 WebAPIs에 전달한다.
    8.이 때 setTimeout 콜백은 최소지연시간이 보장되는 타이밍에 매크로 테스크 큐에 적재된다. 결국 얼마만큼 지연되냐에 관계없이 앞서 맨처음 실행된 setTimeout 콜백이 먼저 실행된다.
    9.첫 번째 setTimeout callback이 실행되어 macrotask queue element가 출력된다.
    10.동일 매크로 테스크 접근 사이클 혹은 다음 사이클에서 두 번째 setTimeout callback이 실행되어 promise callback in macrotask가 출력된다.

0개의 댓글