[JS] 호이스팅, 실행 컨텍스트, 스코프

yongkini ·2023년 7월 20일
0

JS

목록 보기
1/2

목표

: 호이스팅, 실행 컨텍스트, 스코프 이렇게 세가지 키워드를 바탕으로 JS 실행 로직(코드 예시 등을 통한)을 살펴보고자 한다.

호이스팅이란

: 호이스팅이란 일단 용어 그대로 해석하면 뭔가가 끌어올려지는 현상이라고 생각할 수 있다(동사라고 했을 때 끌어올리다 이다). 그럼 뭐가 끌어올려지는 걸까?

console.log(a); // undefined
var a = 4;

위에서 만약 끌어올려지는 현상이 없었더라면 console.log(a)를 했을 때 referenceError 가 나오는게 맞는 것 같다. 왜? 애초에 var a = 4는 그 다음줄에서 실행되는데 그 전 줄에서 a라는 변수를 출력했을 때 어쨌든 일종의 데이터 타입인 undefined가 출력된 것 자체(혹은 리턴)가 미래의 것을 끌어다 가져온 느낌이니까(이게 끌어올려지는 것처럼 보여서 호이스팅이라고 한 것).

그럼 a라는 변수는 실제로 머리채 잡고(?) 끌어올려진걸까?. 일단 여기까지 호이스팅에 대한 설명을 하고, 잠시 멈춰서 자바스크립트가 코드를 평가 및 실행하는 과정을 실행 컨텍스트라는 키워드로 살펴보자.

JS 엔진은 코드를 어떻게 평가 및 실행하나 with 실행 컨텍스트

: 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는 어떤 스코프?

: JS는 렉시컬(정적) 스코프이다. 즉, 선언된 위치에 따라 스코프가 정해진다. 이에 반해 동적 스코프는 실행되는 위치에 따라 스코프가 정해진다. 따라서 스코프 체이닝을 할 때 둘은 다른 방향으로 식별자의 참조값을 선택할 것이다.

블록 스코프(let, const) vs 함수 레벨 스코프(var)

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 부터 바뀐 것으로 이전의 역사를 알아야,, 현대 문물을 알 수 있다 마인드,,로 알아두자.

profile
완벽함 보다는 최선의 결과를 위해 끊임없이 노력하는 개발자

1개의 댓글

comment-user-thumbnail
2023년 7월 20일

뛰어난 글이네요, 감사합니다.

답글 달기