JavaScript

실행 컨텍스트 톺아보기: 실행 컨텍스트 구성 요소

GoJay 2024. 10. 22. 17:41

실행 컨텍스트의 구성 요소

실행 컨텍스트는 자바스크립트 코드가 동작을 관리하고, 코드 실행을 위해 필요한 환경(스코프, this 등)과 데이터 상태 정보를 관리하는 자바스크립트 엔진의 동작 메커니즘이다. 실행 컨텍스트는 자바스크립트의 동작을 관리하는 핵심적인 역할을 하며 스코프 체인, 호이스팅, this, 변수 관리(메모리 관리), 클로저 등 중요한 개념을 이해하는 배경이 된다.

실행 컨텍스트는 작성된 코드 평가 시 생성되며, 콜 스택에 선입선출(FIFO; First In, First Out) 방식으로 공간을 할당하여 스코프를 관리한다. 실행 컨텍스트의 메모리 점유 방식에 대해선 포스트에 기록해뒀다.

이번 포스트에선 실행 컨텍스트의 구성 요소들에 대해 조금 더 알아보려고 한다. 먼저, 실행 컨텍스트는 렉시컬 환경(Lexical Environment)과 this 바인딩(this bindind) 정보로 구성되며, 렉시컬 환경은 VariableEnvironment(변수 환경)와 LexicalEnvironment(렉시컬 환경)라는 두 가지 컴포넌트로 구성된다. 참고로, 렉시컬 환경(Lexical Environment)과 LexicalEnvironement 컴포넌트는 서로 다른 개념이다. 전자가 후자를 포함하는 관계다. 둘의 구분을 위해, 컴포넌트를 지칭할 때에만 영문 표기 LexicalEnvironment를 쓰겠다.

VariableEnvironmentLexicalEnvironment는 각각 EnvironmentRecord(환경 레코드)와 OuterLexicalEnvironmentReference(외부 렉시컬 환경 참조) 정보를 갖고 있으며, VariableEnvironment는 실행 컨텍스트 내의 식별자와 외부 참조 정보의 초기 값을 스냅샷(Snapshot) 형태로 관리하고, LexicalEnvironment는 VariableEnvironment의 초기값을 복제한 후 실제 코드 런타임 시 변경 사항들을 반영한다.

실행 컨텍스트가 관리하는 정보들

실행 컨텍스트의 각 요소들이 어떻게 동작하는지를 정확하게 이해하면 자바스크립트의 핵심적인 여러 개념에 대한 이해가 깊어진다. 하나씩 차근차근 살펴보겠다.

렉시컬 환경(Lexical Environment)

렉시컬 환경은 VariableEnvironmentLexicalEnvironment 두 컴포넌트로 구성된다. 관리하는 상태가 정적인지(스냅샷), 동적인지의 차이만 있지, 두 컴포넌트에서 환경 레코드의 코어한 동작은 유사하다. 그래서, 환경 레코드에 대해 살펴볼 땐 LexicalEnvironment를 기준으로 알아보겠다.

환경 레코드(Environment Record)

먼저, 렉시컬 환경의 LexicalEnvironment 컴포넌트는 환경 레코드를 관리한다. 환경 레코드는 스코프를 구분하여 해당 스코프에 포함된 식별자를 등록하고, 식별자에 바인딩된 값(상태)을 관리한다. 여기서 식별자 정보란 변수, 함수, 함수의 파라미터, arguments 객체 등 메모리 주소를 참조하여 값을 가리키는 모든 식별자를 의미한다.

아래의 예시를 확인해 보자.

let myNum = 10;

function outerFunc() {
    let myNum = 20;

    function innerFunc() {
        let myNum = 30;
        console.log(myNum); // 30
    }

    // let은 블록 스코프로 관리되기 때문에 반복문도 스코프로 구분된다
    for (let myNum = 0; myNum < 5; myNum++) {
        console.log(myNum); // 0 1 2 3 4
    }

    innerFunc();
    console.log(myNum); // 20
}

outerFunc();
console.log(myNum); // 10

위의 코드는 전역, outerFunc(), innerFunc(), for 반복문까지 총 네 개의 스코프를 갖는다. var로 선언한 변수는 함수 스코프를 갖기 때문에 반복문 부분이 스코프로 인지되지 않지만, letconst는 블록 스코프를 갖기 때문에 반복문 내의 변수가 외부 함수와 독립적이다(이 차이 또한 실행 컨텍스트에 구현되어 있다).

