본문 바로가기
Programming/JavaScript

[JS] 자바스크립트 실행 컨텍스트, 스코프 체인, environmentRecord/outerEnvironmentReference

 

*해당 포스팅은 정재남의 '코어 자바스크립트' 도서를 참고했습니다.*


실행 컨텍스트 

실행 컨텍스트는 실행할 코드에 제공할 환경 정보들을 모아놓은 객체. 자바스크립트는 어떤 실행 컨텍스트가 활성화되는 시점에 선언된 변수를 위로 끌어올리고(호이스팅) 외부 환경 정보를 구성하고, this 값을 설정하는 등의 동작을 수행한다. 이를 CallStack에 쌓아올렸다가, 가장 위에 쌓여있는 컨텍스트와 관련있는 코드들을 실행하는 식으로 전체 코드와 환경과 순서를 보장한다. 예제를 보자.

example

//------------(1)
var a = 1;
function outer(){
    function inner(){
    	console.log(a); //undefined
        var a = 3;
    }
    inner(); // -------(2)
    console.log(a); //1    
}
outer(); //----------(3)
console.log(a); //1​

 

실행 순서를 알아보겠다. 먼저, 코드를 실행하는 순간 (1) 전역 컨텍스트가 콜 스택에 담긴다. 최상단의 공간은 코드 내부에서 별도의 실행 명령이 없어도 브라우저에서 자동으로 실행한다고 이해하면 된다.
CallStack에는 전역 컨텍스트 외 다른 덩어리가 없으므로 순차적으로 진행하다가 (3)에서 outer을 호출하면 자바스크립트 엔진은 outer에 대한 환경 정보를 수집해 CallStack에 담는다. CallStack의 맨 위에 outer 실행 컨텍스트가 놓인 상태가 됐으므로 (1)의 관련된 모든 실행을 일시 중단하고 outer 함수 내부의 코드들을 순차로 실행한다. 그림으로 표현하자면 아래와 같다.

 

 

outer을 실행하면, inner(2)를 호출하는 걸 볼 수 있다. CallStack엔 outer 다음으로 inner가 쌓인다. inner 내부에서 a에 3을 할당하고 나면 inner 함수가 종료되면서 CallStack에서 제거된다. 그 후 outer가 최상단이 되며 다시 outer를 실행한다. outer의 출력도 끝나면 전역 공간에 더는 실행할 것이 남아있지 않아 전역 컨텍스트(1)도 제거되고, CallStack에 아무것도 남지 않아 종료된다. 

var a = 1;

function outer() {
    function inner() {
        // 호이스팅된 부분
        var a; // 변수 선언은 호이스팅됨
        console.log(a); // undefined, 초기화되지 않았으므로 undefined
        a = 3; // 변수에 값이 할당됨
    }

    inner(); // inner() 함수 호출

    // outer() 함수 내에서의 a 변수
    console.log(a); // 1, outer() 함수 스코프에 있는 전역 변수 a의 값 출력
}

outer(); // outer() 함수 호출

// 전역 스코프에서의 a 변수
console.log(a); // 1, 전역 변수 a의 값 출력​

 

이렇게 어떤 싱행 컨텍스트가 활성화 될 때 자바스크립트 엔진은 해당 컨텍스트 관련된 코드들을 실행하는데 필요한 환경 정보들을 수집해서 실행 컨텍스트 객체에 저장한다. 이 객체는 자바스크립트 엔진이 활용할 목적으로 생성할 뿐 코드를 확인할 순 없다. 여기에 담기는 정보는 아래와 같다. 


VariableEnvrionment
실행 컨텍스트를 생성할 때 VariableEnvrionment에 정보를 먼저 담은 다음, 이를 그대로 복사해서 LexicalEnvrionment를 만들고, 이후에는 LexicalEnvrionment를 주로 사용하게 된다. VariableEnvrionment과 LexicalEnvrionment 내부는 outerEnvrionmentReference로 구성되어 있다. 초기화 중에는 완전히 동일하고, 이후 코드 진행에 따라 달라진다. 

요약하자면 현재 컨텍트스 내의 식별자들에 대한 정보 + 외부 환경 정보, 선언 시점의 LexicalEnvrionment 의 스냅샷으로 변경사항은 반영되지 않는다.

 

LexicalEnvironment
LexicalEnvrionment에 대한 번역은 제각기 다른데, 도서에서는 '사전적인'이라는 의미로 해석한다. 현재 컨텍스트의 내부에는 a, b, c와 같은 식별자들이 있고 그 외부 정보는 D를 참조하도록 구성돼있다라는. 컨텍스트를 구성하는 환경 정보들을 사전에서 접하는 느낌으로 모아놓은 것. 처음에는 VariableEnvrionment와 같지만 변경 사항이 실시간으로 반영된다. 

 

