구리

[브라우저] 자바스크립트 엔진은 어떤 기준으로 코드를 순서대로 실행할까? 본문

Web

[브라우저] 자바스크립트 엔진은 어떤 기준으로 코드를 순서대로 실행할까?

guriguriguri 2023. 10. 3. 21:45

지난 글에서는 JavaScript 엔진의 기본적인 동작 원리(소스코드가 컴파일되는 과정)에 대해 알아보았습니다.

이번 글에서는 JavaScript 엔진이 컴파일된 코드를 어떤 기준으로 순서를 정해 실행하는지에 대해 알아보겠습니다.

참고로 프로세스와 스레드 관련 정리 글을 읽으시면 이해에 더 도움이 될 수 있습니다.

목차

사전 지식

본문

결론

 


 

실행 컨텍스트

코드를 실행하는데 필요한 환경(코드 실행에 영향을 주는 조건이나 상태)을 제공하는 객체로써 다음과 같은 방식으로 진행되며 전체 코드의 환경과 순서를 보장합니다.

  1. 동일한 환경에 있는 코드들을 실행할 때, 필요한 환경 정보를 모아 컨텍스트를 구성
  2. 이를 호출스택에 쌓아 올림
  3. 가장 위에 쌓여 있는 컨텍스트와 관련 있는 코드들을 실행

보통 실행 컨텍스트를 구성하는 방법은 함수를 실행하는 것 뿐입니다. (자동으로 생성되는 전역 공간과 eval 제외)

참고로 최상단의 공간은 코드 내부에서 별도의 실행 명령 없이도 브라우저에서 자동으로 실행하므로 JS 파일이 열리는 순간 전역 컨텍스트가 활성화됩니다.

실행 컨텍스트에 담기는 정보들은 다음과 같습니다.

  1. VariableEnvironment : 현재 컨텍스트 내의 식별자들에 대한 정보 + 외부 환경 정보. 선언 시점의 LexicalEnvironment의 스냅샷으로 변경사항은 반영되지 않음
  2. LexicalEnvironment : 처음에는 VariableEnvironment와 같지만 변경 사항이 실시간으로 반영됨
  3. ThisBinding : 식별자가 바라봐야 할 대상 객체

실행 컨텍스트에 대해 더 자세히 알고 싶다면 정리글을 참고하면 되겠습니다.

 

자바스크립트 엔진 구조

자바스크립트 엔진은 이전글에서 봤던 파서, 인터프리터, 컴파일러 외에도 호출스택(Call Stack), 메모리힙(Memory Heap)도 존재합니다.

Call Stack

호출 스택은 여러 함수들을 호출하는 스크립트에서 해당 위치를 추적하는 인터프리터(웹 브라우저의 자바스크립트 인터프리터 같은)를 위한 매커니즘입니다. 현재 어떤 함수가 실행 중인지, 그 함수 내에서 어떤 함수가 호출되어야 하는지 등을 제어합니다.
- MDN

호출 스택(Call Stack)은 실행 컨텍스트(Execution Context)를 통해 원시 타입 값이 저장됩니다.

스택(Stack)은 후입선출(Last In First Out, LIFO)을 기본으로 하는 자료 구조로 Call Stack도 가장 마지막에 들어온 함수가 먼저 실행됩니다. (Call Stack 호출 과정은 사이트에서 테스트할 수 있습니다.)

Memory Heap

메모리 힙은  동적으로 데이터를 할당할 수 있는 메모리 영역으로 참조 타입(객체, 배열, 함수 등)이 저장되며 크기를 예측하기 힘든 참조 타입을 저장하기에 적합한 구조가 됩니다.

 

콜백 함수

콜백 함수(Callback Function)란 다른 코드의 인자로 넘겨주는 함수를 의미합니다. 아래의 코드로 예를 들어보겠습니다.

setTimeout(function(){
	console.log('1초가 지나갔다')
}, 1000)

setTimeout 함수는 첫번째 인자로 callback function을 받고, 두번째 인자로 기다릴 시간을 받는 함수입니다.

위 코드를 실행하면 1초 후에 function() { console.log('1초가 지나갔다') }가 실행됨을 알 수 있습니다.

즉, callback function은 함수가 정해놓은 일이 끝난 뒤, 후속으로 하는 일을 알려주는 함수입니다.

