프로그래밍-학습기록/Javascript

자바스크립트에서 실행 컨텍스트(execution context)가 도대체 뭐하는 애에요

leesche 2021. 2. 10. 21:58

이 글은 <코어 자바스크립트>를 기반으로 작성되었습니다.

자바스크립트에서 실행 컨텍스트(execution context)

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

이 때 콜 스택*은 프로그램을 실행할 때 현재 실행 중인 함수가 가장 위에 쌓이고 그 함수의 실행이 끝나면 사라지는 스택 자료구조입니다. 즉, 프로그램이 시작되면 콜 스택에 함수들이 쌓이고, 가장 최근에 호출되어 쌓인 순서대로 함수들이 종료되면서 스택이 모두 비어지면 프로그램이 끝납니다.

하나의 실행 컨텍스트가 구성될 때는 처음 코드가 실행될 때의 전역 공간, eval()함수가 실행될 때, 함수가 실행될 때, 블록(ES6에서*)에 있을 때 입니다. 전역 공간은 자동으로 생성되니 우리가 제어할 수 없습니다. eval()함수는 MDN에서 절대 사용하지 말라고 합니다. 사용자가 실행 컨텍스트를 구성할 때는 함수를 실행할 때 뿐입니다. (ES6에서는 블록에 의해서도 새로운 실행 컨텍스트가 생성됩니다. 하지만 이 글에서는 다루지 않습니다.)

eval을 절대 사용하지 말 것!

어떤 실행 컨텍스트가 활성화될 때 자바스크립트 엔진은 해당 컨텍스트에 관련된 코드들을 실행하는 데 필요한 환경 정보들을 수집해서 실행 컨텍스트 객체에 저장합니다. 이 객체는 자바스크립트 엔진이 활용할 목적으로 내부적으로 생성하기 때문에 개발자가 코드를 통해 확인할 수 없습니다.

실행 컨텍스트 객체에 담기는 정보들은 다음과 같습니다.

  • VariableEnvironment: 현재 컨텍스트 내의 식별자들에 대한 정보, 외부 환경 정보, 선언 시점의 LexicalEnvironment의 스냅샷(snapshot). 변경 사항은 반영되지 않습니다.
  • LexicalEnvironment: 처음에는 VariableEnvironment와 같지만 변경 사항이 실시간으로 반영되어 변합니다.
  • ThisBinding: this 식별자가 바라보아야 할 대상 객체

VariableEnvironmentLexicalEnvironment의 내부는 environmentRecordouterEnvironmentReference로 구성되어 있습니다.

이제 위의 환경 정보들을 하나씩 살펴보겠습니다.

VariableEnvironment

실행 컨텍스트를 생성할 때 VariableEnvironment에 정보를 먼저 담고, 이 정보를 복사하여 LexicalEnvironment를 만듭니다. 이후에는 LexicalEnvironment에 변경 사항을 반영해 주로 활용합니다.

LexicalEnvironment

environmentRecord와 호이스팅(hoisting)

environmentRecord에는 현재 컨텍스트와 관련된 코드의 식별자 정보들이 저장됩니다. 컨텍스트를 구성하는 함수에 지정된 매개변수 식별자, 선언한 함수가 있을 때, var로 선언된 변수의 식별자, 그 함수 자체 등이 식별자에 해당됩니다. 컨텍스트 내부 전체를 처음부터 끝까지 훑어나가며 순서대로 수집합니다.

즉, 코드를 실제로 실행하기 전에 '훑어 봄'으로써 변수 정보를 수집하고 해당 환경에 속한 코드의 모든 변수명들을 알게 되는 셈입니다. 자바스크립트 엔진이 '실제로 어떻게 동작하건' 이런 상태, 즉 코드를 실행하기 전에 변수명을 알게 되는 상태는 '자바스크립트 엔진은 식별자들을 최상단으로 끌어올려 놓은 다음 실제 코드를 실행한다', 라고 생각하더라도 코드를 해석하는 데 문제될 것이 전혀 없을 것입니다. 이것이 (미리 변수 정보를) '끌어올리다' 라는, 사용자가 해석하기 좋도록, 편의상 탄생한 호이스팅의 의미입니다.

호이스팅을 예시를 통해 단계별로 살펴보겠습니다. 다음과 같은 예제 코드가 있다고 하겠습니다.