envrionmentRecord

envrionmentRecord에는 현재 컨텍스트와 관련된 코드의 식별자 정보들이 저장된다. 함수에 지정된 매개변수 식별자, 함수 자체, var로 선언된 변수의 식별자 등이 식별자에 해당한다. 컨텍스트 내부 전체를 처음부터 끝까지 순서대로 수집한다. 수집 과정을 마쳤더라도, 코드들은 실행되기 전의 상태다. 자바스크립트 엔진은 이미 해당 환경에 속한 코드의 변수명을 미리 아는 것이다. 이전에 배웠던(Link) 호이스팅이라는 개념이 이것이다. 

 

❗ 이전 포스팅에서 배웠던 예제를 컨텍스트 개념으로 다시 살펴보겠다.
function a (x) {
    console.log(x);
    var x;
    console.log(x);
    var x = 2;
    console.log(x);
}

a(1);​
우리가 envrionmentRecord는 관련 식별자들을 수집한다고 했다. 이를 호이스팅이라고 편의상 표현했다. 호이스팅이 일어난 다음에 결과는 어떻게 될까? 
function a (x) {
    var x = 1;
    console.log(x);
    var x;
    console.log(x);
    var x = 2;
    console.log(x);
}

a(1);​

일단 먼저 매개변수 선언과 할당을 처리한다. 

function a (x) {
    var x;
    var x;
    var x;
    
    x = 1;
    console.log(x);
    console.log(x);
    x = 2;
    console.log(x);
}

a(1);

envrionmentRecord는 현재 실행될 컨텍스트의 대상 코드 내에 어떤 식별자들이 있는지만 관심이 있고, 각 식별자에 어떤 값이 할당되는지는 관심이 없다. 그래서 변수를 호이스팅할 때 변수 선언만 끌어올린 것을 볼 수가 있다. 이 과정은 매개변수도 마찬가지다. 이와 같이 호이스팅이 일어나면 결과는 1, 1, 2가 될 것.

예시를 하나 더 살펴 보겠다. 아래와 같은 코드가 호이스팅이 일어난다면 어떻게 될까? 

function a (){
    console.log(b);
    var b = 'bbb';
    console.log(b);
    function b () {}
    console.log(b);
}
a();

이전 포스팅에서 함수 선언문은 호이스팅이 된다고 배웠다. 이 예제에서도 마찬가지로 b함수를 끌어올리게 될 것이다. 변수 b도 마찬가지다. 

function a (){
    var b;
    function b () {}
    
    console.log(b);
    b = 'bbb';
    console.log(b);
    console.log(b);
}
a();

호이스팅을 마친 함수 선언문은 함수명으로 선언한 변수에 함수를 할당한 것처럼 사용할 수 있다.

function a (){
    var b;
    var b = function b () {}
    
    console.log(b);
    b = 'bbb';
    console.log(b);
    console.log(b);
}
a();

결과는 b 함수, 'bbb', 'bbb'가 나오게 될 것이다.

 

스코프, 스코프 체인

함수 포스팅에서 스코프에 대해서 알아봤다. 스코프는 식별자에 대한 유효범위이다. ES5 자바스크립트에서는 전역 공간을 제외하면 오직 함수에 의해서만 스코프가 생성된다. 어쨌든, 이러한 식별자의 유효범위를 안에서부터 바깥으로 차례로 검색해나가는 것을 스코프 체인이라고 한다. 

 

스코프 체인

outerEnvrionmentReference는 현재 호출된 함수가 선언될 당시의 LexicalEnvrionment를 참조한다. 선언이 일어날 수 있는 시점이란 CallStack 상에서 어떤 실행 컨텍스트가 활성화 된 상태뿐이다. 

 

A함수 내부에 B함수, B함수 내부에 C함수를 선언한 경우, 함수 C의 outer EnvrionmentReference가 B함수의 LexicalEnvrionment를 참조한다. B함수도 마찬가지로 A함수의 LexicalEnvrionment를 참조한다. 연결 리스트 형태. 이와같이 계속 이전 함수 컨텍스트로 올라가다 보면, 마지막엔 전역 컨텍스트의 LexicalEnvrionment가 있을 것.

또한 각 outerEnvrionmentReference는 오직 자신이 선언된 시점의 LexicalEnvrionment만 참조하고 있으므로 가장 가까운 요소부터 차례대로만 접근할 수 있고 다른 순서로 접근하는 것은 불가능하다. 

