실행 컨텍스트

Javascript 핵심개념인 실행 컨텍스트를 알아보자.

2024-01-08
Execution-context

실행 컨텍스트 (Execution Context) 정의

코드를 실행하려면 크게 식별자코드 실행 순서가 관리되어야 한다. 실행 컨텍스트는 코드가 평가되고 실행되는 어휘적 환경의 식별자 결정과 실행 순서 추적에 사용되는 자료구조다. 명세서에만 존재하는 개념으로 실제 코드에서 이를 확인하는 방법은 없으나, 호이스팅, 클로저, this 바인딩 등을 이해하는 데 필수적인 개념이다.

식별자 결정은 환경 레코드를 통해, 코드 실행 순서는 실행 컨텍스트 스택 자료구조를 통해 관리된다. 두 가지 모두 살펴보자.

실행 컨텍스트 내 요소

실행 컨텍스트에는 실행 컨텍스트 상태를 결정하는 요소가 있다.

요소목적
code evaluation state실행 컨텍스트와 연관된 코드 평가의 실행, 대기, 재개하기 위한 모든 상태.
Function실행 컨텍스트가 평가하는 코드가 함수 객체면, 함수 객체의 참조이고, 그렇지 않다면 null.
RealmRealm Record. (Realm의 명세, 스택오버플로우 답변)
ScriptOrModule연관 코드가 유래된 곳이 Module인 경우 Module Record, Script인 경우 Script Record.

위 요소 외에도, 추가적인 상태 요소가 있다.

요소목적
LexicalEnvironment실행 컨텍스트의 식별자 결정을 위한 환경 레코드.
VariableEnvironmentVariableStatemants에 의해 생성된 binding을 지니고 있는 환경 레코드.
PrivateEnvironment클래스 요소로부터 만들어진 Private Names를 지니고 있는 PrivateEnvironment Record.

LexicalEnvironment와 VariableEnvironment는 항상 환경 레코드이며, 환경 레코드가 무엇인지는 곧이어 살펴본다. 이 외에도 제너레이터를 평가하는 경우 실행 컨텍스트에 Generator 요소가 추가된다.

LexicalEnvironment와 VariableEnvironment의 초깃값은 같으며, 이 글의 목적은 실행 컨텍스트의 핵심 개념을 이해하는 것이기 때문에 이어지는 내용에서는 LexicalEnvironment(렉시컬 환경)만으로 실행 컨텍스트를 설명해보겠다.

환경 레코드 (Environment Record)

환경 레코드는 어휘적 중첩 구조를 기반으로 식별자 <-> 변수/함수의 연관 관계를 정의하는 명세 타입이다. 함수 선언문, 블록문, catch문과 같은 문법 구조와 연관되어 있다. 새로운 코드가 평가될 때마다 그 코드의 식별자 바인딩을 저장하기 위한 환경 레코드가 생성된다.

[[OuterEnv]]

모든 환경 레코드는 [[OuterEnv]]라는 내부 속성을 가지며, 이 속성은 외부 환경 레코드 대한 참조 혹은 null 값을 가진다. 이 속성이 식별자 결정을 위한 스코프 체인 탐색을 가능하게 한다.

환경 레코드 클래스 상속 구조

환경 레코드는 선언적 환경 레코드, 객체 환경 레코드, 전역 환경 레코드 세 개의 구상 클래스(concrete class)를 가진 추상 클래스(abstract class)이고, 함수 환경 레코드와 모듈 환경 레코드는 선언적 환경 레코드를 상속받는다. 이를 그림으로 표현하면 다음과 같다.

환경 레코드 상속 구조

1) 선언적 환경 레코드

선언적 환경 레코드(Declarative Envirion Record)에는 변수 선언, 함수 선언, 클래스 선언, 함수 인자 등의 식별자 바인딩 정보를 저장한다.

// x, y, z 모두 선언적 환경 레코드에 저장된다.
function foo(x) {
  let y = 1;

  function z() {}
}

또, 선언적 환경 레코드는 함수 환경 레코드와 모듈 환경 레코드의 부모 클래스이다.

함수 환경 레코드

