실행 컨텍스트 톺아보기: 실행 컨텍스트 구성 요소
실행 컨텍스트의 구성 요소
실행 컨텍스트는 자바스크립트 코드가 동작을 관리하고, 코드 실행을 위해 필요한 환경(스코프, this 등)과 데이터 상태 정보를 관리하는 자바스크립트 엔진의 동작 메커니즘이다. 실행 컨텍스트는 자바스크립트의 동작을 관리하는 핵심적인 역할을 하며 스코프 체인, 호이스팅, this, 변수 관리(메모리 관리), 클로저 등 중요한 개념을 이해하는 배경이 된다.
실행 컨텍스트는 작성된 코드 평가 시 생성되며, 콜 스택에 선입선출(FIFO; First In, First Out) 방식으로 공간을 할당하여 스코프를 관리한다. 실행 컨텍스트의 메모리 점유 방식에 대해선 포스트에 기록해뒀다.
이번 포스트에선 실행 컨텍스트의 구성 요소들에 대해 조금 더 알아보려고 한다. 먼저, 실행 컨텍스트는 렉시컬 환경(Lexical Environment)과 this 바인딩(this bindind) 정보로 구성되며, 렉시컬 환경은 VariableEnvironment
(변수 환경)와 LexicalEnvironment
(렉시컬 환경)라는 두 가지 컴포넌트로 구성된다. 참고로, 렉시컬 환경(Lexical Environment)과 LexicalEnvironement
컴포넌트는 서로 다른 개념이다. 전자가 후자를 포함하는 관계다. 둘의 구분을 위해, 컴포넌트를 지칭할 때에만 영문 표기 LexicalEnvironment
를 쓰겠다.
VariableEnvironment
와 LexicalEnvironment
는 각각 EnvironmentRecord
(환경 레코드)와 OuterLexicalEnvironmentReference
(외부 렉시컬 환경 참조) 정보를 갖고 있으며, VariableEnvironment는 실행 컨텍스트 내의 식별자와 외부 참조 정보의 초기 값을 스냅샷(Snapshot) 형태로 관리하고, LexicalEnvironment는 VariableEnvironment의 초기값을 복제한 후 실제 코드 런타임 시 변경 사항들을 반영한다.
실행 컨텍스트의 각 요소들이 어떻게 동작하는지를 정확하게 이해하면 자바스크립트의 핵심적인 여러 개념에 대한 이해가 깊어진다. 하나씩 차근차근 살펴보겠다.
렉시컬 환경(Lexical Environment)
렉시컬 환경은 VariableEnvironment
와 LexicalEnvironment
두 컴포넌트로 구성된다. 관리하는 상태가 정적인지(스냅샷), 동적인지의 차이만 있지, 두 컴포넌트에서 환경 레코드의 코어한 동작은 유사하다. 그래서, 환경 레코드에 대해 살펴볼 땐 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
로 선언한 변수는 함수 스코프를 갖기 때문에 반복문 부분이 스코프로 인지되지 않지만, let
과 const
는 블록 스코프를 갖기 때문에 반복문 내의 변수가 외부 함수와 독립적이다(이 차이 또한 실행 컨텍스트에 구현되어 있다).
위의 상황에서 for
반복문이 실행될 때까지 실행 컨텍스트가 점유한 메모리와, 환경 레코드에서 관리되는 식별자 정보를 표현해보면 아래 이미지와 같다.
가장 먼저 전역 실행 컨텍스트가 콜 스택에 생성된다. 코드를 평가하는 단계에서 식별자인 myNum
과 outerFunc
의 정보를 확인하고(이 과정이 호이스팅이다), 해당 식별자에 할당될 것으로 예상되는 데이터의 메모리 사이즈를 추론해서 콜 스택의 메모리 공간을 필요한 만큼 점유한다. 그다음 실제 코드가 실행될 때 식별자에 값을 할당하여 메모리에 초기화한다(var
는 초기에 undefined
로 초기화하지만, let
은 실제 할당 시 초기화된다. 함수 선언문으로 선언된 outerFunc
는 호이스팅 시 함수 객체가 바로 할당된다).
그다음, 전역에서 outerFunc()
라는 함수 호출이 진행되면 그 다음 실행 컨텍스트가 콜 스택에 같은 과정으로 쌓인다. outerFunc
에 포함된 식별자는 myNum
과 innerFunc
이기 때문에, 두 식별자에 대한 메모리 사이즈를 추론해 콜 스택에 공간을 점유하고, 실제 코드 실행 시 값을 할당한다(함수 선언문으로 선언한 함수는 호이스팅 시 값이 같이 할당된다).
참고로, outerFunc
스코프 안에는 블록 스코프인 for
반복문이 있고, 그 안에서도 식별자 myNum
이 선언되었다. var
로 선언한 변수라면 함수 스코프를 적용받기 때문에 myNum
이라는 변수가 재선언된 것으로 인식하고 동작할 것이다. 하지만 let
은 블록 스코프의 적용을 받기 때문에, 함수 스코프의 식별자와 구분하기 위해 별도의 환경 레코드를 생성해 값을 구분해 저장한다. 블록 스코프는 외부 함수와 같은 실행 컨텍스트에 위치하지만 환경 레코드는 다르다는 것에 주의하자(이 차이를 VariableEnvironment
와 LexicalEnvirionment
컴포넌트에서 만들어낸다. 뒤에서 살펴보겠다).
다음, 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
을 따라 그 상위 렉시컬 환경으로 이동해 탐색을 진행한다. 해당 탐색의 종료 조건은 원하는 식별자를 상위 렉시컬 환경(환경 레코드)에서 발견했거나, 아니면 가장 꼭대기에 있는 렉시컬 환경까지 탐색을 완료해도 원하는 값을 찾지 못했을 때이다. 원하는 식별자를 발견했다면 해당 식별자의 참조를 반환하고, 그렇지 않다면 Reference Error가 발생한다.
참고로, 최상위 스코프는 언제나 전역이고, 전역 렉시컬 환경의 OuterLexicalEnvironmentReference
값은 null
이기 때문에, 최상위 렉시컬 환경을 찾을 땐 OuterLexicalEnvironmentReference
의 참조가 없는 경우를 조건으로 걸면 된다. 실제론 자바스크립트 엔진이 해당 과정을 처리해 주기 때문에 개발자가 따로 구현할 내용은 없다.
이렇게, 렉시컬 환경끼리 참조로 연결돼서 상위의 값을 하위 렉시컬 환경에서 참조할 수 있도록 구현되어 있는 것이 스코프 체인의 실체다. 사실, 환경 레코드가 그 자체로 스코프인 셈인 것처럼, OuterLexicalEnvironmentReference
가 스코프 체인 그 자체인 셈이라고 할 수 있다.
VariableEnvironment
와 LexicalEnvironment
컴포넌트
지금까지 살펴본 렉시컬 환경은 VariableEnvironment
와 LexicalEnvironment
컴포넌트로 구성되어 있다고 했다. 이 둘은 동일하게 환경 레코드와 외부 렉시컬 환경에 대한 참조를 값으로 가지며, 차이는 VariableEnvironment
는 처음 코드를 평가할 때 수집된 식별자와 외부 렉시컬 환경에 대한 정보를 스냅샷처럼 정적으로 저장하고 있고, LexicalEnvironment
는 코드 실행에 따른 상태 변화를 계속 관리한다는 점이다.
사실, 이 둘의 차이와 필요를 이해하는 게 실행 컨텍스트를 이해하는 데 있어 가장 힘들었다. 많은 자료에서 둘의 차이를 분명하게 밝히고 있지 않기도 했고, 여러 자료에서 서로 다른 얘기를 하는 경우가 많았기 때문이다.
일단, 여러 자료에서 나온 둘의 차이로 위에 설명한 '식별자의 상태를 동적으로 지속 관리하는가'에 대한 부분이 두 컴포넌트의 차이로 많이 얘기되었다. 모던 자바스크립트나 코어 자바스크립트 같은 서적에서도 둘의 차이를 이 정도로 설명하고, 실제 둘의 세부 동작에 대해선 언급하지 않고 있다.
해당 내용 말고도 여러 자료에서 많이 나온 차이 중 하나가 'VariableEnvironment
는 var
로 선언한 식별자를 관리하고, LexicalEnvironment
는 let
과 const
로 선언한 식별자를 관리한다'는 것이었다. 하지만, 이건 VariableEnvironment
는 식별자의 상태를 바꾸지 않고 스냅샷으로 관리한다는 점과 맞지 않는 내용인 것 같아서 이해에 어려움이 있었다.
그러다, 해당 내용에 대한 단서를 ECMAScript 깃헙 레포 이슈에서 발견할 수 있었다. 아래는 'LexicalEnvrionment
와 VariableEnvironment
컴포넌트의 차이가 무엇인가요?'라는 질문에 대한 공식 답변이다.
요약해 보면, 두 컴포넌트는 각각 var
와 let
(아마도 const
도)에 적용되는 스코프를 관리하는 역할을 한다. var
는 함수 스코프가 적용되고, let
(과 const
)은 블록 스코프가 적용되기 때문에, 이 둘의 차이를 구현하기 위해선 별도의 관리가 필요하다. 그래서, VariableEnvironment
는 함수가 실행될 때 렉시컬 환경을 새로 만들고, 함수 내에 블록 스코프 영역이 있으면 LexicalEnvironment
가 상위 렉시컬 환경을 상속받은 후 별도의 스코프가 적용되도록 관리한다는 것 같다.
VariableEnvironment
가 최초의 식별자 정보를 수집해서 정적으로 저장해 두고, LexicalEnvironment
가 이를 복제해서 식별자 상태를 관리한다는 설명과 연결해서 생각해 보면, 코드 흐름에 따른 렉시컬 환경 관리는 복제된 LexicalEnvironment
에서 담당하지만, VariableEnvironment
컴포넌트는 var
로 선언한 변수에 함수 스코프를 적용하는 데 관여하는 것 같다. 즉, 블록 스코프 내에 있는 식별자는 LexicalEnvironment
컴포넌트와 VariableEnvironment
컴포넌트에서 각각 별도로 관리되는 것이다.
사실, 해당 내용에 대해 아직도 100% 이해를 한 건 아닌 것 같다. 일단 이 정도로만 정리해 두고, 관련해서 이후에 추가로 공부해 봐야겠다.
this
바인딩
실행 컨텍스트는 지금까지 정리한 렉시컬 환경 정보 외에도, this
바인딩에 대한 정보를 저장한다. this
는 함수 객체 내에 존재하는 값이며, 상황에 따라 객체 자기 자신, 생성자로 생성된 인스턴스, 또는 전역을 가리킨다. this
가 가리키는 값이 어디인지는 함수가 호출되는 방식에 따라 동적으로 결정된다.
실행 컨텍스트는 전역, 또는 함수에서 생성되기 때문에, 실행 컨텍스트 역시 함수인 객체가 가지는 this
라는 값을 가지게 된다. 그리고, 동적으로 지정된 this
의 값이 실행 컨텍스트에 렉시컬 환경과 함께 저장된다.
아직 this
가 무엇인지, 어떤 상황에서 어떻게 활용되는지는 충분히 공부되지 않은 상황이다. 그래서, 이 내용은 좀 더 심화해서 공부해 본 후 다음에 별도 포스트로 남겨보겠다.
결론
아직 실행 컨텍스트의 동작에 대해 100% 이해했다고 확신이 들진 않는다. 실행 컨텍스트는 자바스크립트의 핵심 개념을 설명하기 위한 배경이기 때문에, 좀 더 확실하게 이해할 수 있도록 다시 공부해야겠다. 특히, 클로저(Closure)라는 개념이 실행 컨텍스트와 밀접한 관련이 있기 때문에, 클로저에 대해 공부하면서 실행 컨텍스트에 대해 다시 한번 파봐야겠다.