티스토리 뷰

 

공부를 하던 중 실행 컨텍스트와 관련된 개념이 쉽사리 정리되지 않았다.

실행 컨텍스트, 스코프, 렉시컬 환경 등 비슷해 보이는 개념들 사이에서 혼란에 빠졌다.

실행 컨텍스트 개념을 한번은 확실히 이해하고 넘어가고 싶다는 마음에 모던 자바스크립트 Deep Dive 책을 바탕으로 관련 내용을 정리하고자 한다.


용어 정리

실행 컨텍스트(Execution Context)

실행 컨텍스트란 무엇일까?

공신력이 있는 글들에서 실행 컨텍스트에 대한 정의를 발췌해 정리해봤다.

 

  1. 코어 자바스크립트: "실행할 코드에 제공할 환경 정보들을 모아놓은 객체
  2. 모던 자바스크립트 Deep Dive: "식별자(변수, 함수, 클래스 등의 이름)를 등록하고 관리하는 스코프와 코드 실행 순서 관리를 구현한 내부 매커니즘으로, 모든 실행 컨텍스트를 통해 실행되고 관리된다. 실행 컨텍스트는 Lexical Environment 컴포넌트와 Variable Environment 컴포넌트로 구성된다.”
  3. EcmaScript: An execution context is a specification device that is used to track the runtime evaluation of code by an ECMAScript implementation. An execution context is purely a specification mechanism and need not correspond to any particular artefact of an ECMAScript implementation. It is impossible for ECMAScript code to directly access or observe an execution context.

 

렉시컬 환경(Lexical Environment)

렉시컬 환경은 단어 그대로 해석하면 어휘적 환경이다. 이 단어를 이해하기 위해서 '사전'을 떠올려봐도 좋을 것 같다. 모르는 단어를 사전에서 찾듯이, 코드를 실행하는 도중 식별자를 만나면 그 식별자 정보를 렉시컬 환경에서 검색한다. 기본적으로 렉시컬 환경에는 식별자와 그에 바인딩된 값이 저장되며, 이 밖에도 this 바인딩 정보와 스코프 체이닝을 위한 외부 환경에 대한 참조값이 저장된다. 렉시컬 환경은 실행 컨텍스트에 의해 참조된다.

 

스코프(Scope)

스코프는 단어 그대로 해석하면 범위이다. JS에서 스코프란 '값을 참조할 수 있는 유효범위' 정도로 이해할 수 있을 것 같다. '스코프가 다르다'라는 말은 포함된 렉시컬 환경의 범주가 다르다는 의미이다. '블록 레벨 스코프'라는 단어는 블록 단위로 유효 범위가 나뉜다는 개념을 지칭한다. 이런식으로 맥락 속에서 이해해보면 스코프의 의미가 조금 더 와닿는다. 한편, 스코프와 렉시컬 환경이 비슷해 보여 혼란스러울 수 있다. 실제로 두 개념이 아예 별개라고 말하기 어려울 것 같다. 렉시컬 환경과 스코프가 서로 다른 개념이라고 받아들이기보다는 단어를 사용할 때의 관점과 맥락이 다르다는 차원에서 이해해보면 어떨까?


소스코드 유형

참고로, 실행 컨텍스트를 생성하는 소스코드에는 4가지 유형이 있다.

  1. 전역 코드
  2. 함수 코드
  3. eval 코드
  4. 모듈 코드

위의 네 가지 경우에 독자적인 실행 컨텍스트가 생긴다. 여기서 '어? 블록도 독자적인 스코프를 갖는데? 실행 컨텍스트 생성하는 거 아닌가?'라는 의문이 들 수 있을 것 같다. 그런데 엄밀히 따지면 블록은 실행 컨텍스트를 만들지 않는다. 블록이 독자적인 스코프를 갖는 이유는 독자적인 렉시컬 환경을 만들기 때문이다. 이건 또 무슨 말인가? 지금 모든 것을 한 번에 파악하려니 너무 헷갈린다. 이에 대해서는 이후에 추가로 설명을 덧붙이겠다.