함수 환경 레코드는 함수의 최상위 스코프에 존재하는 식별자 바인딩을 저장한다. 만약 함수가 화살표 함수가 아니라면 this 바인딩을 제공한다. 또한 화살표 함수가 아니고 super를 참조한다면, super() 호출을 위한 상태도 포함한다. 함수의 매개변수와 인자, 함수 내 최상위 스코프 변수/함수 선언을 관리하며, 다음과 같은 내부 속성을 가진다.

필드 이름의미
[[ThisValue]]ECMAScript 언어 값함수에서 this 값을 참조하는 데 사용.
[[ThisBindingStatus]]lexical, initialized, uninitialized값이 'lexical'이면 함수가 화살표 함수라는 뜻이며, 이 경우 this 값을 가지지 않음.
[[FunctionObject]]ECMAScript 함수 객체환경 레코드가 만들어지게 한 함수 객체.
[[NewTarget]]객체 or undefined환경 레코드가 [[Construct]] 메소드에 의해 만들어진 경우 값이 [[Construct]] newTarget 파라미터.

모듈 환경 레코드

모듈 환경 레코드는 모듈의 최상위 스코프 식별자 바인딩을 저장한다. 기본적인 속성과 메소드는 선언적 환경 레코드에서 상속 받는다.

2) 객체 환경 레코드

모든 객체 환경 레코드는 binding object라 불리는 객체와 연관된다. 객체 환경 레코드는 바인딩하는 객체의 속성 이름과 직접적으로 대응되는 문자열 식별자를 바인딩한다. [[Enumerable]] 속성의 값과 관계 없이 객체가 소유한 속성과 상속받은 속성 모두 바인딩에 포함된다.

객체 환경 레코드는 다음과 같은 내부 속성을 가진다.

필드 이름의미
[[BindingObject]]객체환경 레코드에 바인딩 된 객체.
[[IsWithEnvironment]]불리언환경 레코드가 with 문을 위해 생성되었는지 여부.

3) 전역 환경 레코드

전역 환경 레코드는 스크립트 전체에서 최상위 스코프 식별자를 저장한다. 빌트인 globals(isFinite, isNan 등), 전역 함수(Number, String, Object 등), global 속성(globalThis 등)과 모든 최상위 선언(var, 함수 선언문 등)의 식별자 바인딩을 저장한다. 전역 환경 레코드의 [[OuterEnv]]null이다. 전역 환경 레코드는 논리적으로 하나의 레코드이지만, 실질적으로는 선언적 환경 레코드와 객체 환경 레코드를 합성한 것이다.

전역 환경 레코드는 다음과 같은 내부 속성을 가진다.

필드 이름의미
[[ObjectRecord]]객체 환경 레코드[[BindingObject]]가 전역 객체다. 전역 객체에는 빌트인 globals, 전역 함수, global 속성, 함수 선언문, 비동기 함수 선언문, 제너레이터 선언문, 비동기 제너레이터 선언문, var 선언문 등의 식별자가 바인딩 됨.
[[GlobalThisValue]]객체전역 this. 호스트 환경마다 다름.
[[DeclarativeRecord]]선언적 환경 레코드함수 선언문, 비동기 함수 선언문, 제너레이터 선언문, 비동기 제너레이터 선언문, var 선언문 이외 모든 변수 선언의 식별자 바인딩.
[[VarNames]]문자열 리스트함수 선언문, 비동기 함수 선언문, 제너레이터 선언문, 비동기 제너레이터 선언문, var 선언문에 의해 바운드 된 문자열 이름

실행 컨텍스트 스택

실행 컨텍스트 스택은 코드의 실행 순서를 직접적으로 추적하고 관리하는 자료구조이다. 실행 컨텍스트 스택 최상단 실행 컨텍스트는 실행 중인 실행 컨텍스트 (running execution context)라 불리며, JS 코드가 실행 중일 때는 항상 하나 이상의 실행 중인 실행 컨텍스트가 있다.

실행 컨텍스트 스택을 이해할 때 주의할 점은 실행 컨텍스트 스택의 순서와 렉시컬 환경 탐색 순서는 다르다는 점이다. Javascript는 함수가 실행될 때 동적으로 스코프가 결정되지 않고, 함수가 정의되는 시점에 정적으로 스코프가 결정되는 렉시컬 스코프를 따르기 때문이다.

