자바스크립트/자바스크립트 정리

[자바스크립트] 실행 컨텍스트 (2)

실행 컨텍스트

JS

실행 컨텍스트는 자바스크립트의 동작 원리를 담고 있습니다. 실행 컨텍스트를 바르게 이해하면 스코프 기반으로 식별자와 식별자에 바인딩된 값을 관리하는 방식, 호이스팅의 이유, 클로저(!!)의 동작 방식등의 동작 방식의 이해가 가능합니다.

 

이번에는 예제를 통해 어떻게 실행 컨텍스트가 생성되고 식별자를 검색하는지 살펴보겠습니다.

예제를 통해 알아보는 실행 컨텍스트의 생성과 식별자 검색 과정

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); // 42

0. 전역 객체 생성

사실 자바스크립트에서는 전역 객체가 먼저 생성됩니다. 자바스크립트가 시작되기 전에(전역 코드가 평가되기 전에) 전역 객체는 생성됩니다. 전역 프로퍼티와 전역 함수, 표준 빌트인 객체, 동작 환경에 따른 호스트 객체가 포함되게 됩니다.

 

1. 전역 코드 평가

소스 코드가 로드되면 자바스크립트 엔진은 전역 코드를 평가하게 됩니다. 전역 코드 평가가 전부 진행되면 아래와 같은 결과가 생기게 됩니다.

 

나날이 발전하는 그림실력(?)

1. 전역 실행 컨텍스트 생성

비어있는 전역 실행 컨텍스트를 생성해서 실행 컨텍스트 스택에 푸시합니다. 이때 전역 실행 컨텍스트는 실행 컨텍스트스택의 최상위에 존재합니다. 이를 실행 중인 실행 컨텍스트라고 부릅니다.

 

 

2. 전역 렉시컬 환경 생성

전역렉시컬 환경을 생성하고 전역 실행 컨텍스트의 렉시컬 환경 컴포넌트에 바인딩한다. (사실 변수환경이라는 컴포넌트도 존재합니다. 여기서는 렉시컬 환경 하나로 이야기 하겠습니다.)

 

2.1 전역 환경 레코드 생성

전역 환경 레코드는 전역 변수를 관리하는 전역 스코프, 전역 객체의 빌트인 전역 프로퍼티와 빌트인 전역 함수, 표준 빌트인 객체를 제공합니다.

 

ES6 이전에는 전역 객체가 전역 환경 레코드의 역할을 수행했습니다. 하지만 let, const 와 같은 키워드가 등장했고 이는 전역 객체의 프로퍼티가 되지 않기 때문에 선언적 환경 레코드가 추가되어 구성되어 있습니다.

 

객체 환경 레코드는 기존의 전역 객체가 관리하던 var 전역 변수, 함수 선언문으로 정의한 전역 함수, 빌트인 전역 프로퍼티, 빌트인 전역 함수, 표준 빌트인 객체를 관리하고 선언적 환경 레코드는 let, const 로 선언한 전역 변수를 관리합니다.

 

 

2.1.1. 객체 환경 레코드 생성

객체 환경 레코드는 BindingObject 라고 부르는 객체와 연결됩니다. BindingObject 는 전역 객체 생성에서 생성된 전역 객체로 BindingObject 를 통해 전역 객체의 프로퍼티와 메서드가 됩니다. 

 

x 는 var 키워드로 선언했기 때문에 선언과 동시에 초기화 단계가 진행됩니다. BindingObject 를 통해 전역 객체에 식별자를 키로 등록하고 암묵적으로 undefined 가 바인딩됩니다. (변수 호이스팅)

 

함수 선언문으로 정의한 함수는 평가되면 이름과 동일한 식별자를 BindingObject 를 통해 객체에 키로 등록하고 생성된 함수 객체를 즉시 할당합니다. 함수 선언문으로 정의한 함수는 함수 선언문 이전에 호출이 가능합니다. (정상적인 사고를 가지신 분들이라면 굳이 선언문 이전에 호출하려는 기괴한 행동은 안 하시라고 믿어욧...)

 

2.1.2. 선언적 환경 레코드 생성

let,const 로 선언된 전역 변수는 선언적 환경 레코드에 존재하게 됩니다.. 위 예제에서 y는 let, const 키워드로 선언됬기 때문에 전역 객체의 프로퍼티로서 참조가 불가능합니다.

 