위의 상황에서 for 반복문이 실행될 때까지 실행 컨텍스트가 점유한 메모리와, 환경 레코드에서 관리되는 식별자 정보를 표현해보면 아래 이미지와 같다.

실제 점유되는 메모리 주소와 크기는 고려하지 않았습니다

가장 먼저 전역 실행 컨텍스트가 콜 스택에 생성된다. 코드를 평가하는 단계에서 식별자인 myNumouterFunc의 정보를 확인하고(이 과정이 호이스팅이다), 해당 식별자에 할당될 것으로 예상되는 데이터의 메모리 사이즈를 추론해서 콜 스택의 메모리 공간을 필요한 만큼 점유한다. 그다음 실제 코드가 실행될 때 식별자에 값을 할당하여 메모리에 초기화한다(var는 초기에 undefined로 초기화하지만, let은 실제 할당 시 초기화된다. 함수 선언문으로 선언된 outerFunc는 호이스팅 시 함수 객체가 바로 할당된다).

그다음, 전역에서 outerFunc()라는 함수 호출이 진행되면 그 다음 실행 컨텍스트가 콜 스택에 같은 과정으로 쌓인다. outerFunc에 포함된 식별자는 myNuminnerFunc이기 때문에, 두 식별자에 대한 메모리 사이즈를 추론해 콜 스택에 공간을 점유하고, 실제 코드 실행 시 값을 할당한다(함수 선언문으로 선언한 함수는 호이스팅 시 값이 같이 할당된다).

참고로, outerFunc 스코프 안에는 블록 스코프인 for 반복문이 있고, 그 안에서도 식별자 myNum이 선언되었다. var로 선언한 변수라면 함수 스코프를 적용받기 때문에 myNum이라는 변수가 재선언된 것으로 인식하고 동작할 것이다. 하지만 let은 블록 스코프의 적용을 받기 때문에, 함수 스코프의 식별자와 구분하기 위해 별도의 환경 레코드를 생성해 값을 구분해 저장한다. 블록 스코프는 외부 함수와 같은 실행 컨텍스트에 위치하지만 환경 레코드는 다르다는 것에 주의하자(이 차이를 VariableEnvironmentLexicalEnvirionment 컴포넌트에서 만들어낸다. 뒤에서 살펴보겠다).

다음, outerFunc 안에서 innerFunc()가 호출될 때 innerFunc의 실행 컨텍스트가 콜 스택에 생성되고, 동일한 과정으로 식별자 확인 및 값의 할당을 진행한다. 이제 innerFunc() 함수 안에서 실행한 console.log(myNum)이 실행되면서 해당 환경 레코드에서 값을 찾아 30이라는 값을 출력하고 해당 실행 컨텍스트는 종료된다(콜 스택에서 제거된다). 그다음, outerFunc에 있는 console.log(myNum) 명령이 실행돼 해당 환경 레코드에 저장된 20이라는 값을 저장하고(블록 스코프의 myNum은 반복문이 끝나는 시점에 이미 해제됐다) 실행 컨텍스트가 종료된다(콜 스택에서 제거된다). 마지막으로, 전역의 console.log(myNum)이 실행되어 해당 환경 레코드의 값 10이 출력되고 실행 컨텍스트가 종료된다(콜 스택에서 제거된다).

이렇게 환경 레코드는 스코프 단위로 구분해서 코드 평가 시 식별자 정보를 사전에 확인(호이스팅)하고, 실제 코드 실행 시 명령문에 따라 식별자에 할당된 값의 상태를 변경하거나 반환하는 등 관리한다. 표현 상 '스코프 단위로 구분해서'라고 작성하긴 했지만, 사실 환경 레코드가 스코프 간의 구분을 만들어내고 독립적인 식별자 활용을 가능하게 하는 '스코프 그 자체'라고 할 수 있다.

외부 렉시컬 환경 참조(OuterLexicalEnvironmentReference)

특정 스코프에서 참조한 값이 환경 레코드에 등록되어 있지 않다면 자바스크립트 엔진은 해당 값을 상위 스코프에서 찾는다. 아래 예시를 살펴보자.

let myNum = 0;

function myFunc() {
    // 1부터 10까지의 누적 합을 myNum에 저장
    for (let i = 1; i <= 10; i++) {
        myNum += i;
    }
}

