
실행 컨텍스트란 무엇인가?
실행 컨텍스트(Execution Context)
는 자바스크립트 코드를 실행할 때 필요한 정보들을 저장하고 제공하는 환경이다. 즉 스코프의 정보를 담은 환경을 의미한다. 현재 실행되고 있는 컨텍스트에서 이 컨텍스트와 관련이 없는 코드가 실행된다면 새로운 컨텍스트가 생성되어 제어권을 가져간다. 그리고 이렇게 생성된 컨텍스트들은 실행 컨텍스트 스택 안에 쌓이게 된다. 각각의 컨텍스트는 실행이 종료된 이후 소멸되며, 소멸된 후 이전의 실행 컨텍스트에 제어권을 넘겨주게 된다. 전역 실행 컨텍스트까지 실행이 완료되면 모든 실행이 완료된 것으로 볼 수 있다.
console.log("global context");
function foo() {
console.log("foo context");
}
function bar() {
foo();
console.log("bar context");
}
bar();
// 출력결과
// global context
// foo context
// bar context
- 가장 먼저 전역 코드가 실행되어 전역 실행 컨텍스트가 제어권을 가져간다. 그 결과 ‘global context’가 출력된다.
- 전역 실행 컨텍스트에서
bar()
함수가 호출되어 새로운 컨텍스트가 생성되어 스택에 쌓인다. 그리고 이 컨텍스트가 제어권을 가져간다. 이 과정이foo()
함수 호출 시에도 반복된다. foo()
함수 안의 코드가 모두 실행되면 foo 실행 컨텍스트가 완료되어 소멸되며, 이후 bar 실행 컨텍스트도 완료되어 소멸된다. 최종적으로 전역 실행 컨텍스트까지 실행이 완료되어 모든 실행이 완료된다.
실행 컨텍스트의 구성 요소
실행 컨텍스트는 Lexical Environment(이하 LE로 표현)와 Variable Environment(이하 VE로 표현) 두 가지 컴포넌트로 구성된다. 그리고 이 컴포넌트들은 Environment Records(이하 ER로 표현)라 불리는 형태로 구성되어 있다.
2020년 12월 기준의 ECMAScript 명세에 나와 있는 실행 컨텍스트는 ES5와 ES2015의 실행 컨텍스트와 변경된 점이 있다.
- this 바인딩에 대한 정보는 ES5에서는 실행 컨텍스트에 별도로 저장되었지만, ES2015 이후부터는 ER에서 this에 대한 정보를 저장한다.
- 이전 ES2015 명세에서는 LE 하위의 구성요소로 ER과 상위 LE에 대한 참조 정보를 저장했다. 하지만 현재 명세에서는 ER에서 식별자, this 바인딩, 상위 ER에 대한 참조까지 저장한다.
- LE와 VE가 ER 자체를 바인딩하는 형태로 변경됐다. ECMAScript에서는 ‘실행 컨텍스트의 LE와 VE는 항상 ER이다’라고 표현하고 있다.
function foo() {
let a = 1;
const b = 2;
var c = 3;
console.log(a + b + c);
}
foo();
foo 실행 컨텍스트의 LE와 VE는 foo 함수 호출에 대한 다양한 정보를 담고 있다. 선언한 식별자와 this 바인딩 그리고 상위 스코프인 전역 실행 컨텍스트의 Global ER을 참조하는 OuterEnv라는 필드가 있다.
Environment Records
와 스코프 체인
ER은 렉시컬 스코프를 기반으로 특정 변수와 함수에 대한 식별자의 연결 정보를 저장한다. 쉽게 말해 코드가 실행되면 변수나 함수에 대한 식별자를 선언하고, 코드가 한 줄 한 줄 실행될 때마다 선언된 식별자에 값을 할당할 수 있도록 바인딩하는 것이다.
함수 선언, 블록문 또는 try catch 절과 같은 구문들이 평가될 때 식별자 바인딩을 위해 새로운 ER이 생성된다.
모든 ER에는 OuterEnv 필드가 있으며, 이 필드는 상위 렉시컬 스코프에 대한 ER을 참조한다. 예를 들어 함수 안에 함수가 정의되어 중첩된 스코프를 가지는 경우 상위 함수 스코프에 대한 ER을 참조한다. 최상위 실행 컨텍스트인 전역 컨텍스트에서는 참조할 스코프가 없기 때문에 OuterEnv 필드는 null이 된다.
function foo() {
var a = 1;
function bar() {
console.log(a); // 1
}
bar();
}
foo();
Bar ER은 OuterEnv 필드를 통해 상위 렉시컬 스코프에 대한 Foo ER을 참조할 수 있다. 만약 실행 컨텍스트에서 코드가 실행될 때 Bar ER에 a라는 식별자가 없다면 OuterEnv 필드를 통해 Foo ER에서 식별자 a를 찾을 수 있는 것이다. 앞에서 보았던 스코프 체인의 원리가 바로 이것이다. OuterEnv 필드를 통해 상위 스코프의 ER에 접근하여 식별자를 찾는 것이다. 그리고 이 과정은 식별자를 찾을 때까지 반복되며 찾는 즉시 중단된다.
스코프 체인이라는 말은 ES3 이후로 명세에서는 사용하지 않는 용어이다. Lexical Environment 개념이 도입된 이후, ECMAScript 명세에서는 스코프 체인이 아닌 lexical nesting structure라는 용어를 사용하고 있다.
다양한 Environment Records
ER은 추상 클래스로서 이를 세부적으로 구현하는 declarative Environment Record
, object Environment Record
, global Environment Record
세 가지의 하위 ER이 있다. 그리고 declarative Environment Record
하위에는 function Environment Record
와 module Environment Record
가 있다.
declarative Environment Record
변수, 상수, 클래스, 모듈 또는 함수 선언 등 렉시컬 스코프 내에 선언된 식별자들을 바인딩한다.
module Environment Record
: 모듈의 외부 스코프를 나타내는 정보를 추가적으로 바인딩한다. 다른 ER에 존재하는 식별자(정확히는 식별자의 바인딩)에 간접적으로 접근할 수 있는 가져오기(import) 바인딩을 제공한다.function Environment Record
: 함수 내의 최상위 위치(top-level)에 선언한 식별자들을 바인딩한다. 함수에서 사용되는 this값과 new.target(함수가 new 연산자를 사용하여 호출되었는지 알 수 있는 프로퍼티)에 대한 정보를 가진다. 그리고 화살표 함수가 아닌 경우에만 this를 바인딩하며, super로 상위 클래스에 대한 정보를 바인딩한다.
object Environment Record
binding object라는 객체의 프로퍼티들을 식별자로 바인딩한다. binding object 객체의 프로퍼티들은 side-effect에 의해 동적으로 변경될 수 있다. with문처럼 동적으로 스코프가 변경되는 경우 binding object를 이용하여 this값을 제공할 수 있다.
global Environment Record
최상위 전역 스코프에서 선언된 식별자와 전역 객체에 대한 바인딩을 한다. global ER은 특이하게도 declarative ER
과 object ER
을 합성한 형태이다. object ER은 내장 전역 객체와 var 변수, 함수 선언문으로 선언된 식별자들에 대한 바인딩을 binding object로 가지고 있으며, 이 binding object가 우리가 window로 접근하는 전역 객체이다. 그 외 나머지 식별자들에 대한 바인딩은 declarative ER에서 저장하며, 이를 우리가 전역 변수라고 부른다.
예를 들어 var a = 1;
로 선언한 변수는 전역 객체에 바인딩되어 window.a로 접근할 수 있지만, let b = 1;
로 선언한 변수는 declarative ER에 바인딩되기 때문에 window.b로 접근할 수 없다. let, const, class와 같은 새로운 구조는 렉시컬한 블록 스코프를 기준으로 동작한다. 따라서 이 스코프 범위를 벗어나는 전역 객체에 바인딩되지 않고 전역 변수에만 선언된다.
Lexical Environment와 Variable Environment
LE와 VE는 모두 ER에 바인딩된 정보를 찾을 수 있는 컴포넌트이다. 단, var 키워드로 선언된 변수의 바인딩은 VE를 통해 찾으며, 이외의 식별자들은 LE를 통해 찾는다.
function foo() {
const a = 1;
var b = 2;
console.log(a, b);
}
foo();
예제 코드가 실행될 때 LE와 VE의 구조는 아래처럼 표현할 수 있다.
{
LexicalEnvironment<FooER>: {
a: 1,
this: undefined,
OuterEnv: GlobalER
},
VariableEnvironment<FooER>: {
b: 2,
this: undefined,
OuterEnv: GlobalER
}
}
var 키워드로 선언된 b는 VE에 바인딩되며, let 키워드로 선언된 a는 LE에 바인딩된다. 또한 LE, VE는 Foo ER과 동일한 형태이기 때문에 this 바인딩과 상위 ER에 대한 참조 정보 또한 함께 담고 있다.
실행 컨텍스트의 생성 과정
function foo() {
var a;
function bar() {
let b;
b = 2;
console.log(a, b); // 1, 2
}
a = 1;
bar();
}
foo();
1단계 : 전역 컨텍스트 생성과 바인딩
전역 실행 컨텍스트가 생성되면 global ER이 생성된다. global ER은 this 바인딩과 OuterEnv 필드만 설정된 상태이며, 식별자에 대한 어떠한 바인딩도 이뤄지지 않은 상태이다. 그리고 LE와 VE가 생성된다.
이후 코드가 실행되며 Global ER에 foo()
함수 선언문을 바인딩한다.
2단계 : foo 실행 컨텍스트 생성과 바인딩
foo()
함수를 호출하여 새로운 foo 실행 컨텍스트가 되어 제어권을 가져간다. foo 실행 컨텍스트도 마찬가지로 LE와 VE로 구성된다. Foo ER은 OuterEnv로 상위 Global ER을 참조하고 있다. 이외에는 어떠한 식별자에 대한 바인딩도 없으며, ThisBinding도 초기화되지 않은 상태이다.
이후 식별자들과 this가 순차적으로 바인딩된다. 엄격 모드라면 this는 undefined로, 엄격 모드가 아니라면 전역 객체로 바인딩된다. this가 함수의 실행 컨텍스트에서 동적으로 바인딩되는 것이다.
예제 코드의 a는 undefined로 초기화되고, bar는 함수로 바인딩된다. var 키워드로 선언된 변수나 함수 선언문에서 호이스팅이 발생하는데, 실행 컨텍스트의 식별자 바인딩 과정이 바로 호이스팅의 원리이다. 선언과 초기화가 동시에 진행되는 a는 undefined로 바인딩과 동시에 초기화되며, 함수 선언문 역시 마찬가지이다. 다만, 함수 선언문은 초기화에서 더 나아가 값의 할당까지 이루어진다. 그리고 이 과정은 1단계 전역 컨텍스트의 foo()
함수 바인딩 과정에서도 동일하게 발생한다.
코드에서 할당 표현식이 실행되면 최종적으로 Foo ER의 식별자 a에 1이 할당된다.
3단계 : bar 실행 컨텍스트 생성과 바인딩
bar 실행 컨텍스트도 초기에는 ER에 식별자들이 바인딩되지 않은 상태이며, OuterEnv로 상위 스코프인 Foo ER을 참조하고 있다.
Bar ER에 식별자 b와 this에 대한 바인딩이 설정된다.
여기서 식별자 b는 앞에서 보았던 식별자 a와는 달리 초기화되지 않았다. 바인딩은 되었지만 초기화되지 않아 접근할 수 없는 상태이며, 선언문을 만나기 전까지 식별자 b는 Bar ER 내에서 초기화되지 않을 것이다. 즉 실행 컨텍스트에서 let과 const의 Temporal Dead Zone(TDZ) 구간이 생성되는 과정이다.
선언문을 만나면 Bar ER의 식별자 b에 2가 할당된다.
4단계 : console.log()
메서드 실행
bar()
함수 내에서 console.log()
메서드를 실행한다. 메서드를 실행하기 위해 식별자 a와 b를 bar 실행 컨텍스트에서 찾게 된다. 식별자 b는 Bar ER에 바인딩되어 있기 때문에 바로 찾을 수 있다.
하지만 식별자 a는 Bar ER에 없기 때문에 OuterEnv 필드를 통해 Foo ER에서 검색한다. 이 과정이 앞서 스코프 체인이라고 불렀던 lexical nesting structure를 통해 검색하는 과정이다.
Foo ER에서 식별자 a를 찾았기 때문에 OuterEnv 필드를 통한 검색이 종료된다. 이후 console.log()
메서드가 실행되어 1, 2가 출력되며, 모든 실행이 끝났기 때문에 bar 실행 컨텍스트, foo 실행 컨텍스트가 차례대로 소멸된다.
실행 컨텍스트와 클로저
function foo() {
var a = 1;
function bar() {
console.log(a); // 1
}
return bar;
}
const baz = foo();
baz(); // 1
foo()
함수의 실행이 끝난 후, 반환 값인 bar()
함수는 전역 변수 baz에서 참조하게 된다. 그리고 실행이 종료된 foo 실행 컨텍스트는 소멸된다. 하지만 전역 변수 baz가 참조하고 있는 bar()
함수는 렉시컬 스코프 규칙에 의해 여전히 foo()
함수의 스코프를 상위 스코프로 참조한다. 즉 bar()
함수에서 여전히 Foo ER을 참조하고 있기 때문에 foo 실행 컨텍스트가 종료되어도 Foo ER은 제거되지 않는 것이다. 그렇기 때문에 전역 실행 컨텍스트에서 baz()
함수를 호출하여도 Foo ER을 참조할 수 있는 것이다.
클로저는 매우 강력한 기능이지만 자신의 상위 ER을 계속 참조하고 있어 실행이 종료되어도 메모리에서 ER을 제거할 수 없다. 그렇게 때문에 불필요하게 클로저 사용을 남발하면 메모리에 부담을 줄 수 있다. 아무리 강력한 기술이어도 단점이 없는 기술은 없다. 클로저도 마찬가지로 메모리 낭비라는 단점이 있으므로 상황에 맞게 사용해야 한다.
< 참고 : https://nohack.tistory.com/35
https://medium.com/@g.smellyshovel/the-ecmascript-executable-code-and-execution-contexts-chapter-explained-fa6e098e230f
https://meetup.nhncloud.com/posts/86 >