실행 컨텍스트와 이벤트 루프
실행 컨텍스트
자바스크립트 엔진(이하 JSE)은 코드를 실행하기에 앞서 코드를 평가하는 과정을 거친다. 이를 '실행 컨텍스트 평가'라고 부르고, 이후 선언문을 제외한 소스코드가 실행된다. 사실 클로저를 지원하는 대부분의 언어에서 이와 유사하거나 동일한 개념이 적용되어있다.
실행 컨텍스트 평가 과정을 통해 JSE는 실행할 코드에 제공할 환경 정보들을 모아놓은 객체를 생성하게 된다. 이 객체가 바로 실행 컨텍스트(이하 컨텍스트)이다. 컨텍스트가 구성되면 JSE는 이를 콜 스택에 쌓아 올렸다가, 가장 위에 쌓여있는 컨텍스트와 관련 있는 코드를 하나씩 실행하는 식으로 전체 코드의 환경과 순서를 보장한다.
하나의 컨텍스트를 구성할 수 있는 방법으로는 전역 공간, eval 함수, 함수 실행 등이 있다. 자바스크립트 파일이 JSE에 의해 읽히는 순간 전역 컨텍스트가 활성화 되는데, 이는 해당 컨텍스트가 전역 공간에 대하여 생성되었다는 점이 다를 뿐 일반적인 컨텍스트와 크게 다르지 않다. 일반적으로 컨텍스트를 구성하는 방법은 함수를 실행하는 것 뿐이다.
이렇게 구성된 컨텍스트는 크게 VariableEnvironment와 LexicalEnvironment로 분류된다. 두 환경 모두 JSE가 생성한 객체를 가지고 있지만, VariableEnvironment는 생성된 초기 환경으로부터 변경 사항이 반영되지 않고 LexicalEnvironment(이하 렉시컬 환경)는 변경 사항이 반영된다는 점이 다르다. 렉시컬 환경은 아래와 같은 형태로 구성되어있다.
- LexicalEnvironment
ㄴ EnvironmentRecord
ㄴ [[ThisValue]]
ㄴ OuterLexicalEnvironmentReference
EnvironmentRecord와 호이스팅
EnvironmentRecord에는 현재 컨텍스트와 관련된 코드의 식별자 정보가 저장된다. 이러한 정보는 컨텍스트 내부 전체를 처음부터 끝까지 쭉 훑어나가며 순서대로 수집한다. 이 과정을 마치면 JSE는 특정 코드를 해당 환경에 속한 변수명과 선언문을 제외한 코드를 분리해놓은 것처럼 동작시킬 수 있게 된다. 이처럼 변수 정보를 수집하는 과정을 더욱 이해하기 쉬운 방법으로 대체한 가상의 개념을 '호이스팅'이라고 한다.
컨텍스트를 생성하는 과정에서 JSE는 변수 선언 키워드 const, let, var 를 통해 선언된 식별자 정보를 수집한다. 이때 사용한 선언 키워드에 따라 각 변수의 특징이 결정된다. 이렇게 수집된 식별자들은 이 시점에서는 선언만 된 상태이므로 초기화도 되지 않았고 할당도 되지 않은 상태이다.
이러한 변수는 렉시컬 환경이 인스턴스화될 때 생성되지만, 변수의 LexicalBinding이 평가될 때까지는 어떤 식으로든 엑세스할 수 없다. 따라서 변수는 생성될 때가 아니라 LexicalBinding이 평가될 때 Initializer를 통해 할당표현식(AssignmentExpression)의 값이 할당된다. 만약 let 변수의 LexicalBinding에 Initializer가 없는 경우, LexicalBinding이 평가될 때 변수를 undefined 값으로 초기화한다.
function foo1() {
let x // LexicalBinding 평가됨, Initializer 없음
console.log(x) // undefined
}
function foo2() {
let x = 1 // LexicalBinding 평가됨, Initializer 있음
console.log(x) // 1
}
JSE이 미리 식별자 정보를 수집하였다고 해도, 실제로 코드를 한 줄 씩 읽으며 실행하는 과정에서 해당 변수의 LexicalBinding이 평가되는 것이다. 변수의 LexicalBinding이 평가될 때까지는 어떤 식으로든 엑세스할 수 없기 때문에 아래의 코드는 ReferenceError가 발생하는 것이다.
function foo() {
// 변수 x를 참조하려 하지만
// 아직 LexicalBinding이 되지 않아 엑세스할 수 없음
// 결국 ReferenceError 발생
console.log(x) // ReferenceError: Cannot access 'x' before initialization
let x
}
let과 const로 선언한 변수처럼 LexicalBinding이 평가되기 전이라 엑세스할 수 없는 경우, 해당 변수에 엑세스할 수 없는 구간을 일시적 사각지대(Temporal Dead Zone)라고 부른다. 앞서 작성한 foo 함수의 경우 6번 라인까지는 TDZ인 셈이다.
일반적으로 var 변수는 선언과 초기화가 동시에 일어나는 것으로 알려져있다. 그러나 var 변수 역시 LexicalBinding이 평가될 때까지는 어떤 식으로든 엑세스할 수 없다. 그렇다면 어째서 var 변수는 선언과 초기화가 동시에 일어나는 것처럼 보이는 걸까?
사실 var 변수는 렉시컬 환경이 인스턴스화될 때 생성되면서 동시에 LexicalBinding이 평가된다. 따라서 실제로 컨텍스트가 생성될 때 선언과 초기화가 동시에 일어났다고 봐도 무방한 것이다.
다만, LexicalBinding이 평가되었음에도 불구하고 Initializer의 존재는 이 시점에서는 알 수 없다. 따라서 ReferenceError가 발생하지 않을 뿐, let 변수와 마찬가지로 LexicalBinding에 Initializer가 없는 경우로 처리되어 변수가 undefined 값으로 초기화되는 것이다.
function foo() {
// LexicalBinding이 되어있지 않은 변수 x를 참조하려 할 때
// 해당 변수 x가 var 키워드를 통해 선언되었다면 즉시 LexicalBinding을 평가한다
console.log(x) // undefined
// 이후 JSE이 Initializer를 발견하여
// 할당표현식의 값을 할당한다
var x = 3
}
function 키워드로 선언된 함수 선언문은 렉시컬 환경이 인스턴스화될 때 생성되면서 동시에 LexicalBinding이 평가되는데, 이 과정에서 블록의 내용들이 Initializer 없이 초기화 및 할당되는 듯하다.
OuterLexicalEnvironmentReference와 스코프 체인
OuterLexicalEnvironmentReference(이하 OLER)는 현재 호출된 함수가 선언될 당시의 LexicalEnviroment를 참조한다. 선언이란 행위가 실제로 일어날 수 있는 시점은 콜 스택 상에서 어떤 실행 컨텍스트가 활성화된 상태 뿐이다.
let a = 1
function foo() {
function phoo() {
console.log(a)
var a = 3
}
}
foo()
이런 JS 파일이 있을 때, foo() 함수로 인해 생성되는 컨텍스트의 EnvironmentRecord에는 phoo 함수가 존재하며, OLER에는 전역 컨텍스트의 LexicalEnviroment가 참조되고 있다. 다시 phoo() 함수로 인해 생성되는 컨텍스트의 EnvironmentRecord에는 a 변수가 존재하며, OLER에는 전역 컨텍스트의 LexicalEnviroment가 참조되고 있는 foo() 함수의 LexicalEnviroment가 참조되고 있다.
따라서 JSE는 우선 특정한 식별자가 EnvironmentRecord에 존재하는지를 확인한다. 존재한다면 해당 식별자를 사용하고, 존재하지 않는다면 OLER을 거슬러 올라가며 지금까지 생성된 컨텍스트 내에 찾고자 하는 식별자가 존재하는지를 확인한다.
이처럼 '식별자의 유효범위'를 안에서부터 바깥으로 차례로 검색해나가는 것을 스코프 체인이라고 부른다. 여러 스코프에서 동일한 식별자를 여럿 발견할 경우 무조건 스코프 체인 상에서 가장 먼저 발견된 식별자를 참조하게 된다.
let a = 1
function foo() {
var a = 3
phoo()
}
function phoo() {
console.log(a) // 1
}
foo()
거듭 말하지만 OLER는 현재 호출된 함수가 선언될 당시의 LexicalEnviroment를 참조한다. 위의 코드에서 foo 함수를 호출하면, 해당 함수가 phoo 함수가 호출된다. foo 함수 내에도 a라는 식별자가 존재하지만, phoo는 전역 공간에 선언되어있다. 따라서 phoo 함수의 OLER는 전역 컨텍스트의 LexicalEnviroment를 참조하게 되는 것이다.
그리하여 console.log(a)가 호출될 때, 우선 phoo 함수로 인한 컨텍스트의 EnvironmentRecord에 a 식별자가 존재하는지를 확인한다. 존재하지 않기 때문에 OLER을 통해 전역 컨텍스트의 LexicalEnviroment에 a 식별자가 존재하는지를 확인한다. 전역 컨텍스트의 LexicalEnviroment 안의 EnvironmentRecord에 a 식별자가 존재하며, LexicalBinding도 끝나서 초기화 및 할당까지 진행되어있다. JSE는 가장 먼저 발견된 이 식별자를 참조하여 동작하게 된다.
그래서 console.log(a) 는 3이 아니라 1이 나오는 것이다.
this
대부분의 객체지향 언어에서 this는 클래스로 생성한 인스턴스 객체를 의미한다. 그러나 자바스크립트에서 this는 컨텍스트가 생성되는 과정에서 결정된다. 즉, this는 함수를 호출할 때 비로소 결정된다는 것이다.
함수와 메소드의 this
function foo() {
console.log(this)
}
const obj = {
method: foo,
}
foo()
obj.method()
어떤 함수를 호출할 때 그 함수 이름 앞에 객체가 명시돼 있는 경우에는 메소드로 호출한 것이고, 그렇지 않은 모든 경우에 함수로 호출한 것이다. 그리고 이 특성이 this 바인딩에 직접적으로 영향을 미친다. 메소드로 호출한 경우 해당 함수 내의 this는 함수를 호출한 객체가 되며, 그렇지 않은 경우 호출 주체를 알 수 없기 때문에 this는 전역 객체가 된다.
const obj = {
a: 1,
method: function () {
console.log(this); // obj
function z() {
console.log(this); // 전역 객체
}
z();
},
};
obj.method();
이 경우 method는 obj가 호출했기 때문에 그 안의 this는 obj가 된다. 그런데 z의 경우 호출 주체를 알 수 없기 때문에 그 안의 this는 전역 객체가 된다. 이를 우회하기 위해 ES5 시절까지는 method 내부에서 this를 고정해버리는 방식을 사용했었다.
const obj = {
a: 1,
method: function () {
let self = this
function z() {
console.log(self); // obj
}
z();
},
};
obj.method();
이런 방식에서 탈피하기 위해 ES6 부터는 화살표 함수가 도입되었다. 화살표 함수는 컨텍스트를 생성하는 과정에서 this 바인딩 과정을 생략한다. 따라서 이 함수의 내부 this에 접근하려고 하면, 그 대신 스코프체인상 가장 가까운 this에 접근하게 된다.
const obj = {
a: 1,
method() {
console.log(this); // obj
let func = () => {
console.log(this); // obj
};
func();
},
};
obj.method();
콜백 함수의 this
addEventListener 메소드는 콜백 함수를 호출할 때 자기 자신의 this를 상속하지만, 그러지 않는 경우가 더 많다. 콜백 함수의 제어권을 가지는 메소드 혹은 함수가 콜백 함수의 this 바인딩을 결정하게 되며, 특별히 정의되지 않은 경우 함수와 마찬가지로 this는 전역 객체를 바라보게 된다.
생성자 함수 내부의 this
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"
new 명령어와 함께 함수 선언문으로 선언된 함수를 호출하면, 생성자 함수 내부의 this는 생성될 인스턴스를 바라보게 된다.
클로저
클로저는 여러 함수형 프로그램 언어에서 등장하는 보편적인 특징이다. 자바스크립트에서 클로저를 이해하기 위해서는 앞서 이야기한 EnvironmentRecord와 변수의 선언 과정을 제대로 이해하고 있어야 한다고 생각한다.
코어 자바스크립트에서는 클로저를 "어떤 함수 A에서 선언한 변수 a를 참조하는 내부 함수 B를 외부로 전달하는 경우, A의 실행 컨텍스트가 종료된 이후에도 변수 a가 메모리 힙에서 가비지 콜렉팅 되지 않는 현상"이라 정의하고 있다. 여기서 중요한 점은 내부 함수 B를 외부로 전달하는 경우가 return만 있는 것은 아니라는 것이다. setTimeout이나 addEventListener 같은 함수로도 내부 함수를 외부로 전달할 수 있다.
function A() {
const a = 15
return a
}
let a = A()
처음 코딩을 배울 때, 나는 위의 함수가 내부 변수 a를 외부로 전달한다고 생각했다. 실제로는 15라는 값이 들어있는 메모리 주소를 리턴하고 있을 뿐이다. 따라서 ─ 당연하게도 ─ 이것은 클로저가 아니다.
function A() {
const a = 15
const B = () => a
return B
}
let C = A()
console.log(C()) // 15
- 전역 컨텍스트가 생성되며 함수 A와 변수 a의 정보가 EnvironmentRecord에 기록된다.
- 11번 줄에서 함수 A가 실행되며 컨텍스트가 생성된다.
- 함수 A의 LexicalEnvironment에는 내부 변수 a와 내부 함수 B가 EnvironmentRecord에 기록되어있고, 함수 A와 변수 a의 정보가 OLER에 기록되어있다. 이 내용들은 모두 인스턴스화 되어 메모리 힙 어딘가에 저장되어 있다.
- 함수 A의 컨텍스트가 종료되고, a에는 함수 B가 할당된다.
- 12번 줄에서 a()를 실행함으로써 함수 B를 실행한다. 함수 B의 내부에는 선언된 변수나 함수가 없기 때문에 EnvironmentRecord는 비어있다. 함수 B는 함수 A의 내부에 선언되어있으므로, 그 선언된 위치로부터 함수 B의 OLER에는 A 함수의 LexicalEnvironment가 참조복사된다.
- B 함수는 변수 a에 할당된 메모리 주소를 리턴하게 되어있다. 그런데 현재 변수 a가 두 개가 있다. 우선 EnvironmentRecord를 확인하여 변수 a가 없는 것을 확인한다.
- 그 다음으로는 OLER을 통해 함수 A의 LexicalEnvironment의 EnvironmentRecord에 변수 a가 존재하는지를 확인한다. 함수 A의 LexicalEnvironment의 EnvironmentRecord에 변수 a가 존재하기 때문에 스코프체이닝에 의해 해당 변수의 메모리 주소에 접근한다.
이것이 클로저가 실제로 동작하는 과정이다. 그런데 위 과정을 곰곰히 생각해보면 조금 이상하다는 것을 알 수 있다. 함수 B가 실행되는 시점에 함수 A의 컨텍스트는 이미 종료되어있는 상태이다. 그런데 어떻게 함수 B는 함수 A의 LexicalEnvironment를 참조복사할 수 있었을까?
사실 모든 함수는 [[Environment]] 라는 내부 프로퍼티를 갖고 있다. 이 프로퍼티는 함수가 만들어질 때 그 함수를 둘러싼 외부 렉시컬 환경에 대한 참조를 저장한다. 따라서 특정 함수가 실행되지 않아 특정 함수에 대한 렉시컬 환경이 존재하지 않아도, 일단 특정 함수를 실행하면 내부의 [[Environment]] 프로퍼티를 통해 외부 렉시컬 환경을 참조하여 실행 컨텍스트가 렉시컬 환경을 구성한다.
함수의 실행 여부와 별개로 함수 내부에서는 [[Environment]] 프로퍼티가 외부 렉시컬 환경을 이미 참조하고 있으므로 가비지 콜렉터의 수집 대상에서 '일단은' 제외된다. 이후로 내부 함수가 외부로 전달되지 않은 채 외부 함수로 인한 컨텍스트가 종료되면 모든 참조가 끊어지면서 싹 다 가비지 콜렉팅의 대상이 된다. 그렇지 않고 내부 함수가 외부로 전달되면 [[Environment]] 프로퍼티가 외부 렉시컬 환경을 참조하고 있는 채로 유지되므로, 참조 카운터에 의해 가비지 콜렉터의 수집 대상에서 제외되는 것이다.
따라서 클로저에 대한 나의 정의는 이러하다. "어떤 함수 A에서 선언한 변수 a를 참조하는 내부 함수 B를 외부로 전달하는 경우, A의 실행 컨텍스트가 종료된 이후에도 내부 함수 B의 [[Environment]] 프로퍼티가 함수 A의 LexicalEnvironment를 참조하면서 함수 A의 렉시컬 환경이 메모리 힙에서 가비지 콜렉팅 되지 않는 현상"
중간 정산
컨텍스트는 실행되고 종료된다. 컨텍스트가 실행되면 코드를 평가하고, 그 내용을 LexicalEnvironment라는 '객체' 형태의 데이터 인스턴스로 만들어 메모리 힙에 넣어둔다. 컨텍스트가 종료될 때, 일반적으로 해당 컨텍스트로 인해 생성된 LexicalEnvironment도 가비지 콜렉터에 의해 수집된다. 그러나 특수한 조건이 만족된다면 컨텍스트가 종료되어도 LexicalEnvironment가 가비지 콜렉터에 의해 수집되지 않게 된다. 이러한 현상을 클로저라고 부른다.
이벤트 루프
이벤트 루프를 한 줄로 정의하자면 "콜 스택과 콜백 큐를 감시하며 싱글 스레드 엔진이 블록킹 없이 동작할 수 있도록 담보하는 기능"이라 할 수 있겠다.
자바스크립트 엔진
크게 봤을 때 자바스크립트 엔진은 '메모리 힙'과 '콜 스택'을 담당한다.
- 메모리 힙memory heap : 프로그램에서 동적으로 할당된 메모리를 관리하는 데 사용되는 영역
- 콜 스택call stack : 현재 실행중인 컨텍스트를 담아두는 스택 구조의 영역
자바스크립트는 단일 호출 스택을 사용한다. 따라서 컨텍스트 하나가 종료되지 않은 채 콜 스택을 점거하고 있다면 새로운 컨텍스트가 시작될 수 없다. 무기한 기다려야 하는 것이다. 이를 스택 블록킹이라고 한다. 브라우저와 node.js는 각각 web API와 Node.js API를 사용해 이 문제를 해결했다. web API와 Node.js API는 거의 비슷하게 작동하기 때문에 이 포스트에서는 web API만을 다루기로 한다.
web API
내가 기억하기로는, 자바스크립트 입문서 거의 모두는 사실상 크롬의 개발자 도구에서 콘솔에 console.log("Hello World")를 작성하는 것으로 시작한다. 그런데 console.log 메소드는 ECMAScript 표준이 아니다. web API를 통해 브라우저에서 제공하는 문법일 뿐이다. 이는 node.js에서도 마찬가지이다. 이처럼 브라우저는 자바스크립트를 더욱 강력하게 만들어주는 다양한 기능을 제공한다. getElementById 처럼 DOM에 접근하는 메소드도 모두 web API이다. setTimeout이나 setInterver처럼 시간이라는 조건으로 콜백 함수를 호출하는 전역 함수 역시 web API에서 제공하는 기능이다. 서버와 비동기 통신을 위한 fetch 전역 함수 역시 두 말 하면 잔소리다.
콜 스택에서 web API로
자바스크립트 엔진으로 인해 전역 컨텍스트가 생성되고, 함수가 호출됨에 따라 실행 컨텍스트가 콜 스택에 쌓이게 된다. 이 중 web API에서 제공하는 함수로 인해 생성된 컨텍스트는 콜 스택에서 종료를 기다리지 않고 web API의 스레드에서 처리된다. 처리가 완료된다면 그 결과가 콜백 큐callback queue로 이동하게 된다.
콜백 큐
Web APIs가 여러 API들을 묶어 말하듯이, Callback Queue도 세 개의 데이터 큐를 묶어 말하는 것이다. 이 세 개의 데이터큐는 각각 microtask queue, AnimationFrame Queue, macrotask queue를 말한다. web API의 스레드에서 처리된 결과들은 그에 맞는 큐로 들어가게 된다. 어떤 결과가 어떤 큐로 들어가는지는 일반적으로 크게 중요하지는 않은 것 같다.
브라우저에서는 microtask queue, AnimationFrame Queue, macrotask queue의 순서로 콜백 큐가 해결된다. 즉, microtask queue에 쌓인 결과들을 '이벤트 루프'를 통해 콜 스택으로 보내서 microtask queue가 비어있다면, 그 다음으로는 이벤트 루프가 AnimationFrame Queue의 결과를 콜 스택으로 보내기 시작한다는 것이다.
이벤트 루프
앞서 잠깐 언급했던 것처럼 이벤트 루프는 콜백 큐의 결과들을 콜 스택으로 보내 해결되도록 한다. 이벤트 루프는 콜 스택이 비어있는지를 우선 확인한다. 그리고 만약 실행 컨텍스트가 끝나 콜 스택이 비게 된다면 콜백 큐에 있는 결과들을 하나씩 순차적으로 콜 스택으로 이동시키게 된다.
이벤트 루프가 콜백 큐의 결과를 처리하는 과정에는 우선순위가 있다. 우선 microtask queue가 제일 먼저 해결된다. 그리고 그 다음이 AnimationFrame Queue이고, macrotask queue는 마지막이다. microtask queue가 비어있지 않으면 이벤트 루프는 결코 macrotask queue를 해결하려고 하지 않는다.
여기서 중요한 점은 이벤트 루프가 macrotask queue의 결과들을 콜 스택으로 옮기다가도, web API에 의해 microtask queue가 채워졌다면, 이벤트 루프는 macrotask queue 해결을 멈추고 microtask queue를 해결하려고 한다는 것이다.
async/await과 이벤트 루프
앞에서 다룬 fetch와 setTimeout, addEventListener는 모두 web API가 제공하는 기능이다. 그러나 async/await은 ECMAScript에서 제공하는 기능이다. async는 어떤 함수로 하여금 프로미스 객체를 리턴하게 하고, 그 함수 내부에 await 키워드를 사용할 수 있도록 한다. 그 외에는 다른 모든 것이 일반적인 함수와 다르지 않다. 똑같이 컨텍스트를 생성하고 종료한다. 중요한 것은 await이다.
컨텍스트를 생성하고 해결하는 과정에서 await 키워드를 만나면 자바스크립트 엔진은 이 컨텍스트를 통채로 microtask queue로 보내버린다. 이후로 콜 스택이 비워지면 이벤트 루프를 통해 다시금 멈춰있던 컨텍스트가 콜 스택으로 돌아오게 되고, 해결되는 것이다.
익숙해지면 가끔 잊어버리게 되는 것이기도 한데, ─ class 키워드가 prototype을 대체하는 게 아닌 것처럼 ─ async/await 키워드는 promise.then() 메소드를 대체하는 것이 아니라, promise.then() 메소드를 간결하고 명확하게 작성할 수 있게 해주는 문법적인 편의 기능(syntactic sugar)에 불과하다는 것이다.
실제로 await 키워드 다음에 나오는 동일 라인의 코드들은 모두 then 메소드의 콜백 함수와 같이 동작한다.
블로그의 정보
Ayden's journal
Beard Weard Ayden