안녕하세요. IT 엘도라도 에 오신 것을 환영합니다.
글을 쓰는 것은 귀찮지만 다시 찾아보는 것은 더 귀찮습니다.
완전한 나만의 것으로 만들기 위해 지식을 차곡차곡 저장해 보아요.   포스팅 둘러보기 ▼

자바스크립트 (JavaScript)

[JavaScript] 실행 맥락 (Execution Context) 이해하기 (feat. 클로저와 호이스팅의 원리)

피그브라더 2021. 7. 12. 00:19

이번 포스팅에서는 JavaScript의 실행 맥락(Execution Context)에 대해서 알아볼 것이다. 이는 JavaScript로 작성된 코드가 어떠한 원리로 실행되는 것인지 이해함에 있어 매우 중요한 개념이다. 그리고 본 포스팅의 제목에서도 밝혔듯이, 실행 맥락의 개념을 제대로 이해한다면 클로저와 호이스팅의 원리도 덤으로 이해할 수 있게 된다. 물론 클로저와 호이스팅의 기본 개념은 알고 있다는 전제 하에 말이다. 그럼 한 번 시작해보자.

 

1. 실행 맥락 (Execution Context, EC)

JavaScript에서 실행 맥락(Execution Context, EC)이란 특정 코드를 실행하기 위해 필요한 환경, 혹은 그 환경을 나타내는 객체를 뜻한다. 여기서 실행의 대상이 되는 코드는 크게 두 가지로 나뉘는데, 하나는 전역 코드이고 다른 하나는 함수 코드이다(Eval 코드도 있지만, 흔히 사용되지는 않으므로 여기서는 설명을 생략). 전역 코드는 특정 함수 내의 코드가 아닌 최상위에 존재하는 코드를 의미하고, 함수 코드는 말 그대로 특정 함수 내의 코드를 의미한다.

 

처음 전역 코드가 실행되기 시작하면 전역 실행 맥락(Global Execution Context, GEC)이 스택에 푸시되고, 이후 함수가 하나 호출될 때마다 그 함수에 해당하는 함수 실행 맥락(Function Execution Context, FEC)이 스택에 푸시된다. 함수의 실행이 완료되면 해당 함수 실행 맥락이 스택에서 팝 되고, 이후 전역 코드까지 실행이 완료되면 전역 실행 맥락이 스택에서 팝 된다. CS를 성실하게 공부한 분이라면 이쯤에서 스택 프레임(Stack Frame)이나 활성 레코드(Activation Record)가 떠오를 것이다. 실제로, 실행 맥락은 이러한 개념들과 상당히 유사하다.

 

실행 맥락을 이해하는 것은 JavaScript로 작성된 코드가 실행되는 원리를 이해함에 있어서 매우 중요하지만, 이것뿐만 아니라 스코프, this 키워드, 클로저, 호이스팅 등의 개념을 이해함에 있어서도 매우 중요하다. 실행 맥락만 제대로 이해하면 이러한 부가 개념들은 자연스럽게 이해가 될 것이다. 특히, 클로저와 호이스팅의 원리에 대해서는 아래에서 더 자세히 다룰 것이다.

 

실행 맥락은 다음과 같은 세 개의 프로퍼티를 가지는 객체이다. Variable Object 프로퍼티는 변수 객체(Variable Object, VO)의 참조값을 저장하고, Scope Chain 프로퍼티는 스코프 체인(Scope Chain, SC)의 참조값을 저장하며, thisValue 프로퍼티this 키워드에 바인딩되는 값을 저장한다. 각각에 대한 설명은 아래에서 자세히 진행한다.

 

[Figure 1] 실행 맥락 (Execution Context)

 

2. 변수 객체 (Variable Object, VO)

실행 맥락의 Variable Object 프로퍼티가 가리키는 객체로, 해당 실행 맥락에서 선언된 변수 및 함수를 프로퍼티로 갖는 객체이다. 이는 JavaScript 엔진이 코드를 실행할 때 참조하는 객체로, JavaScript 코드로 이를 직접 참조하는 것은 불가능하다. 변수 객체의 종류로는 두 가지가 있다. 하나는 전역 코드를 실행할 때에 해당하는 전역 객체, 다른 하나는 함수 코드를 실행할 때에 해당하는 활성 객체이다.

 

2-1. 전역 객체 (Global Object, GO)