function a(x) {
  console.log(x);
  var x;
  console.log(x);
  var x = 2;
  console.log(x);
}
a(1);
  1. 함수 a의 인자로 전달된 1은 함수 a 내부에서 변수(매개변수)를 선언한 것과 같습니다. 자바스크립트 엔진은 식별자들을 처음부터 끝까지 훑어나가며 순서대로 수집하기 때문에 가장 먼저 수집되는 변수명(var x;)입니다.

  2. 3번째 줄의 var x;가 자바스크립트 엔진에 의해 수집됩니다(끌어올려집니다).

  3. 5번째 줄의 var x;가 끌어올려집니다.

  4. 호이스팅이 완료된 뒤의 코드는 (실제로는 아니지만 해석하기 좋게) 다음과 같습니다.

     function a(x) {
       var x; // (매개)변수 선언
       var x; // 3번째 줄의 변수 선언
       var x; // 5번째 줄의 변수 선언
    
       x = 1; // 매개변수의 (인자로 들어온) 값 할당
       console.log(x);
       console.log(x);
       x = 2; // 5번째 줄 변수의 값 할당
       console.log(x);
     }
     a(1);
  5. 이제 실제로 코드를 실행합니다.

  6. 첫번째로 선언한 변수 x가 메모리에 공간이 할당되고 두번째, 세번째 변수 선언은 (이미 선언된 변수가 있기 때문에) 무시됩니다.

  7. x1이 할당되고 11이 차례로 출력됩니다.

  8. x2가 할당되고 2 가 출력됩니다.

  9. 함수 내부의 모든 코드가 실행되고 실행 컨텍스트가 콜 스택에서 제거됩니다.

다시 이 항목의 첫 문단으로 돌아가서,

environmentRecord에는 현재 컨텍스트와 관련된 코드의 식별자 정보들이 저장됩니다. 컨텍스트를 구성하는 함수에 지정된 매개변수 식별자, 선언한 함수가 있을 때, var로 선언된 변수의 식별자, 그 함수 자체 등이 식별자에 해당됩니다. 컨텍스트 내부 전체를 처음부터 끝까지 훑어나가며 순서대로 수집합니다.

를 보면, 함수 선언을 호이스팅할 때는 함수 선언문 전체를 끌어올립니다. 예제를 보겠습니다.

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

a함수가 실행되는 순간 a함수의 실행 컨텍스트가 생성되고 변수명과 함수 선언 정보를 위로 끌어올립니다. 그 결과 코드는 (실제로는 아니지만 해석하기 좋게) 다음과 같이 변합니다.

function a() {
    var b;
    var b = function b() {}

    console.log(b);
  b = "bbb";
  console.log(b);
  console.log(b);
}
a();

결과로, b 함수, 'bbb', 'bbb'가 출력됩니다.

스코프, 스코프 체인, outerEnvironmentReference

스코프(scope)는 식별자에 대한 유효범위를 뜻합니다. 어떤 경계 A의 외부에서 선언한 변수는 A의 외부뿐 아니라 A의 내부에서도 접근이 가능합니다. 하지만 A의 내부에서 선언한 변수는 오직 A의 내부에서만 접근할 수 있습니다.

자바스크립트에서 스코프는 ES5까지는 오직 함수에 의해서만 스코프가 생성됐습니다. ES6에서는 블록에 의해서도 스코프가 생성이 됩니다. 하지만 이러한 블록에서 var로 선언한 변수에서는 스코프가 적용되지 않고 let, const, class, strict mode에서의 함수 선언 등에 대해서만 스코프로서의 역할을 수행합니다.

스코프 체인은 이러한 스코프(식별자의 유효 범위)를 안에서부터 바깥으로 차례차례 검색해 나가는 것을 말합니다. 이를 가능한 것은 LexicalEnvironment의 두 번째 수집 자료인 outerEnvironmentReference 때문입니다.

긴 문서에 지치셨을까봐 다시 언급하겠습니다. 이 글은 실행 컨텍스트를 설명하고 있습니다. 실행 컨텍스트는 실행할 코드에 제공할 환경 정보를 모아 놓은 객체입니다. 이 객체에 들어가는 정보 중 하나가 LexicalEnvironment이고, LexicalEnvironment에 담기는 자료 중 하나가 outerEnvironmentReference 입니다.

outerEnvironmentReference 은 현재 호출된 함수가 선언될 당시LexicalEnvironment를 참조합니다. '선언될 당시'라는 말이 되려면 무언가 선언되어야 하고, 무언가 선언될 때는 어떤 코드가 실행될 때입니다. 즉, 전역 공간이건 어디선가 실행되어야 합니다. 즉, 콜 스택 상에서 어떤 실행 컨텍스트가 활성화된 상태일 때 뿐입니다. 모든 코드는 실행 컨텍스트가 활성화 상태일 때 실행되기 때문입니다.