myFunc();
console.log(myNum) // 55

myFunc의 렉시컬 환경에는 myNum이라는 식별자 선언이 없다. 따라서, 해당 렉시컬 환경의 환경 레코드에는 myNum에 대한 정보가 저장돼있지 않는 것이다. 하지만, 함수를 호출해서 실행하면 전역에 있는 myNum에 의도한 1부터 10까지의 누적 합이 잘 계산돼서 저장돼 있다. 서로의 렉시컬 환경이 구분되어 있음에도 불구하고 값을 상위 렉시컬 환경의 값을 참조하고 있는 것이다.

이러한 자바스크립트의 동작을 '스코프 체인(Scope Chain)'이라고 한다. 그리고, 스코프 체인을 구현하기 위해 렉시컬 환경에는 OuterLexicalEnvironmentReference라는 참조 정보가 존재한다.

OuterLexicalEnvironmentReference는 이름 그대로 현재의 렉시컬 환경의 외부(상위)에 존재하는 렉시컬 환경을 참조한다. 시각화해 보면 아래와 같다.

OuterLexicalEnvironmentReference는 상위 렉시컬 환경을 참조한다.

어떠한 식별자가 참조되면 자바스크립트 엔진은 가장 먼저 해당 식별자가 참조된 곳의 렉시컬 환경 - 환경 레코드를 확인한다. 환경 레코드에 선언돼 있는 식별자 중 참조된 식별자가 있는지 확인하고, 존재한다면 해당 값을 참조된 위치에 값으로 전달한다. 하지만, 해당 환경 레코드에 선언된 값이 없다면, 렉시컬 환경에 있는 OuterLexicalEnvironmentReference에 참조된 상위 렉시컬 환경으로 이동해 탐색을 이어간다.

만약에 상위 렉시컬 환경에 참조된 값의 선언이 있다면 해당 식별자에 할당된 값을 가지고 원래의 스코프로 돌아와 값을 반환한다. 만약에 상위 렉시컬 환경에도 식별자 선언이 없다면 해당 렉시컬 환경의 OuterLexicalEnvironmentReference을 따라 그 상위 렉시컬 환경으로 이동해 탐색을 진행한다. 해당 탐색의 종료 조건은 원하는 식별자를 상위 렉시컬 환경(환경 레코드)에서 발견했거나, 아니면 가장 꼭대기에 있는 렉시컬 환경까지 탐색을 완료해도 원하는 값을 찾지 못했을 때이다. 원하는 식별자를 발견했다면 해당 식별자의 참조를 반환하고, 그렇지 않다면 Reference Error가 발생한다.

참고로, 최상위 스코프는 언제나 전역이고, 전역 렉시컬 환경의 OuterLexicalEnvironmentReference 값은 null이기 때문에, 최상위 렉시컬 환경을 찾을 땐 OuterLexicalEnvironmentReference의 참조가 없는 경우를 조건으로 걸면 된다. 실제론 자바스크립트 엔진이 해당 과정을 처리해 주기 때문에 개발자가 따로 구현할 내용은 없다.

이렇게, 렉시컬 환경끼리 참조로 연결돼서 상위의 값을 하위 렉시컬 환경에서 참조할 수 있도록 구현되어 있는 것이 스코프 체인의 실체다. 사실, 환경 레코드가 그 자체로 스코프인 셈인 것처럼, OuterLexicalEnvironmentReference가 스코프 체인 그 자체인 셈이라고 할 수 있다.

VariableEnvironmentLexicalEnvironment 컴포넌트

지금까지 살펴본 렉시컬 환경은 VariableEnvironmentLexicalEnvironment 컴포넌트로 구성되어 있다고 했다. 이 둘은 동일하게 환경 레코드와 외부 렉시컬 환경에 대한 참조를 값으로 가지며, 차이는 VariableEnvironment는 처음 코드를 평가할 때 수집된 식별자와 외부 렉시컬 환경에 대한 정보를 스냅샷처럼 정적으로 저장하고 있고, LexicalEnvironment는 코드 실행에 따른 상태 변화를 계속 관리한다는 점이다.

사실, 이 둘의 차이와 필요를 이해하는 게 실행 컨텍스트를 이해하는 데 있어 가장 힘들었다. 많은 자료에서 둘의 차이를 분명하게 밝히고 있지 않기도 했고, 여러 자료에서 서로 다른 얘기를 하는 경우가 많았기 때문이다.