setTimeout 이외에도 Callback 함수는 AJAX, DOM API 등에 사용되며 자바스크립트 비동기 처리를 공부하기 전 알아야 하는 개념입니다.

 

컴파일과 실행

지난 글에서 봤던 것처럼 자바스크립트 코드는 결론적으로 인터프리터(Ignition)이나 컴파일러(TurboFan)에 의해서 바이트 코드 혹은 네이티브 코드로 (1)컴파일 후, (2)해당 코드가 실행되며 아래와 같은 과정으로 진행됩니다.

  1. 처음 컴파일시 전역 컨텍스트가 생성되고 호출 스택에 쌓임
  2. 함수 호출시 컴파일 단계(실행 컨택스트 생성 단계 포함) 진행 (이때 호출 스택의 최상단에 올려짐)
  3. 컴파일 단계가 완료되면 실행 단계가 진행되며 코드 실행

컴파일 단계에서는 실행 컨텍스트 컴파일 후 실행 컨텍스트 생성 단계가 진행되며 변수 선언을 처리합니다.

그 후 실행 단계에서는 변수 할당 및 나머지 코드를 실행합니다.

즉, 컴파일 단계와 실행 단계의 결론은 다음과 같습니다.

  • 자바스크립트 엔진은 함수 호출 전까지 함수 내 코드를 컴파일하지 않음
  • 함수가 컴파일되었을 때, 새로운 실행 컨텍스트가 생성되고 call stack 최상단에 올려짐
  • 함수가 호출될 때마다 컴파일 (실행 컨텍스트 생성 단계 포함) 과 실행 단계인 2개의 step이 진행됨

그런데 만약 코드 중간에 시간이 오래 걸리는 작업이 있다면 어떻게 될까요? 자바스크립트는 싱글 스레드 언어로 한 번에 한가지 일밖에 처리하지 못하기에 작업이 다 끝날 때까지 사용자는 기다려야 하는데 그렇다면 어떻게 해야 할까요?  이때 사용되는 것이 비동기 콜백입니다.

비동기 콜백을 이해하기 위해선 Web API, 콜백큐(Callback Queue), 이벤트 루프(Event Loop)에 대해 알아야 합니다.

 

능률적으로 일하기 (Web API, Callback Queue, Event Loop)

위 사진은 브라우저 환경에서 자바스크립트 코드 실행에 사용되는 요소들입니다.

Browser Web APIs

Browser Web APIs는 타이머, 네트워크 요청, 파일 입출력, 이벤트 처리 등 브라우저에서 제공하는 다양한 API를 포괄하는 총칭입니다.
Web API는 브라우저(Chrome)에서 멀티 스레드로 구현되어 있습니다. 그래서 브라우저는 비동기 작업에 대해 자바스크립트 싱글 스레드의 영향을 받지 않고, 독립적으로 이벤트를 동시에 처리할 수 있습니다.

console.log("시작");

setTimeout(() => {
  console.log("실행 중");
}, 0);

console.log("종료");

위 코드를 실행하면 Call Stack에서 모두 처리되는 것이 아니라 setTimeout은 Web API에서 3초의 타이머를 수행한 후 콜백 함수인 console.log('1초가 지나갔다')Callback Queue에 옮겨지게 됩니다.

Web APIs의 대표적인 종류들은 다음과 같습니다.

  • DOM : 문서의 구조와 내용을 표현하고 조작할 수 있는 객체
  • XMLHttpRequest : 서버와 비동기적으로 데이터를 교환할 수 있는 객체 (AJAX 기술의 핵심)
  • Timer API : 일정한 시간 간격으로 함수를 실행하거나 지연시키는 메소드들을 제공
  • Console API : 개발자 도구에서 콘솔 기능을 제공
  • Canvas API : <canvas> 를 통해 그래픽을 그리거나 애니메이션을 만들 수 있는 메소드들을 제공
  • Geolocation API : 웹 브라우저에서 사용자의 현재 위치 정보를 얻을 수 있는 메소드들을 제공

이때 모든 Web API들이 비동기로 동작되는 것은 아닙니다. 예를 들어 DOM API나 Console API는 동기적으로 처리되고, XMLHttpRequest나 Timer API는 비동기적으로 처리됩니다.

 

Callback Queue

