Javascript 1. - 실행 컨텍스트와 스코프 (Execution Contexts)

개요

javascript 실행 컨텍스트란, 말 그대로 javascript가 실행되는 환경을 말한다. 웹 환경을 예로 들면, 브라우저가 처음 사이트에 접속하고 나서 사용자가 브라우저를 닫을 때까지의 모든 변수, 함수 선언과 할당, 실행과 관련된 모든 동작 방식을 결정하는 추상적 개념이다. 사실 실행 컨텍스트가 어떻게 동작하는지만 명확히 알고 있다면 그로부터 파생되는 this, scope, closure 에 대한 개념이 저절로 잡힘은 물론이고, 왜 발생하는지 도무지 원인을 알 수 없는 각종 오류에 대해 조금이나마 논리적인 추적을 할 수 있다. 따라서 나는 이것이 javascript에서 가장 중요한 개념 중 하나라고 생각한다. 그래서 첫 javascript 포스팅으로 선택했다. 실행 컨텍스트에 대해 지금부터 차근차근 알아보자.

기본 개념

ECMAscript 스펙 문서에서는 실행 컨텍스트를, 실행 가능한 코드를 평가하고 추적하기 위한 개념이라고 정의한다. 그것을 위해 기본적으로 js의 런타임이 시작되는 시점부터 끝나는 시점까지 실행되며, 해당 코드가 필요한 변수, 함수, 스코프 등을 모두 포함한다. 또 이것은 기본적으로 stack 구조로 관리된다. 이 스택에는 다음과 같은 환경들이 포함되어 있다.

  • Global context
  • Function context

Global 이나 Function 이나 글 읽는 분이 생각하는 그 개념이 맞다. 우리는 js가 글로벌 스코프와 함수 단위 스코프를 가지고 있다는 것을 알고 있으며, 이는 실행 컨텍스트에서 기인하는 것이다. ECMAscript 스펙에서는 또한 실행 컨텍스트를 이렇게 정의한다. 잠깐 스펙을 살펴보자.

Evaluation of code by the running execution context may be suspended at various points defined within this specification. Once the running execution context has been suspended a different execution context may become the running execution context and commence evaluating its code. At some later time a suspended execution context may again become the running execution context and continue evaluating its code at the point where it had previously been suspended. Transition of the running execution context status among execution contexts usually occurs in stack-like last-in/first-out manner.

출처: ecmascript execution-contexts

해석하자면, 실행 컨텍스트는 규격에 정의된 다양한 지점에서 변경될 수 있으며, 이 지점에 이르면 기존까지의 실행 컨텍스트는 잠깐 중단되고, 해당 지점에서 다른 실행 컨텍스트가 실행된다는 뜻이다. (여기서 말하는 이 지점들이 바로 위에 언급한 global scope, functional scope 등이다!) 또 실행 컨텍스트는 후입선출(LIFO) 의 스택 방식이므로, 나중에 실행된 실행 컨텍스트가 끝나면 나머지 실행 컨텍스트가 재실행된다. 실행 컨텍스트 환경이 변경 시점을 global, function 시점으로 잡고 있기 때문에 js가 기본적으로 함수 단위 스코프를 가지고 있는 것이다. 또 이 추상적 개념을 js 엔진이 물리적 객체로 관리하기는 하나, 사용자가 이 부분에 접근해서 컨트롤 할 수는 없다.

작동 방식

자바스크립트 코드가 실행되기 위해서 실행 컨텍스트는 다음과 같은 정보를 저장한다.

  • 변수 : 전역변수, 지역변수, 매개변수, 객체의 프로퍼티
  • 함수
  • 유효범위 (스코프)
  • this가 무엇인지 저장

이것이 어디에 저장되는가? 실행 컨텍스트는 저 정보들을 물리적 객체의 형태로 관리한다고 했다. 즉 객체 자체가 실행 컨텍스트라면, 그 프로퍼티들은 다음과 같다.

  1. Scope Chain
  2. Variable Object
  3. This value