const, let 키워드로 선언한 변수는 선언과 초기화 단계가 분리되기 때문에 TDZ(일시적 사각 지대) 에 빠져 참조가 불가능합니다. (이 부분은 let, const 는 변수 호이스팅이 일어나지 않나요? 라는 질문과 아주 밀접한 관계가 있습니다.

 

 

let, const 는 선언문 이전에 접근이 불가능합니다. 하지만 이것은 할당이 되지 않아 접근이 불가능 하다는 에러를 표출하는 것이지 변수 호이스팅이 일어나지 않는 것은 아닙니다. 즉, 접근이 불가능한 영역을 TDZ 라고 부릅니다.)

 

let foo = 1; // 전역 변수

{
  // let, const 키워드의 변수들이 변수 호이스팅이 발생하지 않는다면 전역 변수를 참조해야합니다.
  // 하지만 호이스팅이 발생하기 때문에 참조 에러(ReferenceError)가 발생한다.
  console.log(foo); // ReferenceError: Cannot access 'foo' before initialization
  let foo = 2; // 지역 변수
}

2.2 this 바인딩

전역 환경 레코드의 [[GlobalThisValue]] 내부 슬롯에 this 를 바인딩합니다. 일반적으로 전역 코드의 this 는 전역 객체를 가리킵니다. 전역 코드에서 this 를 참조하면 전역 환경 레코드의 [[GlobalThisValue]] 내부 슬롯에 바인딩되어 있는 객체를 반환합니다. this 바인딩은 전역 환경 레코드와 함수 환경 레코드에만 존재합니다.

 

 

2.3 외부 렉시컬 환경에대한 참조 결정

외부 렉시컬 환경에 대한 참조는 현재 평가중인 소스코드를 포함하는 외부 소스크드이 렉시컬 환경인 상위 스코프를 가리킵니다. 이를 통해 단방향 링크드 리스트인 스코프 체인의 구현이 가능합니다.

 

현재 전역 코드를 평가중이기 때문에 전역 코드를 포함하는 소스코드는 존재하지 않습니다. 이 때문에 전역 렉시컬 환경의 외부 렉시컬 환경에 대한 참조에는 null 이 할당되게 됩니다. (전역 렉시컬 환경은 스코프 체인의 종점이다 라고 할 수 있습니다.)

 

2. 전역 코드 실행

드디어 전역 코드가 순차적으로 실행되기 시작합니다. 전역 변수에 값이 할당되고 foo 함수가 호출됩니다. 변수 할당문이나 함수 호출문을 실행하기위해서는 변수나 함수 이름이 선언된 식별자인지 확인해야 합니다. 만약 선언되지 않앗다면, 참조가 불가능하기 때문입니다. 

 

만약 식별자의 스코프가 다르다면 같은 이름을 가져도 문제가 생기지 않습니다. 그렇기 때문에 다른 스코프들 마다 같은 이름의 식별자가 존재 할 수 있습니다. 이를 위해 어디의 스코프의 식별자를 참조해야 할 지 결정해야 합니다. 이를 식별자 결정이라고 합니다.

 

식별자 결정을 하려면 실행 중인 실행 컨텍스트에서 식별자를 검색합니다. 실행 컨텍스트의 렉시컬 환경의 환경 레코드에 선언된 식별자가 등록되어 있으므로 식별자를 검색하기 시작합니다. 만약 식별자가 존재하지 않는다면 외부 렉시컬 환경에 대한 참조를 통해 상위 스코프(렉시컬 환경) 으로 이동해서 검색합니다.

 

 

스코프 체인의 동작 원리는 이와 같습니다. 지금 코드는 전역 렉시컬 환경을 가지고 있고, 전역 렉시컬 환경은 스코프 체인의 종점(null 을 가리키고 있습니다.) 이기 때문에 만약 전역 렉시컬 환경에서 식별자 검색이 불가능하다면 참조 에러가 발생합니다.

3. foo 함수 코드 평가

다시 예제를 살펴보자면 현재 전역 코드를 평가해 전역 실행 컨텍스트가 생성, 전역 코드를 실행하고 있습니다. 현재 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); // ← 호출 직전

foo 함수 호출시에는 전역 코드의 실행을 일시 정지 시킵니다. 이후 foo 함수 내부로 코드 제어권이 이동합니다. 실행 순서는 평가, 실행의 2가지로 진행되므로 먼저 함수 코드 평가가 시작됩니다. 평가가 전부 끝난다면 아래와 같이 그려지게 됩니다.