Callback Queue란 Browser Web API에 있는 event가 실행되고 나면 자바스크립트 콜스택에서 실행할 callback을 저장하고 있는 저장소 입니다. 해당 callback function들은 이벤트 루프에 의해 콜스택에 옮겨집니다.
참고로 Queue는 자료 구조 중 하나로 선입 선출(First In First Out, FIFO)의 룰을 따릅니다.
또한 Callback Queue에는 (macro)task queuemicrotask queue 2가지 종류가 있습니다.

 

Task Queue

setTimeout, setInterval, fetch, addEventListener 와 같이 비동기로 처리되는 함수들의 콜백 함수가 들어가는 큐입니다.

function bar() {
  setTimeout(() => {
    console.log("Second");
  }, 0);
}

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

function baz() {
  console.log("Third");
}

bar();
foo();
baz();

위 코드에서는 setTimeout의 딜레이 시간이 0초이지만 제일 마지막에 실행되었는데 이유는 setTimeout은 Web API → Task Queue → Call Stack 순서로 이동하기 때문입니다.

여기서 중요한 점은 Task Queue에 대기하고 있는 콜백 함수Call Stack이 비어져 있을 때만 이벤트 루프가 Call Stack으로 콜백 함수를 전달하기에 시작, 종료 console.log()가 처리된 후 (Call Stack이 모두 비어진 후) Task Queue에 있는 setTimeout의 콜백 함수가 Call Stack에 옮겨져 처리됩니다.

위 코드들이 어떻게 실행되는지 자세히 알아보겠습니다.

  1. bar() 함수가 호출되어 Stack에 쌓이고 실행되면 내부의 setTimeout() 함수가 호출되어 Call Stack에 쌓임
  2. setTimeout() 함수의 콜백 함수를 Web API에 전달하고 Web API에서 백그라운드로 0초 타이머를 실행
  3. bar() 함수 실행 종료로 Stack에서 제거
  4. foo() 함수가 호출되어 Stack에 쌓이고 실행되어 콘솔창에 First가 출력
  5. 이때 0초 타이머 실행이 만료되어, 이벤트 루프는 Web API에 있던 콜백 함수를 Task Queue로 옮김
  6. baz() 함수가 호출되어 Stack에 쌓이고 실행되어 콘솔창에 Third가 출력
  7. baz() 함수 실행 종료로 Stack에서 제거되면 Call Stack은 비어짐
  8. 이벤트 루프는 Call Stack이 비어있는 경우를 탐지해, Task Queue에 있는 콜백 함수를 Call Stack으로 옮김
  9. Call Stack에서는 콜백 함수가 실행되어 콘솔창에 Second가 출력

위 과정을 통해 Task Queue에 담긴 setTimeout의 콜백 함수는 Stack이 비면 이벤트 루프에 의해 Call Stack으로 옮겨져 실행되기에 setTimeout의 지연 시간은 해당 시간 만큼 지연된다는 것이 아니라 요청을 처리하기 전에 대기할 최소 시간입니다.

참고로 Task Queue에는 아무리 많은 콜백에 많이 쌓여있어도 단 하나의 콜백만 Call Stack으로 옮겨져 처리 후 이벤트 루프는 다른 queue로 이동합니다.

MicroTask Queue

promise.thenprocess.nextTickMutationObserver 와 같이 우선적으로 비동기로 처리되는 함수들의 콜백 함수가 들어가는 큐를 의미합니다. 

console.log("script start");

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

Promise.resolve()
  .then(function () {
    console.log("promise1");
  })
  .then(function () {
    console.log("promise2");
  });

console.log("script end");

// 결과
// script start
// script end
// promise1
// promise2
// setTimeout

위 코드에서 Promise가 먼저 실행된 이유는 Callback Queue에도 실행 우선 순위가 존재하기 때문입니다.

Callback Queue의 종류에 따라 이벤트 루프가 Call Stack으로 옮기는 우선 순위가 달라집니다. 일반적으로 microtask queue의 우선 순위가 가장 높으며 먼저 microtask queue를 모두를 처리한 후 그 다음 task queue의 콜백을 처리합니다.

즉, Micotask Queue가 완전히 비어질 때까지 이벤트 루프는 다른 Callback Queue로 순회하지 못합니다. 또한 도중에 새로운 작업이 도착하면 새로운 작업도 실행하게 됩니다.

따라서 자칫 잘못하면 Microtask Queue의 무한 루프에 빠지게 되면 유저와의 인터렉션, DOM 렌더링 등이 동작하지 않기에 주의해야 합니다.

