
실행 컨텍스트
자바스크립트 엔진(이하 JSE)은 소스 코드를 실행하기 위해 현재 어떤 코드가 실행 중인지, 식별자를 어느 환경에서 찾아야 하는지, this는 무엇인지 같은 상태를 관리한다. ECMAScript 사양은 이 상태를 실행 컨텍스트(execution context)라는 추상적인 장치로 설명한다. 실제 엔진이 사양의 모양 그대로 자바스크립트 객체를 만들어 메모리 힙에 넣는다는 뜻은 아니다. 실행 컨텍스트는 엔진의 구현체라기보다, 자바스크립트가 어떻게 실행되어야 하는지를 설명하기 위한 사양상의 모델에 가깝다.
스크립트나 함수가 실행될 때는 선언으로부터 바인딩을 준비하는 과정과 실제 문장을 평가하는 과정이 이어진다. 흔히 이를 평가 단계와 실행 단계로 나누어 설명한다. 이 구분 덕분에 코드가 아직 해당 줄에 도달하지 않았는데도 함수 선언을 호출할 수 있고, var로 선언한 변수에서 undefined를 읽을 수 있으며, let과 const로 선언한 변수에서는 ReferenceError가 발생한다.
브라우저에서 일반 스크립트를 실행하면 스크립트의 실행 컨텍스트가 활성화되고, 함수를 호출하면 새로운 함수 실행 컨텍스트가 만들어진다. 실행 중인 컨텍스트는 실행 컨텍스트 스택에 쌓인다. 흔히 말하는 콜 스택을 이 관점에서 이해할 수 있다. 엔진은 스택의 가장 위에 있는 컨텍스트를 실행하고, 함수 실행이 끝나면 해당 컨텍스트를 제거한 뒤 그 아래의 실행을 이어간다.
실행 컨텍스트에는 식별자를 찾는 데 사용하는 LexicalEnvironment, var 선언으로 만들어지는 바인딩을 관리하는 VariableEnvironment, 클래스의 비공개 이름을 관리하는 PrivateEnvironment 같은 구성 요소가 있다. 오래된 설명에서는 렉시컬 환경을 EnvironmentRecord와 OuterLexicalEnvironmentReference의 묶음으로 표현하기도 한다. 현재 사양의 표현에 맞춰 단순화하면 다음과 같이 볼 수 있다.
Execution Context ├─ LexicalEnvironment ──> Environment Record │ └─ [[OuterEnv]] ├─ VariableEnvironment ─> Environment Record └─ PrivateEnvironmentLexicalEnvironment와 VariableEnvironment는 하나가 변경되고 다른 하나는 초기 상태로 고정되는 두 개의 복사본이 아니다. 둘은 각각 현재 코드에서 식별자를 찾을 환경과 var 바인딩을 둘 환경을 가리킨다. 대부분의 단순한 함수에서는 같은 환경 레코드를 가리킬 수 있지만, 블록이나 catch처럼 새로운 렉시컬 스코프에 들어가면 LexicalEnvironment 쪽이 새로운 환경을 가리키기도 한다.
Environment Record와 호이스팅
Environment Record는 식별자와 바인딩의 관계를 관리한다. 여기서 바인딩은 단순히 변수명과 값만을 뜻하지 않는다. 바인딩이 생성되었는지, 아직 초기화되지 않았는지, 값을 바꿀 수 있는지 같은 상태도 포함한다. 함수 호출로 만들어지는 Function Environment Record는 필요하다면 this, super, new.target을 처리할 상태도 가진다. 따라서 모든 Environment Record가 무조건 [[ThisValue]]를 가진다고 보면 안 된다.
흔히 사용하는 호이스팅이라는 말은 선언문이 코드의 위로 실제 이동한다는 뜻이 아니다. 코드를 실행하기 전에 선언의 종류에 따라 바인딩을 준비하기 때문에, 마치 선언이 위로 끌어올려진 것처럼 관찰되는 현상을 가리킨다. 중요한 것은 var, let, const, 함수 선언이 모두 같은 방식으로 준비되지 않는다는 점이다.
let과 const로 선언한 식별자의 바인딩은 해당 스코프를 준비할 때 생성되지만, 선언문을 평가하기 전까지 초기화되지 않는다. 바인딩이 존재하지 않는 것이 아니라, 존재하지만 아직 값을 읽을 수 없는 상태다. 이 구간을 일시적 사각지대(Temporal Dead Zone)라고 부른다.
실행이 let x에 도달하면 x가 undefined로 초기화된다. 초기화 식이 있다면 그 값을 평가해 바인딩에 넣는다.
function foo() { console.log(x) // ReferenceError: Cannot access 'x' before initialization let x}var는 다르게 동작한다. 함수 코드의 선언을 준비할 때 var 바인딩을 만들고 먼저 undefined로 초기화한다. 이후 실행이 할당 표현식에 도달하면 오른쪽 값을 평가해 다시 할당한다. 엔진이 console.log(x)를 만난 순간 선언을 뒤늦게 발견하거나 바인딩을 즉석에서 만드는 것은 아니다.
function foo1() { let x console.log(x) // undefined}function foo2() { let x = 1 console.log(x) // 1}function foo3() { console.log(x) // undefined var x = 3 console.log(x) // 3}함수 선언은 선언을 준비하는 과정에서 바인딩뿐 아니라 함수 객체까지 함께 준비된다. 그래서 선언문보다 앞에서 호출할 수 있다.
foo() // "foo"function foo() { console.log("foo")}이 차이를 모두 “위로 끌어올린다”라는 한 문장으로 외우면 let과 var의 차이가 다시 이상해진다. 실제로 구분해야 하는 것은 선언의 위치가 아니라 바인딩을 언제 만들고 언제 초기화하느냐이다.
외부 환경과 스코프 체인
각 환경 레코드는 [[OuterEnv]]를 통해 바깥 렉시컬 환경과 연결될 수 있다. 함수를 만들 때 함수 객체의 [[Environment]]에는 그 시점의 렉시컬 환경이 기록된다. 이후 함수를 호출하면 이 참조를 바탕으로 함수의 새 환경 레코드가 만들어지고, 그 바깥 환경이 연결된다.
여기서 중요한 것은 함수를 어디서 호출했는가가 아니라 어디서 만들었는가이다.
let a = 1function foo() { let a = 3 phoo()}function phoo() { console.log(a) // 1}foo()phoo는 foo 안에서 호출됐지만 전역 환경에서 선언됐다. phoo를 실행하다가 a를 찾으면 먼저 자신의 환경 레코드를 확인한다. 그곳에 a가 없으므로 [[OuterEnv]]를 따라 함수가 만들어진 전역 환경으로 올라간다. 전역 환경에서 a를 발견하므로 결과는 3이 아니라 1이다.
이처럼 현재 환경에서 식별자를 찾지 못했을 때 바깥 환경을 차례로 따라가며 검색하는 관계를 흔히 스코프 체인이라고 부른다. 호출 스택은 지금 어떤 함수가 실행 중인지를 보여주고, 스코프 체인은 식별자를 어디에서 찾을지를 결정한다. 둘은 함께 움직이는 것처럼 보이지만 같은 구조는 아니다.
this
대부분의 객체지향 언어에서 this는 클래스로 생성한 인스턴스를 떠올리게 한다. 자바스크립트의 일반 함수에서는 대체로 어떻게 호출했는가가 this를 결정한다. 다만 화살표 함수는 자신의 this 바인딩을 만들지 않고, 바깥 렉시컬 환경의 this를 사용한다.
함수와 메서드의 this
function foo() { "use strict" console.log(this)}const obj = { method: foo,}foo() // undefinedobj.method() // objobj.method()처럼 객체의 프로퍼티를 통해 함수를 호출하면 호출 표현식의 기준이 된 obj가 this가 된다. 같은 함수라도 const detached = obj.method처럼 프로퍼티에서 꺼낸 뒤 detached()로 호출하면 obj와의 호출 관계가 사라진다. 반면 foo()처럼 독립된 함수로 호출하면 strict mode에서 this는 undefined다. non-strict mode의 일반 스크립트에서는 undefined나 null로 전달된 this가 전역 객체로 치환되기 때문에 전역 객체가 보일 수 있다. ES 모듈과 클래스 본문은 기본적으로 strict mode이므로, “호출 주체가 없으면 언제나 전역 객체”라고 외우면 곤란하다.
함수 안에 다시 일반 함수를 선언해 호출하는 경우도 마찬가지다.
const obj = { a: 1, method() { "use strict" console.log(this) // obj function inner() { console.log(this) // undefined } inner() },}obj.method()예전에는 바깥 함수의 this를 self 같은 변수에 저장하거나 bind()를 사용해 이 문제를 다루곤 했다. 화살표 함수는 자신의 this 바인딩을 만들지 않기 때문에 바깥 메서드의 this를 그대로 사용할 수 있다.
const obj = { a: 1, method() { const inner = () => { console.log(this) // obj } inner() },}obj.method()이를 “스코프 체인에서 가장 가까운 this를 찾는다”라고 표현할 수도 있지만, 더 정확하게는 화살표 함수의 this가 렉시컬하게 결정된다고 말하는 편이 낫다. call(), apply(), bind()로 화살표 함수의 this를 바꾸려 해도 바뀌지 않는다.
이벤트 리스너의 this
콜백 함수의 this는 콜백을 나중에 호출하는 API의 규칙에 달려 있다. 브라우저의 addEventListener()가 일반 함수 리스너를 호출할 때 this는 리스너가 등록된 EventTarget, 즉 이벤트 객체의 currentTarget과 같은 값이 된다. addEventListener() 자신의 this를 상속하는 것은 아니다.
button.addEventListener("click", function (event) { console.log(this === event.currentTarget) // true})화살표 함수는 별도의 this를 만들지 않으므로 이 규칙을 따르지 않는다. 이벤트 대상이 필요하다면 this에 기대기보다 event.currentTarget을 쓰는 쪽이 의도가 더 잘 드러날 때도 많다.
button.addEventListener("click", (event) => { console.log(event.currentTarget === button) // true})생성자 함수 내부의 this
생성 가능한 함수를 new와 함께 호출하면 새로운 객체가 만들어지고, 생성자 본문의 this는 그 객체를 가리킨다. 함수 선언문만 생성자가 될 수 있는 것은 아니다. 일반 함수 객체라면 생성 가능한 경우가 많지만 화살표 함수는 생성자로 호출할 수 없다. 생성자가 명시적으로 다른 객체를 반환하면 new 표현식의 결과는 처음 만들어진 인스턴스가 아니라 그 객체가 될 수도 있다.
function Animal(bark, eat, name) { this.bark = bark this.eat = eat this.name = name this.barking = () => { console.log(this.bark) }}const ayden = new Animal("fuck", "meat", "ayden")ayden.barking() // "fuck"클로저
클로저는 여러 함수형 프로그래밍 언어에서 등장하는 보편적인 특징이다. 자바스크립트에서는 함수가 만들어질 때 함수 객체의 [[Environment]]가 자신을 둘러싼 렉시컬 환경을 참조한다. 이 함수와 렉시컬 환경의 결합을 클로저라고 이해할 수 있다.
처음 코딩을 배울 때 나는 다음 함수가 내부 변수 a를 외부로 전달하므로 클로저의 예라고 생각했다.
function A() { const a = 15 return a}const result = A()하지만 이 코드는 a의 값을 반환할 뿐, A의 지역 바인딩에 다시 접근할 함수를 외부로 전달하지 않는다. 우리가 보통 클로저의 동작을 설명할 때 보고 싶은 장면은 다음과 같다.
function A() { const a = 15 const B = () => a return B}const C = A()console.log(C()) // 15A()를 실행하면 a의 바인딩과 함수 B가 만들어진다. B가 만들어질 때 B.[[Environment]]는 A 실행의 렉시컬 환경을 참조한다. 이후 A()의 실행 컨텍스트는 스택에서 제거되지만, 반환된 B를 C가 계속 참조하고 있다. C()를 호출하면 새 실행 컨텍스트와 환경 레코드가 만들어지고, 식별자 a는 B.[[Environment]]로 연결된 바깥 환경에서 발견된다.
여기서 렉시컬 환경을 “참조 복사”한다고 표현할 필요는 없다. 함수 객체가 자신이 만들어진 환경에 대한 연결을 유지하고 있고, 호출할 때 그 연결이 새 함수 환경의 바깥 환경으로 사용된다고 보면 된다.
실행 컨텍스트가 스택에서 제거됐다고 해서 그 실행에서 사용한 모든 데이터가 즉시 사라지는 것도 아니다. 반환된 함수처럼 도달 가능한 객체가 환경을 계속 참조하면, 그 환경과 필요한 바인딩 역시 도달 가능한 상태로 남을 수 있다. 반대로 더는 어디에서도 해당 함수에 도달할 수 없다면 관련 환경도 가비지 컬렉션의 대상이 될 수 있다.
이를 “참조 카운터가 0이 아니어서 수집되지 않는다”라고 단정하면 실제 엔진의 가비지 컬렉션 구현과 맞지 않는다. 현대 엔진은 보통 도달 가능성을 추적하는 계열의 가비지 컬렉션을 사용하고, 사용하지 않는 바인딩을 최적화할 수도 있다. 사양이 보장하는 것은 클로저를 통해 바깥 식별자에 접근한 결과이지, 엔진이 렉시컬 환경 전체를 어떤 모양으로 메모리에 보관해야 한다는 구현 방식이 아니다.
중간 정산
실행 컨텍스트와 렉시컬 환경은 함께 설명되는 일이 많지만 수명이 완전히 같지는 않다. 실행 컨텍스트는 현재 실행 상태를 나타내며 함수가 끝나면 실행 컨텍스트 스택에서 빠진다. 반면 함수가 참조하는 렉시컬 환경은 그 함수에 도달할 수 있는 동안 계속 필요할 수 있다. 클로저는 “종료된 실행 컨텍스트가 살아남는 현상”이라기보다, 함수가 자신이 만들어진 렉시컬 환경과 연결되어 바깥 식별자를 계속 찾을 수 있는 구조에 가깝다.
이 구분은 비동기 콜백을 이해할 때 중요하다. 타이머나 이벤트 리스너에 넘긴 함수는 지금 실행 중인 컨텍스트 자체가 큐로 이동한 것이 아니다. 브라우저가 나중에 그 함수를 호출할 기회를 만들고, 실제 호출 시점에 새로운 실행 컨텍스트가 스택에 올라간다.
브라우저 이벤트 루프
이벤트 루프를 “콜 스택과 콜백 큐를 감시하는 장치”라고 설명하면 처음 그림을 그리기에는 편하다. 하지만 이 그림만으로는 task와 microtask, 렌더링, async/await이 만나는 지점을 설명하기 어렵다. 더구나 브라우저와 Node.js는 ECMAScript를 실행한다는 점은 같아도, 비동기 작업을 조직하는 호스트 환경의 이벤트 루프는 서로 다르다. 여기서는 브라우저의 이벤트 루프만 다룬다.
자바스크립트 엔진과 브라우저
자바스크립트 엔진은 실행 컨텍스트 스택과 객체를 위한 메모리 같은 실행 기반을 관리한다. 브라우저는 여기에 DOM, 타이머, 네트워크 요청, 사용자 입력, 렌더링 같은 기능을 제공한다. document.querySelector(), setTimeout(), fetch(), addEventListener()는 ECMAScript 언어 자체가 아니라 브라우저 호스트 환경이 제공하는 API다. console 역시 ECMAScript의 일부가 아니라 실행 환경이 제공한다.
자바스크립트가 주로 한 스레드에서 실행된다는 말은 한 순간에 하나의 자바스크립트 실행 흐름이 메인 스레드의 스택을 점유한다는 뜻이다. 오래 걸리는 콜백이 스택을 붙잡고 있으면 다른 자바스크립트 콜백뿐 아니라 사용자 입력 처리와 렌더링도 늦어진다. 비동기 API는 기다리는 동안 스택을 붙잡지 않도록 도와주지만, 콜백 안에서 오래 계산하면 결국 메인 스레드를 다시 막는다.
브라우저 API를 호출한다고 그 함수의 실행 컨텍스트가 통째로 별도의 “Web API 스레드”로 이동하는 것은 아니다. 타이머는 브라우저가 시간을 추적하고, 네트워크 요청은 브라우저 내부의 네트워크 기능이 진행하며, 이벤트 리스너는 특정 이벤트가 발생했을 때 호출할 함수를 등록한다. 브라우저가 내부적으로 여러 스레드와 프로세스를 사용할 수는 있지만, 모든 Web API를 하나의 별도 스레드가 처리한다고 가정할 필요는 없다.
task와 microtask
브라우저는 실행할 일을 task와 microtask 같은 단위로 조직한다. setTimeout() 콜백, 사용자 입력 이벤트, 네트워크와 관련된 이벤트, 스크립트의 최초 실행 등은 task가 되는 대표적인 예다. 흔히 이 task를 macrotask라고 부르지만, HTML 사양에서는 그냥 task라는 용어를 사용한다.
하나의 거대한 “콜백 큐”에 모든 일이 등록 순서대로 들어가는 것도 아니다. 브라우저에는 task source에 따른 여러 task queue가 있을 수 있다. 같은 task source 안의 순서는 보존되지만, 브라우저는 사용자 입력처럼 반응성이 중요한 작업을 고려해 다음에 실행할 task queue를 선택할 수 있다. 그러므로 서로 다른 종류의 비동기 작업 사이에 등록 순서만으로 전체 실행 순서를 보장할 수는 없다.
API 이름만 보고 동기와 비동기를 나눌 수도 없다. 실제 사용자 입력으로 발생한 이벤트는 브라우저가 task를 통해 전달하지만, 코드에서 target.dispatchEvent(event)를 호출하면 리스너는 현재 실행 안에서 동기적으로 호출된다. fetch()의 네트워크 작업은 브라우저 내부에서 진행되고 후속 처리는 networking task source를 사용할 수 있지만, fetch(...).then(callback)의 callback은 Promise가 이행된 뒤 Promise 반응 microtask로 실행된다. “이벤트 콜백은 task, Fetch 콜백은 네트워크 큐”처럼 API마다 큐 하나를 고정해서 외우면 다시 틀어진다.
Promise의 반응 콜백, queueMicrotask(), MutationObserver 콜백 등은 microtask queue에 들어간다. 브라우저는 현재 task의 자바스크립트 실행이 끝나고 스택이 비면 microtask checkpoint를 수행한다. 이때 큐가 빌 때까지 microtask를 계속 실행한다. microtask가 다시 microtask를 추가하면 새 microtask도 다음 task로 넘어가기 전에 실행된다.
console.log("script start")setTimeout(() => { console.log("timeout")}, 0)Promise.resolve().then(() => { console.log("promise")})console.log("script end")이 코드는 다음 순서로 출력된다.
script startscript endpromisetimeout최초 스크립트 자체가 하나의 task로 실행된다. 그 task가 끝난 뒤 Promise 반응인 microtask가 실행되고, 이후 타이머 콜백을 위한 task가 실행될 기회를 얻는다. setTimeout(..., 0)의 0은 지금 실행하라는 뜻도, 정확히 0밀리초 뒤에 실행하겠다는 약속도 아니다. 최소 지연 조건을 만족한 뒤 콜백을 실행할 task가 예약될 수 있다는 뜻에 가깝다. 현재 task, microtask, 다른 브라우저 작업이 남아 있다면 더 늦어질 수 있다.
microtask의 우선순위가 높다고 해서 실행 중인 task를 중간에 끊는 것은 아니다. 다음 코드에서 Promise 콜백은 longTask()의 중간에 끼어들 수 없다. 현재 자바스크립트 실행이 끝난 뒤에야 microtask checkpoint가 열린다.
Promise.resolve().then(() => { console.log("microtask")})longTask()console.log("task end")따라서 “이벤트 루프가 task queue를 처리하다가 microtask가 들어오면 즉시 task 처리를 멈춘다”라고 설명하면 선점이 일어나는 것처럼 보인다. 정확히는 task 하나를 끝낸 뒤 다음 task로 넘어가기 전에 microtask queue를 비운다.
렌더링과 requestAnimationFrame
requestAnimationFrame() 콜백을 microtask와 task 사이에 언제나 존재하는 또 하나의 큐로 놓기도 한다. 하지만 렌더링은 매 이벤트 루프 반복마다 반드시 일어나지 않는다. 브라우저가 화면을 갱신할 기회라고 판단했을 때 렌더링 단계를 수행하고, requestAnimationFrame() 콜백은 다음 화면을 그리기 전에 실행된다. 백그라운드 탭에서는 호출 빈도가 크게 줄거나 멈출 수도 있다.
브라우저의 한 반복을 학습용으로 단순화하면 다음과 같다.
실행할 task 하나 선택 ↓해당 task의 자바스크립트 실행 ↓microtask queue가 빌 때까지 처리 ↓필요하다면 렌더링 갱신 └─ requestAnimationFrame 콜백 └─ 스타일 계산과 레이아웃 └─ 페인팅 ↓다음 반복이 그림에서도 렌더링 단계의 세부 순서는 브라우저 구현과 사양의 더 복잡한 절차를 단순화한 것이다. 여기서 기억할 것은 microtask → requestAnimationFrame → task라는 세 개 큐의 고정 우선순위가 아니라, task가 끝난 뒤 microtask checkpoint가 있고, 렌더링은 별도의 갱신 기회에 일어난다는 점이다.
microtask가 계속해서 새 microtask를 만들면 큐가 비지 않으므로 렌더링과 다음 task가 오래 밀릴 수 있다. Promise를 사용했다고 해서 무거운 계산이 메인 스레드 밖으로 이동하는 것도 아니다. 실행 시점만 다음 task보다 앞선 작은 단위로 미뤄질 뿐이다.
콜백과 실행 컨텍스트가 만나는 지점
이벤트 루프가 콜백 함수를 큐에서 콜 스택으로 물건처럼 옮긴다고 표현하기도 한다. 실제 연결은 한 단계 더 있다.
브라우저가 실행할 task를 고른다→ task에 연결된 콜백을 호출한다→ 함수 실행 컨텍스트가 만들어진다→ 실행 컨텍스트가 스택의 맨 위에서 실행된다→ 함수가 끝나면 실행 컨텍스트가 스택에서 제거된다→ microtask checkpoint를 수행한다즉 큐에서 기다리는 것과 스택에서 실행되는 것은 같은 상태가 아니다. 타이머에 전달한 함수는 자신의 [[Environment]]를 통해 기존 렉시컬 환경을 참조할 수 있지만, 타이머를 등록하던 실행 컨텍스트가 큐에 보관됐다가 돌아오는 것은 아니다. 나중에 콜백이 호출되면서 새로운 실행 컨텍스트가 만들어진다.
function startTimer() { const message = "finished" setTimeout(() => { console.log(message) }, 0)}startTimer()startTimer()의 실행 컨텍스트는 먼저 스택에서 사라진다. 하지만 타이머 콜백 함수가 message가 있는 렉시컬 환경을 참조하므로, 나중에 콜백이 실행될 때도 그 값을 찾을 수 있다. 여기서 실행 컨텍스트, 클로저, 이벤트 루프가 하나의 흐름으로 연결된다.
async/await과 이벤트 루프
async 함수는 언제나 Promise를 반환한다. 함수 안에서 await를 만나면 엔진은 피연산자를 Promise로 다루고, async 함수의 실행을 일시 중단한다. 현재 async 함수의 실행 컨텍스트는 스택에서 빠지므로 호출자는 계속 실행될 수 있다.
기다리던 Promise가 이행되거나 거부되면, await 다음 부분을 재개하는 작업이 Promise reaction job으로 예약된다. 브라우저에서는 이 작업이 microtask queue를 통해 실행된다. 큐로 이동하는 것은 기존 실행 컨텍스트 전체가 아니라, 중단된 async 함수의 실행을 재개할 작업이다. 재개될 때는 async 함수의 실행 컨텍스트가 다시 활성화된다.
async function foo() { console.log("foo start") await Promise.resolve() console.log("foo end")}console.log("script start")foo()console.log("script end")실행 결과는 다음과 같다.
script startfoo startscript endfoo endfoo()는 await 전까지 동기적으로 실행된다. 이미 이행된 Promise를 기다리더라도 await 다음 코드는 현재 스택에서 곧바로 이어지지 않고 microtask로 재개된다. Promise가 아직 대기 중이라면, 그 Promise가 결론을 낸 뒤에야 재개 작업을 실행할 수 있다.
async/await을 Promise의 then()을 읽기 좋게 표현한 문법이라고 이해하면 실행 순서를 짐작하는 데 도움이 된다. 하지만 단순히 소스 코드를 then() 호출로 치환한 것과 완전히 같다고 볼 필요는 없다. 사양에는 async 함수의 중단과 재개, 반환값과 예외를 Promise에 연결하는 별도의 의미가 정의되어 있다. 중요한 공통점은 await 이후의 실행도 Promise 반응과 마찬가지로 microtask를 거쳐 이어진다는 것이다.
정리
실행 컨텍스트는 지금 실행 중인 코드의 상태를 설명하고, 렉시컬 환경은 식별자를 어디에서 찾을지 결정한다. 클로저는 함수가 자신이 만들어진 환경과의 연결을 유지하는 구조다. 브라우저 이벤트 루프는 실행 가능한 task를 고르고, task가 끝난 뒤 microtask checkpoint를 수행하며, 필요하다면 화면을 갱신한다.
이 개념들은 서로 붙어 있지만 하나는 아니다. 비동기 API가 실행 컨텍스트를 다른 스레드나 큐로 옮기는 것도 아니고, 이벤트 루프가 콜백을 그대로 스택에 밀어 넣는 것도 아니다. 브라우저가 콜백을 호출할 시점을 마련하면 그 호출을 위해 새로운 실행 컨텍스트가 만들어지고, 함수는 자신이 가진 렉시컬 환경의 연결을 따라 식별자를 찾는다.
결국 실행 순서를 이해하려면 “어느 큐가 더 빠른가”만 외워서는 부족하다. 지금 실행 중인 task가 무엇인지, 그 실행이 끝난 뒤 어떤 microtask가 생기는지, 브라우저가 렌더링할 기회를 얻는지, 그리고 호출된 함수가 어느 렉시컬 환경을 참조하는지를 함께 봐야 한다.
댓글
댓글을 불러오는 중...