전역 변수(함수 표현식 포함)와 전역 함수를 프로퍼티로 갖는 객체이다. 그리고 Math, String, Array 등의 내장 객체와 BOM API 및 DOM API까지 기본적으로 설정되어 있다. 전역 코드가 실행될 때 스택에 푸시되는 실행 맥락의 Variable Object 프로퍼티가 가리키는 객체로, 단일 사본으로만 존재한다.

 

2-2. 활성 객체 (Activation Object, AO)

매개 변수(함수 표현식 포함), 지역 변수(함수 표현식 포함), 그리고 지역 함수를 프로퍼티로 갖는 객체이다. 함수 코드가 실행될 때 스택에 푸시되는 실행 맥락의 Variable Object 프로퍼티가 가리키는 객체로, CS에서 다루는 활성 레코드(Activation Record)의 개념과 일대일 대응된다고 생각해도 될 듯하다.

※ 여기서 말하는 '변수'에는 함수 표현식을 대입하는 변수까지도 포함한다. 예를 들면 var func = function () { . . . }과 같은 코드를 말한다. 이러한 선언문은 의미적으로는 단순한 함수의 선언문과 거의 동일하지만 문법적으로는 일반적인 변수의 선언문과 똑같이 취급된다. 이를 이해하는 것은 (뒤에서 설명할) 호이스팅의 원리를 이해함에 있어 매우 중요하다.

 

3. 스코프 체인 (Scope Chain, SC)

실행 맥락의 Scope Chain 프로퍼티가 가리키는 객체로, 해당 실행 맥락의 변수 객체와 이전 실행 맥락들의 변수 객체들을 차례대로 담고 있는 배열 객체이다. 예를 들어, 전역 코드가 실행된 이후 세 개의 함수(func1, func2, func3)가 차례대로 호출되었다면, 스코프 체인은 [AO_func3, AO_func2, AO_func1, GO]의 모습일 것이다.

AO_funcNfuncN의 활성 객체를 의미하며, GO는 전역 객체를 의미한다.

 

프로토타입 체인이 객체의 프로퍼티를 참조하는 코드를 해석하는 메커니즘이라면, 스코프 체인은 변수/함수를 참조하는 코드를 해석하는 메커니즘에 해당한다. 스코프 체인에 담긴 변수 객체들을 차례대로 검색함으로써 참조하고자 하는 변수/함수를 찾는 것이다. 만약 마지막 변수 객체까지 검색했는데도 찾지 못한다면 정의되지 않은 변수/함수로 간주하여 Reference 에러를 발생시킬 것이다.

 

참고로, 함수 객체의 [[Scopes]] 프로퍼티는 자신이 선언된 실행 맥락의 스코프 체인을 가리킨다. 이는 뒤에서 알아볼 클로저의 원리를 이해함에 있어 매우 중요하니 기억해두고 넘어가자.

 

4. this 키워드에 바인딩되는 값

실행 맥락의 thisValue 프로퍼티에는 this 키워드에 바인딩되는 값이 저장된다. 전역 실행 맥락의 경우에는 반드시 전역 객체가 바인딩되지만, 함수 실행 맥락의 경우에는 바인딩되는 값이 그 함수가 호출되는 상황에 따라 동적으로 결정된다. 단, ES6에서 등장한 화살표 함수의 경우에는 바인딩되는 값이 그 함수가 선언되는 시점에 정적으로 결정된다.

 

5. 실행 맥락이 스택에 푸시되는 과정

앞서 설명한 내용이 아직까지는 추상적으로만 느껴질 것이다. 그러니 이제 예시 코드를 살펴보며 앞서 설명한 내용을 조금 더 구체적으로 이해해보도록 하자. 이번 포스팅에서는 아래의 예시 코드를 통해 실행 맥락이 스택에 푸시되는 과정을 자세히 알아볼 것이다. 

var x = 1;

function outerFunc() {
    var y = 2;

    function innerFunc() {
        var z = 3;
        console.log(x + y + z);
    }

    innerFunc();
}

outerFunc();

 

먼저, 여기서 설명할 모든 내용을 집약한 그림을 보여주도록 하겠다. 목차를 본다는 느낌으로 먼저 한 번 쓱 보고(당장 이해하지 못해도 괜찮다), 이 그림을 바탕으로 이후에 진행할 설명을 차근차근 이해해보도록 하자.

 


