Execution context
블로깅을 시작하고 100개가 넘는 포스팅을 했지만 JavaScript의 기본인 실행 컨텍스트에 대한 포스팅을 한 적이 없다. 왜냐하면 내 스스로 실행 컨텍스트에 대한 이해가 부족하다고 생각하고 정리하기 어려운 개념이기 때문이다. 하지만 이를 계속 회피할 수는 없기 때문에 내가 생각하는 실행 컨텍스트에 대해 정리해보고자 한다. 이 글의 내용은 특히 필자의 주관적인 의견이 담겨있을 수도 있고, 정확한 정보가 아닐 수도 있다. 혹시 읽는 사람이 있다면 이러한 점을 고려해주었으면 좋겠다,,
1. Context
문맥(Context)이란 말을 코드 이외의 곳에서 들어본 적이 있는가? 나는 학창시절 국어시험에서 본 경험이 있다. 국어 시험에선 지문의 '배' 와 같은 단어에 밑줄을 쳐놓고 '문맥'상 어떤 의미인지 고르시오 라는 문제들이 종종 있었다. 이 문제의 의미는 지문의 전개. 즉, 논리적 흐름에 의하면 배라는 단어가 이 지문에서 어떤 의미로 사용이 되었는지 판단해보라는 의미이다. 이러한 문제를 내는 이유는 지문을 제대로 이해하기 위해선 문맥상 해당 단어가 어떻게 쓰였는지 알아야 하기 때문이다.
문맥을 코드로 가져와보자. 코드에서 식별자(메모리의 별명) 'a'가 있을 때 이 a는 '문맥'상 무엇을 의미하는지 알아야 코드를 실행할 수 있다. 코드의 논리적 흐름상 a라는 식별자가 어떤 의미를 갖는지 판단 또는 평가해야한다는 것이다. 이 때 이 문맥이라는 것을 JavaScript에선 실행 컨텍스트라고 한다. 실행 컨텍스트란 코드가 실행되는 환경을 의미한다. 실행 컨텍스트에는 식별자가 무엇인지 등을 판단하기 위한 정보. 즉, 코드 실행에 필요한 환경 정보가 포함되어 있는 객체이다.
2. Create Context
var name = 'ihl';
var age = 11;
function getName(){
return { name: name, age: age }
}
실행 컨텍스트의 생성 과정은 크게 생성단계와 실행단계로 나뉜다(관련글). 나는 이 문장을 보는 순간 생성 과정이라면서 그 안에서 다시 생성과 실행 두 단계로 나뉜다는 것인지 의문과 반발심(?!)이 들었다. 일단, 위 코드의 전역 실행 컨텍스트를 만들어보자.
컨텍스트 생성 단계에서는 Global 객체와 this를 생성한 후 코드 실행을 위해 필요한 정보를 미리 수집한다. 이를 위해 해당 범위의 코드를 처음부터 끝까지 훑어 나가며 순서대로 식별자 정보를 수집한다. 나는 여기서 떠오르는 개념이 있다. 바로 호이스팅이다. 호이스팅이란 변수의 선언이 스코프 최상단으로 끌어올려진 것처럼 보이는 현상이다. 특히 var로 선언된 변수의 경우 선언과 초기화를 동시에 수행하기 때문에 선언문 이전에 해당 변수를 콘솔로 찍어보면 undefined가 출력된다. 즉, 실행 컨텍스트의 생성 단계에서 식별자 정보를 수집하는 과정 때문에 생긴 현상을 호이스팅이라고 이름 붙인 것이다.
컨텍스트 실행 단계는 실제로 코드가 한 줄씩 실행되는 과정을 의미한다. 코드를 실행하다보면 변수의 값들이 메모리에 할당되고 연산을 통해 변화되기도 한다. 즉, 실행 컨텍스트는 항상 일정한 값이 아니라 변화하는 값이다.
var name = 'ihl';
var grade = 'A';
function getUser (grade) {
grade = grade + "+";
return { name: name, grade: grade}
}
getUser(grade);
이번엔 함수 실행 컨텍스트를 만들어보자. 전역과 달리 함수는 매개변수를 받을 수 있다는 특징이 있다. 또한 위 코드의 전역 컨텍스트는 이전 코드의 전역 컨텍스트와 동일하다.
함수 생성 컨텍스트는 전역과 달리 전역 객체가 없고, arguments라는 요소가 있다는 것이 차이점이다. 정리하자면 전역 컨텍스트는 Global 객체(window) + this + 그 외 식별자가 포함되어있고, 함수 컨텍스트는 this + arguments + 함수 파라미터 + 그 외 식별자가 포함된다. 코드 실행에 의한 전역 컨텍스트의 변화를 볼 수 있는 https://ui.dev/javascript-visualizer/ 라는 사이트가 있다. 더 궁금한 내용이 있다면 사이트에서 직접 실험해보자.
3. Call Stack
실행 컨텍스트는 코드를 실행하기 위한 정보를 미리 갖고 있는 객체이다. 실행컨텍스트는 언제 만들어질까? 우선 프로그램을 실행한 직후 전역범위의 코드를 실행시키기 위한 전역 실행컨텍스트가 하나 만들어질 것이다. 그 후 함수를 실행할 때 함수 별로 문맥상의 의미가 달라질 수 있으므로 함수 실행컨텍스트가 만들어진다. (그 외에는 eval 함수로 실행되는 코드가 있는데 eval은 무려 MDN에서 사용하지 말라는 위험한 함수이기 때문에 생략하겠다.)
함수가 실행될 때마다 콜스택에 해당 함수가 쌓인다는 것은 알고있을 것이다. 사실 함수가 콜 스택에 쌓인다는 것은 실행 컨텍스트가 콜 스택에 쌓이는 것을 의미한다.(실행컨텍스트와 실행스택) JS 엔진은 스크립트를 실행할 때 먼저 전역 실행 컨텍스트를 실행하여 스택에 넣는다. 해당 컨텍스트를 이용하여 코드를 실행하다가 새로운 함수 호출을 발견할 때마다 실행 컨텍스트를 생성하여 스택에 push한다. 함수 호출이 끝난 경우 해당 컨텍스트는 pop된다. 즉, 가장 상위에 있는 컨텍스트가 현재 활성화된 컨텍스트이고, 현재 실행되는 코드에 영향을 주는 정보인 것이다. 해당 컨텍스트가 관여할 영역이 끝나면 콜 스택에서 없어지고, 다음 컨텍스트가 활성화 된다. 콜 스택으로 인해 순서에 맞는 코드의 환경 구성이 보장된다.
function fn(){
let a = 1;
if(true) {
let a = 2;
console.log(a); // 2
}
console.log(a); // 1
}
여기서 나는 의문이 든다. '컨텍스트란 코드를 실행하기 위해 필요한 정보인데 그렇다면 블럭 단위도 컨텍스트를 생성할 수 있는 것이 아닐까?' 위 코드에서 같은 함수지만, 블럭단위에서의 식별자 a와 블럭 밖의 식별자 a의 값은 다르게 평가된다. 이 의문의 대답은 'No' 이다. ECMA 문서에 의하면 Stack은 실행 컨텍스트를 추적에 사용되며, 실행 중인 실행 컨텍스트는 항상 스택의 최상위 요소라고 적혀있다. 실행 컨텍스트가 되기 위해서는 콜 스택에 쌓여야 할텐데, 나는 블럭이 생길 때 콜 스택에 뭐가 쌓인다는 이야기는 들어본 적이 없다. 블럭 단위의 식별자를 평가하기 위한 정보는 누가 갖고 있는 걸까?
사실 실행 컨텍스트는 Variable Environment, Lexical Environmnet, This Binding 3가지로 구성된다. 이 중 위 코드의 블럭 내의 a는 Lexical Environment로 인해 판단된다. 단어가 어렵지만 쉽게 말하자면 Variable Environment은 함수가 실행될 때의 환경정보이다. 반면, Lexical Environment는 처음에는 Variable Environment와 같지만, 실시간으로 변경사항이 반영되는 것에 차이가 있다. 다음 문단에서 실행 컨텍스트의 구성을 정리해보겠다.
4. Context Structure
실행 컨텍스트는 위와 같이 구성되어있다. Variable Environment와 Lexical Environment는 현재 컨텍스트 내의 식별자 정보와 외부 환경 정보를 담고있다. Variable Environment는 실행컨텍스트 생성 시 먼저 생성되며, Lexical Environment는 코드 실행에 의해 업데이트된 Variable Environment, ThisBinding는 함수 내에서 this 식별자가 바라볼 객체를 의미한다.
1) Variable Environment
Variable Environment는 실행 컨텍스트 생성 중 생성단계의 정보이다. 함수 내의 코드 실행 직전에 생성되며, environment Record는 현재 컨텍스트 내의 매개변수명, 변수 식별자, 선언된 함수명 등이 담기고, outer Environment Reference는 외부 환경 정보를 의미한다.
environment Record는 컨텍스트 내부를 처음부터 끝까지 훑어 식별자 정보를 수집하여 작성된다. 즉, 식별자의 바인딩 정보가 담겨있다. Variable Environment의 environment Record는 어떤 식별자가 있는지에만 관심 있고 어떤 값이 할당되었는지 까지는 관심이 없다. 따라서 변수의 선언만 끌어올리고 할당과정은 원래 자리에 남겨두는 호이스팅이 발생한다.(관련글) 할당은 코드가 진짜 실행되면서 Lexical Environment의 environment Record에만 갱신된다.
outer Environment Reference(외부 환경 정보)는 바로 직전 컨텍스트의 Lexical Environment을 참조시켜 작성한다. outer Environment Reference는 추후 설명할 Scope Chaining에 사용된다.
2) Lexical Environment
Lexical Environment를 만들 때 우선 이미 만들어진 Varibale Environment를 복사한다. 그 후 코드를 실행하며 변수에 값이 할당되거나 변경되면 Lexical Environment에만 업데이트 시킨다. 블록이 만들어지면 Lexical Environment는 어떻게 변화할까?(참고글: 실행 컨텍스트, ECMA문서: Block, 스택오버플로우: Lexical과 Scope)
이를 알기위해 먼저 스코프에 대해 알아보자. 스코프는 식별자가 유효한 범위를 의미한다. ES6 이후 스코프는 Global / Functional / Block 3가지 범위로 나뉜다. 이 중 Block은 ES6에서 추가된 것이고, Global과 Functional만 봤을 때 Scope는 실행 컨텍스트와 거의 유사하다. 전역 변수는 전역 스코프를 가지며, 함수가 실행되면 함수 내의 변수는 함수 스코프를 갖게 되기 때문이다.
function fn(){
let a = 1;
if(true) {
let a = 2;
console.log(a); // 2
}
console.log(a); // 1
}
var는 Functional Scope를 갖고, let과 const는 Block Scope를 갖는다. 따라서 블럭이 있더라도 변수가 var로 선언되었다면 Functional Scope를 갖기 때문에 원래 있던 Lexical Environment에 업데이트시켜도 예측대로 코드가 실행될 것이다. 그러나 let과 const는 조금 애매하다.
위 코드는 Call Stack 파트에서 보았던 코드이다. let으로 선언된 a 식별자가 2개 있는데 블록 안에서는 2로 판단되고, 블록 밖에서는 1 판단된다. 내 조사에 의하면 블럭이 실행될 때 현재 Lexical Environment를 기반으로 새로운 Lexical Environment가 생성된다.
JS 엔진이 if 블럭을 만나도 원래의 Lexical Environment는 변하지 않는다. 대신, 새로운 블록을 위한 새로운 Lexical Environment가 생성되어 원래 Lexical Environment를 잠시 대체한다. 원래 Lexical Environment는 oldEnv에 저장되며, 새로운 blockEnv는 oldEnv를 상위 스코프로 삼는다. 정리하자면 블록을 만나면 새로운 Lexical Environment를 만들어지고 이를 참조하여 블럭 내의 코드를 실행한다. 다만 블럭 내에서 새롭게 선언된 식별자가 아니라면 상위 스코프인 원래 Lexical Environment를 이용하여 값을 판단한다. 블럭이 끝나면 원래 Lexical Environment로 돌려놓는다.
3) This Binding
This Binding은 this 식별자가 바라봐야할 객체를 의미한다. This Binding은 함수 호출 방법에 따라 달라진다. This에 대한 내용이 엄청 길어질 수 있으므로 추후 정리하여 블로깅 후 링크로 추가하겠다.
5. Scope
function fn(){
if(true) {
let a = 2;
}
console.log(a); // a is not defined
}
Scope는 식별자의 유효 범위를 의미한다. 예를 들어 위 코드에서 블럭 내의 a는 블럭 스코프를 갖고 있으므로, 블럭 밖에서 블럭 내의 a에 접근할 수 없다. 스코프는 크게 전역과 지역으로 나뉘고, 지역 내에서 함수와 블럭으로 나뉜다. 이쯤에서 나는 Lexical Environment와 Scope는 같은 개념일까? 라는 의문이 든다. Lexical Environment 파트에서 if 블럭이 새롭게 생성되었을 때 새로운 Lexical Environment가 기존 Lexical Environment를 잠시 대체하는 것을 보았기 때문이다.
결론부터 이야기 하자면 내 생각에 Lexical Environment는 식별자-값 매핑을 위한 실행 컨텍스트의 구성요소이고, Scope는 식별자마다 갖고 있는 해당 식별자에 접근 가능한 범위를 의미한다. ECMA 문서를 보면 Lexical Environment는 Scope에 의해 특정된다고 적혀있다. 이는 역으로 생각하면 식별자의 Scope는 현재 실행 컨텍스트의 Lexical Environment라고도 유추할 수 있다. (Stack OverFlow 글 의 Magnus 님의 의견을 인용했다.)
function fn(){
let a = 1;
if(true) {
console.log(a); // 1
}
}
Scope Chaining이란 어떤 위치에서 어떤 식별자를 사용했을 때 해당 범위에 식별자 정보가 없어서 정보를 내부에서 바깥으로 즉, 더 상위 스코프에서 차례대로 검색해 나가는 것이다. 예를 들어 위 코드의 if문 범위에 a 식별자 정보가 존재하지 않기 때문에 상위 스코프인 fn 함수 스코프에서 a를 찾는다. 따라서 콘솔에는 1이 출력된다.
Scope Chaining은 실행 컨텍스트의 outer EnvironmentReference를 사용한다. outer EnvironmentReference는 외부 환경 정보로, 현재 호출된 함수가 선언될 당시의 Lexical Environment를 참조한다. 즉, 자신을 호출한 전역/함수의 환경을 담고 있는 것이다.
function fn1() {
let a = 1;
function fn2() {
let b = 2;
console.log(a); // 1
}
fn2();
}
fn1();
위 코드의 출력 결과는 1이다. 그 이유를 생각해보자. fn1을 호출하면 그 안에서 fn2이 선언되고 호출된다. fn2가 호출될 때 생성된 실행 컨텍스트의 Lexical Environment의 outer Environment Reference는 선언될 당시의 Lexical Environment. 즉, fn1의 Lexical Environment를 참조하게 된다. 따라서 fn2의 Environment Record에 없는 a는 Outer Environment Reference를 통해 fn1의 Lexical Environment에 도달하여 이 안에서 a를 찾는다. 만약 이 곳에도 없었다면 다시 fn1의 Outer Environment Reference인 전역 컨텍스트의 Lexical Environment에서 찾게 될 것이다. 이것이 바로 Scope Chaining 과정이다.
const x = 7;
function fn() {
const x = 8;
inner();
}
function inner() {
console.log(x);
}
fn();
하나만 더 살펴보자. 코드의 실행 결과는 7이다. 이유는 무엇일까? inner 함수 내에서 스코프 체이닝이 시작할 때 outer EnvironmentReference가 가리킬 후보는 fn과 전역 실행 컨텍스트 2곳이다. 그런데, outer EnvironmentReference는 현재 호출된 함수가 선언될 당시의 Lexical Environment를 참조한다고 했다. 즉, inner 함수를 호출한 fn이 아니라, 전역을 의미한다. 따라서 함수 호출의 결과는 7이 된다.
[기타 정보]
변수와 식별자
식별자: 메모리의 별명(Alias). 어떤 메모리를 식별하는데 사용하는 이름. 변수의 이름.
변수: 변경 가능한 데이터가 담길 수 있는 공간.
-나만의 구분법: 변수라는 단어를 써서 문장을 만든 후 변수의 이름으로 대체할 수 있다면 식별자다.
변수 은닉화(Variable Shadowing): 전역에 선언된 함수 A가 있고 그 내부에 선언된 B가 있을 때 B에서 식별자 a에 접근한다 가정해보자. 이 때 식별자 a는 전역과 A() 둘 다 존재한다. 이 경우 전역의 식별자 a는 접근할 수 없다. Scope Chaining이 내부에서 바깥으로 차례대로 진행되어 A에서 식별자 a를 발견했다면 더이상 Scope Chaining을 진행하지 않기 때문이다. 이러한 케이스를 Variable Shadowing라고 한다. (variable-shadowing)
실행컨텍스트에 직접 액세스 하는 것은 불가능: https://262.ecma-international.org/5.1/#sec-10.3
-실행컨텍스트는 단순한 기술 스펙이며 ECMA Script 내의 특정 요소와 일치할 필요가 없다.
[풀지 못한 의문점]
1. Variable/Lexical Environment의 outer Environment Reference
-outer Environment Reference는 함수가 선언될 당시의 환경을 의미한다. 그렇다면 함수가 선언될 당시의 환경은 추후에 변경 가능성이 있는 것일까? Variable/Lexical Environment의 차이는 전자는 고정 값이고, 후자는 변경가능하다는 점이다. 두 요소 모두 outer Environment Reference를 포함하고 있다는 것은 코드 실행 도중에 Lexical Environment에서 outer Environment Reference가 변경될 가능성이 있다는 것인가, 아니면 Lexical Environment를 만들기 위해 Variable Environment를 복사하면서 불가피하게 함께 복사된 것일까?
2-1. this = 현재 실행컨텍스트의 Lexical Environment일까?
-JS의 모든 변수는 특정 객체의 프로퍼티로 동작하기 때문에 실행컨텍스트 생성 과정에서 수집된 정보는 실행 컨텍스트의 Lexical Environment의 속성으로 붙는다. 예를 들어 전역 실행 컨텍스트의 Lexical Environment는 전역 객체가 된다. 따라서 전역에서 a를 선언하면 window.a로도 접근 가능하다. 라고 나는 생각하고 있다.
-그렇다면 this는 실행컨텍스트 객체의 Lexical Environment를 의미하는 것일까? this는 함수가 어떤 객체에서 동작하고 있는지 알아내기 위한 것이다.(관련 책 164p) 따라서 현재 실행컨텍스트와 this 무언가 관계가 있을 것같은 느낌이 든다. 그런데 그렇지만도 않은 것이 전역에 함수 선언후 함수 내에 식별자 b가 있다면 함수 호출 시에 window.b가 있어야 하는데 그렇지 않다. 뭔가 비슷한 느낌인데 다르네? 헛다리인가?
2-2. 함수 내의 식별자는 그럼 누구의 속성(Lexical Environment)으로 붙어 있을까? 이를 확인할 방법은 없을까?
-위처럼 클로저를 만들었을 때 내부 함수에서 내부함수 자체를 console.dir로 찍어보면 Scopes에 외부 함수 속성으로 b가 있는 것처럼 보이긴 한다. Scope Chaining을 담당하는 outer Environment Reference는 함수가 선언될 당시의 Lexical Environment를 의미한다. 따라서 [[Scopes]]의 내용은 outer Environment Reference 체인을 의미하는 것으로 추측되고, 이를 통해 간접적으로 상위의 Lexical Environment를 확인할 수 있을 것 같긴 하다. 직접 확인할 방법은 없을까?
3. Variable Environment와 Lexical Environment에 저장되는 식별자가 다르다?
어떤 글 에 의하면 ES6의 Lexical Environment는 var로 선언된 변수를 저장하고, Variable Environment는 let과 const로 선언된 변수를 저장한다. 고 한다. 내가 알기론 Variable Environment는 고정된 값이고 Lexical Environment는 그렇지 않다. 그런데 let은 재할당이 가능한데 Variable Environment에만 저장되고 Lexical Environment에 저장되지 않는다? 재할당된 let 변수의 값은 대체 어디로 가는것인가.? 혼란하다...
[추가로 읽어볼 관련 글]
JS 함수 생성과정: https://meetup.toast.com/posts/123
Lexical Environment: https://meetup.toast.com/posts/129
실행컨텍스트와 스택: https://blog.bitsrc.io/understanding-execution-context-and-execution-stack-in-javascript-1c9ea8642dd0
전역객체 생성과정: https://ui.dev/ultimate-guide-to-execution-contexts-hoisting-scopes-and-closures-in-javascript/
어휘환경과 블록스코프: https://sub2n.github.io/2019/06/05/34-Execution-Context/
ECMA Block 문서: https://tc39.es/ecma262/#sec-block-runtime-semantics-evaluation
Variable Environment와 호이스팅: https://medium.com/joonsikyang/variable-environment-hoisting-and-the-tdz-392e8aeb9fd0
관련 책: 코어 자바스크립트(https://book.naver.com/bookdb/book_detail.nhn?bid=15433261)