또한 같은 queue 안에 적재되는 콜백이라도 어떤 비동기 작업이냐에 따라 우선 순위가 다른 태스크들이 있을 수 있습니다. 예를 들면 Microtask Queue에 적재되는 Promise와 Mutation Observer 콜백 중 Mutation Observer이 먼저 처리됩니다.

Q) 그런데 첫번째 코드인 console.log()는 언제 처리되나요?

처음 스크립트가 로드될 때 "스크립트 실행"이라는 task가 먼저 Macrotask Queue에 들어가게 됩니다. 그리고 이벤트 루프가 Macrotask Queue에서 해당 task를 가져와 Call Stack을 실행합니다.

즉, Call Stack에는 이미 GEC(Global Execution Context)가 생성되어 있는 상태에서 "스크립트 실행"이라는 태스크를 실행하면 그제서야 GEC에 속한 코드가 실행되는 방식입니다.

 

Promise.resolve()
    .then(function () {
      console.log('promise 1-1');
    })
    .then(function () {
      console.log('promise 1-2');
    })
    .then(function () {
      console.log('promise 1-3');
    });
  
    Promise.resolve()
      .then(function () {
        console.log('promise 2-1');
      })
      .then(function () {
        console.log('promise 2-2');
      })
      .then(function () {
        console.log('promise 2-3');
      });

// 결과
// promise 1-1
// promise 2-1
// promise 1-2
// promise 2-2
// promise 1-3
// promise 2-3

위처럼 이벤트 루프는 Microtask Queue에 새로운 작업 도착하면 해당 작업도 실행하게 되기에 위와 같은 실행 순서로 처리가 됩니다.

 

Render Queue

브라우저의 큐는 Callback Queue 뿐만 아니라 Render Queue도 존재하며 다음 렌더링이 발생하기 전에 수행될 작업이 포함되어 있습니다. 프레임 렌더링은 위와 같은 단계로 나눌 수 있습니다.

일반적으로 브라우저는 초당 60프레임을 지원하는데 이는 하나의 task당 0.016s 기준으로 처리하는 것을 목표합니다. 
이벤트 루프는 0.016s마다 Render Queue에 방문해 queue에 남은 task가 있다면 처리하고 남은 작업이 없다면 Render Queue가 아닌 Task Queue에 있는 작업을 처리합니다. 

즉, MTQ(MicroTask Queue)와 TQ(Task Queue)는 루프를 한번 돌때마다 방문하지만 RQ(Render Queue)는 매번 방문하지 않고 약 0.016s마다 방문한다고 볼 수 있습니다.

Web API인 requestAnimationFrame() 함수를 호출하면 그 안에 등록된 콜백이 Render Queue에 쌓이게 되며 이벤트 루프가 Render Queue에 방문하게 되면 requestAnimationFrame의 콜백 함수를 처리해 렌더링하게 됩니다.

참고로 Render Queue에 있는 모든 task를 처리하고 이벤트 루프는 다른 queue로 이동합니다.

console.log("start");

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

Promise.resolve()
  .then(() => {
    console.log("microtask 1");
  })
  .then(() => {
    console.log("microtask 2");
  });

requestAnimationFrame(() => {
  console.log("requestAnimationFrame callback");
});

console.log("end");
start
end
microtask 1
microtask 2
macrotask 1 // requestAnimationFrame callback와 순서가 바뀔 수 있음
requestAnimationFrame callback

위 코드를 실행하면 requestAnimationFrame의 콜백 함수가 setTimeout의 콜백 함수보다 더 먼저 실행될 줄 알았지만 그렇지 않습니다.
위에서 살펴봤듯이 RQ는 0.016s 주기로 이벤트 루프가 방문하며 모든 Queue를 한바퀴 도는데 일반적으로0.001s도 걸리지 않습니다.
따라서 MTQ인 Promise의 콜백 함수가 먼저 실행되고 0.016s가 지나지 않았으므로 TQ에 있는 setTimeout의 콜백 함수가 실행되고 RQ에 있던 requestAnimationFrame의 콜백 함수가 실행된 것입니다.

 

Event Loop

 

Call Stack이 비어있는지를 주기적으로 확인해 Queue(MTQ, RQ, TQ)에서 callback function을 가져와 Call Stack에서 자바스크립트 코드가 실행될 수 있도록 돕는 역할을 합니다. Event Loop가 반복적으로 Call Stack이 비어있는지 확인 하는 것을 tick이라고 합니다.

 