먼저, var x = 1;로 시작하는 전역 코드가 본격적으로 실행되기 전에 전역 객체가 생성된다. 이 객체에는 Math, String, Array 등의 내장 객체와 BOM API 및 DOM API가 기본적으로 설정된다. 단일 사본으로만 존재하며, 이후에 전역으로 선언되는 변수나 함수는 이 객체의 프로퍼티로 지정될 것이다. 위 그림에서 GO라고 표시된 부분에 해당한다.

 

[Figure 3] 전역 객체의 생성

 

그러고 나면 이제 전역 실행 맥락부터 시작해서 함수 실행 맥락들을 차곡차곡 스택에 푸시하면서 JavaScript 코드를 실행해 나간다. 실행 맥락이 스택에 푸시되는 과정은 크게 세 단계로 이뤄져 있다. 차례대로 스코프 체인의 생성 및 초기화, 변수 객체화, thisValue 바인딩이다. 각 단계에 대해 하나씩 차근차근 알아보도록 하자.

 

5-1. 스코프 체인의 생성 및 초기화

가장 먼저, 실행 맥락의 Scope Chain 프로퍼티가 가리키는 객체, 즉 스코프 체인이 생성 및 초기화된다. 위 그림에서 노란색으로 표시된 부분에 해당한다. 이 과정은 전역 실행 맥락이냐 함수 실행 맥락이냐에 따라 조금 달라진다.

 

만약 전역 실행 맥락이라면, 이미 전역 객체가 생성되어 있으므로 스코프 체인에 해당 전역 객체를 푸시하기만 하면 끝이다.

 

반면 함수 실행 맥락이라면, 먼저 활성 객체가 생성된다. 활성 객체는 가장 먼저 arguments 프로퍼티가 설정되는데, 여기에는 해당 함수에 전달된 매개 변수들을 프로퍼티로 갖게 될 객체를 생성하여 붙인다(실제로 매개 변수들을 프로퍼티로 붙이는 과정은 다음 단계에 이뤄짐). 그러면 이제 스코프 체인에 해당 활성 객체를 푸시하고, 이어서 이전 실행 맥락들의 변수 객체들도 차례대로 푸시하기만 하면 끝이다.

 

5-2. 변수 객체화

다음 단계는 현재 실행 맥락에서 선언된 변수 및 함수를 변수 객체(전역 객체 혹은 활성 객체)에 프로퍼티로 붙이는 과정이다. 위 그림에서 푸른색으로 표시된 부분에 해당한다. 전역 실행 맥락이라면 전역 객체에 전역 변수와 전역 함수가 프로퍼티로 붙을 것이고, 함수 실행 맥락이라면 활성 객체에 지역 변수와 지역 함수가 프로퍼티로 붙을 것이다. 그 절차를 일반화하면 다음과 같다.

 

  1. (함수 실행 맥락인 경우) 매개 변수 할당 : arguments 프로퍼티가 가리키는 객체에 매개 변수들을 프로퍼티로 붙인다. 키(Key)는 매개 변수의 이름이고, 값(Value)은 매개 변수의 값(= 인자)이다.
  2. 함수 할당 : 현재 실행 맥락에서 선언된 함수들을 프로퍼티로 붙인다. 키(Key)는 함수의 이름이고, 값(Value)은 함수 객체이다(물론 함수 객체의 할당이 선행됨). 단, 함수 표현식을 대입하는 변수는 이 과정에서 처리되지 않고 '변수 할당' 과정에서 처리된다.
  3. 변수 할당 : 현재 실행 맥락에서 선언된 변수들을 프로퍼티로 붙인다. 키(Key)는 변수의 이름이고, 값(Value)은 undefined이다(이 변수에 유의미한 값이 할당되는 것은 실제로 코드가 실행될 때임). 단, 이렇게 선언과 동시에 초기화가 되는 것은 var로 선언된 변수만 해당되고, let이나 const로 선언된 변수라면 선언만 되고 초기화는 이뤄지지 않는다. 이것이 어떤 차이를 낳는지는 뒤에서 알아보자. 한편, 함수 표현식을 대입하는 변수도 이 과정에서 처리된다(따라서 함수 객체의 할당도 실제 코드의 실행 전까지 보류됨).
※ 이 중에서 2번 과정은 함수 호이스팅(Function Hoisting)과 밀접한 연관이, 3번 과정은 변수 호이스팅(Variable Hoisting)과 밀접한 연관이 있다. 호이스팅과 관련한 자세한 내용은 아래에서 다시 설명하겠다.

 

