[JavaScript] 실행 컨텍스트 (execution context) 톺아보기

Donghwa Kim·2022년 12월 2일
1

JavaScript 동작원리

목록 보기
6/7

본 글은 정재남님의 <코어 자바스크립트>를 읽고 정리한 내용입니다.

들어가기 전에

실행 컨텍스트를 한 문장으로 정의하면 아래와 같습니다.

실행 컨텍스트: 실행할 코드에 제공할 환경 정보들을 모아놓은 객체

자바스크립트는 어떤 실행 컨텍스트활성화 되는 시점에 아래의 동작들을 수행합니다.

  1. 선언된 변수를 위로 끌어 올리고(hoisting),
  2. 외부 환경 정보를 구성하고
  3. this값을 설정

이로 인해 다른 언어에서 발견할 수 없는 특이한 현상들이 발생합니다.

실행 컨텍스트는 자바스크립트에서 가장 중요한 핵심 개념 중 하나입니다. 클로저를 지원하는 대부분의 언어에서 이와 유사하거나 동일한 개념이 적용되어 있습니다.

지금 부터 실행 컨텍스트를 구성하는 환경 정보들에 대해서 꼼꼼히 살펴보겠습니다.

실행 컨텍스트란?

앞서 실행 컨텍스트를 실행할 코드에 제공할 환경 정보들을 모아놓은 객체라고 했다.

동일한 환경에 있는 코드들을 실행할 때 필요한 환경 정보들을 모아 컨텍스트를 구성하고, 이를 콜 스택 (call stack)에 쌓아 올렸다가 가장 위에 쌓여있는 코드들을 실행하는 식으로 전체 코드의 환경과 순서를 보장한다.

여기서 동일한 환경 즉, 하나의 실행 컨텍스트를 구성할 수 있는 방법으로 전역공간, eval() 함수, 함수 등이 있다.

자동으로 생성되는 전역공간과 악마로 취급받는 eval을 제외하면 우리가 흔히 실행 컨텍스트를 구성하는 방법은 함수를 실행하는 것 뿐이다.

아래 예제를 보며 이해해 보자

// ------------------------ (1)
var a = 1;
function outer() {
  function inner() {
    console.log(a); // undefined
    var a = 3;
  }

  inner(); //---------------(2)
  console.log(a) // 1
}

outer(); //-----------------(3)
console.log(a); // 1

위 자바스크립트 코드를 실행하는 순간(1) 전역 컨텍스트콜 스택에 담긴다. 전역 컨텍스트라는 개념은 일반적인 실행 컨텍스트와 특별히 다를 것이 없다. 최상단의 공간은 코드 내부에서 별도의 실행 명령이 없어도 브라우저에서 자동으로 실행하므로 자바스크립트 파일이 열리는 순간 전역 컨텍스트가 활성화된다고 이해 하면 된다.

현재 콜 스택에는 전역 컨텍스트 외에 다른 덩어리가 없으므로 전역 컨텍스트와 관련된 코드를 순차로 진행한다.

그러다 (3)에서 outer 함수를 호출하면 자바스크립트 엔진은 outer에 대한 환경 정보를 수집해서 outer 실행 컨텍스트를 생성한 후 콜 스택에 담는다.

콜 스택 맨 위에 outer 실행 컨텍스트가 놓인 상태가 됐으므로 전역 컨텍스트와 관련된 코드의 실행을 일시중단하고 대신 outer 실행 컨텍스트와 관련된 코드, 즉 outer 함수 내부 코드들을 순차로 실행한다.

다시 (2)에서 inner 함수의 실행 컨텍스트가 콜 스택의 가장 위에 담기면 outer 컨텍스트와 관련된 코드의 실행을 중단하고 inner 함수 내부의 코드를 순서대로 진행한다.

그러다가 각 컨텍스트의 가장 마지막 줄이 실행되고 함수의 실행이 종료되면 해당 컨텍스트가 콜 스택에서 제거된다.