대충 매칭되지 않는가? 사실 그대로 저장된다. 변수와 함수 선언은 variable object 에 저장되고, scope chain은 순서가 보장된 배열의 형태로 순차적으로 쌓이며, this 또한 해당 시점의 this 값이 저장된다.

흐름을 살펴보자. 우리가 브라우저를 통해 사이트에 접속하면 알다시피 먼저 전역 객체가 생성된다. 전역 객체는 BOM, 즉 브라우저 내장 객체와 DOM, 그리고 Array, Object, String 등의 기본 빌트인 객체들을 포함한다. 이후에 실행 컨텍스트가 전역 객체로 진입한다. 이 시점의 컨텍스트는 실행 컨텍스트 스택에 쌓이며, 이 컨텍스트는 다음과 같은 작업을 (반드시 차례대로) 수행하게 된다.

  1. 스코프 체인이 생성되고 초기화된다. 인덱스 0부터 시작하는 리스트의 형태로 관리된다.
  2. 함수 선언 저장 (변수 객체화), 함수 => 변수 순으로 저장되며, 함수의 경우 함수명이 프로퍼티, 생성된 함수 객체가 값으로 저장된다. 함수 객체는 [[Scopes]] 값을 가지고 있는데, 이 [[Scopes]] 는 선언된 시점의 실행 컨텍스트와 상위의 실행 컨텍스트를 포함한 객체이다. 즉 앞서 생성된 스코프 체인을 가리킨다.
  3. 변수의 경우 변수명이 프로퍼티이고 초기값은 undefined 이다.
  4. this값이 결정된다. 전역 객체에서의 this 값은 전역 객체이다.

지금까지는 코드가 실행되기 전 작업이었다. 코드가 실행되면 컨트롤이 이동하면서 순차적으로 나머지 코드를 실행하게 된다. 여기서 잠깐, 위의 작업들이 순차적으로 수행된다고 했는데, 사실 이것은 호이스팅의 원리이다. 즉 변수나 함수가 지정되기 전의 코드 라인에서 함수를 실행하거나 변수를 할당하게 되어도, 먼저 실행부터 되는 것이 아니라 컨텍스트가 저 정보들을 수집한 뒤에 코드가 실행되기 때문에 오류가 나지 않는 것이다.

예시

다음과 같은 코드를 보자

var x = 'xxx';

function foo () {
  var y = 'yyy';

  bar();
  function bar () {
    var z = 'zzz';
    console.log(x + y + z);
  }
}

foo();

전역 스코프에 이러한 코드가 있다고 가정을 해 보자.

  1. 먼저 설명한 대로, 전역 객체 생성 후 실행 컨텍스트는 자신의 컨텍스트 내에서 필요한 정보들을 저장할 것이다. 변수 x, 함수 foo, this값, 스코프 등이다.
  2. 먼저, 아직 전역에서 코드가 실행되려 하고 있으므로 스코프 체인 리스트에는 전역 객체만이 0번 인덱스에 들어갈 것이다. 전역 객체의 다음 스코프는 null을 참조한다.
  3. 다음으로, 함수 foo에 대한 정보가 variable object 에 들어갈 것이다. 함수 foo의 프로퍼티 네임은 foo가 될 것이고, value는 함수 객체 자신이 될 것이다. 여기에서 주의해야 할 점은, 아직 컨트롤이 foo를 실행하지 않고 있기 때문에 foo 함수 내의 정보는 실행 컨텍스트에 반영되기 전이라는 것이다. 또한 함수 선언식은 선언과 동시에 함수 객체를 할당하지만, 표현식의 경우에는 일반 변수의 방식과 똑같이 실행 될 떄 값을 할당한다.
  4. 변수 x 에 대한 정보가 variable object 에 들어갈 것이다. 이 떄의 값은 undefined 이다.
  5. This 값이 결정된다. 이 때의 this값 역시 함수 호출 패턴이기 때문에 전역 객체이다.
  6. 이후 코드가 실행된다. x = ‘xxx’ 코드를 만났기 때문에 variable object(이하 VO) 의 x 프로퍼티에는 xxx의 value 가 들어간다.
  7. foo() 를 만난다.