모던 자바스크립트 Deep Dive에 나온 예시 소스코드를 통해 실행 컨텍스트 관련 동작이 어떻게 이루어지는지, 그리고 실행 컨텍스트의 구성 요소에는 무엇이 있는지 구체적으로 알아보자.

 

먼저 소스코드 동작이 평가와 실행 과정으로 나누어 이루어진다는 점을 짚고 넘어가자.

평가와 실행

평가 과정에는 실행 컨텍스트를 생성하고 변수, 함수 등의 선언문만 먼저 실행하여 생성된 변수나 함수 식별자를 key로 하여 실행 컨텍스트가 가리키는 렉시컬 환경의 환경 레코드에 등록한다.

실행 과정에는 소스 코드를 순차적으로 실행한다(런타임 시작). 실행에 필요한 정보(변수, 함수 참조)는 실행 컨텍스트의 스코프에서 검색해서 취득한다. 또한 변수 값 변경 등의 실행 결과는 다시 실행 컨텍스트의 스코프에 반영된다.

여기서 변수에 값이 들어가는 할당은 평가가 아닌 실행 과정에 포함된다는 점에 유의하자.

 

예시를 통한 JS 코드 실행 과정 탐구

아래는 JS Deep Dive에 나온 예시 코드이다.

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. 전역 객체 생성

전역 코드가 평가 되기 전, 전역 객체가 생성된다. 이때 전역 객체에는 우리가 유용하게 사용하는 빌트인 프로퍼티/함수/객체가 포함된다. 또한 환경(브라우저/노드)에 따라 Web API나 호스트 객체가 포함된다.

2. 전역 코드 평가

소스코드가 로드되면 JS 엔진은 전역 코드 평가를 시작한다.

 

2-1. 먼저 비어있는 전역 실행 컨텍스트(Global Execution Context)를 생성해 실행 컨택스트 스택에 푸시한다. 이때 이 컨텍스트는 스택의 최상위에 있으므로 실행 중인 실행 컨텍스트(running execution context)가 된다.

 

2-2. 전역 렉시컬 환경(Global Lexical Environment)을 생성하여 전역 실행 컨텍스트에 바인딩한다. 이때 렉시컬 환경은 식별자 정보 저장을 위한 전역 환경 레코드(Environment Record)와 스코프 체이닝을 위한 외부 렉시컬 환경에 대한 참조(Outer Lexical Environment Reference)로 구성된다. 이 중 전역 환경 레코드는 또 다시 객체 환경 레코드(object environment record)와 선언적 환경 레코드(declarative environment record)로 구성된다.

 

2-2-1. 객체 환경 레코드(Object Environment Record)가 생성된다. 정확히 말하면 객체 환경 레코드의 BindingObject 프로퍼티에 앞서 생성된 전역 객체가 연결된다. 객체 환경 레코드에는 빌트인 프로퍼티/함수/객체, var 선언 변수, 함수 표현식 선언 함수가 담긴다.

 

전역 객체(BindingObject)에 연결된 프로퍼티와 메서드들은 객체를 가리키는 식별자(window) 없이 참조할 수 있다. 이것이 window.alert를 alert로 호출할 수 있는 이유이다. 또한 var로 선언된 변수는 "선언 단계"와 "초기화 단계(undefined)"가 동시에 진행된다. 그래서 전역 코드 평가 시점에 객체 환경 레코드에 undefined가 바인딩된다. 한편 함수 표현식으로 선언된 함수는 var 변수와 달리 함수 객체가 즉시 할당된다. 그래서 변수 호이스팅으로 *끌어올려진 변수의 값은 항상 undefined이지만, 함수 호이스팅으로 끌어올려진 함수는 호출까지 가능한 것이다. 

 

*끌어올려졌다는 것은 동작의 관점에서 바라본 일종의 비유적 표현이다. 엄밀한 의미에서 호이스팅이 발생한 이유는 "선언 단계"와 "초기화 단계"가 동시에 진행되어 실행 흐름 이전에 할당이 이루어졌기 때문이다. 끌어올리는 동작이 별도로 수행된 건 아니다.

 