콜 스택을 그림으로 나타내면 아래와 같다.

스택 구조를 잘 생각해 보면 한 실행 컨텍스트가 콜 스택의 맨 위에 쌓이는 순간이 곧 현재 실행할 코드에 관여하게 되는 시점임을 알 수 있다.

즉, 어떤 실행 컨텍스트가 활성화 될 때 자바스크립트 엔진은 해당 컨텍스트에 관련된 코드들을 실행하는데 필요한 환경 정보들을 수집해서 실행 컨텍스트에 저장한다.

실행 컨텍스트에 담기는 정보는 아래와 같다.

  • VariableEnviroment
    • 현재 컨텍스트 내의 식별자들에 대한정보
    • 외부 환경 정보
    • 선언 시점의 LexicalEnviroment의 스냅샷으로, 변경사항은 저장되지 않음
  • LexicalEnviroment
    • 처음에는 VariableEnviroment와 같지만 변경 사항이 실시간으로 반영됨
  • ThisBinding
    • this 식별자가 바라봐야 할 대상 객체

VariableEnviroment

  • VariableEnviroment에 담기는 내용은 LexicalEnviroment와 같지만 최초 실행 시의 스냅샷을 유지한다는 점이 다름
  • 실행 컨텍스트를 생성할 때, VariableEnviroment에 정보를 먼저 담은 다음 이를 그대로 복사해서 LexicalEnviroment를 만들고, 이후에는 LexicalEnviroment를 주로 활용
  • VariableEnviromentLexicalEnviroment 의 내부는 environmentRecord와 outer-EnvironmentReferences로 구성
  • 초기화 과정 중에는 완전히 동일, 이후 코드 진행에 따라 달라짐

LexicalEnvironment

  • 한국어로 어휘적 환경, 정적 환경 으로 번역 됨
  • 어휘적 이란 표현은 사전적 의미로서 와닿지가 않고 정적은 수시로 변화는 환경 정보를 의미하는 LexicalEnvironment에 대한 적절한 번역이라 보기 힘듦 (저자의 주관적 의견)
  • 저자는 이해를 위해서는 사전적인 환경이라는 표현이 적절하다고 함
    • 컨텍스트를 구성하는 환경 정보들을 사전에서 접하는 느낌으로 모아놓은 것이기 때문
  • 타인과의 커뮤니케이션을 위해서는 가급적 원어를 문자 그대로 받아들이는 편이 좋음

1. environmentRecord와 호이스팅

environmentRecord에는 현재 컨텍스트와 관련된 코드의 식별자 정보들이 저장됨

  • 컨텍스트를 구성하는 함수에 지정된 매개변수 식별자
  • 함수 자체 (선언한 함수가 있을 경우)
  • var로 선언된 변수의 식별자
  • 저장을 할 때는 컨텍스트 내부 전체를 처음부터 끝까지 쭉 훑어나가며 순서대로 수집
  • (참고) 전역 실행 컨텍스트는 변수 객체를 새엉하는 대신 JS 구동 환경이 별도로 제공하는 객체, 즉 전역 객체를 활용
    • 브라우저의 window, Node.js의 global 객체가 이에 해당
    • 이들은 JS 내장 객체가 아닌 호스트 객체로 분류 됨

변수 정보를 수집하는 과정을 모두 마쳤더라도 아직 실행 컨텍스트가 관려할 코드들은 실행 전의 상태. 즉, 코드가 실행되기 전에 JS엔진은 이미 해당 환경에 속한 코드의 변수명들을 모두 알고 있게 되는 셈

그렇다면 위에서 설명한 엔진의 실제 동작 방식 대신에 '엔진이 식별자들을 최상단으로 끌어올려놓은 다음 실제 코드를 실행한다' 라고 설명해도 코드를 해석하는 데는 문제가 없음