5-3. thisValue 바인딩

마지막 단계는 실행 맥락의 thisValue 프로퍼티에 값을 저장하는 과정이다. 위 그림에서 연두색으로 표시된 부분에 해당한다. 이 과정도 마찬가지로 전역 실행 맥락이냐 함수 실행 맥락이냐에 따라 조금 달라진다.

 

만약 전역 실행 맥락이라면, 반드시 전역 객체의 참조값을 저장한다. 즉, 전역 객체를 가리킨다.

 

반면 함수 실행 맥락이라면, 그 함수가 호출되는 맥락에 따라 저장할 값이 동적으로 결정된다. 기본적으로는 전역 객체가 바인딩되지만, call(), apply(), bind() 함수를 이용하여 this 키워드에 바인딩될 값을 명시적으로 정해줬다면 그 값이 바인딩되고, 특정 객체의 메소드로서 실행이 된다면 해당 객체에 바인딩된다.

 

6. 클로저(Closure)의 원리

클로저의 기본 개념은 이해하고 있다는 전제 하에, 지금까지 설명한 내용이 클로저의 원리와 어떻게 연관되어 있는지 살펴보자. 앞서 설명한 내용에 따르면, 함수 객체의 [[Scopes]] 프로퍼티는 자신이 선언된 실행 맥락의 스코프 체인을 가리킨다고 하였다. 이러한 사실을 기억한 채로, 다음과 같은 상황을 생각해보자.

 

 

함수 outerFunc가 내부적으로 함수 innerFunc를 선언하여 이를 반환한다고 해보자. 그러면 outerFunc 함수 실행 맥락이 먼저 스택에 푸시되고, 이후에 innerFunc 함수 객체가 할당될 것이다. 그런데 innerFunc 함수 객체의 [[Scopes]] 프로퍼티는 outerFunc 함수 실행 맥락의 스코프 체인을 가리키고, 이 스코프 체인은 outerFunc 실행 맥락의 변수 객체를 가리키고 있다. 따라서 outerFunc의 실행이 완료되어 outerFunc 함수 실행 맥락이 스택에서 팝 되어도, 여전히 innerFunc 함수 객체의 [[Scopes]] 프로퍼티가 가리키는 스코프 체인을 통해 outerFunc 함수 실행 맥락의 변수 객체를 참조하는 것이 가능할 것이다. 물론 outerFunc 함수를 호출한 상위 함수에 해당하는 함수 실행 맥락의 변수 객체를 참조하는 것도 가능할 것이다. 이것이 바로 클로저의 원리이다.

※ 비동기 프로그래밍에서 이벤트 루프에 의해 호출되는 콜백 함수가 전역 변수와 전역 함수를 참조할 수 있는 것도 같은 원리일 것으로 추측된다(확인해보지는 않음). 참고로, 이벤트 루프에 의해 콜백 함수가 호출되는 것은 기본적으로 전역 실행 맥락을 포함하여 모든 실행 맥락이 스택에서 팝 되어 스택이 비어 있을 때이므로, 콜백 함수가 호출되고 나면 스택에는 그 콜백 함수에 해당하는 함수 실행 맥락만이 존재하고 있을 것이다.

 

7. 호이스팅(Hoisting)의 원리

마지막으로, 지금까지 설명한 내용이 호이스팅의 원리와는 어떻게 연관되어 있는지 살펴보자.

 

기본적으로 호이스팅(Hoisting)은 변수 및 함수의 선언문을 최상위로 끌어올리는 것을 말한다. 여기서 끌어올리는 대상은 정확히 말하면 var, let, const로 선언한 변수의 선언문과 함수의 선언문이다. 이는 JavaScript 엔진이 JavaScript 코드 전체를 미리 한 번 스캔하여 변수 및 함수의 선언문을 찾고 나서 이뤄지는 과정이다. 함수 표현식을 대입하는 변수의 선언문은 함수의 선언문으로 취급되지 않고 일반적인 변수의 선언문과 똑같이 취급된다.

 

이러한 현상을 이해하기 위해 먼저 다음 예시 코드를 살펴보자. 어디서 에러가 발생할까?

/* Before Hoisting */

console.log(x);
console.log(y);
console.log(z);
foo();
bar();

var x = 1;
let y = 2;
const z = 3;

function foo() {
    console.log('foo');
}

var bar = function () {
    console.log('bar');
};

 