2-2-2. 선언적 환경 레코드(Declarative Environment Record)가 생성된다. 선언적 환경 레코드에는 var 선언 변수와 함수 선언문 선언 함수 이외의 선언, 즉 let, const 키워드로 선언한 전역 변수(함수 표현식 포함)가 선언적 환경 레코드에 등록된다. 이들은 객체 환경 레코드에 등록된 변수와 다르게 전역 객체의 프로퍼티가 되지 않는다. 그래서 let, const로 선언한 변수는 window 객체를 통해 접근할 수 없는 것이다. 또한 let, const로 선언한 변수는 "선언 단계"와 "초기화 단계"가 분리되어 진행된다. 그래서 실행 흐름이 변수 선언문에 도달하기 전까지 값을 참조할 수 없어 TDZ(일시적 사각지대)에 빠지게 된다.

 

2-3. this 바인딩이 이뤄진다. 전역 환경 레코드의 [[GlobalThisValue]] 내부 슬롯에 this가 바인딩된다. 전역 코드에서는 this가 전역 객체를 가리키므로 전역 객체가 바인딩된다. 전역 코드에서 this를 참조하면 전역 환경 레코드의 [[GlobalThisValue]] 내부 슬롯에 바인딩되어 있는 객체가 반환된다.

 

2-4. 전역 레시컬 환경의 외부 렉시컬 환경에 대한 참조(OuterLexicalEnvironmentReference)가 결정된다. 외부 렉시컬 환경에 대한 참조는 외부 소스코드의 렉시컬 환경, 즉 상위 스코프를 가리킨다. 이를 틍해 스코프 체인을 구현한다. 그런데 현재 평가 중인 소스코드는 전역 코드이므로 상위 스코프가 없다. 그래서 null이 할당된다.

 

지금까지의 상황을 정리하면 이와 같다.

3. 전역 코드 실행

전역 코드가 순차적으로 실행된다.

전역 코드의 변수 할당문이 실행되어 전역 변수 x, y에 값이 할당된다. 그리고 이어서 foo 함수가 호출된다.

 

여기서 변수 할당문과 함수 호출문을 실행하기 위해서는 먼저 변수 또는 함수 이름이 선언된 식별자가 맞는지 확인해야 한다. 선언되지 않은 식별자는 참조할 수 없으므로 할당이나 호출이 불가하기 때문이다. 또한 식별자는 스코프가 다르면 여러개 존재할 수 있다. 그렇기에 어느 스코프의 식별자를 참조하면 되는지 결정하는 과정이 필요하다. 이를 식별자 결정(Identifier resolution)이라 한다.

 

식별자 결정을 위해 검색할 때는 실행중인 실행 컨텍스트에서 식별자를 검색하기 시작한다. (선언된 식별자는 실행 컨텍스트의 렉시컬 환경의 환경 레코드에 등록되어 있다.) 만약 실행 중인 컨텍스트의 렉시컬 환경에서 식별자를 검색할 수 없으면 상위 스코프로 이동하여 검색한다(스코프 체이닝). 만약 종점인 전역 렉시컬 환경에서까지 검색할 수 없는 식별자가 있다면 참조 에러를 발생시킨다.

 

4. 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); // ⭐️ 이 곳 호출 직전

 

4-1. foo 함수의 함수 실행 컨텍스트가 생성된다. 이때, 바로 실행 컨텍스트 스택에 푸시되는 것은 아님에 유의하자. 스택에 푸시되는 건 렉시컬 환경이 완성된 이후이다.

 

4-2. 함수 렉시컬 환경이 생성된다. 렉시컬 환경은 환경 레코드외부 레시컬 환경에 대한 참조(Outer Lexical Environment Reference), 두 개의 컴포넌트로 구성된다.

 

4-2-1. 함수 환경 레코드(Function Environment Record)가 생성된다. 함수 환경 레코드는 매개변수, arguments 객체, 함수 내부에서 선언한 지역 변수와 중첩 함수(함수 내 선언된 함수)를 등록하고 관리한다.