아래 예시에서 실행 컨텍스트 스택은 global | foo | bar로 순차적으로 쌓인다. 하지만 렉시컬 환경 체인은 global <- foo, global <- bar 두 가지 체인이 존재한다. foo와 bar 함수 모두 전역 스코프에 선언되었기 때문이다.

/**
 * 렉시컬 스코프 예시
 */
const x = 1;

function foo() {
  const x = 10;
  bar();
}

function bar() {
  console.log(x);
}

foo(); // 1
실행 컨텍스트 스택렉시컬 환경 체인
고려하는 것함수가 실행된 시점함수가 정의된 시점

실행 컨텍스트 생성과 식별자 검색 과정

이제 아래의 간단한 예제를 통해 실행 컨텍스트가 어떻게 생성/관리되는지 알아보자. 예제는 모던 자바스크립트 Deep Dive 23장 실행 컨텍스트를 참조했다.

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

모든 소스코드는 평가와 실행 단계를 거친다. 소스코드가 평가될 때 실행 컨텍스트가 생성되고, 변수, 함수의 선언문만 먼저 실행하여 실행 컨텍스트의 렉시컬 환경의 환경 레코드에 식별자 바인딩이 저장된다. 평가가 완료되어 실행되면 선언문을 제외한 소스코드가 순차적으로 실행된다. 이때 변수나 함수의 참조를 환경 레코드에서 검색한다. 런타임에 변수 값이 변경되면 환경 레코드에 반영된다.

위 예제가 평가되고 실행되는 과정을 순차적으로 살펴보며 실행 컨텍스트를 더 깊이 이해해보자.

1) 전역 객체 생성

전역 코드가 평가되기 전에 전역 객체가 생성된다. 전역 객체에는 빌트인 globals, 전역 함수, global 속성 등이 있다. 이 외에도 호스트 환경에 따른 객체가 추가된다. 클라이언트의 경우 DOM, BOM 등이 있다.

2) 전역 코드 평가

소스코드가 로드되면 JS 엔진이 전역 코드를 평가한다. 전역 코드 평가는 다음과 같은 순서로 진행된다.

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

비어있는 전역 실행 컨텍스트가 생성되어 실행 컨텍스트 스택에 push 된다.

실행 컨텍스트 생성
  1. 전역 환경 레코드 생성

전역 환경 레코드가 생성되고, 실행 컨텍스트의 LexicalEnvironment 컴포넌트에 바인딩 된다.

전역 환경 레코드 생성
  1. 객체 환경 레코드 생성

객체 환경 레코드가 생성되고, [[BindingObject]]에 전역 객체가 바인딩 된다. 전역 코드 평가 과정에서 전역 스코프에 있는 var로 선언한 변수, 함수 선언문, 비동기 함수 선언문, 제너레이터 선언문, 비동기 제너레이터 선언문은 객체 환경 레코드에 연결된 [[BindingObject]]를 통해 전역 객체의 프로퍼티와 메소드가 된다. 예제에서는 전역 변수 var x와 전역 함수 function foo(a) {}가 전역 객체의 프로퍼티와 메소드가 된다.

객체 환경 레코드 생성

var로 선언한 변수는 평가 단계에서 undefined로 초기화 되기 때문에 코드 실행 단계에서 변수 선언문 이전에도 참조할 수 있다. 이것이 변수 호이스팅의 원인이다. 함수 선언문으로 선언한 함수는 평가 단계에서 생성된 함수 객체를 함수 이름과 동일한 식별자로 전역 객체에 할당하기 때문에 코드 실행 단계에서 함수 선언문 이전에도 참조할 수 있다. 이것이 함수 호이스팅의 원인이다.

추가 예제를 살펴보자.

// 선언하기 전에 변수와 함수를 참조해도 에러가 나지 않는다.
console.log(x); // undefined
foo(1); // 1
bar(2).then((v) => console.log(v)); // 2

var x = 1;

function foo(a) {
  console.log(a);
}