여기서 등장한 개념이 호이스팅 ('끌어 올리다'의 뜻인 hoist에 ing를 붙여 만든 동명사)

즉, 변수 정보 수집 과정을 더욱 이해하기 쉬운 방법으로 대체한 가상의 개념. 실제로 끌어올리지는 않지만 편의상 끌어올린 것으로 간주하자는 것

호이스팅 규칙

environmentRecord에는 매개변수의 이름, 함수 선언, 변수명 등이 담긴다고 했다.

  • environmentRecord는 현재 실행 될 컨텍스트의 대상 코드 내에 어떤 식별자들이 있는지에만 관심이 있고, 각 식별자에 어떤 값이 할당될 것인지는 관심 없음
  • 따라서 변수명만 끌어올리고 할당 과정은 원래 자리에 그대로 남겨둠
  • 매개변수의 경우도 마찬가지

몇 가지 예제를 통해서 알아보자.

매개변수와 변수에 대한 호이스팅(1) - 원본

function a(x) {   // 수집 대상 1 (매개 변수)
  console.log(x); // (1)
  var x;          // 수집 대상 2 (변수 선언)
  console.log(x); // (2)
  var x = 2;      // 수집 대상 3 (변수 선언)
  console.log(x); // (3)
}

a(1);

우선 호이스팅이 되지 않았을 때 (1)~(3)에서 어떤 값들이 출력될지를 예상해 보자. (1)에는 함수 인자로 전달한 1이 출력되고, (2)는 변수 x에 할당한 값이 없으니까 undefined, (3)은 2가 출력 될 것 같다.

실제로는 어떤 결과가 나오는지 살펴보자.

(주의) 지금부터는 자바스크립트 엔진 구동 방식을 좀 더 사람의 방식에서 이해해보고자 코드를 변경해 볼 것이다. 실제 엔진은 이러한 변환 과정을 거치지 않으니 오해하지 말자!

매개변수와 변수에 대한 호이스팅(1) - 호이스팅을 마친 상태

function a() {
  var x;
  var x;
  var x;

  x = 1;
  console.log(x); // (1)
  console.log(x); // (2)
  x = 2;
  console.log(x); // (3)
}

a(1)

따라서 실제 결과(1)1, (2)1, (3)2 가 된다.

다음은 함수 선언에 대한 예제를 살펴보자.

함수 선언의 호이스팅(1) - 원본

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

a();

함수 선언의 호이스팅(1) - 호이스팅을 마친 코드

  • 함수 선언함수 전체를 끌어올림
function a() {
  var b;
  function b() { };

  console.log(b);
  b = 'bbb';
  console.log(b);
  
  console.log(b);
}

a();

함수 선언의 호이스팅(1) - 함수 선언문을 함수 표현식으로 변경

  • 해석의 편의를 위해서 함수 선언문함수 표현식으로 변경
function a() {
  var b;
  var b = function b() { }; // 바뀐 부분

  console.log(b); // (1)
  b = 'bbb';
  console.log(b); // (2)
  
  console.log(b); // (3)
}

실행 결과는 (1)[Function: b], (2)'bbb' (3)'bbb' 가 된다

함수 선언문과 함수 표현식

자바스크립트에서 함수를 정의하는 방식은 크게 세가지가 있고, 그 방법에 따라 hoisting 동작에 차이가 있다.

  • 함수 선언문

    • function 정의부만 존재, 별도의 할당 명령이 없음
    • 반드시 함수명이 정의돼 있어야 함
  • 함수 표현식

    • 정의한 function을 별도의 변수에 할당
    • 함수명이 정의돼 있으면 -> 기명 함수 표현식
    • 함수명이 정의돼 있지 않으면 -> 익명 함수 표현식
    • 일반적으로 함수 표현식은 익명 함수 표현식을 말함
  • 함수를 정의하는 세 가지 방법

    function a() {/* ... */ } // 함수 선언문. 함수명 a가 곧 변수명.
     a(); // 실행 OK.
    
     var b = function () {/* ... */ } // (익명) 함수 표현식. 변수명 b가 곧 함수명
     b() // 실행 OK.
    
     var c = function d() {/* */ } // 기명 함수 표현식. 변수명은 c, 함수명은 d
     c(); // 실행 OK.
     d(); // ReferenceError: d is not defined