마침내, 전역 실행 컨텍스트가 함수 foo의 실행부를 만났다. 이 때 함수 컨텍스트가 생성되는 것이다! 이 함수 컨텍스트는 실행 컨텍스트 스택에 쌓이게 되고 이전의 과정을 반복한다. 단, 전역 컨텍스트의 경우 VO가 전역 객체를 레퍼런스로 가리켰다면, 함수 컨텍스트는 지금 실행되고 있는 활성 객체, 즉 Activation Object 를 가리킨다. 이 활성 객체는 전역 객체에 추가적으로, 함수의 인자(argument), 즉 매개변수를 추가적으로 저장한다. 이 매개변수는 맨 처음 저장된다. 즉 매개변수 => 함수 선언 => 변수 선언 순으로 VO에 저장된다. 다시 흐름을 살펴보도록 하자.

  1. 활성 객체가 생성되면서 실행 컨텍스트에 스코프 체인이 초기화된다. 여기서 스코프 체인은 0번 인덱스가 현재의 활성 객체, 즉 foo 함수의 컨텍스트가 되며, 다음의 1번은 전역 객체, 즉 전역 컨텍스트이다. 이 안에서 변수나 함수를 선언, 할당할 때 엔진은 0번 스코프부터 차례대로 검색한다. 0번에 없으면 1번을 검색한다. 즉 상위 컨텍스트로 올라가면서 찾는다. 이것이 함수 기반 스코프가 발생하는 이유이다!
  2. foo 함수 내의 bar 함수 객체를 VO에 저장한다. 원래 bar 함수에 매개변수가 있었다면, 이 매개변수를 저장하는 것이 첫 번째 순서가 되었을 것이다.
  3. 마찬가지로 변수 y의 선언을 VO에 저장한다. 값은 역시 undefined 이다.
  4. This 값을 설정한다. 이 때의 this값 역시 함수 호출 패턴에 따라 전역 객체이다.
  5. 이후 코드를 실행하면서 y = ‘yyy’, bar()를 만난다. bar가 선언보다 먼저 실행되었음에도 오류가 나지 않는 것은 마찬가지로 실행 컨텍스트의 VO생성 순서에서 비롯된 호이스팅의 원리이다.
  6. bar 함수의 컨텍스트인 활성 객체를 생성하여 이전의 과정을 되풀이한다.

건너뛰고, 이 부분을 보자

console.log(x + y + z);

이제 이 부분이 어떻게 실행되는지 감이 올 것이다. bar 함수의 컨텍스트는 먼저 x를 자신의 [[Scopes]] 프로퍼티에서 찾을 것이고, 없으면 1번 인덱스인 foo함수 내에서 찾을 것이다. 그래도 없기 때문에 검색은 2번 인덱스인 전역 객체로 넘어가게 된다. y도 마찬가지로 0번부터 순차적으로 검색을 할 것이고, 1번 인덱스인 foo함수 내에서 결과를 발견할 것이다. z는 다행히 자신의 컨텍스트 안에 있다.

또 한가지 중요한 것은, 스코프 체인 프로퍼티에 저장된 객체의 레퍼런스는, 만약 외부함수의 컨텍스트가 실행되고 소멸하였다고 하더라도 스코프 체인에서 지워지지 않는다. 즉 foo() 함수가 먼저 마감되는 코드가 있다고 가정하더라도, y의 검색이 실패하지 않는다는 이야기이다. 무엇을 말하는지 알겠는가? 바로 클로저의 기본 원리이다. 클로저에 관련해서는 다음에 서술하도록 하겠다.

2018년 09월 16일 작성

대부분 제가 배운 것들을 남기기 위해 글을 쓰고 있습니다. 이 글이 도움이 된다면 매우 기쁘겠지만, 설명이 다소 불친절하거나 오류가 있다면 댓글 남겨주세요. 더 성장하는 기회가 될 거에요 :)

🚀 bob on Github