결론

자바스크립트는 Call Stack이 하나인 싱글 스레드임에도 여러 가지일을 동시에 처리하는 것처럼 동작할 수 있는 이유는 이벤트 루프에 기반한 동시성 모델을 가지기 때문입니다.

동시성 (Concurrency)
물리적으로 동시에 일어나는 것이 아닌 흐름을 돌아가며 실행(Context Switching)하면서 동시에 일어나는 것처럼 보이게 하는 방식

 

Event Loop 동작과정

  1. Macrotask Queue에서 가장 오래된 태스크를 꺼내 실행 (예: 스크립트를 실행)
  2. 모든 Microtask Queue를 실행
    • 이 작업은 Microtask Queue가 빈 상태로 될 때까지 반복되며 task는 오래된 순서대로 처리
  3. 렌더링할 것이 있으면 처리 (Render Queue)
  4. Macrotask Queue가 비어있으면 새로운 Macrotask Queue가 나타날 때까지 기다림
  5. 1번으로 돌아감

Queue 우선 순위

  • Microtask Queue는 Task Queue보다 높은 우선순위를 가짐
  • Render Queue는 브라우저 렌더링 시점에 처리되며 렌더링 주기가 도래하지 않았다면 Task Queue 작업을 처리

참고 자료

https://developer.mozilla.org/ko/docs/Glossary/Call_stack

 

호출 스택 - MDN Web Docs 용어 사전: 웹 용어 정의 | MDN

호출 스택은 여러 함수들(functions)을 호출하는 스크립트에서 해당 위치를 추적하는 인터프리터 (웹 브라우저의 자바스크립트 인터프리터같은)를 위한 메커니즘입니다. 현재 어떤 함수가 실행중

developer.mozilla.org

https://cabulous.medium.com/javascript-execution-context-part-1-from-compiling-to-execution-84c11c0660f5

 

JavaScript execution context part 1 — from compiling to execution

To many, JavaScript is a mystery. It has unique characteristics.

cabulous.medium.com

https://dev.to/ibrahzizo360/unveiling-the-javascript-magic-event-loop-single-thread-and-beyond-10pi

 

Journey into JavaScript's Event Loop, Single Thread, and Beyond

Introduction: Welcome to the fascinating world of JavaScript, where a few lines of code...

dev.to

https://stackoverflow.com/questions/77284605/how-are-web-apis-handled-in-the-browser

 

How are web APIs handled in the browser?

While studying the JavaScript event loop, many materials showed that web APIs (setInterval, DOM event, etc.) are processed in the browser and are processed multi-threaded rather than single-threade...

stackoverflow.com

https://html.spec.whatwg.org/multipage/webappapis.html#event-loops

 

HTML Standard

 

html.spec.whatwg.org

https://dev.to/lydiahallie/javascript-visualized-event-loop-3dif

 

✨♻️ JavaScript Visualized: Event Loop

Oh boi the event loop. It’s one of those things that every JavaScript developer has to deal with in o...

dev.to

https://developer.mozilla.org/en-US/docs/Web/API/HTML_DOM_API/Microtask_guide/In_depth

 

In depth: Microtasks and the JavaScript runtime environment - Web APIs | MDN

When debugging or, possibly, when trying to decide upon the best approach to solving a problem around timing and scheduling of tasks and microtasks, there are things about how the JavaScript runtime operates under the hood that may be useful to understand.

developer.mozilla.org

https://blog.xnim.me/event-loop-and-render-queue

 

Browser Event loop: micro and macro tasks, call stack, render queue: layout, paint, composite

Browser Event loop: micro and macro tasks, call stack, render queue: layout, paint, composite

blog.xnim.me

https://youngju-js.tistory.com/28

 

[JavaScript] 이벤트 루프(Event loop) 정리

🍀 목차 글의 목적 프로세스와 스레드? 자바스크립트의 런타임 환경 이벤트 루프 Task Queue Microtask Queue Render 큰 그림으로 이해 마치며 글의 목적 자바스크립트 언어 자체는 싱글 스레드(단일 스

youngju-js.tistory.com

https://ko.javascript.info/event-loop

 

이벤트 루프와 매크로태스크, 마이크로태스크

 

ko.javascript.info