함수 선언문과 함수 표현식 (1) - 원본 코드

console.log(mySum(1, 2));
console.log(myMultiply(3, 4));

function mySum (a, b) {
  return a + b;
}

var myMultiply = function (a, b) {
  return a * b;
}

함수 선언문과 함수 표현식 (1) - 호이스팅을 마친 상태

function mySum (a, b) {
  return a + b;
}

var myMultiply;

console.log(mySum(1, 2)); // (1)
console.log(myMultiply(3, 4)); // (2)

function mySum (a, b) {
  return a + b;
}

myMultiply = function (a, b) {
  return a * b;
}

실행 결과는 아래와 같을 것이다.
(1) 3
(2) TypeError: myMultiply is not a function

상대적으로 함수 표현식이 안전하다.

이해를 위해 좀 극단적인 예를 들어 보겠다.

프로그래머 A가 50번째 줄에 숫자를 더하는 SUM 함수를 함수 선언문으로 정의하고 100번째 줄에서 사용하고 있다. 그 후 프로그래머 B가 1000번째 줄에 문자열을 더하는 함수 SUM을 함수 선언문으로 정의했다.

호이스팅 규칙에 따라 두 함수 모두 호이스팅 되고, 순서에 따라 프로그래머 B가 정의한 함수 SUM이 프로그래머 A가 정의한 함수SUM을 덮어쓰게 된다.

그러면 100번째 줄에서는 문자열을 더하는 SUM 함수가 호출이 되고, 이는 전혀 의도하지 않은 결과를 반환할 것이다.

더욱이, 함수 자체는 아무런 문제가 없기 때문에 에러를 찾기도 쉽지 않을 것이다.

하지만 함수 표현식으로 선언한 함수의 경우 위와 같은 상황은 발생하지 않을 것이다.

2. 스코프, 스코프 체인, outerEnvironmentReference

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

자바스크립트의 경우 ES5까지는 전역공간을 제외하면 오직 함수에 의해서만 스코프가 생성되었다. ES6에서는 블록에 의해서도 스코프 경계가 발생하게 되었다. (다만 이러한 블록은 var로 선언한 변수에 대해서는 작용하지 않고 새로 생긴 let/const/class/strict mode에서의 함수 선언 등에서만 작용한다. ES6는 이를 구분하기 위해 함수 스코프, 블록 스코프라는 용어를 사용)

어쨋든 이러한 식별자 유효범위를 안에서 부터 바깥으로 차례로 검색해 나가는 것을 스코프 체인(scope chain)이라고 한다.

이를 가능하게 하는 것이 LexicalEnvironment의 두 번째 수집 자료인 outerEnvironmentReference 이다.

스코프 체인

outerEnvironmentReference는 현재 호출된 함수가 선언될 당시LexicalEnvironment를 참조한다.

예를 들어, A 함수 내부에서 B 함수를 선언하고 다시 B 함수 내부에서 C 함수를 선언한 경우, 함수 CouterEnvironmentReference함수 BLexicalEnvironment를 참조한다. 함수 BLexicalEnvironment에 있는 outerEnvironmentReference는 다시 함수 B가 선언되던 때 ALexicalEnvironment를 참조한다.

이처럼 outerEnvironmentReference는 연결 리스트 형태를 띈다. 선언 시점의 LexicalEnvironment를 거스러 올라가면 마지막엔 전역 컨텍스트의 LexicalEnvironment가 있을 것이다. 또한, 각 outerEnvironmentReference는 오직 자신이 선언된 시점의 LexicalEnvironment만 참조하고 있으므로 가장 가까운 요소부터 차례대로만 접근할 수 있고 다른 순서로 접근하는 것은 불가능하다. 이런 구조적 특성 때문에 여러 스코프에서 동일한 식별자를 선언한 경우에는 무조건 스코프 체인 상에서 가장 먼저 발견된 식별자에게만 접근 가능하게 된다.