일단, 여러 자료에서 나온 둘의 차이로 위에 설명한 '식별자의 상태를 동적으로 지속 관리하는가'에 대한 부분이 두 컴포넌트의 차이로 많이 얘기되었다. 모던 자바스크립트나 코어 자바스크립트 같은 서적에서도 둘의 차이를 이 정도로 설명하고, 실제 둘의 세부 동작에 대해선 언급하지 않고 있다.

해당 내용 말고도 여러 자료에서 많이 나온 차이 중 하나가 'VariableEnvironmentvar로 선언한 식별자를 관리하고, LexicalEnvironmentletconst로 선언한 식별자를 관리한다'는 것이었다. 하지만, 이건 VariableEnvironment는 식별자의 상태를 바꾸지 않고 스냅샷으로 관리한다는 점과 맞지 않는 내용인 것 같아서 이해에 어려움이 있었다.

그러다, 해당 내용에 대한 단서를 ECMAScript 깃헙 레포 이슈에서 발견할 수 있었다. 아래는 'LexicalEnvrionmentVariableEnvironment 컴포넌트의 차이가 무엇인가요?'라는 질문에 대한 공식 답변이다.

요약해 보면, 두 컴포넌트는 각각 varlet(아마도 const도)에 적용되는 스코프를 관리하는 역할을 한다. var는 함수 스코프가 적용되고, let(과 const)은 블록 스코프가 적용되기 때문에, 이 둘의 차이를 구현하기 위해선 별도의 관리가 필요하다. 그래서, VariableEnvironment는 함수가 실행될 때 렉시컬 환경을 새로 만들고, 함수 내에 블록 스코프 영역이 있으면 LexicalEnvironment가 상위 렉시컬 환경을 상속받은 후 별도의 스코프가 적용되도록 관리한다는 것 같다.

VariableEnvironment가 최초의 식별자 정보를 수집해서 정적으로 저장해 두고, LexicalEnvironment가 이를 복제해서 식별자 상태를 관리한다는 설명과 연결해서 생각해 보면, 코드 흐름에 따른 렉시컬 환경 관리는 복제된 LexicalEnvironment에서 담당하지만, VariableEnvironment 컴포넌트는 var로 선언한 변수에 함수 스코프를 적용하는 데 관여하는 것 같다. 즉, 블록 스코프 내에 있는 식별자는 LexicalEnvironment 컴포넌트와 VariableEnvironment 컴포넌트에서 각각 별도로 관리되는 것이다.

사실, 해당 내용에 대해 아직도 100% 이해를 한 건 아닌 것 같다. 일단 이 정도로만 정리해 두고, 관련해서 이후에 추가로 공부해 봐야겠다.

this 바인딩

실행 컨텍스트는 지금까지 정리한 렉시컬 환경 정보 외에도, this 바인딩에 대한 정보를 저장한다. this는 함수 객체 내에 존재하는 값이며, 상황에 따라 객체 자기 자신, 생성자로 생성된 인스턴스, 또는 전역을 가리킨다. this가 가리키는 값이 어디인지는 함수가 호출되는 방식에 따라 동적으로 결정된다.

실행 컨텍스트는 전역, 또는 함수에서 생성되기 때문에, 실행 컨텍스트 역시 함수인 객체가 가지는 this라는 값을 가지게 된다. 그리고, 동적으로 지정된 this의 값이 실행 컨텍스트에 렉시컬 환경과 함께 저장된다.

아직 this가 무엇인지, 어떤 상황에서 어떻게 활용되는지는 충분히 공부되지 않은 상황이다. 그래서, 이 내용은 좀 더 심화해서 공부해 본 후 다음에 별도 포스트로 남겨보겠다.

결론

아직 실행 컨텍스트의 동작에 대해 100% 이해했다고 확신이 들진 않는다. 실행 컨텍스트는 자바스크립트의 핵심 개념을 설명하기 위한 배경이기 때문에, 좀 더 확실하게 이해할 수 있도록 다시 공부해야겠다. 특히, 클로저(Closure)라는 개념이 실행 컨텍스트와 밀접한 관련이 있기 때문에, 클로저에 대해 공부하면서 실행 컨텍스트에 대해 다시 한번 파봐야겠다.