코드가 실행될 때의 환경을 의미하며, 코드의 실행을 관리하고 정보(변수, 함수, 객체 등)을 저장하는 역할을 한다.
ECMAScript 사양은 소스코드를 4가지 타입으로 구분한다. 4가지 타입의 소스코드는 각각의 실행 컨텍스트를 생성한다.
전역 코드: 전역에 존재하는 소스 코드를 말한다.(전역에 정의된 함수, 클래스 등의 내부 코드는 포함되지 않는다.)
함수 코드: 함수 내부에 존재하는 소스 코드를 말한다.(함수 내부에 중첩된 함수, 클래스 등의 내부 코드는 포함되지 않는다.)
eval 코드: 빌트인 전역 함수인 eval 함수에 인수로 전달되어 실행되는 소스 코드를 말한다.
모듈 코드: 모듈 내부에 존재하는 소스 코드를 말한다.(모듈 내부의 함수, 클래스 등의 내부 코드는 포함되지 않는다.)
💡 ECMAScript란 무엇일까?
자바스크립트 표준 스펙으로, 자바스크립트 언어의 문법, 데이터 구조, 제어 구조 등의 정의한다.
ECMAScript 국제 표준화 기구(ECMA International)에서 관리하며 자바스크립트의 기능과 호환성을 보장하기 위해 정기적으로 업데이트 된다.
ECMAScript에는 웹 관련 기능도 포함되지 않고, 데이터를 입/출력하는 방법도 포함되지 않는다.(이러한 기능은 브라우저에서 제공해야 한다.)
즉,Object
,Array
,Promise
와 같은 표준 전역 클래스는 포함되지만HTMLElement
,setTimeout
,fetch()
는 포함되지 않는다.
모든 소스 코드는 실행에 앞서 평가 과정을 거치면 코드를 실행하기 위한 준비를 한다.
자바스크립트 엔진은 소스 코드를 아래와 같은 2개의 과정으로 나누어 처리한다.
소스 코드 평가 과정
실행 컨텍스트를 생성하고 변수, 함수 등의 선언문만 먼저 실행하여 생성된 변수나 함수 식별자를 키로 실행 컨텍스트가 관리하는 스코프(렉시컬 환경의 환경 레코드)에 등록한다.
소스 코드 실행 과정
평가 과정이 끝나면 선언문을 제외한 소스 코드가 순차적으로 실행되기 시작한다. 즉, 런타임이 시작된다.
이 때 소스 코드 실행에 필요한 정보, 즉 변수나 함수의 참조를 실행 컨텍스트가 관리하는 스코프에서 검색하여 취득한다.
그리고 변수 값의 변경 등 소스 코드의 실행 결과는 다시 실행 컨텍스트가 관리하는 스코프에 등록된다.
var age;
age = 8;
위 코드로 설명하면 먼저 소스 코드 평가 과정에서 변수 선언문 var age;
를 먼저 실행한다. 이 때 생성된 변수 식별자 age
는 실행 컨텍스트가 관리하는 스코프에 등록되고 undefined
로 초기화된다.(var 키워드로 선언했기 때문에 선언과 동시에 초기화가 된다.)
소스 코드 실행 과정에서는 변수 할당문 age = 8
만 실행된다. 이 때 age
변수에 값을 할당하려면 먼저 age
변수가 선언된 변수인지 확인해야 한다.
이를 위해 실행 컨텍스트가 관리하는 스코프에 age
변수가 등록되어 있는지 확인한다. 만약 age
변수가 실행 컨텍스트가 관리하는 스코프에 등록되어 있다면 age
변수에 값을 할당하고 결과를 실행 컨텍스트에 등록하여 관리한다.
자바스크립트의 실행 컨텍스트를 관리하는 구조로, 현재 실행 중인 컨텍스트와 대기 중인 컨텍스트를 추적한다.
이 스택은 LIFO(Last In First Out) 방식으로 동작하여, 가장 최근에 생성된 실행 컨텍스트가 가장 먼저 실행되고 종료된다.
function firstFn() {
secondFn();
}
function secondFn() {
console.log("ㅇ_ㅇ");
}
firstFn();
firstFn()
이 호출되면 firstFn()
의 실행 컨텍스트가 스택에 추가된다.
secondFn()
이 호출되면 secondFn()
의 실행 컨텍스트가 스택의 최상단에 추가된다.
secondFn()
가 종료되면 해당 컨텍스트가 스택에서 제거된다.
firstFn()
가 종료되면 해당 컨텍스트가 스택에서 제거된다.
실행 컨텍스트의 구성 요소 중 하나로, 변수와 함수의 스코프를 관리하는 구조이다. 이는 코드가 작성된 위치에 따라 변수와 함수에 접근할 수 있는 환경을 제공한다.
렉시컬 환경의 다음과 같이 두 개의 컴포넌트로 구성된다.
환경 레코드(Environment Record): 현재 렉시컬 환경에서 정의된 변수와 함수의 정보를 저장한다. 이 레코드는 변수의 값을 포함하고 있으며, 함수의 매개 변수도 포함된다.
외부 렉시컬 환경에 대한 참조(Outer Lexical Environment Reference): 현재 렉시컬 환경이 참조하는 외부 렉시컬 환경을 가르킨다. 이는 스코프 체인을 형성하여 변수를 찾기 위해 상위 스코프를 탐색할 수 있게 한다.
function outerFunction() {
const outerVariable = "outside!";
function innerFunction() {
const innerVariable = "inside!";
console.log(outerVariable); // "outside!"
}
innerFunction();
console.log(innerVariable); // ReferenceError: innerVariable is not defined
}
outerFunction();
위 코드를 실행하면 ReferenceError: innerVariable is not defined
에러가 발생한다.
그 이유는 스코프(scope)의 개념 때문이다. 자바스크립트에서는 각 함수가 자신의 렉시컬 환경을 가지고 있으며, 이 환경은 함수가 정의된 위치에 따라 결정된다.
innerFunction
은 outerFunction
내부에 정의 되어 있기 때문에 innerFunction
은 outerFunction
의 렉시컬 환경에 접근할 수 있다. 따라서 outerVariable
에 정상적으로 접근 할 수 있다.
하지만 innerVariable
은 innerFunction
의 내부에 정의된 변수이다. 따라서 innerVariable
은 innerFunction
의 렉시컬 환경에만 존재하고, 외부 outerFunction
에서는 접근할 수 없다.
실행 컨텍스트 스택에 함수 실행 컨텍스트가 푸쉬되는 것은 함수 실행 시작을 의미한다.
함수가 호출된 순서대로 순차적으로 실행되는 이유는 함수가 호출된 순서대로 함수 실행 컨텍스트가 실행 컨텍스트에 푸쉬되기 때문이다. 이처럼 함수의 실행 순서는 실행 컨텍스트 스택으로 관리한다.
자바스크립트 엔진은 단 하나의 실행 컨텍스트 스택을 갖는다.
이는 함수를 실행할 수 있는 창구가 단 하나이며, 동시에 2개 이상의 함수를 동시에 실행할 수 없다는 것을 의미한다.
실행 컨텍스트 스택의 최상위 요소인 실행 중인 실행 컨텍스트
를 제외한 모든 실행 컨텍스트는 모두 실행 대기 중인 태스크(Task)들이다.
대기 중인 태스크들은 현재 실행 중인 실행 컨텍스트가 팝되어 실행 컨텍스트 스택에서 제거(현재 실행 중인 함수가 종료)되면 순차적으로 실행되기 시작한다.
이처럼 자바스크립트 엔진은 한 번에 하나의 태스크만 실행할 수 있는 싱글 스레드(Single Thread) 방식으로 동작한다.
싱글 스레드 방식은 한 번에 하나의 태스크만 실행할 수 있기 때문에 오래 걸리는 태스크를 실행하는 경우 블로킹(작업 중단)이 발생한다.
function sleep(func, delay){
// Date.now(): 현재 시간을 ms로 반환한다.
const delayUntil = Date.now() + delay;
// delayUntil이 현재 시간보다 작으면 계속 반복한다.
while(Date.now() < delayUntil);
// 일정 시간(delay)가 지나면 func를 호출한다.
func();
}
function hello(){
console.log("hello");
}
function test(){
console.log("test");
}
sleep(hello, 3000) // sleep 함수는 3초 이상 실행된다.
test(); // test 함수는 sleep 함수의 실행이 종료된 이후에 호출되므로 3초 이상 블로킹된다.
위 코드를 보면 sleep()
함수는 3초 후에 hello()
함수를 호출 한다. 이 때 test()
함수는 sleep()
함수의 실행이 종료된 이후에 호출되므로 3초 이상 호출되지 못하고 블로킹된다.
이처럼 현재 실행 중인 태스크가 종료할 때까지 다음에 실행될 태스크가 대기하는 방식을 동기(synchronous) 처리
라고 한다.
동기 처리 방식은 태스크를 순서대로 하나씩 처리되므로 실행 순서가 보장되는 장점이 있지만, 앞선 태스크가 종료할 때까지 이후 태스크들이 블로킹되는 단점이 있다.
위 코드를 타이머 함수인 setTimeout
을 사용하여 수정하게 되면 아래와 같다.
function hello(){
console.log("hello");
}
function test(){
console.log("test");
}
// setTimeout은 일정 시간(3초)가 지난 후에 콜백 함수 hello를 호출한다.
// 타이머 함수인 setTimeout은 test 함수를 블로킹 하지 않는다.
setTimeout(hello, 3000)
test();
setTimeout
함수는 앞서 살펴본 sleep
함수와 유사하게 일정 시간이 경과한 이후에 콜백 함수를 호출하지만 setTimeout
함수 이후의 태스크를 블로킹하지 않고 곧바로 실행한다. 이처럼 현재 실행 중인 태스크가 종료되지 않는 상태라 해도 다음 태스크를 실행하는 방식을 비동기(asynchronous) 처리
라고 한다.
비동기 처리 방식은 현재 실행 중인 태스크가 종료되지 않는 상태라 해도 다음 태스크를 곧바로 실행하므로 블로킹이 발생하지 않는다는 장점이 있지만, 태스크의 실행 순서가 보장되지 않는 단점이 있다.
💡
setTimeout
,setInterval
,HTTP 요청
,이벤트 핸들러
는 비동기 처리 방식으로 동작한다!
일반적으로 자바스크립트 엔진이라고 불리는 것들은 더 정확하게는 ECMAScript 엔진이라고 할 수 있다.
그 이유는 이들은 추가 기능 없이(또는 거의 없이) ECMA-262를 구현하기 때문이다. 자바스크립트 엔진은 브라우저(호스트)에 내장되도록 설계되었으며, 브라우저는 입력과 출력을 위한 추가 기능을 정의한다.
가장 잘 알려지는 자바스크립트 엔진들은 다음과 같다.
V8: Chromium 프로젝트용 자바스크립트 엔진으로 만들어졌으며 현재 Node.js와 Deno에서도 사용된다. Edge와 Opera는 Chromium를 기반으로 하기 때문에 V8은 가장 자주 사용되는 자바스크립트 엔진이다.
SpiderMonkey: Firefox의 자바스크립트 엔진이다.
JavaScriptCore: MacOS와 ios의 Safari를 위해 만들어진 자바스크립트 엔진이며, Bun에서도 사용된다.
자바스크립트 엔진은 ECMAScript만 구현하고 브라우저에 의해 확장되도록 되어 있기 때문에 다양한 런타임 환경에서 사용할 수 있다.
자바스크립트 런타임은 ECMAScript의 호스트이다. 즉, 자바스크립트 엔진을 내장하고 자바스크립트를 통해 접근할 수 있는 추가 기능을 정의하는 프로그램이다.
Chrome
, Firefox
, Edge
, Safari
, Node.js
, Deno
, Bun
은 모두 자바스크립트 엔진을 내장하고 자바스크립트를 통해 접근할 수 있는 추가 기능을 정의하기 때문에 자바스크립트 런타임이다.
웹 브라우저는 DOM 및 기타 웹 API를 구현하는 반면 서버 측 런타임은 파일 시스템 접근을 구현한다.
자바스크립트 런타임에 추가할 수 있는 기능에 대한 규칙은 없으며, 런타임 개발자가 직접 결정할 수 있다.
이것이 바로 Node.js
, Deno
, Bun
이 모두 다른 방식으로 파일 시스템을 구현하는 이유이며, Deno
가 fetch()
와 같은 웹 API를 선호하는 반면 Node.js
는 처음에 자체 HTTP 클라이언트를 구현하기로 한 이유이다.(이후 Node.js
도 fetch()
를 채택하였다.) 하지만 자바스크립트 런타임을 독특하게 만드는 것은 자바스크립트 API뿐만 아니다. 자바스크립트 엔진을 사용하는 방식 또한 중요하다.
예를 들어 런타임이 자바스크립트 실행과 다른 작업 수행 사이를 전환할 수 있게 해주는 프로세스인 이벤트 루프는 ECMA-262에 정의되어 있지 않으므로 어떤 자바스크립트 엔진에서도 구현되어 있지 않다.
각 자바스크립트 런타임이 자체 이벤트 루프를 구현해야 한다. 웹 브라우저는 HTML 명세에 정의된 이벤트 루프 버전이 있지만, Node.js
와 같은 서버 측 런타임은 자체적으로 정의해야 한다.
이벤트 루프는 자바스크립트 런타임에 필수적인 것은 아니지만 범용 자바스크립트 런타임에서 찾아볼 수 있다.
자바스크립트 엔진: ECMA-262 표준에 정의된 대로 ECMAScript(JavaScript의 핵심 기능)를 구현한다.
ex) V8, SpiderMonkey, JavaScriptCore 등
자바스크립트 런타임: 자바스크립트 엔진을 내장하고 입/출력을 위한 추가 기능과 런타임에 필요한 다른 기능을 보강하는 ECMAScript 브라우저(호스트)이다.
ex) Chrome, Firefox, Edge, Safari, Node.js, Deno, Bun 등
위에서 살펴봤듯이 자바스크립트 특징 중 하나는 싱글 스레드로 동작한다는 것이다.
하지만 브라우저가 동작하는 것을 살펴보면 많은 태스크가 동시에 처리되는 것처럼 느껴진다.
예를 들면 HTML 요소가 애니메이션 효과를 통해 움직이면서 이벤트를 처리하기도 하고, HTTP 요청을 통해 서버로부터 데이터를 가지고 오면서 렌더링하기도 한다.
이처럼 자바스크립트의 동시성(concurrency)을 지원하는 것이 바로 이벤트 루프
이다.
이벤트 루프는 브라우저에 내장되어 있는 기능 중 하나(자바스크립트 런타임 내의 구성 요소)이다. 브라우저 환경을 그림으로 표현하면 다음과 같다.
함수 호출의 순서를 추적하는 스택이다. 자바스크립트 엔진이 어떤 함수를 호출할 때마다 해당 함수의 실행 컨텍스트를 콜 스택에 추가한다.
웹 브라우저가 활용하는 기능과 상호 작용할 수 있는 인터페이스이다. 이 API들은 웹 어플리케이션이 다양한 기능을 활용할 수 있도록 도와주며, 자바스크립트를 통해 쉽게 접근할 수 있다.
많은 Web API들은 비동기적으로 작동하여, 사용자가 페이지를 탐색하는 동안에도 서버와 통신할 수 있도록 한다.
🚨 주의사항
모든 Web API가 다 비동기적으로 동작하는 것은 아니다document.getElementById()
,localStorage.setItem()
등의 메서드는 동기적으로 처리 된다.
Web API 콜백과 이벤트 핸들러가 일시적으로 보관되는 영역이다.
프로미스의 후속 처리 메서드의 콜백 함수가 일시적으로 보관되는 영역이다.
콜백 함수나 이벤트 핸들러를 일시적으로 저장한다는 점에서 태스크 큐와 동일하지만 마이크로태스크 큐는 태스크큐보다 우선순위가 높다.
즉 이벤트 루프는 콜 스택이 비면 먼저 마이크로태스크 큐에서 대기하고 있는 함수를 먼저 가져와 실행한다.
그 이후 마이크로태스크 큐가 비면 태스크 큐에서 대기하고 있는 함수를 가져와 실행한다.
🚨 주의사항
마이크로태스크는 다른 마이크로태스크를 예약 할 수 있다. 이는 무한 마이크로태스크 루프를 생성하여 태스크 큐를 무기한 지연시키고 애플리케이션을 정지시킬 수 있으므로 주의해야 한다!
콜 스택에 현재 실행 중인 실행 컨텍스트가 있는지, 그리고 태스크 큐에 대기 중인 함수가 있는지 반복해서 확인한다.
만약 콜 스택이 비어 있고, 태스크 큐가 대기 중인 함수가 있다면 이벤트 루프는 순차적으로 태스크 큐에 대기 중인 함수를 콜 스택으로 이동시킨다.
setTimeout(() => {
console.log("1초");
}, 1000);
Promise.resolve().then(() => {
console.log("promise");
});
setTimeout(() => {
console.log("0초");
}, 0);
queueMicrotask(() => {
console.log("queueMicrotask 콜백");
});
console.log("ㅇ_ㅇ");
콘솔 출력: ㅇ_ㅇ ➔ promise ➔ queueMicrotask 콜백 ➔ 0초 ➔ 1초
console.log("ㅇ_ㅇ")
위에 코드들은 각각에 맞는 Microtask Queue, Task Queue에 들어가기 때문에 Call Stack에서 가장 먼저 실행된다.Call Stack이 비어있고 Microtask Queue에 들어있는 것 중 Promise 관련 코드가 먼저 호출되었기 때문에 promise
가 출력된다.
다음 Microtask Queue에 있는 queueMicrotask 콜백
이 출력된다.
Microtask Queue가 비어있고, Task Queue에 0초에 등록된 setTimeout
콜백이 실행되어 0초
가 출력된다.
마지막으로 1초 후에 등록된 setTimeout
콜백이 실행되어 1초
가 출력된다.
자바스크립트는 싱글 스레드에서 작동하므로 한 번에 하나의 태스크를 처리할 수 있다.
Web APIs는 브라우저에서 활용되는 기능과 상호작용하는 데 사용된다. 이러한 API 중 일부는 백그라운드에서 비동기 작업을 시작할 수 있게 해준다.
비동기 작업을 시작하는 함수 호출은 Call Stack에 추가되지만, 이는 단지 브라우저로 작업을 넘기기 위한 것이고, 실제 비동기 작업은 백그라운드에서 처리되며, Call Stack는 남아 있지 않다.
Task Queue는 콜백 기반 Web API가 비동기 작업이 완료된 후 콜백을 대기열에 추가할 때 사용한다.
Microtask Queue는 Promise 핸들러, await 뒤의 async 함수 본문, MutationObserver 콜백, queueMicrotask 콜백에 사용되며 이 큐는 Task Queue보다 우선순위가 높다.
Call Stack이 비어 있을 때, Event Loop는 먼저 Microtask Queue에서 작업을 처리하여 이 큐가 완전히 비어질 때까지 진행한다.
그 다음 Task Queue로 넘어가서 첫 번째 사용 가능한 작업을 Call Stack으로 이동시킨다. 첫 번째 사용 가능한 작업을 처리한 후에는 Microtask Queue를 다시 확인하며 과정을 반복한다.
💡
MutationObserver()
,queueMicrotask()
이란 무엇일까?
MutationObserver: DOM의 변화를 감지하기 위해 사용되는 API이다. 이 API를 사용하면 요소의 추가, 삭제, 속성 변경 등과 같은 변화를 감지하고, 이러한 변화가 발생할 때 콜백 함수를 실행할 수 있다.
주로 UI 업데이트나 데이터 동기화 같은 작업에 유용하다.
queueMicrotask: 특정 작업을 Microtask Queue에 추가하는 함수이다. 이 함수를 사용하면 현재 실행 중인 코드가 완료된 후, 다음 Microtask Queue에서 처리될 작업을 지정할 수 있다. 주로 Promise를 사용하지 않고도 비동기 작업을 처리하고자 할 때 유용하다.
JavaScript Visualized: Event Loop, Web APIs, (Micro)task Queue
자바스크립트 엔진과 런타임의 차이점은 무엇인가요?
모던 자바스크립트 Deep Dive - 23장 실행 컨텍스트, 42장 비동기 프로그래밍, 45장 7절 마이크로태스크 큐