async function bar(b) {
  return b;
}
  1. 선언적 환경 레코드 생성

var 키워드로 선언한 전역 변수와 함수 선언문, 비동기 함수 선언문, 제너레이터 선언문, 비동기 제너레이터 선언문을 제외한 선언은 선언적 환경 레코드에 등록된다. let, const 키워드로 선언한 변수는 선언적 환경 레코드에 등록된다. letconst로 선언한 변수는 전역 변수임에도 선언적 환경 레코드에 등록되기 때문에 var 키워드로 선언한 변수처럼 전역 객체에 할당되지 않는다. 또한 "선언 단계"와 "초기화 단계"가 분리되어 실행되기 때문에 변수 선언문이 도달하기 전에 참조할 수 없는 일시적 사각지대(Temporal Dead Zone: TDZ) 에 빠지게 된다. 이는 letconst로 선언한 함수 표현식도 마찬가지다.

선언적 환경 레코드 생성

그림에서 <uninitialized>는 초기화되지 않았음을 표현한 것이며, <uninitialized>라는 값이 바인딩 된 것이 아니다. 아래 예제를 보면, 선언은 되었으나 초기화되지 않은 변수를 참조해 에러가 발생한다.

console.log(y); // ReferenceError: Cannot access 'y' before initialization
let y = 2;

letconst로 선언한 변수도 호이스팅이 발생한다. 아래 예제를 보면, 같은 스코프 내에 x가 존재하지만 초기화되지 않았기 때문에 에러가 발생한다.

let x = 1;

{
  console.log(x); // ReferenceError: Cannot access 'y' before initialization

  let x = 2;
}

위 현상을 통해 호이스팅의 원리를 이해할 수 있다. 변수/함수 선언은 코드 평가 단계에서 환경 레코드에 등록되기 때문에 실행 단계에서 참조할 수 있게 된다. 선언문 이전에 변수를 참조할 수 있도록 끌어올려지는 것과 유사하기 때문에 호이스팅이라 부른다.

  1. this 바인딩

전역 환경 레코드의 [[GlobalThisValue]]에 this가 바인딩된다. 일반적으로 전역 환경 레코드의 this는 전역 객체이기 때문에 전역 객체가 바인딩 된다. 이로 인해 전역에서 this를 참조하면 전역 객체가 반환된다.

this 바인딩
  1. 외부 렉시컬 환경에 대한 참조 결정

전역 환경 레코드의 [[OuterEnv]]에는 null이 저장된다. 외부 렉시컬 환경에 대한 참조에는 현재 평가 중인 소스코드를 포함하는 외부 소스코드의 렉시컬 환경이 저장된다. 전역 환경은 가장 바깥 쪽에 위치한 환경이기 때문에 [[OuterEnv]]null이다. 이는 전역 렉시컬 환경이 스코프 체인의 종점에 존재하는 것을 의미한다.

외부 렉시컬 환경 참조 결정

3) 전역 코드 실행

전역 코드 평가가 완료되면 전역 코드가 순차적으로 실행된다. x에는 1이, y에는 2가 할당된다. 그리고 foo()가 호출된다. 이 과정에서 변수 할당문이나 함수 호출문을 실행하기 위해서는 변수 혹은 함수 이름이 선언된 식별자인지 확인해야 한다. 선언되지 않은 식별자면 할당도 호출도 할 수 없기 때문이다.

동일한 이름의 식별자가 여러 스코프에 동시에 존재할 수 있는데, 이때 어떤 식별자의 값을 참조할지 결정하는 것이 식별자 결정이다. 식별자 결정을 위해 식별자를 검색할 때는 실행 중인 실행 컨텍스트부터 검색을 시작한다. 실행 중인 실행 컨텍스트의 렉시컬 환경의 환경 레코드에 식별자가 없으면 [[OuterEnv]]가 가리키는 참조를 통해 외부 렉시컬 환경의 환경 레코드로 검색을 이어간다. 이것이 스코프 체인의 동작 원리다.

전역 렉시컬 환경은 [[OuterEnv]]null로, 스코프 체인의 종점이기 때문에 전역 렉시컬 환경에서 식별자를 찾지 못하면 ReferenceError가 발생한다.