1. 함수 실행 컨텍스트 생성

foo 함수 실행 컨텍스트를 생성합니다. 스택에는 전역 실행 컨텍스트가 남아있으므로 그 위에 foo 함수 실행 컨텍스트가 쌓이게 됩니다. 이제 foo 함수 실행 컨텍스트는 스택의 최상위, 즉 실행 중인 실행 컨텍스트가 됩니다.

2. 함수 렉시컬 환경 생성

foo 함수의 렉시컬 환경을 생성하고 foo 함수 실행 컨텍스트에 바인딩 해줍니다.

 

2.1 함수 환경 레코드 생성

함수 렉시컬 환경을 구성하는 컴포넌트 중 하나인 함수 환경 레코드를 생성합니다. 함수 환경 레코드는 매개변수, arguments 객체, 함수 내부에서 선언한 지역 변수와 중첩 함수를 등록하고 관리합니다.

 

2.2 this 바인딩

함수 환경 레코드의 [[ThisValue]] 내부 슬롯에 this 가 바인딩되게 됩니다. (전역이 아니라 [[ThisValue]]입니다. [[GlobalThisValue]] 랑은 다릅니다). 저번 this 포스팅에서 살펴보았듯이 this 는 정적이 아닌 동적으로 결정됩니다. 이때 foo 함수는 일반 함수로 호출되었으므로 this 는 전역 객체를 가리키게 됩니다.

 

2.3 외부 렉시컬 환경에 대한 참조 결정

외부 렉시컬 환경에 대한 참조에 foo 함수 정의가 평가된 시점에 실행 중이었던 실행 컨텍스트의 렉시컬 환경의 참조를 할당합니다. foo 함수는 전역 코드에 정의됬으므로 전역 코드 평가 시점에 평가됩니다. 이때 전역 실행 컨텍스트가 실행 중인 실행 컨텍스트 였으므로, 전역 렉시컬 환경의 참조가 할당됩니다.

 

자바스크립트의 함수는 어디서 호출했는지가 아닌 어디서 정의했는지에 따라 상위 스코프가 결정됩니다. 함수 객체는 자신의 상위 스코프를 기억합니다. 이는 함수 객체가 생성될 때 함수 객체의 내부 슬롯 [[Environment]] 에 함수의 상위 스코프를 저장하기 때문입니다. 

 

4. foo 함수 코드 실행

코드의 실행은 런타임의 시작입니다. foo 함수의 소스코드가 순차적으로 실행되고 매개변수에 인수 할당, 변수 할당문을 통한 지역 변수 x, y 에 값이 할당되기 시작합니다. foo 함수 내부에서 bar 함수를 호출했으므로 bar 함수가 호출됩니다.

 

x, y 에 값을 할당하기 전에 식별자 결정이 필요합니다. 실행 중인 실행 컨텍스트의 렉시컬 환경에서 식별자를 검색하기 시작하고 현재 실행 중인 실행 컨텍스트부터 검색을 시작합니다. 현재 foo 함수가 현재 실행 중인 실행 컨텍스트이므로 foo 함수의 렉시컬 환경에서부터 식별자를 찾기 시작합니다.

 

만약 식별자가 존재하지 않는다면 외부 렉시컬 환경에 대한 참조에 저장되어있는(현재는 전역 렉시컬 환경) 으로 이동해 식별자를 찾겠지만 현재 foo 함수의 렉시컬 환경에 변수가 존재하므로 검색한 식별자에 값을 바인딩 합니다.

 

5. bar 함수 코드 평가

현재 foo 함수 코드 평가를 통해 foo 함수 실행 컨텍스트가 생성되었습니다. 이후 foo 함수 코드를 실행했고 이제 bar 함수를 호출하려고 합니다.

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);

bar 함수 호출시에 bar 함수 내부로 코드 제어권이 이동합니다. 이후 bar 함수 코드를 평가하고 foo 함수 코드 평가와 같은 과정을 거쳐 아래와 같은 렉시컬 환경을 가집니다.

 

6. bar 함수 코드 실행

1. console 식별자 검색