예시를 들어보겠습니다. 다음과 같이 A, B, C 함수를 차례차례 각 함수 내부에 선언한 경우, outerEnvironmentReference 는 상위 함수의 LexicalEnvironment를 참조합니다.

  • A 함수 선언 → (선언되는 순간) A 함수의 outerEnvironmentReference 는 전역 컨텍스트의 LexicalEnvironment를 참조합니다.
    • B 함수 선언 → (선언되는 순간) B 함수의 outerEnvironmentReference 는 A 함수의 LexicalEnvironment를 참조합니다.
      • C 함수 선언 → (선언되는 순간) C 함수의 outerEnvironmentReference 는 B 함수의 LexicalEnvironment를 참조합니다.

outerEnvironmentReference 는 오직 자신이 선언된 시점의 LexicalEnvironment만 참조하고 있으므로 가장 가까운 요소부터 차례대로만 접근할 수 있습니다. 이런 구조적 특성 덕분에 여러 스코프에서 동일한 식별자를 선언한 경우, 무조건 스코프 체인 상에서 가장 먼저 발견된 식별자에만 접근 가능합니다. 예제를 살펴보면,

var a = 1;
function outside() {
  function inside() {
    console.log(a);
    var a = 3;
  }
  inside();
  console.log(a);
}
outside();
console.log(a);
  1. 전역 컨텍스트가 활성화되고 전역 스코프에 변수 a = 1, outside에 함수 자체를 할당합니다.
  2. outside함수를 호출합니다.
  3. outside실행 컨텍스트의 environmentRecordinside식별자(함수명)가 저장됩니다. outsideouterEnvironmentReference에 선언 시점의 (상위 컨텍스트, 즉 전역 컨텍스트)LexicalEnvironment가 참조됩니다.
  4. outside스코프에 있는 inside변수명에 inside함수를 할당합니다.
  5. inside함수를 호출합니다.
  6. inside실행 컨텍스트의 environmentRecorda 식별자(변수명)를 저장합니다. outerEnvironmentReference에는 inside함수가 선언될 당시의 LexicalEnvironment가 참조됩니다.
  7. (첫번째 콘솔로그) console.log(a);를 실행합니다. 식별자 a에 접근하고자 합니다. 현재 활성화(스택 최상층) 상태인 inside컨텍스트의 environmentRecord에서 a를 검색합니다. a를 발견했지만 할당된 값이 없습니다. undefined를 출력합니다.
  8. inside스코프에 있는 변수 a3을 할당합니다. inside함수가 종료됩니다. inside실행 컨텍스트가 콜 스택에서 제거되고 outside 실행 컨텍스트가 다시 활성화됩니다.
  9. (두번째 콘솔로그) console.log(a);를 실행합니다. 식별자 a에 접근하고자 합니다. 현재 활성화 상태인 outside 컨텍스트의 environmentRecord에서 a를 검색합니다. a를 발견하지 못했습니다. outerEnvironmentReference에 있는 environmentRecord로 넘어가서 계속 a를 찾습니다. 전역 LexicalEnvironment에서 a를 찾았습니다. a에 할당된 값은 1입니다. 1을 출력합니다.
  10. outside함수가 종료됩니다. outside의 실행 컨텍스트가 콜 스택에서 제거되고 바로 아래 전역 컨텍스트가 다시 활성화됩니다.
  11. (세번째 콘솔로그) console.log(a);를 실행합니다. 식별자 a에 접근하고자 합니다. 현재 컨텍스트인 전역 컨텍스트의 environmentRecord에서 a를 검색합니다. 바로 a를 찾았습니다. a에 할당된 값 1을 출력합니다.
  12. 모든 코드의 실행이 완료되고 전역 컨텍스트가 콜 스택에서 제거됩니다. 프로그램은 종료됩니다.

긴 과정이 끝났습니다.

스코프 체인 상에 있는 변수라고 해서 무조건 접근 가능한 것은 아닙니다. 위 예제에서 변수명 a는 전역, 그 아래 함수, 또 아래 함수에서도 등장합니다. 예를 들어 inside함수에서 변수명 a는 이미 선언되었기 때문에 식별자와 값을 찾은 것입니다. (값은 할당된 것이 없었기 때문에 undefined를 출력이 됐습니다.) 즉, 이미 선언된 a의 값이 없다고 해서 상위 스코프의 a 값을 가져올 수 없습니다. 이를 변수 은닉화(variable shadowing)라고 합니다.


참고 및 출처