전역 코드 실행

4) foo 함수 코드 평가

다시 예제로 돌아가서, foo(20)이 실행되기 직전이다. 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); // <- 현재 실행 시점
  1. 함수 실행 컨텍스트 생성

먼저 foo 함수를 위한 실행 컨텍스트가 생성되어 실행 컨텍스트 스택에 push 된다. 이제 실행 중인 실행 컨텍스트는 foo 함수 실행 컨텍스트가 된다.

함수 실행 컨텍스트 생성
  1. 함수 렉시컬 환경 생성

foo 함수 실행 컨텍스트에 LexicalEnvironment 컴포넌트가 추가된다.

렉시컬 환경 생성
  1. 함수 환경 레코드 생성

함수 환경 레코드가 생성되어 LexicalEnvironment에 바인딩된다. 함수 환경 레코드에는 매개변수, arguments 객체, 함수 몸체에서 선언한 지역 변수와 중첩 함수를 등록하고 관리한다.

함수 환경 레코드 생성
  1. this 바인딩

[[ThisValue]]this가 바인딩된다. this호출될 때 동적으로 결정되기 때문에 해당 값이 바인딩 된다. 예제에서는 strict mode가 아니고, 객체 메서드가 아닌 일반 함수로 호출되었기 때문에 this에 전역 객체가 바인딩된다. [[ThisBindingStatus]]uninitialized에서 initialized로 변경된다.

this 바인딩
  1. 외부 렉시컬 환경에 대한 참조 결정

외부 렉시컬 환경의 참조는 foo 함수 평가되는 시점에 실행 중인 실행 컨텍스트의 렉시컬 환경의 환경 레코드를 가리킨다. foo 함수는 전역 함수로 전역 코드 평가 시점에 평가된다. 이 시점에 실행 중인 실행 컨텍스트는 전역 실행 컨텍스트이기 때문에 [[OuterEnv]]는 전역 환경 레코드를 가리킨다.

외부 렉시컬 환경 참조 결정

함수는 호출 시점이 아닌 정의 시점에 스코프가 결정된다고 했다. JS 엔진은 함수 정의를 평가하고 함수 객체를 생성할 때, 함수의 내부 속성 [[Environment]]에 실행 중인 실행 컨텍스트의 렉시컬 환경의 참조를 저장한다. 또한 함수 실행 컨텍스트의 렉시컬 환경의 [[OuterEnv]]에 저장되는 값도 함수의 [[Environment]] 속성에 저장된 렉시컬 환경의 참조다. 함수 내부 속성 [[Environment]]에 렉시컬 환경에 대한 참조를 저장함으로써 렉시컬 스코프가 결정되고, 클로저를 구현할 수 있는 것이다.

5) foo 함수 코드 실행

함수 코드 평가가 완료되고 런타임이 실행되면 foo 함수 코드가 순차적으로 실행된다. 매개변수에 인수가 할당되고, x, y가 순차적으로 할당된다. 그리고 bar(20)이 호출된다.

foo 함수 코드를 실행하면 식별자 결정을 위해 실행 중인 실행 컨텍스트인 foo 함수 실행 컨텍스트를 검색한다. x, y 식별자가 모두 foo 함수 실행 컨텍스트에 있으므로 해당 식별자에 대해 값을 바인딩한다.

foo 함수 코드 실행

6) bar 함수 코드 평가

foo 함수가 순차적으로 실행되어 bar 함수를 호출하는 시점에 도달했다. 이때 bar 함수 코드 평가는 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); // 42

bar 함수 코드 평가가 완료된 후의 렉시컬 환경은 다음과 같다.

bar 함수 코드 평가

7) bar 함수 코드 실행

런타임이 시작되면 bar 함수 코드가 순차적으로 실행된다. 매개변수에 인수가 할당되고, z에 값이 할당된다.

bar 함수 코드 실행

