15 min to read
JS의 근간. 실행 컨텍스트 ②
실행 컨텍스트 스택에서 렉시컬 스코프 예제까지
들어가며
2주 전 쯤에 글을 작성하였는데, 2주간 글을 제대로 쓰지 못했네요. 지난주에 독감에 걸려, 말 그대로 사람이 일시정지 되었답니다.
그래도 JS를 다른 언어에서는 보기 힘든, 호이스팅, 클로저 같은 특이한 점을 만들게 한, 실행 컨텍스트, 렉시컬 환경 등에 대한 설명은 이어나가야 한다 생각하고, 계획했던 분량 만큼 해볼 수 있도록 노력하겠습니다.
이번 글에서는 이전 글에 이어서, 실행 컨텍스트 스택과, 실제 JS 코드의 흐름을 따라가며, JS 내부에서 사용되는 실행 컨텍스트 스택과 렉시컬 환경의 도식에 대해서 살펴보고자 합니다.
실행 컨텍스트 스택
이전 글의 실행 컨텍스트의 역할 부분 에서 간단하게 다룬 바 있는데, ‘평가-실행’을 통해서 실행 컨텍스트가 생성되고, 프로그래밍의 흐름을 제어하기 위해서 스택 자료구조를 사용한다고 언급하였습니다. 간단한 내용이지만, 이번 글에서 다룰 부분의 근간이기 때문에, 간단한 도식과 함께 다시 살펴보겠습니다.
아래의 코드의 실행 흐름을 따라가보겠습니다.
const x = 1;
function foo() {
const y = 2;
function bar() {
const z = 3;
console.log(x + y + z);
}
bar();
}
foo();
- 전역 코드와 평가와 실행
- foo 함수 코드의 평가와 실행
- bar 함수 코드의 평가와 실행
- foo 함수 코드로 복귀
- 전역 코드로 복귀.
이것이 스택을 통해서 관리되는 모습을 열심히 그려봤습니다…
통상적인 JS 코드는 이러한 형식으로 동작하지만, ES6의 시대 이후로는 예외가 생겼습니다. generator
문법인데, 이 부분을 깊게 다루기에는 내용이 꽤나 있기도 하고, 이번 글에서 다룰 실행 맥락에 관한 내용들도 꽤나 많기 때문에, 관련한 ECMAScript 명세의 issue의 링크를 달아놓는 것으로 갈음하겠습니다. 관련 주제인 코루틴 등도 꽤나 깊은 주제인듯 합니다…
그래도 간단하게 해설을 하자면, generator 함수는, 일반적인 함수와 다르게 실행의 중단/재개가 가능하며, 일반적인 LIFO 흐름을 따라가지 않는다는 것으로 매우 러프한 요약을 할 수 있겠습니다.
렉시컬 환경
위의 도식에서 ‘실행 컨텍스트’ 라는 매우 추상적인 말로 넘어갔는데, 실제로 이 안에 무엇이 있는지 알아보고자 합니다.
실행 컨텍스트는 관련된 렉시컬 환경을 바인딩 하고 있는 객체입니다. 그러면 어찌보면 본체라고 할 수 있는 부분은 렉시컬 환경일 것인데, 이 렉시컬 환경이라는 것을 파헤쳐 보려 합니다.
렉시컬 환경이란, 특정 코드가 선언된 환경을 의미하는 객체라고 하는데, 조금 더 구체적으로 말하자면, 변수 등의 식별자, 그리고 그 식별자에 바인딩 된 값, 그리고 이전 포스트에서도 간단히 언급했던 스코프 체인을 위한 상위 스코프에 대한 참조를 담고 있는 객체입니다. 즉 렉시컬 환경이 스코프에 대한 정보를 담고 있는, JS의 코어라고 할 수 있는 부분이라 볼 수 있겠습니다.
렉시컬 환경의 내부 명세는 ECMAScript 버전이 갱신될 때 마다, 조금씩 달라져 왔지만 필드 이름이 바뀌거나, this
바인딩이 어디에 저장되는지, private
에 관한 명세가 추가되어 추가적인 필드가 생기는 등의, 상대적으로 사소한 변경사항이라 볼 수 있고, 객체 안에 식별자에 대한 정보가 저장되고, 외부 스코프와 연결된다는 전체적인 동작에 대해서는 제가 참고 하고 있는 책에서 다루는 ES6이나, ES2022 까지의 버전과 큰 차이가 없어 보이고, 다른 C계열 언어에서 보기 힘든 ‘호이스팅’ 현상, 그리고 ‘클로저’의 원리 등을 알아보는데에는 큰 문제가 없을듯 하여, ES6 기준으로 글을 마저 이어나가고자 합니다.
ES6를 기준으로, 실행 컨텍스트는 LexicalEnvironment
컴포넌트와 VariableEnvironment
컴포넌트로 구성 됩니다. 이 둘의 차이를 설명한 글이 있는데, 간단히 요약하자면, 처음에는 같은 내용을 바라보고 있지만, with
문이나 catch
문 등 임시 스코프를 형성하는데에 사용되지만, 그렇게 중요하지는 않다고 합니다. 혹여나 중요한 개념인데 놓치지 않았을까 하였지만, 나름 딥다이브를 표방하는 글이나, JS 딥다이브 책에서도 하나로 뭉뚱그려 표현하는 것을 보면, 이 부분은 크게 중요하지 않은 부분인듯 하고 넘겨도 될 듯 합니다.
그리고 LexicalEnvironment
컴포넌트와 VariableEnvironment
컴포넌트는 두개의 컴포넌트로 구성됩니다.
- 환경 레코드 : 스코프에 포함된 식별자를 등록하고, 등록한 식별자의 값을 관리하는 저장소.
- 외부 렉시컬 환경에 대한 참조 : 상위 스코프를 가리킨다. 이것을 통해서 전 포스팅에서 설명했던 스코프 체인이 구현된다.
실제 실행 컨텍스트의 생성과 코드 실행
다음 예제 코드를 이용해서 실제 실행 컨텍스트가 동작하는 것을 설명해 보고자 합니다.
var x = 1;
const y = 2;
function foo(a) {
var x = 3;
const y = 4;
function bar(b) {
const z = 5;
console.log(a + b + x + y + z);
}
bar(10);
}
foo(20);
전역 객체 생성
전역 코드를 평가하기 이전에, 빌트인 전역 프로퍼티(console
과 같은 것들), 전역 함수(setTimeOut
등), Web API 같은것을 포함합니다. 이를 Realm이라고도 하는데, 관련해서 잘 설명해 놓은 블로그 링크를 첨부하는 것으로 지면 절약을 하겠습니다.
전역 코드 평가
이전 글에서부터 지금까지, 실행 이전 ‘평가’를 한다라고 반복을 했는데, 실제 ‘평가’의 순서를 정리하자면 다음과 같습니다.
- 전역 실행 컨텍스트 생성
- 전역 렉시컬 환경 생성
- 전역 환경 레코드 생성
- 객체 환경 레코드 생성
- 선언적 환경 레코드 생성
- this 바인딩
- 외부 렉시컬 환경에 대한 참조 결정
- 전역 환경 레코드 생성
전역 실행 컨텍스트 생성
처음엔 실행 컨텍스트 스택이 비어있는 상태 입니다. 이 스택에 전역 실행 컨텍스트를 하나 만들어서 푸쉬합니다.
전역 렉시컬 환경 생성
컨텍스트의 내용을 실제로 채우는 단계입니다. 이 글의 서두에서 렉시컬 환경은 환경 레코드와 외부 환경으로의 참조로 이루어 진다고 하였습니다.
전역 환경 레코드는 단순하게 var
로 선언된 변수와 함수 선언문으로 선언된 객체 환경 레코드와, 선언적 환경 레코드로 나뉩니다.
이 단계에서는 이러한 환경 레코드가 할당될 전역 렉시컬 환경이 생성된다고 이해하면 될 것 같습니다.
전역 환경 레코드 생성
브라우저 환경에서는 window
, node 환경에서는 global
과 같은 BindingObject
에다가 var로 선언된 변수, 함수 선언문으로 선언된 전역 함수들을 프로퍼티로 부착하는 단계입니다. 이 단계를 거친 이후의 전역 환경 레코드와 객체 환경 레코드를 도식화하면 아래와 같게 됩니다.
이렇게 부착된 요소들은 실제로 window
와 같은 전역 객체에 속성과 메소드로 등록이 되기 때문에, window.x
등으로 실제로 접근이 가능합니다. 그래서 module
속성 없이 가져와진 스크립트들이 서로 다른 스크립트에서 선언된 var 변수를 공유하는것이 가능했던 이유이기도 합니다. window
객체는 하나 뿐이니까요.
유의할 점은, 이 때, x
와 같은 var로 선언된 변수는 undefined
로 등록되지만, 전역 함수 foo
는 함수 내용 전체가 등록됩니다. 이렇게 코드 평가 단계에서 전역 var
변수 등이 미리 등록되기 때문에, 초기화 전에 해당 변수들을 읽으면 undefined
를 읽게 되는 것이고, 이것이 호이스팅이라고 부르는 현상의 실체입니다.
여기서 한 걸음 더 나아가서, 왜 이러느냐를 묻는다면, 초기 JS는 일주일만에 만들어졌던 브라우저 이벤트 부착용 땜빵용 언어였고, 순환참조 문제를 해결하기 위해 함수를 실제로 정의하기 전에 호출하는 것 또한 필요했기 때문입니다. 참고로 C언어에서는 아래와 같은 방법으로 함수의 프로토타입을 먼저 작성함으로서 순환 참조 문제를 극복하면서 함수를 작성할 수 있습니다.
void B(); // 함수 프로토타입
void A() { B(); } // A에서 B 호출
void B() { /* B 구현 */ }
JS에서도 이러한 것을 가능하게 하기 위해서 당시 채택한 방법이 호이스팅 이었고, var의 호이스팅은 어디까지나 부수적인 것 이었습니다. 하지만 이것이 여러분이 잘 아시는 여러 문제점의 원인이 되었고, TDZ로 변수의 실제 초기화 이전에 접근을 막는 let
이나 const
로 변수를 선언하는 문법이 등장하게 됩니다.
선언적 환경 레코드 생성
아까 var
로 선언한 변수들과 함수 선언문으로 선언한 전역 함수들을 처리하였으니, 이제 전역 코드에 있는 나머지 것들 (let
이나 const
로 선언된 변수 안에 할당된 객체들(함수 포함))을 처리할 시간입니다.
여기서 y
는 const
키워드로 생성되었기 때문에, window
의 속성 등으로 등록 되지 않습니다. 그렇기 때문에 window.y
등으로 접근이 되지 않기에, 스코프 분리를 하는데에 이러한 ES6 변수 문법들이 도움을 준다 라고 하는 것입니다. 그리고 uninitialized
라는 값으로 초기화가 되어 있는데, 이는 실제 변수 초기화 이전에 추적이 어려운 undefined
대신에, ReferenceError
를 발생 시키게 하여, 에러 추적에 도움을 주는 부분이라 볼 수 있겠습니다.
this 바인딩
전역 환경 레코드의 this가 이때 바인딩 됩니다. window
가 들어가겠네요
외부 렉시컬 환경에 대한 참조 결정
상위 렉시컬 환경으로의 포인터를 결정하게 됩니다. 너무나도 당연하게도, 전역 렉시컬 환경 보다 상위 렉시컬 환경은 없기 때문에 null
이 할당되게 됩니다.
전역 코드 실행
평가가 완료되었으면, 이제 실행을 하게 됩니다. 변수 할당문이 실행되어, 전역 변수 x
, y
에 실제로 값이 할당 됩니다. 그리고 foo
함수 호출문을 만나서, foo
함수를 실행할 준비를 하게 됩니다.
foo
함수 코드 평가
foo
함수를 실행하기 직전의 상황입니다. 새로운 함수가 호출되었으니, 전역 코드의 실행을 중단하고, foo
함수 내부로 코드의 제어권이 이동합니다. 이 때, 전역 코드를 실행했던 것과 유사하게, 함수의 코드를 평가하게 됩니다. 순서는 다음과 같습니다.
- 함수 실행 컨텍스트 생성
- 함수 렉시컬 환경 생성
- 함수 환경 레코드 생성
- this 바인딩
- 외부 렉시컬 환경에 대한 참조 결정
이 작업이 완료된 모습을 도식화 하면 아래와 같습니다.
전역 코드 평가에서 설명했던 것과 유사합니다. 다만, 전역 환경과 다르게 함수의 환경 레코드는 var
로 선언된 친구들과, let
과 const
로 생성된 친구들이 별도의 레코드로 분리되지 않은것이 차이가 되겠고, 함수이기 때문에, 매개변수인 식별자와, arguments
식별자가 초기화 된다는 차이가 있겠네요.
외부 렉시컬 환경에 대한 참조 결정
전역 코드를 평가할 때에는, 더 상위가 없으니, null
로 평가되고 끝이었지만, 함수는 그렇지 않습니다. 상위 렉시컬 환경에 대한 링크가 결정되고, foo
함수에 대한 경우에는 전역 렉시컬 환경의 참조가 할당될 것입니다. 이것이 위의 그림에도 잘 나타나져 있습니다.
이전 포스트에서 JS는 함수의 ‘정의된 위치’에 따라 상위 스코프가 결정되는 렉시컬 스코프 방식을 택한다고 서술한 바 있다. 그렇기에 상위 스코프는 ‘자신이 호출된 스코프’ 라는 사실을 한번 더 리마인드 하고 설명을 이어나가겠다. 함수의 상위 스코프에 대한 정보의 저장 위치는, JS 딥 다이브 책에서는 [[Environment]]
라는 내부 슬롯에 저장된다라고 하고, 이는 렉시컬 환경에 대한 도식에 나오지 않는데, 이 부분에 대한 것을 찾아보니, 비슷한 질문의 stack overflow 글을 찾을 수 있었습니다. 한줄로 요약한다면, 코드 평가 때 함수 오브젝트의 [[Environment]]
내부 필드에 상위 스코프에 관한 참조가 할당되고, 실행 이전에 함수 환경 에서 외부 렉시컬 환경 참조를 등록하게 된다 라고 이해 했습니다. 함수 객체의 내부 슬롯이기 때문에, 실행 맥락에 관한 도표에 표시가 되지 않는것도 이해도 되네요.
해당 내용이 JS의 클로저에 대한 이해를 하는데에 중요한 부분을 차지하기에 여기까지의 과정을 JS로 무언가를 한다면 내재화 해야할 지식 중 하나지 않을까 하고 감히 생각해봅니다.
foo
함수 코드 실행
런타임이 시작되어서 소스코드가 실제로 실행되기 시작한다. 환경 레코드에 등록된 식별자들에 실제로 값이 들어가게 되고, 식별자를 만났을 때, 현재 실행중인 스코프부터, 순차적으로 식별자를 검색할 것입니다.
var x = 1;
const y = 2;
function foo(a) {
var x = 3;
const y = 4;
function bar(b) {
const z = 5;
console.log(a + b + x + y + z);
}
bar(10);
}
foo(20);
이 코드에서는 x
, y
에 값을 할당하는 것, 그리고 bar 함수를 호출하는 것 까지 실행중인 foo 함수의 스코프 내에서 해결할 수 있기 때문에, 별도의 스코프 체인을 타고 올라가지는 않을 것 입니다.
bar(10) 이라는, 새로운 함수 호출 구문을 만났기 때문에, 다시 bar 함수의 코드를 평가하는 과정이 진행이 될 것 입니다.
bar
함수 코드 평가
bar 내부로 제어권이 이동하고, foo 함수의 코드를 평가했던 것 처럼 bar의 코드 역시 평가 될 것 입니다.
함수 코드 평가의 과정은 foo 함수에 대해서 설명했던것과 같기 때문에, 코드 평가가 완료된 도식으로 최대한 짧게 줄여 보겠습니다.
bar
함수 코드 실행
여기에서도 foo 함수와 같이 매개변수에 값이 할당되고, 지역변수 z에 값이 할당 됩니다. 그 이후 console.log(a + b + x + y + z)
가 실행되는데, 이 과정이 스코프 체이닝을 통한 식별자 결정을 잘 보여주는 예시라고 생각해서, 책에서도 소개한 듯 합니다.
1. console 식별자 검색
우선 실행중인 bar
함수 컨텍스트에서 console
식별자를 찾아봅니다. 없네요.외부 렉시컬 환경참조를 따라서 foo 함수 컨텍스트에 가서 확인해봐도 없습니다. 전역 렉시컬 환경에 가보니 처음 realm 이라는 전역 객체를 만들면서, 그 때 들어간 console
이라는 식별자를 찾았습니다!
2. log
메서드 검색
console
이라는 식별자에 바인딩된 객체, 즉 console
객체에서 log
메서드를 검색합니다. 이때 프로토타입 체인을 통해서 찾아 나가는데, log
메서드는 console
객체에서 직접 소유하고 있는 프로퍼티라서 더 올라갈 일은 없습니다.
3. a + b + x + y + z의 평가
a,b,x,y,z라는 식별자들을 스코프체인에서 찾는다. 현재 실행중인 렉시컬 환경에서 시작하여 외부 렉시컬 환경으로 이어지는 연속체에서 찾는 것이다. 찾은 결과를 도식에서 표현하면 다음과 같다. 푸른 색으로 강조표시 하였습니다.
4. console.log 메소드 호출
표현식 a+b+x+y+z를 20 + 10 + 3 + 4 + 5 즉, 42라고 알아냈으므로, 이 값을 console.log 메소드로 넘겨서 호출 합니다.
함수 실행 종료
bar 함수가 console.log를 완료하고, 실행 컨텍스트에서 bar 함수에 대한 컨텍스트가 팝되고, 실행중인 컨텍스트가 foo 함수가 될 것 입니다. 이때 유의 할 점은, bar 함수 렉시컬 환경은 바로 사라지지 않았다 라는 것 입니다. 렉시컬 환경은 실행 컨텍스에 의해 참조되긴 하지만, 독립적인 객체고, 렉시컬 환경의 소멸은 아무도 참조하고 있지 않을 때, 가비지 컬렉터가 할 일 이라는 점에 유의합시다.
이 성질이 JS에서 클로저가 동작할 수 있는 이유 중 하나가 되니, ‘내재화’ 시킬 것 중 하나라고 생각 합니다.
블록 레벨 스코프와 실행 컨텍스트
함수를 호출 할 때에는 실행 컨텍스트 스택에 새로운 실행 컨텍스트가 등록되어, 새로운 렉시컬 환경을 참조한다라는것을 학습 하였습니다. 그러면 아래와 같은 코드가 실행될 때, 실행 컨텍스트는 어떻게 관리가 될까요?
let x = 1;
if (true) {
let x = 10;
console.log(x); // 10
}
console.log(x); // 1
let
, const
는 블록 레벨 스코프를 가지고 있기 때문에 이렇게 같은 식별자 이름을 사용하더라도, 스코프에 알맞는 값을 읽어옴을 확인할 수 있습니다.
JS에서 이러한 블록 레벨 스코프를 만났을 때의 상황을 우선 글로 표현하자면 다음과 같습니다.
- 코드 블록이 실행되면 코드 블록을 위한 블록 레벨 스코프를 생성한다
- 이를 위해 새로운 선언적 환경 레코드를 갖는 새로운 렉시컬 환경 스코프를 생성하여 기존의 스코프를 교체
- 실행이 완료되면 이전의 렉시컬 환경으로 되돌리기
도식으로 나타내면 이러할 것이다.
우선 처음 블록 문이 실행되기 전의 실행 컨텍스트 스택 + 렉시컬 환경
블록 문에 진입해서 블록 렉시컬 환경이 생성 되었습니다. 이때 실행 컨텍스트는 따로 생성되지 않는다는 점에 유의!
그리고 실행이 종료되면, 전역 실행 컨텍스트의 렉시컬 환경에 대한 참조만 바뀌는 것을 확인 할 수 있습니다.
이렇게 미싱 링크처럼 덩그러니 남겨진 환경은, 다른 어디에서도 참조하지 않는다면, 가비지 컬렉터가 원할한 메모리 사용을 위해서 정리 할 것 입니다.
for문, 블록 스코프, 그리고 클로저
if문 뿐만 아니라, 모든 블록에 이것이 적용 됩니다. for문의 변수 선언문에 let 키워드를 사용했을 때, 코드 블록이 반복해서 실행될 때 마다, 코드 블록을 위한 새로운 렉시컬 환경을 생성합니다. 그래서 만약 for문의 코드 블록 내에서 정의된 함수가 있다면, 이 함수의 상위 스코프는 for문의 코드 블록이 생성한 렉시컬 환경이 될 것이고, 이것이 var로 선언된 반복문과 함수를 썼을 때, 직관적이지 않은 결과를 불러올 수 있습니다.
이때 함수의 상위 스코프는 for문의 코드 블록이 반복해서 실행될 때 마다 식별자(for 문의 변수 선언문 및 for 문의 코드 블록 내에서 선언된 지역 변수 등)의 값을 유지해야 합니다. 이를 위해 for문의 코드 블록이 반복해서 실행될 때마다 독립적인 렉시컬 환경을 생성해 식별자의 값을 유지합니다. 이 부분과 관련해서는 다음에 JS의 클로저에 관한 글을 적을 때 더 논의해보고자 합니다.
마치며
JS를 수박 겉핥기 식으로 공부했던 저를 반성하면서, 지식을 제대로 ‘체화’ 하는데에 계속 노력을 해보려 합니다. 이렇게 공개된 글을 작성하면서 제 생각도 한번 더 정리하고, 다시 관련된 지식을 공부하게 되면, ‘예전에 공부했던 것’과 조금씩 겹쳐지면서 학습 효과가 좀 더 좋아지지 않을까 하는 생각도 해봅니다.
끝까지 읽어주셔서 감사하며, 혹시나 ECMAScript 명세와 맞지 않는 등의 문제가 있다면 댓글로 지적하여 주시면 감사하겠습니다!
참고 문헌
- 모던 자바스크립트 딥 다이브 (이웅모 저)
- 23장 실행 스코프
- Realm 관련 내용