아래 예제 코드를 보자

var a = 1;

var outer = function () {
  var inner = function() {
    console.log(a); // (1) undefiend
    var a = 3;
  }

  inner();
  console.log(a); // (2) 1
}

outer();
console.log(a); // (3) 1

위에서 설명한 대로 콜스택을 거슬러 올라가며 식별자를 검색해서 출력하는 것을 알 수 있다.

this

실행 컨텍스트의 thisBinding에서는 this로 지정된 객체가 저장된다. 실행 컨텍스트 활성화 당시에 this가 지정되지 않은 경우 this에는 전역 객체가 저장된다. 그 밖에는 함수 를 호출하는 방법에 따라 this에 저장되는 대상이 다름

this에 대해서는 다음 포스팅에서 자세히 다룸

정리

  • 실행 컨텍스트란 실행할 코드에 제공할 환경 정보들을 모아놓은 객체

    • 실행 컨텍스트에는 전역 컨텍스트 / eval 및 함수 실행에 의한 컨텍스트 등이 있다
  • 실행 컨텍스트객체는 활성화되는 시점에 VariableEnvironment, LexicalEnvironment, ThisBinding 세 가지 정보를 수집한다

  • VariableEnvironmentLexicalEnvironment

    • 매개변수명, 변수의 식별자, 선언한 함수의 함수명 등을 수집하는 environmentRecord와 바로 직전 컨텍스트의 LexicalEnvironmet 정보를 참조하는 outerEnvironmentReference로 구성
    • 차이점
      • 실행 컨텍스트를 생성할 당시에는 동일한 내용으로 구성되지만 LexicalEnvironment는 함수 실행 도중에 변경되는 사항이 즉시 반영 됨
      • VariableEnvironment는 초기 상태를 유지
  • 호이스팅은 코드 해석을 수월하게 하기 위해 environmentRecord의 수집 과정을 추상화한 개념

    • 실행 컨텍스트가 관여하는 코드 집단의 최상단으로 이들을 끌어올린다 라고 해석
    • 실제로는 끌어올리지 않지만, 코드를 실행하기 전에 식별자 정보를 다 수집해서 알고있으므로 끌어올린다고 이해해도 무방
    • 끌어올리는 대상은 '선언부'만임
      • 예외: 함수 선언문으로 정의된 함수는 함수 전체가 호이스팅
    • 할당 과정은 원래 자리에 남아있음
  • 스코프는 변수의 유효 범위를 말함

    • outerEnvironmentReference는 해당 함수가 선언된 위치의 LexicalEnvironmet를 참조함
    • 식별자를 찾을 때 먼저 자기자신의 LexicalEnvironment 를 탐색하고 없으면 outerEnvironmentReference에 담긴 LexicalEnvironment를 탐색
    • 그렇게 식별자를 찾을 때까지 outerEnvironmentReference 를 거슬러 올라가 탐색하고 전역에서도 못찾으면 undefined를 반환
  • 전역 컨텍스트의 LexicalEnvironment 에 담긴 변수를 전역변수라 하고, 그 밖의 함수에 의해 생성된 실행 컨텍스트의 변수들은 모두 지역변수

    • 안전한 코드 구성을 위해 가급적 전역변수의 사용은 최소화하는 것이 좋음
  • this에는 실행컨텍스트를 활성화하는 당사에 지정된 this가 저장 됨

    • 함수 호출 방법에 따라 그 값이 달라지는데, 지정되지 안흔 경우에는 전역 객체가 저장됨
profile
Slow but steady wins the race🏃‍♂️

0개의 댓글