그리고 console.log(a + b + x + y + z)가 다음의 순서로 실행된다.

  1. console 식별자 검색

    • 먼저 console 식별자를 검색한다. 식별자 검색은 실행 중인 실행 컨텍스트에서부터 스코프 체인 바깥으로 이동하며 이루어지기 때문에 bar -> foo -> global의 순서로 탐색이 이루어진다. console 식별자는 전역 환경 레코드의 객체 환경 레코드의 [[BindingObject]]가 참조하는 전역 객체에서 찾을 수 있다.
  2. log() 메서드 검색

    • console 식별자에 바인딩 된 객체의 프로토타입 체인을 통해 log() 메서드를 검색한다.
  3. 표현식 평가

    • 표현식을 평가하기 위해 식별자 a, b, x, y, z를 검색한다. 각각 다음의 위치에서 찾을 수 있다.

    • a: foo의 환경 레코드

    • b: bar의 환경 레코드

    • x: foo의 환경 레코드

    • y: foo의 환경 레코드

    • z: bar의 환경 레코드

  4. console.log() 메서드 호출

    • 표현식의 평가 결과인 (20 + 10 + 3 + 4 + 5) == 42console.log()에 전달되어 호출된다.

8) 코드 실행 종료

console.log() 호출이 종료되면 bar 함수 코드 중 더 이상 실행할 부분이 없기 때문에 실행이 종료된다. bar 함수의 실행 컨텍스트는 스택에서 pop된다. foo 함수 실행 컨텍스트가 실행 중인 실행 컨텍스트가 된다.

bar 함수가 종료되면 foo 함수도 실행할 부분이 없기 때문에 종료된다. foo 함수의 실행 컨텍스트는 스택에서 pop되고, 전역 실행 컨텍스트가 실행 중인 실행 컨텍스트가 된다.

foo 함수가 종료되면 전역에서도 실행할 부분이 없기 때문에 종료된다. 전역 실행 컨텍스트가 스택에서 pop되고, 실행 컨텍스트에는 아무것도 남지 않는다.

렉시컬 환경의 메모리 해제

실행 컨텍스트가 스택에서 pop 되어도, 해당 실행 컨텍스트의 렉시컬 환경은 곧바로 메모리에서 해제되지 않는다. Javascript에서 객체는 객체를 더 이상 참조하는 곳이 없을 때 가비지 콜렉터에 의해 메모리가 해제되기 때문에 어디에서도 해당 렉시컬 환경을 참조하지 않을 때 가비지 콜렉터에 의해 메모리가 해제된다. 다만 그 시점이 언제인지는 보장할 수 없다.

블록 레벨 스코프의 렉시컬 환경

함수 레벨 스코프를 따르는 var 변수와 달리 letconst는 블록 레벨 스코프를 따른다. 이를 구현하기 위해서는 블록마다 독립적인 렉시컬 환경이 필요하다.

let x = 1;

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

console.log(x); // 1

위 예제에서 x라는 식별자가 두 번 사용되었다. 이 경우 렉시컬 환경은 다음과 같이 변화한다.

  1. 블록문을 위한 선언적 환경 레코드를 생성하고 블록문을 평가한다. [[OuterEnv]]가 기존 환경 레코드를 참조하게 한다.

    선언적 환경 레코드 생성 및 평가
  2. 실행 중인 실행 컨텍스트의 LexicalEnvironment가 새롭게 생성된 선언적 환경 레코드를 참조하게 하고, 블록문을 실행한다.

    LexicalEnvironment 참조 변경
  3. 블록문 실행이 완료되면 LexicalEnvironment가 다시 기존 환경 레코드를 참조하게 한다.

    LexicalEnvironment 참조 복구

if문 이외의 블록문도 위와 같이 독립적인 렉시컬 환경을 생성한다. 자세한 내용은 명세를 참조할 수 있다.

참고자료

9 Executable Code and Execution Contexts
자바스크립트 실행 컨텍스트와 클로저
JavaScript - Execute context
자바스크립트 함수(3) - Lexical Environment


비슷한 글이 더 있어요.

javascript

JS에서 비동기 작업을 처리하는 원리

이벤트 루프와 태스크 큐
2024/01/15

이벤트 루프와 태스크 큐

javascript

Javascript 'this' is not this.

자유로운 this
2023/11/13

자유로운 this