: 호이스팅, 실행 컨텍스트, 스코프 이렇게 세가지 키워드를 바탕으로 JS 실행 로직(코드 예시 등을 통한)을 살펴보고자 한다.
: 호이스팅이란 일단 용어 그대로 해석하면 뭔가가 끌어올려지는 현상
이라고 생각할 수 있다(동사라고 했을 때 끌어올리다 이다). 그럼 뭐가 끌어올려지는 걸까?
console.log(a); // undefined
var a = 4;
위에서 만약 끌어올려지는 현상이 없었더라면 console.log(a)
를 했을 때 referenceError 가 나오는게 맞는 것 같다. 왜? 애초에 var a = 4
는 그 다음줄에서 실행되는데 그 전 줄에서 a라는 변수를 출력했을 때 어쨌든 일종의 데이터 타입인 undefined가 출력된 것 자체(혹은 리턴)가 미래의 것을 끌어다 가져온 느낌이니까(이게 끌어올려지는 것처럼 보여서 호이스팅이라고 한 것).
그럼 a라는 변수는 실제로 머리채 잡고(?) 끌어올려진걸까?. 일단 여기까지 호이스팅에 대한 설명을 하고, 잠시 멈춰서 자바스크립트가 코드를 평가 및 실행하는 과정을 실행 컨텍스트
라는 키워드로 살펴보자.
: JS 엔진은 예를 들어, 브라우저 단에서 js 코드를 받아와서 파싱하고(토크나이징, AST Tree로 만들기 과정 등등) 이걸 실행하려고 할 때 일단 코드를 평가하는 과정을 거친다. 그럼 맨처음에 JS 엔진이 하는 행동은 뭔가 하면 전역 실행 컨텍스트를 만든다.
이 때 전역 실행 컨텍스트 자체가 전역 객체와 완전히 동일하기에 전역 객체라고 불러도 무방하다. 그리고 이 전역 실행 컨텍스트에는 다음과 같은 내용이 담긴다.
// 웹 브라우저라고 했을 때 웹 브라우저의 전역 환경
GlobalEnvironment = {
EnvironmentRecord :{
ObjectEnvironmentRecord: {
bindObject: window
},
DeclarativeEnvironmentRecord: {}
},
OuterLexicalEnvironmentReference: null // 최상위니까 Outer 환경은 없다.
}
// 전역 실행 문맥
ExecutionContext = {
// 렉시컬 환경 컴포넌트
LexicalEnvironmentComponent : GlobalEnvironment,
// This 바인딩 컴포넌트
ThisBindingComponent: window,
// 변수 환경 컴포넌트
VariableEnvironmentComponent: {}
}
정확히는 ExecutionContext이 전역 실행 컨텍스트이다. 저렇게 한 뒤에 코드를 순차적으로 실행한다(그리고 정확히는 인터프리터가 저 과정을 실행한다). 그럼 저렇게 전역 실행 컨텍스트를 만들고(이걸 평가 과정이라 할 수 있다), 이제 실제로 코드를 실행하게 되는거다. 그리고 이러한 실행 컨텍스트는 Call Stack에 순차적으로 쌓인다. 다시 호이스팅으로 잠깐 돌아와서, 그러면 호이스팅은 언제 일어나는 것일까?. 아까 말한 격한 표현인 머리채가 잡히는 시점은 바로 이 실행 컨텍스트가 생성되는, 즉, 코드 평가 과정이다. 이 과정에서 정확히는 실행 컨텍스트 내에 렉시컬 환경 내에 환경 레코드 내에 DeclarativeEnvironmentRecord
에 변수, 함수(선언문) 등이 호이스팅 돼 등록된다.
console.log(a); // undefined
var a = 4;
이 코드로 치면 console.log(a)
를 실행하기 이전에 이미 전역 객체를 만들었고,
그 전역 객체(전역 실행 컨텍스트) 안에 DeclarativeEnvironmentRecord
안에 a 라는 변수에 undefined를 이렇게 선언 및 초기화 해놓은 것이다.
GlobalEnvironment = {
EnvironmentRecord :{
ObjectEnvironmentRecord: {
bindObject: window
},
DeclarativeEnvironmentRecord: {
a : undefined
}
},
OuterLexicalEnvironmentReference: null // 최상위니까 Outer 환경은 없다.
}
그래서 console.log(a)
시점에 undefined를 리턴할 수 있던거다. 그럼 이제 실행 컨텍스트 및 JS 인터프리터의 실행 로직에 따라 호이스팅이 어느 시점 그리고 왜 일어나는지 알았다. 알고보니 사실 호이스팅은 별건 아니었다. 단순히 JS 인터프리터가 코드를 평가 실행 하는 과정에서 실행 컨텍스트를 만들고, 거기에 변수 및 함수 선언문 등의 메모리를 할당 및 초기화하는 로직이 선행되는데 그 과정에서 호이스팅처럼 보이는 현상이 일어나는 것이었다(정확히는 끌어올렸다기엔 그렇게 보이는거니까). let, const는 호이스팅 과정에서 var과 차이가 있는데 이건 간단하게 말하면 let, const는 ES6 에 나온 스펙으로 이 때부터는 일부러
console.log(a); // undefined
var a = 4;
위의 var 키워드처럼 선언한 뒤에 console.log를 하면 reference error 를 리턴하도록 했다. 다른 블로그들을 보면 선언과 초기화를 var과 같이 동시에 안하고 따로 하기 때문에 이렇게 된다고 하는데 이것도 참고할만한 것 같다. 어쨌든 여기서 포인트는 ES6 부터는 let, const를 제공하면서 강제로 TDZ(Temporal Dead Zone)을 만들고, 그에 따라 위와 같이 호이스팅 되는 현상(직관적이지는 않은 현상이므로 막을 필요가 있다고 판단한 것 같다)을 일부로 막은거라고 생각하는게 심플할 것 같다. 그래서 let으로 위와 똑같이 하면
console.log(a); // reference error
---------------------- 여기까지가 TDZ
let a = 4;
이렇게 된다는걸 알고 넘어가자. 그럼 이제 여기까지 실행 컨텍스트와 호이스팅을 바탕으로 JS 코드가 어떻게 실행되고, 여기서 호이스팅이란 정확히 뭔지를 실행 컨텍스트라는 키워드를 바탕으로 이해해봤다. 그러면 이 실행 컨텍스트라는 키워드를 바탕으로 이제는 스코프에 대해서 이해해보자.
: 스코프는 간단하게 말해서 식별자의 유효 범위
이다. 여기서 식별자란 변수, 클래스 등이 해당된다. 그럼 이 식별자가 유효한 범위는 뭘까?. 예를 들어,
console.log(first);
var first = 1;
function func () {
console.log(second);
var second = 2;
var func2 = function () {
let third = 3;
console.log(second);
console.log(third);
}
func2();
}
func();
이 코드를 가지고 얘기를 해보자. 여기서 호이스팅 복습을 위해 호이스팅 개념을 위한 코드도 섞었다. 여기서 보면 포인트는 func2 안에 있는 console.log(second)
이 코드이다. 우리는(FE 개발자) 직관적으로 이 second라는 변수의 참조가 2를 가리키고 있을 것으로 알고 2가 출력될 것을 기대한다. 하지만 JS 엔진은 이걸 어떻게 알 수 있을까?. 이 때 필요한 개념이 스코프이다. 식별자가 유효한 범위를 바탕으로 뭐를 참조할지 선택하기 때문이다.
코드를 파싱할 때 위에서 말한 인터프리터의 평가 및 실행 과정 및 호이스팅 까지 더해서 설명해보겠다. 그리고 call stack에 실행 컨텍스트가 쌓였다가 pop 되는 것까지 같이 보겠다.
// 웹 브라우저라고 했을 때 웹 브라우저의 전역 환경 생성
GlobalEnvironment = {
EnvironmentRecord :{
ObjectEnvironmentRecord: {
bindObject: window
},
DeclarativeEnvironmentRecord: {
first: undefined,
func: function () { ... }
}
},
OuterLexicalEnvironmentReference: null // 최상위니까 Outer 환경은 없다.
}
// 전역 실행 문맥
GlobalExecutionContext = {
// 렉시컬 환경 컴포넌트
LexicalEnvironmentComponent : GlobalEnvironment,
// This 바인딩 컴포넌트
ThisBindingComponent: window,
// 변수 환경 컴포넌트
VariableEnvironmentComponent: {}
}
// call stack에 전역 실행 컨텍스트가 쌓인다.
// 평가가 끝났고, 실제로 코드를 실행한다.
console.log(first);
// 전역 실행 컨텍스트의 렉시컬 환경 안의 환경 레코드 안에
// DeclarativeEnvironmentRecord 을 보면 first : undefined로
// 초기화 돼 있다. 이를 통해 undefined를 출력한다.
var first = 1;
// first에 값을 할당했으므로 실행 컨텍스트 내의 값도 바꿔준다
GlobalEnvironment = {
EnvironmentRecord :{
ObjectEnvironmentRecord: {
bindObject: window
},
DeclarativeEnvironmentRecord: {
first: 1,
func: function () { ... },
}
},
OuterLexicalEnvironmentReference: null // 최상위니까 Outer 환경은 없다.
}
function func () {
console.log(second);
// second를 찾아보니 undefined다
var second = 2;
DeclarativeEnvironmentRecord: {
second : 2,
func2 : undefined
}
// 잠시 func 실행 컨텍스트의 실행을 stop 하고
// call stack에 func2의 실행 컨텍스트를 생성한다(코드 평가)
Func2ExecutionContext = {
// 렉시컬 환경 컴포넌트
LexicalEnvironmentComponent : {
EnvironmentRecord :{
ObjectEnvironmentRecord: {
...
},
DeclarativeEnvironmentRecord: {
third: undefined,
}
},
OuterLexicalEnvironmentReference: FuncExecutionContext.LexicalEnvironmentComponent
},
// This 바인딩 컴포넌트
ThisBindingComponent: window,
// 변수 환경 컴포넌트
VariableEnvironmentComponent(=== LexicalEnvironmentComponent)
}
var func2 = function () {
let third = 3;
DeclarativeEnvironmentRecord: {
third: 3,
}
console.log(second);
// 일단 해당 환경 레코드엔 없다.
// 따라서 OuterLexicalEnvironmentReference 를 참조하여
// 스코프 체이닝을 해본다.
DeclarativeEnvironmentRecord: {
second : 2,
func2 : undefined
}
// 여기서 찾았으니 2를 출력한다
console.log(third);
DeclarativeEnvironmentRecord: {
third: 3,
}
// 3을 출력한다
}
func2();
// func2 실행 컨텍스트를 call stack에서 pop 하고 끝낸다.
}
// call stack에 func 실행 컨텍스트가 쌓인다(평가)
// func의 실행 컨텍스트(잠시 전역 실행 컨텍스트는 stop)
FuncExecutionContext = {
// 렉시컬 환경 컴포넌트
LexicalEnvironmentComponent : {
EnvironmentRecord :{
ObjectEnvironmentRecord: {
...
},
DeclarativeEnvironmentRecord: {
second : undefined,
func2 : undefined
}
},
OuterLexicalEnvironmentReference: GlobalExecutionContext.LexicalEnvironmentComponent
},
// This 바인딩 컴포넌트
ThisBindingComponent: window,
// 변수 환경 컴포넌트
VariableEnvironmentComponent(=== LexicalEnvironmentComponent)
}
func();
// func 실행 컨텍스트를 call stack에서 pop 하고 끝낸다
// 전역 실행 컨텍스트를 call stack에서 pop 하고 끝낸다.
좀 두서없이 작성하긴 했지만 위와 같다. 이를 구체적으로 해석해보면, 스코프는 식별자의 유효 범위이기 때문에 이 scope 정보를 바탕으로 해당 식별자의 참조값으로 뭐를 택할지를 정한다. 이 때, 가장 먼저 참조하는건 현재 실행 컨텍스트의 렉시컬 환경이다. 여기에 아까말한 환경 레코드 안에 DeclarativeEnvironmentRecord
이걸 참조하는데, 여기에 해당 식별자가 없으면 일단 여긴 없다는 걸로 판단한다. 그러면 어떻게? 다시 렉시컬 환경 내에 OuterLexicalEnvironmentReference
를 참조한다. 이는 상위 실행 컨텍스트 내의 렉시컬 환경을 가리키는 포인터 역할을 하는건데, 여기를 통해 상위 렉시컬 환경 내의 환경 레코드를 확인하여 해당 식별자가 정의 돼있는지 확인한다. 그 다음에 또 똑같이 상위를 확인한다(없으면). 그렇게 null이 나올 때까지 확인한다(전역 실행 컨텍스트의 렉시컬 환경 내에 OuterLexicalEnvironmentReference
는 null 이다. 즉, 전역 실행 컨텍스트까지 쭉 스코프 체이닝을 해본다는거다 식별자가 나타날 떄까지). 끝까지 가도 없으면 undefined를 리턴하고, 중간에 찾으면 해당 값을 참조해서 리턴한다. 이게 스코프 체이닝이다. JS 엔진은 식별자의 참조값을 찾을 때 이런식으로 렉시컬 환경 안에 환경 레코드와 OuterLexicalEnvironmentReference
를 바탕으로한 스코프 체이닝을 통해 찾는다. 그래서 식별자의 유효 범위인 스코프는 실행 컨텍스트 안에 렉시컬 환경과 직접적으로 연관된다고 할 수 있다.
: JS는 렉시컬(정적) 스코프이다. 즉, 선언된 위치에 따라 스코프가 정해진다. 이에 반해 동적 스코프는 실행되는 위치에 따라 스코프가 정해진다. 따라서 스코프 체이닝을 할 때 둘은 다른 방향으로 식별자의 참조값을 선택할 것이다.
var funcArr = [];
for (let i = 0; i < 10; i++) {
funcArr[i] = function printI() {
console.log(i);
};
console.dir(funcArr[i]);
}
for (var j = 0; j < 10; j++) {
funcArr[j]();
}
마지막으로 위 코드가 정상적으로 0,1,2,3,4,.... 9를 출력하지만,
var funcArr = [];
for (var i = 0; i < 10; i++) {
funcArr[i] = function printI() {
console.log(i);
};
console.dir(funcArr[i]);
}
for (var j = 0; j < 10; j++) {
funcArr[j]();
}
위 코드는 (let -> var 만 바뀌었다) 계속해서 10을 리턴하는 이유를 알아보자. 이건 let 키워드가 블록 스코프를 가지기 때문에 이런거다. funcArr[j]();
포인트는 여기서 함수가 실행되고, 그에 따라 call stack 에 funcArr[j]
실행 컨텍스트가 하나씩 쌓이고 없어지고 할거다. 이 때, js 인터프리터 로직에 따라 해당 함수를 평가한 뒤에 실행하게 된다. 그럼 평가할 때 하나의 실행 컨텍스트를 만들고 실행할건데 console.log(i)
를 하면 i의 참조값은 일단 해당 함수 내의 렉시컬 환경엔 없다. 그래서 스코프 체이닝을 할건데, 이 때, var 키워드 같은 경우에는 함수 레벨 스코프이기 때문에 for 문 블록 내에서 하나의 스코프로 잡히지 않는다. 이에 따라 전역 객체까지 스코프 체이닝을 해서 참조를 할건데, 전역 객체에서의 i는 호이스팅 됐고, for문의 i++를 모두 겪은 뒤이기 때문에 아까 말한 것처럼 10이 계속 참조되는거다. 하지만, let 키워드를 쓰면 블록 레벨 스코프이기 때문에 printI 함수의 [[scopes]]에 바로 상위의 for문의 블록 스코프가 잡히고 거기에는 각각의 i(0 ~ 9) 의 값이 있기에 그것을 참조하여 정상적으로 출력되는 것이다. 사실 모두가 var를 잘안쓰기 때문에 이런일을 겪을 일이 없지만, 쓸 일이 있을지 모르고 ES6 부터 바뀐 것으로 이전의 역사를 알아야,, 현대 문물을 알 수 있다 마인드,,로 알아두자.
뛰어난 글이네요, 감사합니다.