4-2-2. this 바인딩이 이루어진다. 함수 환경 레코드의 [[ThisValue]] 내부 슬롯에 this가 바인딩된다. 이때 내부 슬롯에 바인딩될 객체는 함수 호출 방식에 따라 결정된다. foo 함수는 일반 함수로 호출되었으므로 this에 전역 객체가 바인딩된다. 이로써 foo 함수 내부에서 this를 참조하면 함수 환경 레코드의 [[ThisValue]] 내부 슬롯에 바인딩되어 있는 객체가 반환된다.

 

4-3. 외부 렉시컬 환경에 대한 참조가 결정된다. OuterLexicalEnvironmentReference에 foo 함수 정의가 평가되는 시점에 실행 중인 실행 컨텍스트의 렉시컬 환경의 참조가 할당된다. foo 함수는 전역 코드 평가 시점에 평가됐으므로 전역 렉시컬 환경의 참조가 할당된다. 

 

 

5. foo 함수 코드 실행

이제 런타임이 시작되어 foo 함수의 소스코드가 순차적으로 실행된다. 매개변수에 인수가 할당되고, 변수 할당문이 실행되어 지역 변수 x, y에 값이 할당된다. 그리고 함수 bar가 호출된다. 이때 식별자 결정을 위해 실행 중인 실행 컨텍스트의 렉시컬 환경에서 식별자를 검색하기 시작한다. 즉, foo 함수 렉시컬 환경에서 식별자 x, y를 검색하기 시작한다. 만약 실행 중인 실행 컨텍스트의 렉시컬 환경에서 식별자를 검색할 수 없으면 외부 렉시컬 환경에 대한 참조가 가리키는 렉시컬 환경으로 이동하여 검색한다. 검색된 식별자에 값을 바인딩한다.

 

 

6. bar 함수 코드 평가

현재는 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 함수 코드 평가 과정과 동일하므로 생략한다.

 

7. bar 함수 코드 실행

이제 런타임이 시작되어 bar 함수의 소스코드가 순차적으로 실행된다. 

 

7-1.  매개변수에 인수가 할당되고, 변수 할당문이 실행되어 지역 변수 z에 값이 할당된다.

 

7-2. console.log가 실행된다.

 

7-2-1. "console" 식별자를 스코프 체인에서 검색한다. 현재 실행 중인 컨텍스트인 bar 함수 실행 컨텍스트의 렉시컬 환경 -> foo 함수  렉시컬 환경 -> 전역 렉시컬 환경 순으로 console을 검색한다. console은 전역 렉시컬 환경의 BindingObject에서 검색된다.

 

7-2-2. 검색된 console 객체에서  log 메서드를 검색한다. console의 프로토타입 체인을 통해 log 메서드를 검색한다.

 

7-2-3. 표현식 a + b + x + y + z를 평가한다. console.log 메서드에 전달할 인수인 표현식을 평가하기 위해 a, b, x, y, z 식별자를 이전과 마찬가지로 스코프 체이닝을 통한 렉시컬 환경의 연속에서 검색한다. a 식별자는 foo 함수 렉시컬 환경에서, b 식별자는 bar 함수 렉시컬 환경에서, x와 y 식별자는 foo 함수 렉시컬 환경에서, z 식별자는 bar 함수 렉시컬 환경에서 검색된다.

 

7-2-4. console.log 메서드를 호출한다. 표현식 a + b + x + y + z가 평가되어 생성한 값 42를 console.log 메서드에 전달하여 호출한다. (이때도 호출을 위한 실행 컨텍스트가 생긴다)

 

8. bar 함수 코드 실행 종료

console.log 메서드가 호출되고 종료하면 더 실행할 코드가 없으므로 bar 함수 코드의 실행이 종료된다. 이때 실행 컨텍스트 스택에서 bar 함수 실행 컨텍스트가 팝되어 제거되고 foo 실행 컨텍스트가 실행 중인 실행 컨텍스트가 된다.

 