런타임(코드 실행)이 시작되어 소스코드가 순차적으로 실행됩니다. 마찬가지로 매개변수에 인수가 할당되고 변수 할당문이 실행됩니다. 이제 내부에 console.log(a + b + x + y + z) 가 실행되게 되는데 사실 여기서 console 도 식별자입니다. 식별자를 찾기 위해 스코프 체인을 돌아다니고 전역 렉시컬 환경에 있는 console 을 찾게 됩니다.

 

2. log 메서드 검색

console 을 찾았으니 메서드인 log 메서드를 검색합니다. 이를 console 객체의 프로토타입 체인에서 검색합니다. log 메서드는 상속이 아닌 console 객체가 직접 소유한 프로퍼티입니다.

3. a + b + x + y + z 평가

표현식을 평가하기 위해 각자의 식별자를 검색합니다. 식별자는 스코프 체인에서 검색하는데, 현재 bar 함수 렉시컬 환경에 있으므로 현재 위치에서부터 식별자를 찾을 때 까지 스코프 체인에서 검색합니다.

 

7. bar 함수 코드 실행 종료

더는 실행할 코드가 없으므로 bar 함수 코드의 실행이 종료되고 bar 함수 실행 컨텍스트가 실행 컨텍스트 스택에서 pop됩니다. 이제 foo 함수 실행 컨텍스트가 실행 중인 실행 컨텍스트가 됩니다. 

 

bar 함수 실행 컨텍스트는 pop 됬지만 bar 함수의 렉시컬 환경이 바로 소멸되지는 않습니다. 실행 컨텍스트에 의해서 참조는 되지만 독립적인 객체이기 때문입니다. 객체를 포함한 모든 값은 누군가가 참조하지 않는다면 그때서야 가비지 컬렉터에 의해 메모리 공간 확보 해제되어 소멸하게 됩니다.

 

즉, 누군가가 bar 함수 렉시컬 환경을 참조한다면 bar 함수 렉시컬 환경은 소멸하지 않습니다. 이는 클로저의 아주 중요한 개념이 됩니다.

 

8. foo 함수 코드 종료

bar 함수가 종료되면 foo 함수도 마찬가지로 실행할 코드가 존재하지 않습니다. 이때 실행 컨텍스트 스택에서 foo 함수 실행 컨텍스트가 pop 되고 전역 실행 컨텍스트가 마지막으로 남은 실행 중인 실행 컨텍스트가 됩니다.

 

9. 전역 코드 실행 종료

더는 실행할 전역 코드가 없으므로 실행 컨텍스트 스택에서 전역 실행 컨텍스트가 pop됩니다. 이제 실행 컨텍스트 스택에는 아무것도 남아있지 않습니다.

실행 컨텍스트와 블록 레벨 스코프

var 키워드로 선언한 변수는 함수의 코드 블록 만을 지역 스코프로 인정합니다. 하지만 let, const 로 선언한 변수는 모든 코드 블록을 지역 스코프로 인정합니다. 이를 블록 레벨 스코프라고 합니다. 아래의 예제와 함께 살펴보겠습니다.

 

let x = 1;

if (true) {
  let x = 10;
  console.log(x); // 10
}

console.log(x); // 1

if 문 코드 블록 내에서 let 키워드로 변수가 선언되었습니다. if 문의 코드 블록 실행시 if 문의 코드 블록을 위한 블록 레벨 스코프를 생성해야 합니다. 이는 선언적 환경 레코드를 갖는 렉시컬 환경을 새롭게 생성합니다. 이후 기존의 전역 렉시컬 환경을 교체하게 됩니다. 이때 외부 렉시컬 환경에 대한 참조는 if 문이 실행되기 전의 전역 렉시컬 환경을 가리킵니다.

 

if 문 코드 블록 실행 종료시에는 if 문의 코드 블록 실행 전의 렉시컬 환경으로 되돌립니다. if 문은 함수, 전역, eval, 모듈에 해당하지 않아 실행 컨텍스트를 만들지 않습니다.

 

 

지금까지 실행 컨텍스트에 대해서 살펴봤습니다. 이 개념은 처음에는 어렵지만 다음 단계인 클로저를 위해서 꼭 정확하게 이해해야 하는 내용입니다.

 

 

보다 더 자세한 내용은 아래 링크에서 확인 가능하고, 저 또한 내용들을 공부하며 정리하기 위한 목적으로 글을 남기는 것을 알려드립니다. 읽어주셔서 감사합니다!

 

출처 :https://poiemaweb.com