그렇기 때문에, 여러 스코프에서 동일한 식별자를 선언한 경우에는 무조건 스코프 체인 상에서 가장 먼저 발견된 식별자에만 접근이 가능하다. 

 

var a = 1;
var outer = function(){
	var inner = function(){
    	console.log(a);
        var a = 3;
    };
    inner();
    console.log(a);
};

outer();
console.log(a);

 

 

그러면 비슷한 예제를 살펴보자. 

step 1 - callStack에 전역 컨텍스트 push하기 

일단 맨 처음, 전역 컨테스트가 스택에 push된다. 전역 컨텍스트의 environmentRecord에 a와, outer의 식별자를 저장한다. 선언 시점이 없으므로 전역 컨텍스트의 outerEnvrionmentReference에는 아무것도 담기지 않는다. 그 다음, 전역 컨텍스트의 a에 1을, outer에 함수를 할당한다. 

 

step2  - outer 함수 callStack에 push하기

outer 함수를 호출하면 전역 컨텍스트의 코드는 임시 중단되고 outer 함수가 CallStack에 push된다. 

outer 실행 컨텍스트의 envrionmentRecord에 inner 식별자를 저정한다. outer 함수는 전역 공간에서 선언됐으므로 전역 컨텍스트(Global)의 LexicalEnvrionment(a와 outer)를 참조할 수 있다. 또한, inner 변수에 함수를 할당한다. (inner: f{})

 

step3 - inner 함수 callStack에 push 하기

inner 함수가 호출되어 outer를 중단하고 CallStack에 inner 실행 컨텍스트를 활성화한다. 

inner의 envrionmentRecord에 a 식별자를 저장한다. inner의 outerEnvrionmentReference에는 outer의 LexicalEnvrionment를 참조 복사한다. 식별자 a를 출력하기 위해 a에 접근한다. 하지만 console 명령문이 a의 할당보다 먼저 실행되어(호이스팅) 선언만 한 상태이기 때문에 undefined가 출력된다. 

 

step4 - inner 함수 끝, outer 함수로 돌아가기 

inner 함수에서 undefined를 출력하고 a를 할당한 후에 inner가 실행 컨텍스트에서 삭제된다. 이제 다시 outer로 돌아가보자. outer 함수가 해야 할 일은 a를 출력하는 것. a가 현재 outer의 envrionmentRecord에 존재하지 않으므로 outerEnvrionmentReference가 참조하는 값으로 찾아가본다. 거기에 보니 전역 컨텍스트에 a가 존재한다. 제일 첫 줄에 a는 1로 할당되어 있으므로 1이 출력된다. 

 

step5 - outer 함수 끝, 전역 컨텍스트로 돌아가기

이제 outer 함수까지 성공적으로 수행했으므로 실행 컨텍스트에서 outer 함수를 삭제한다. 남은 건 전역 컨텍스트 뿐인데, 전역 컨텍스트에서 중단했던 일을 마저한다. outer()를 호출하고 난 후 console.log가 보인다. 전역 컨텍스의 envrionmentRecord에 a가 1로 할당된 채로 존재한다. 1을 출력하고 전역 컨텍스트 마저 실행 컨텍스트에서 삭제되고 실행을 종료한다.

 

 


여기까지 꽤나 복잡하게 알아봤는데, 그렇다면 이런 스코프 개념은 왜 존재하는 것일까?



 변수 var를 설명할 때 들었던 예시 중 하나인 A개발자와 B개발자의 이야기를 기억할 것이다. A개발자가 sum 함수를 전역에 선언했다고 치자. A개발자는 sum 함수를 a + b로 정의했다. 그런데, B 개발자가 이를 모르고 sum 함수를 재선언해 내부를 a * b로 바꿨다. 그러면 A개발자가 영문모를 이유로 함수가 뒤바뀐 것이다. 예시가 사칙연산이라 다행이지, 만약 운영중인 서비스였다면 슬플지도 모르겠다. 
그렇기 때문에 스코프를 사용하는 것이다. A개발자가 전역에 A함수를 선언해 그 안에 sum 함수를 만들었다면? A함수를 통해서만 sum함수에 접근할 수 있었을 것이다. 자연스럽게 sum 함수를 호출하는 다른 코드들도 A함수 안에 위치하게 될 것이고, 이는 B개발자든 C개발자든 누구도 접근할 수 없게 된다. 결국 안정성을 위해 전역 변수를 최소화 하는 것이다.