다음은 호이스팅이 이뤄진 후의 코드를 나타낸 모습이다. 

/* After Hoisting */

var x;  // Declared (initialized with undefined)
let y;  // Declared (not initialized)
const z;  // Declared (not initialized)
var bar;  // Declared (initialized with undefined)

function foo() {
    console.log('foo');
}

console.log(x);  // print 'undefined'
console.log(y);  // ReferenceError: Cannot access 'y' before initialization
console.log(z);  // ReferenceError: Cannot access 'z' before initialization
foo();  // print 'foo'
bar();  // TypeError: bar is not a function

x = 1;
y = 2;
z = 3;

bar = function () {
    console.log('bar');
};

var, let, const로 선언한 변수(함수 표현식 포함)의 선언문과 함수의 선언문이 최상위로 끌어올려진 것을 볼 수 있다. (참고로, 호이스팅 시에는 변수의 선언문이 함수의 선언문보다 위로 가게 된다.) 다만 선언문만 끌어올려졌을 뿐, 값의 할당 코드는 원래 자리를 그대로 유지하고 있다. 그럼 이제 에러가 나는 코드와 에러가 나지 않는 코드에 대해 하나씩 알아보도록 하자.

 

x를 출력하는 코드는 에러 없이 undefined를 출력한다. 그리고 bar을 호출하는 코드는 bar가 아직 함수가 아닌 undefined이므로 에러를 발생시킨다. 반면, foo를 호출하는 코드는 에러 없이 foo를 출력한다. 함수 선언문이 최상위로 끌어올려졌기 때문이다. 그런데 letconst로 선언한 변수는 의문이 생긴다. 똑같이 호이스팅이 되었는데 왜 에러가 발생할까?

 

그것은 바로 변수 객체화 과정이 약간 다르기 때문이다. var로 선언된 변수는 변수 객체화 과정에서 선언과 undefined로의 초기화가 동시에 이뤄지지만, let이나 const로 선언된 변수는 초기화 없이 선언만 이뤄진다. 그리고 초기화는 실제로 값을 할당하는 코드를 마주쳤을 때 진행하게 된다. 이렇듯 선언과 초기화 사이에 존재하는 시간 텀을 TDZ(Temporal Dead Zone)라고 부르는데, TDZ에 해당 변수를 접근하면 에러가 발생하게 되어 있다. 따라서 초기화 전에 출력을 시도한 yz의 경우 에러가 발생한 것이다.

 

위에서 '변수 할당' 과정을 설명할 때, 2번 과정(이하 함수 할당 과정)은 함수 호이스팅과 밀접한 연관이 있고 3번 과정(이하 변수 할당 과정)은 변수 호이스팅과 밀접한 연관이 있다고 하였다. 이제 이걸 연결 지어 생각해면, 최상위로 끌어올려진 함수 foo는 함수 할당 과정에서 처리된 것이고, 최상위로 끌어올려진 변수 x, bar, y, z는 변수 할당 과정에서 처리되는 것임을 알 수 있다.

 

그런데 함수 할당 과정은 함수 객체를 즉시 할당하여 프로퍼티에 붙이지만, 변수 할당 과정은 undefined를 프로퍼티에 붙이는 것이었다. 이로 인해 foobar은 똑같이 함수임에도 불구하고 실행 결과가 달라진 것이다. foo는 함수로 취급되어 함수 객체가 즉시 프로퍼티에 붙었지만, bar은 변수로 취급되어 undefined가 프로퍼티에 붙었기 때문이다. 이처럼 실행 맥락과 호이스팅의 개념은 아주 밀접한 연관 관계가 있다.

 

let이나 const로 선언된 변수는 호이스팅이 되지 않는다고 설명했었는데, 서행차선 개발자 님의 댓글을 보고 제가 오해하고 있었음을 파악하여 글을 수정하였습니다! 서행차선 개발자 님에게 감사의 말씀을 드립니다 :)

 

 

 

 

 

 

본 글은 아래 링크의 내용을 참고하여 학습한 내용을 나름대로 정리한 글임을 밝힙니다.

https://poiemaweb.com/js-execution-context

https://stackoverflow.com/questions/33869145/is-it-possible-for-global-execution-context-to-pop-off-the-execution-stack

https://dev.to/mhnd3/comment/h00p

https://gmlwjd9405.github.io/2019/04/22/javascript-hoisting.html

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Closures