참고로 bar 실행 컨텍스트가 팝되어 제거되었다고 해서 bar 함수 렉시컬 환경까지 즉시 소멸하는 것은 아니다. 실행 컨텍스트가 렉시컬 환경을 참조하는 것은 맞지만, 어디까지나 둘은 독립적인 객체다. 따라서 다른 객체와 마찬가지로 렉시컬 환경도 다른 누군가에 의해 참조되지 않을 때 비로소 가비지 컬렉팅의 대상이 된다. 만일 bar 함수 실행 컨텍스트가 소멸되었더라도 누군가 bar 함수 렉시컬 환경을 참조하고 있다면 bar 함수 렉시컬 환경은 소멸하지 않고 유지된다.

 

9. foo 함수 코드 실행 종료

foo 함수에는 bar 함수가 종료되면 더 이상 실행할 코드가 없으므로 foo 함수 코드의 실행이 종료된다. 이때 foo 함수 실행 컨텍스트가 실행 컨텍스트 스택에서 팝되어 제거되고 전역 실행 컨텍스트가 실행 중인 실행 컨텍스트가 된다.

 

10. 전역 코드 실행 종료

foo 함수가 종료되면 더는 실행할 전역 코드가 없다. 따라서 전역 코드의 실행이 종료되고 전역 실행 컨텍스트도 실행 컨텍스트 스택에서 팝되어 실행 컨텍스트 스택에 아무것도 남아있지 않게 된다.

 


번외) 블록 레벨 스코프

var가 아닌 let, const는 모든 코드 블록(함수, if 문, for 문, while 문, try/catch 문)을 지역 스코프로 인정하는 블록 레벨 스코프를 따른다. 그런데 글의 초반부에서 블록은 독자적인 실행 컨텍스트를 갖지 않는다고 했다. 그렇다면 어떻게 독자적인 스코프를 갖는 것일까?

블록 단위로 새로운 실행 컨텍스트가 생성되지는 않지만, 대신 새로운 렉시컬 환경이 생기기 때문이다.

 

예제를 통해 알아보자.

let x = 1;

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

console.log(x);

 

위 예제에서는 if 문의 코드 블록 내에서 let 키워드로 변수가 선언됐다. 따라서 if 문 코드 블록이 실행되면 if 문 코드 블록을 위한 렉시컬 환경(선언적 환경 레코드를 가짐)이 새롭게 생성된다. 실행 컨텍스트의 렉시컬 환경 참조가 새롭게 생성된 렉시컬 환경의 참조를 가리킨다. 또한 새롭게 생성된 블록 렉시컬 환경의 Outer Lexical Environment Reference는 전역 렉시컬 환경을 가리킨다.

 

 

if 문 코드 블록의 실행이 종료되면, 실행 컨텍스트의 렉시컬 환경 참조를 if 문 코드 블록이 실행되기 이전의 렉시컬 환경, 즉 전역 렉시컬 환경에 대한 참조로 되돌린다.

 

이는 if 문뿐만 아니라, for 문, try/catch 문 등의 모든 블록문에 적용된다.


짧은 소감

실행 컨텍스트 관련 공부를 마치고 나니 JS 코드가 실행되는 과정이 머릿속에 조금 더 선명하게 그려진다. 당장 구현을 하면서 이 모든 것들을 떠올릴 수는 없겠지만, 구현 사항이 복잡한 상황에 큰 힘이 될 것이라 생각한다. 또한, 관련 개념을 익혀나가는 데에도 큰 도움이 될 것 같다. 한편 실행 컨텍스트를 공부하며 어려움을 많이 느끼기도 했다. 구체적인 구현체가 아닌 추상적인 스펙에 관한 학습이다보니, 학습 내용이 명료하게 와닿지 않아 이를 나만의 그림으로 정리하는 노력이 필요했다. 당연하게 받아들이고 간편하게 사용했던 JS가 상당히 복잡한 과정에 의해 동작하고 있었음을 어렴풋하게나마 알 수 있었다. 아직은 어렵게만 느껴지지만 정진해서 언젠가는 이 내용을 나만의 설명으로 풀어내보고 싶다.


참고 자료 🙇🏻‍♂️

서적/문서

이웅모님 모던 자바스크립트 Deep Dive

EcmaScript

코어 자바스크립트

 

이미지 출처

Narcoker님의 블로그

정현수님의 블로그

 

'자바스크립트' 카테고리의 다른 글

createElement vs innerHTML  (0) 2024.04.09