챕터 목표:
실행 컨텍스트를 정확히 이해하여 호이스팅, 스코프, this 등 자바스크립트 이해도를 높이고, 개발자로서 실력을 향상한다.
실행 컨텍스트는 자바스크립트의 동적 언어로서의 성격을 가장 잘 파악할 수 있는 개념이다. 자바스크립트에서는 어떤 실행 컨텍스트가 활성화되는 시점에 호이스팅(선언된 변수를 위로 끌어올리는 개념)이 이루어지고, 외부의 환경 정보가 구성되고, this 값이 설정된다.
실행 컨텍스트는 스택과 관련이 있고, 이벤트는 큐와 관련이 있다.
우선 스택과 큐를 설명하자면 다음과 같다. (출처: 냉정과 열정 사이 블로그)
(왼쪽: Stack, 오른쪽: Queue)
Stack은 입력과 반대되는 순서로 출력된다. 반면, Queue는 입력과 같은 순서로 출력된다.
Stack에 stack의 크기보다 더 많은 데이터가 들어가려고 하면 Stack overflow 현상이 발생하면서 아래와 같은 에러가 발생한다.
자바스크립트는 동일한 환경에 있는 코드를 실행할 때 필요한 환경 정보들을 모아 컨텍스트를 구성한다. 또한, 이 컨텍스트를 call stack에 쌓아올렸다가 가장 위에 쌓여있는 컨텍스트와 관련 있는 코드를 실행하는 식으로 전체 코드의 환경과 순서를 보장한다.
하나의 실행 컨텍스트(동일한 환경)를 구성할 수 있는 방법은 전역공간, eval() 함수, 함수 등이 있다. 전역공간은 자동 생성되는 것으로, 개발자는 함수를 실행하거나 블록 {}
을 생성하여 의도적으로 컨텍스트를 구성할 수 있다.
다음의 예제를 통해 실행 컨텍스트와 콜 스택의 연관된 흐름에 대해 살펴보자.
var a = 1;
function outer() {
function inner() {
console.log(a); // undefined
var a = 3;
}
inner();
console.log(a); // 1
}
outer();
console.log(a); // 1
(입력 순서)
1. 위의 자바스크립트 파일이 열리는 순간, 전역 컨텍스트가 활성화된다. 즉, 전역 컨텍스트가 콜스택에 담긴다.
2. outer 함수가 호출되면, 자바스크립트 엔진은 outer에 대한 환경 정보를 수집해서 outer 실행 컨텍스트를 생성한 후 콜 스택에 담는다.
3. outer가 콜 스택의 맨 위에 있으므로, 전역 컨텍스트와 관련된 코드의 실행은 일시중단되고 outer 함수 내부의 코드들이 순차적으로 실행된다.
4. inner 함수가 호출되면, inner 함수의 실행 컨텍스트가 콜 스택의 가장 위에 담긴다.
5. 3번과 동일하게 outer 컨텍스트 관련 코드 실행이 중단되고, inner 함수 내부 코드가 순서대로 실행된다.
(출력 순서)
6. inner 함수 내부의 코드가 모두 실행되면, inner 함수 실행이 종료되면서 inner 실행 컨텍스트가 콜 스택에서 제거된다.
7. 이제 콜 스택의 가장 상단에는 outer 함수가 존재한다. 4번에서 inner 함수가 호출됨과 동시에 중단되었던 outer 함수가, 중단된 시점부터 다시 실행된다.
8. 동일한 순서로 전역 공간의 코드 실행도 모두 종료되면, 전역 컨텍스트가 제거되고 콜 스택에는 아무것도 남지 않은 상태로 종료된다.
위의 순서를 그림으로 표현하자면 다음과 같다.
즉, "한 실행 컨텍스트가 콜 스택의 맨 위에 쌓이는 순간 = 현재 실행할 코드에 관여하게 되는 시점" 이다.
이와 같이 어떤 실행 컨텍스트가 활성화될 때 자바스크립트 엔진은 해당 컨텍스트에 관련된 코드들을 실행하는 데 필요한 환경 정보들을 수집해서 실행 컨텍스트 객체에 저장한다. 이 객체는 자바스크립트 엔진이 활용할 목적으로 생성되며, 개발자가 코드로 확인할 수 없다.
활성화된 실행 컨텍스트의 수집 정보는 다음과 같다.
"변수 환경"
VariableEnvironment & LexicalEnvironment 공통점:
담기는 내용이 동일하다.
VariableEnvironment & LexicalEnvironment 차이점:
VariableEnvironment는 최초 실행 시의 스냅샷을 유지하고, LexicalEnvironment는 변경 사항을 실시간으로 반영한다.
실행 컨텍스트를 생성할 때 VariableEnvironment에 정보를 먼저 담고, 이를 그대로 복사하여 LexicalEnvironment를 만든다.
이후 코드 진행에 따라 두 환경의 정보는 서로 달라지기 때문에, 개발자는 최신 변경사항이 반영되는 LexicalEnvironment를 주로 활용하게 된다.
컨텍스트를 구성하는 환경 정보들을 사전에서 접하는 느낌으로 모아놓은 것.
"어휘적 환경", "정적 환경", "사전적 환경", "사전적인"
environmentRecord에는 현재 컨텍스트와 관련된 코드의 식별자 정보(매개변수의 이름, 함수 선언, 변수명 등)들이 저장된다. 컨텍스트 내부 전체를 처음부터 끝까지 쭉 훑어나가며 순서대로 수집한다.
전역 실행 컨텍스트는 변수 객체를 생성하는 대신 자바스크립트 구동 환경이 별도로 제공하는 객체 = 전역 객체(global object)를 활용한다. 전역 객체에는 브라우저의 window, Node.js의 global 객체 등이 있다. window와 global 객체는 자바스크립트 내장 객체(native object)가 아닌 호스트 객체(host object)로 분류된다.
이처럼 변수 정보를 수집하는 과정을 통해 코드가 실행되기 전에 이미 자바스크립트 엔진은 해당 환경에 속한 변수명을 모두 인식하고 있다.
바로 여기서 호이스팅 개념이 나타난다. 자바스크립트 엔진이 식별자들을 최상단으로 끌어올려놓은 다음 실제 코드를 실행하는 것처럼 움직이기 때문에, 이러한 변수 정보를 수집하는 과정을 호이스팅 개념으로 이해할 수 있다.
function a (x) {
console.log(x);
var x;
console.log(x);
var x = 2;
console.log(x);
}
a(1);
위의 예제를 통해 호이스팅 처리의 흐름을 살펴보자.
인자와 함께 함수를 호출하면 코드 내부에서 가장 먼저 선언 및 할당이 이루어진 것과 동일하게 동작한다.
호이스팅을 고려하지 않은 채 위의 코드를 본다면, 콘솔에 찍히는 결과가 1
, undefined
, 2
라고 예상할 수 있다.
그러나 실제 연산이 이루어질 때의 컴퓨터는 호이스팅으로 인해 위의 코드를 다음과 같이 인식한다.
function a () {
var x;
var x;
var x;
x = 1;
console.log(x);
console.log(x);
x = 2;
console.log(x);
}
a(1);
따라서, 실제 콘솔에 찍히는 결과는 다음과 같다.
1
, 1
, 2
function a () {
console.log(b);
var b = 'bbb';
console.log(b);
function b () {}
console.log(b);
}
a();
함수 선언이 들어있는 예제를 하나 더 살펴보자.
변수는 선언부와 할당부를 나누어 선언부만 끌어올린다. 반면, 함수는 전체를 호이스팅한다.
따라서 위의 코드를 컴퓨터는 아래와 같이 인식한다.
function a () {
var b;
function b () {}
console.log(b);
b = 'bbb';
console.log(b);
console.log(b);
}
a();
호이스팅이 이루어진 코드 흐름에서는 선언한 변수 b에 함수를 할당한 것처럼 여겨진다. 이에 기존 예상과 달리 결과는 b 함수
, 'bbb'
, 'bbb'
가 출력된다.
함수 선언문: function 정의부를 이용한 함수 정의 방법. 함수명이 곧 변수명이며, 반드시 함수명이 정의돼 있어야 한다.
function a () {} // 함수명 a가 곧 변수명이다.
a();
함수 표현식: 정의한 function을 별도의 변수에 할당하는 함수 정의 방법. 익명 함수 표현식, 기명 함수 표현식 모두 가능하다.
//익명 함수 표현식
var b = function () {} // 변수명 b가 곧 함수명
b();
//기명 함수 표현식
var c = function d () {} // 변수명은 c, 함수명은 d이다.
c(); // (실행된다)
d(); // error!
기명 함수 표현식에서 함수명으로 함수를 호출하려 했더니, error가 확인되었다. '함수명으로 하는 함수 호출'은 함수 외부에서 불가능하고, 함수 내부에서만 가능하기 때문이다.
따라서 함수 내부에서 재귀함수를 호출하는 용도로 함수명을 사용할 수 있으나, 변수명으로 함수를 호출할 수 있기 때문에 사실상 함수명은 크게 필요하지 않다.
함수 선언문과 함수 표현식은 호이스팅과 처리에서 큰 차이를 보인다.
예제)
console.log(sum(1, 2));
console.log(multiply(3, 4));
function sum (a, b) {
return a + b;
}
const multiply = function (a, b) {
return a * b;
}
호이스팅 된 위의 예제)
// 함수 선언문은 전체가 호이스팅된다.
var sum = function sum (a, b) {
return a + b;
}
// 변수는 선언부만 호이스팅된다.
const multiply;
console.log(sum(1, 2));
console.log(multiply(3, 4));
// 변수의 할당부는 원래 자리에 남겨져있다.
multiply = function (a, b) {
return a * b;
}
따라서, 혹시 모를 변수명의 중복이 있을 때 호이스팅으로 인해 스코프 오염이 발생하지 않게 하려면, 항상 함수 표현식으로 함수를 정의하는 것이 안전하다.
스코프: 식별자에 대한 유효범위이다.
자바스크립트에서는 (전역공간을 제외하면) 함수와 블록에 의해서만 스코프가 생성된다.
(블록은 let, const, class, strict mode에서의 함수 선언에 대해서만 스코프 경계가 발생한다.)
스코프 체인: 식별자의 유효범위를 안에서부터 바깥으로 차례차례 검색해나가는 것이다.
outerEnvironmentReference: 스코프 체인을 가능케 하는 것이다.
- outerEnvironmentReference는 LexicalEnvironment의 두 번째 수집 자료이다.
- 중첩 유효 범위를 가질 수 있는 환경에서 상위 Lexical Environment를 참조한다.
- 즉, 외부 환경에 대한 참조를 가지고 있다.
- 전역 환경에서는 null이다.
outerEnvironmentReference는 현재 호출된 함수가 선언될 당시의 LexicalEnvironment를 참조한다. '선언될 당시'란 콜 스택 상에서 어떤 실행 컨텍스트가 활성화된 상태일 때이다.
스코프 체인은 변수를 검색해나가고, 프로토타입 체인은 객체의 프로퍼티를 검색해나간다.
const a = function () {
const b = function () {
const c = function () {}
}
}
함수 c의 outerEnvironmentReference는 함수 b의 LexicalEnvironment를 참조한다.
함수 b의 outerEnvironmentReference는 함수 a의 LexicalEnvironment를 참조한다.
이와 같이 선언 시점의 LexicalEnvironment를 계속 찾아 올라가면, 전역 컨텍스트의 LexicalEnvironment까지 참조를 하게 된다.
즉, outerEnvironmentReference는 linked list의 형태를 띤다.
각 outerEnvironmentReference는 오직 자신이 선언된 시점의 LexicalEnvironment만 참조할 수 있으므로, 가장 가까운 요소부터 차례대로만 접근할 수 있고 다른 순서로 접근하는 것은 불가능하다.
이런 구조적 특성 덕분에 여러 스코프에서 동일한 식별자를 선언한 경우, 무조건 스코프 체인 상에서 가장 먼저 발견된 식별자에만 접근이 가능하다.
var a = 1;
var outer = function () {
var inner = function () {
console.log(a);
var a = 3;
};
inner();
console.log(a);
};
outer();
console.log(a);
조금 전 알아본 것과 같이 코드는 스코프 체인 상에서 가장 먼저 발견된 식별자에만 접근이 가능하므로, 나는 위의 결과가 undefined
, 3
, 3
으로 출력될 것이라 예상했다.
그러나 실제 결과는 undefined
, 1
, 1
이다. 이유는 다음과 같다.
첫 번째 콘솔로그는 inner 스코프의 LexicalEnvironment부터 검색을 시작했다. a 식별자가 존재했기 때문에 더이상 스코프 체인 검색을 진행하지 않고, 즉시 inner LexicalEnvironment 상의 a(할당되기 전 변수 선언만 된 a)를 반환했다. 따라서 undefined
이 출력되었다.
(만약 inner 함수 내에 var a = 3;
가 없다면, 첫 번째 콘솔로그는 전역 컨텍스트의 var a = 1;
을 확인하고 1
을 출력한다.)
변수 은닉화: inner 함수 내부에서 a 변수를 선언했기 때문에 전역 공간에서 선언한 동일한 이름의 a 변수에는 접근할 수 없는 현상.
즉, 현재 스코프의 변수를 우선으로 하여, 바깥의 범위에 있는 변수에 접근하지 않는 현상
이후 var a = 3;
라는 변수 선언 및 할당식으로 인해 변수 a의 값은 3이 되었으나, 이 값은 inner 함수의 스코프 내에서 유효한 값이다.
두 번째 콘솔로그는 자신의 outerEnvironmentReference에 의해 LexicalEnvironment를 참조하여, 전역에 선언된 var a = 1;
를 확인했다. 이에 1
을 반환했다.
세 번째 콘솔로그는 현재 활성화 상태인 전역 컨텍스트의 environmentRecord에서 a를 검색하여, var a = 1;
를 확인하고 1
을 반환했다.
전역 컨텍스트 -> outer 컨텍스트 -> inner 컨텍스트 순으로 규모가 작아지는 반면, 스코프 체인을 타고 접근 가능한 변수의 수는 늘어난다.
위 예제를 통해 설명하자면,
전역변수는 전역 스코프에서 선언한 a와 outer이고
지역변수는 outer 함수 내부에서 선언한 inner와 inner 함수 내부에서 선언한 a이다.
즉, 전역 공간에서 선언한 변수는 전역변수이고, 함수 내부에서 선언한 변수는 지역변수이다.
코드의 안전성을 보장하기 위해서는 전역변수 사용을 최소화 하는 것이 좋다.
실행 컨텍스트의 thisBinding에는 this로 지정된 객체가 저장된다.
실행 컨텍스트 활성화 당시에 this가 지정되지 않은 경우, this에는 전역 객체가 저장된다.
함수를 호출하는 방법에 다라 this에 저장되는 대상이 다른데, 이 부분은 03장에서 더욱 자세히 살펴 볼 예정이다.