[Core JavaScript] 2. 실행 컨텍스트

Fe·2022년 9월 9일
0

Core Javascript

목록 보기
2/3
post-thumbnail

본문에 앞서서

실행 컨텍스트가 뭔지 이해하고 나니 자바스크립트만의 매력을 더 느낄 수 있었다. 공부하면서 초반에는 어색하고 한 눈에 내용이 들어오지 않았지만, 천천히 읽다 보니 실행 컨텍스트를 이해하는 것이 이 개념과 유기적으로 연결된 다른 핵심 개념들을 이해하는 데 굉장히 도움이 된다는 사실을 깨닫게 되었다. 꼭 이해하고 넘어가는 것이 좋을 것 같다.


실행 컨텍스트

실행 컨텍스트(Execution context)실행할 코드에 제공할 환경 정보들을 모아놓은 객체이다. 동일한 환경의 코드들을 실행할 때 필요한 환경 정보들을 모아 컨텍스트를 구성하고, 이를 콜 스택(call stack)에 쌓아올린다. 콜 스택의 가장 위에 쌓여있는 컨텍스트와 관련 있는 코드부터 실행하여 전체 코드의 순서를 보장한다.

동일한 환경은 어떻게 구성할까?

하나의 컨텍스트가 되면 그 구성 정보들은 모두 동일한 환경이며, 이를 구성하기 위해서는 전역공간, 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. 코드 실행 시 브라우저가 자동으로 전역 컨텍스트를 콜 스택에 담는다.
  2. (3)에서 outer함수를 호출하면 전역 컨텍스트 관련 코드 실행이 잠시 중단되고 outer함수가 그 위에 담긴다. 그리고 outer 내부의 코드를 실행한다.
  3. 코드를 실행하다가 (2)에서 inner함수를 호출하면 outer 컨텍스트 관련 코드 실행이 잠시 중단되고 inner함수가 그 위에 담긴다. 그리고 inner 내부의 코드를 실행한다.
  4. a에 3을 할당하고 inner함수는 종료된다. 이때 inner 실행 컨텍스트가 콜 스택에서 삭제된다.
  5. 콜 스택에 outer가 맨 위에 있는 상태이다. (2) 다음 줄부터 이어서 실행된다. a가 출력되고 outer 함수는 종료된다. outer 실행 컨텍스트가 콜 스택에서 삭제된다.
  6. 콜 스택에는 전역 컨텍스트만 남았다. (3) 다음 줄부터 이어서 실행된다. a가 출력되고 더 이상 실행할 코드가 없으므로 전역 컨텍스트가 콜 스택에서 삭제된다.
  7. 콜 스택에 아무것도 남지 않고 실행이 종료된다.

규칙은 간단하다. 코드를 읽으면서 함수가 호출되는 순간에 실행 컨텍스트를 생성해서 콜 스택에 쌓아올리고, 모든 코드를 읽은 다음에는 콜 스택의 맨 위에 있는 실행 컨텍스트 관련 코드를 실행한다. 다 실행했으면 삭제된다.

그럼 이 객체는 어떻게 생겼을까?

사실 이 객체는 JS 엔진이 활용할 목적으로 생성할 뿐 개발자는 코드로 확인할 수 없다. 객체에 담기는 정보는 다음과 같다.

  • VariableEnvironment: 현재 컨텍스트 내의 식별자들에 대한 정보 + 외부 환경 정보. 선언 시점의 LexicalEnvironment의 스냅샷으로, 변경 사항은 반영되지 않는다.

  • LexicalEnvironment: 처음에는 VariableEnvironment와 같지만 변경 사항이 실시간으로 반영된다.

  • ThisBinding: this 식별자가 바라봐야 할 대상 객체. 실행 컨텍스트 활성화 당시 this가 지정되지 않은 경우 전역 객체가 저장된다.

VariableEnvironment

실행 컨텍스트가 처음 만들어질 때 VariableEnvironment가 먼저 만들어지고 이를 복사해서 LexicalEnvironment를 만들고, 이후에는 LexicalEnvironment를 주로 활용한다. 변경 사항을 실시간으로 반영하기 때문이다.

초기 상태에서는 VariableEnvironmentLexicalEnvironment가 완전히 동일하지만 이후에 달라지게 된다.

LexicalEnvironment

LexicalEnvironment는 "현재 컨텍스트 내부에는 a, b, c 식별자들이 있고, 그 외부 정보는 D를 참조하도록 구성되어 있다"와 같이 컨텍스트를 구성하는 환경 정보를 사전식으로 모아놓은 것이다. Lexical을 한국어로 번역해서 이해하려 하면 도통 이해가 되지 않는다.

environmentRecordouterEnvironmentReference로 구성되어 있다.


호이스팅

LexicalEnvironmentenvironmentRecord에는 현재 컨텍스트와 관련된 코드의 식별자 정보가 저장된다. 여기서 식별자란?

  • 컨텍스트 구성 함수에 지정된 매개변수 식별자
  • 선언한 함수가 있을 경우 그 함수 자체
  • var로 선언된 변수의 식별자
    컨텍스트 내부를 쭉 훑으면서 순서대로 수집한다.

이렇게 되면 자바스크립트 엔진은 코드를 실행하지도 않았는데 각 환경에 속한 코드의 변수명을 다 알고 있는 것이 된다. 그렇다면 식별자들만 맨 위로 끌어올린 뒤 코드를 실행한다고 생각해도 문제가 없게 되는데, 이를 호이스팅(hoisting)이라 한다. 끌어올린다는 의미로, 엔진이 실제로 끌어올리지는 않지만 편의상 그렇게 간주하도록 한다.

매개변수와 변수에 대한 호이스팅

environmentRecord에 담기는 식별자들은 매개변수/변수와 함수 자체로 나눌 수 있는데, 전자를 먼저 살펴보자.

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), (2), (3)의 결과로 1, undefined, 2가 출력될 것 같다. 하지만 여기서 호이스팅의 개념을 적용해 보면?

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

첫 번째 코드와 두 번째 코드는 함수의 argument에 전달된 인자를 담는 것을 제외하고는 다른 것이 없다. a를 호출할 때 1을 전달하므로 두 번째 코드에서는 첫 줄에 1을 할당해준 것이다.

environmentRecord는 현재 실행될 컨텍스트의 대상 코드 내에 어떤 식별자가 있는지에만 관심이 있다. 거기에 어떤 값이 할당되는지는 관심이 없다. 따라서 우리는 호이스팅 과정에서 변수명만 위로 올리고 값을 할당하는 부분은 그냥 두면 된다. 그러면 다음과 같이 바뀐다.(실제로 바뀌는 것은 아니다!)

function a () {
  var x;
  var x;
  var x;
  
  x = 1;
  console.log(x);
  console.log(x);
  x = 2;
  console.log(x);
}
a(1);

두 번째 줄에서 x를 선언하고 메모리에 공간을 확보해서 그 공간의 주솟값을 x에 연결한다. 하지만 이어서 x를 다시 선언하는데, 이미 선언된 것이 있으므로 무시된다. 그리고 나서 값을 할당하고 출력하므로 우리는 각각의 출력 결과가 1, 1, 2라는 것을 알게 된다. 처음에 예측한 1, undefined, 2와는 전혀 다른 결과인데, 호이스팅 개념을 알지 못한다면 맞추기 어렵다.

함수 선언의 호이스팅

이번에는 함수 선언을 했을 때의 경우이다. environmentRecord함수를 만났을 때 통채로 끌어올린다.

function a () {
  console.log(b);
  var b = 'bbb'; // 수집 대상 1
  console.log(b);
  function b() { } // 수집 대상 2
  console.log(b);
}
a();

이번에도 이 코드만 보고 결과를 예측해보자면, undefined, 'bbb', function b가 출력될 것 같다.

a 함수를 실행하는 순간 a의 실행 컨텍스트가 생성된다. 그리고 변수명과 함수 선언 정보를 위로 끌어올린다. 이때 변수는 할당부는 그냥 두고 선언부만 끌어올리고, 함수 선언은 전체를 끌어올린다. 끌어올리면 다음과 같이 바뀐다.

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

호이스팅이 끝난 뒤에 함수 선언문은 함수명으로 선언한 변수에 함수를 할당한 것처럼 여길 수 있다.

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

이렇게 말이다. 처음에 b를 선언하고 그 다음 줄에서 b는 함수를 가리키게 된다. 그리고 출력을 하게 되므로 우리가 예상했던 결과와 다르게 function b, 'bbb', 'bbb'가 출력된다.


함수 선언문과 함수 표현식

function a () {/*...*/} // 함수 선언문
a(); // ok

var b = function () {/*...*/} // 익명 함수 표현식
b(); // ok

var c = function d () {/*...*/} // 기명 함수 표현식
c(); // ok
d(); // Error

코드로 보는 것이 가장 확실하다.
함수 선언문: function 정의부만 존재하고 별도의 할당 명령이 없다. 반드시 함수명이 정의돼 있어야 한다.
함수 표현식: 정의한 function을 별도의 변수에 값으로 할당한다. 함수명이 있다면 기명 함수 표현식이고, 함수명이 없다면 익명 함수 표현식이다.

기명 함수 표현식은 한 가지 주의해야 할 점이 있다. 함수 외부에서는 함수명으로 함수를 호출할 수 없다. 할당한 변수명으로 호출해야만 한다. 하지만 함수 내부에서는 함수명으로 호출할 수 있다.

함수 선언문과 함수 표현식에 호이스팅을 적용하면 어떻게 될까?

console.log(sum(1, 2));
console.log(multiply(3, 4));

function sum (a, b) { // 함수 선언문
  return a + b;
}

var multiply = function (a, b) { // 함수 표현식
  return a * b;
}

위는 초기 코드이다. 보면 함수들이 선언되기도 전에 함수를 호출하고 있다. 에러가 날 것 같이 생겼다. 호이스팅을 적용해 보자.

함수 선언은 통채로 끌어올리되 함수명과 같은 변수에 값으로 할당한 것처럼 취급할 수 있고, 변수는 선언부만 위로 끌어올리면 다음과 같이 변한다.

var sum = function sum (a, b) {
  return a + b;
}
var multiply;

console.log(sum(1, 2));
console.log(multiply(3, 4));
multiply = function (a, b) {
  return a * b;
}

함수도 하나의 값으로 취급할 수 있다는 것이 이런 것이다. 함수 표현식이 함수를 다른 변수에 값으로써 할당한 것이다. 호이스팅된 결과를 보니 함수를 선언 전에 호출해도 아무런 문제가 없다. 하지만 이것은 혼란을 줄 수 있으므로 다른 언어에서 그랬던 것처럼 함수를 선언한 뒤에 호출하는 것이 좋을 것 같다.

함수 선언문은 위험하다

제목 그대로 함수 선언문이 위험할 수 있는 경우가 있다. 다음 코드를 보자.

...
console.log(sum(3, 4));
...
function sum (x, y) {
  return x + y;
}
...
var a = sum(1, 2); // 여기까지 개발자 A가 작성하고 사용
...
function sum (x, y) { // 여기부터 개발자 B가 작성하고 사용
  return x + ' + ' + y + ' = ' + (x + y);
}
...
var c = sum(1, 2);
console.log(c);
...

개발자 A가 먼저 두 수를 더하는 함수 sum을 함수 선언문으로 선언하고 사용했다. 그러다가 개발자 B가 같은 파일의 한참 밑에서 sum 함수를 새로 선언한다. B는 자신의 sum 함수가 자신이 작성한 부분에서만 영향을 줄 것이라 생각했다.

A는 당연히 자신이 작성한 부분에서는 두 수를 더한 값을 반환할 것이라 생각했지만 갑자기 뜬금 없는 문자열이 출력되는 것을 보게 된다. 동일한 변수명에 서로 다른 값을 할당하면 override된다. 즉, 값이 덮어씌워진다. 따라서 실행 중에 호출되는 함수는 A가 작성한 함수가 아닌 B가 작성한 함수가 된다. 물론 코드에 문제는 없으므로 에러가 발생하지도 않는다. 이런 경우 디버깅에 정말 애를 먹을 것 같다.

A와 B가 모두 함수를 표현식으로 선언했다면 어떻게 됐을까?

...
console.log(sum(3, 4));
...
var sum = function (x, y) {
  return x + y;
}
...
var a = sum(1, 2);
...
var sum = function (x, y) {
  return x + ' + ' + y + ' = ' + (x + y);
};
...
var c = sum(1, 2);
console.log(c);
...

함수 표현식으로 선언하면 함수가 담기는 변수만 호이스팅되고 함수의 몸체는 원래 자리에 그대로 있으므로 override되지 않고 각 개발자의 의도대로 본인이 작성한 부분에만 함수가 적용되었을 것이다.

또한 sum 함수가 처음 선언되기 이전에 함수를 호출한다면 위 사진과 같이 에러가 난다. 호이스팅 되면 sum을 호출하는 시점에서 sum은 선언만 됐을 뿐 아무런 값도 할당되지 않은 상태이기 때문이다.

이런 점에서 함수를 선언할 때는 함수 표현식을 사용하는 것이 보다 안전하다.


스코프, 스코프 체인, outerEnvironmentReference

스코프(Scope)는 식별자에 대한 유효범위이다. 해당 식별자를 사용할 수 있는 공간을 뜻하는데, 어떤 경계 A의 밖에서 선언한 변수는 A의 안팎에서 모두 접근이 가능하지만, A의 안에서 선언한 변수는 안에서만 접근할 수 있다. 포함 관계인 두 집합의 밴 다이어그램을 생각하면 편하다. ES5까지는 전역공간을 제외하면 오직 함수에 의해서만 스코프가 생성되었지만 ES6부터는 블록에 의해서도 스코프가 생성된다.(다만 var로 선언한 변수가 아닌 let, const, class, strict mode에서 선언한 함수에 대해서만 생성된다)

스코프 체인

스코프 체인을 이해하기 전에 짚고 넘어갈 것이 있다.
outerEnvironmentReference는 현재 호출된 함수가 선언될 당시의 LexicalEnvironment를 참조한다. 여기서 '선언될 당시'는 콜 스택 상에서 어떤 실행 컨텍스트가 활성화된 상태를 뜻한다. 한 가지 예시를 보자.

var a = function () {
  var b = function () {
    var c = function () {
      // ...
    };
  };
};

함수 a 안에 함수 b를 선언하고, 다시 b 안에 함수 c를 선언한 모습이다. 함수 c는 b일 때 선언되었으므로 함수 c의 outerEnvironmentReference는 함수 b의 LexicalEnvironment를 참조한다. 같은 이유로 함수 b의 outerEnvironmentReference는 함수 a의 LexicalEnvironment를 참조한다. 이처럼 outerEnvironmentReference는 연결리스트의 형태를 띈다. 선언 시점의 LexicalEnvironment를 찾아 올라가다 보면 마지막에는 전역 컨텍스트의 LexicalEnvironment가 있게 된다. 또한 각각의 outerEnvironmentReference는 자신이 선언된 시점의 LexicalEnvironment만 참조하고 있기 때문에 가장 가까운 요소만 접근이 가능하다. 이렇듯 스코프 체인은 각각의 스코프들이 어떻게 연결되어 있는지를 나타내는 일종의 연결리스트이다. 스코프 체인의 구조 특성상 여러 스코프에서 동일한 식별자를 선언하는 경우에는 스코프 체인 상에서 가장 먼저 발견된 식별자에만 접근 가능하다.

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

스코프 체인을 살펴보자.

L.E: LexicalEnvironment / e: environmentRecord / o: outerEnvironmentReference

e는 현재 컨텍스트에 있는 식별자들을 저장하고, o는 현재 호출된 함수가 선언될 당시의 L.E를 저장한다. 전역 컨텍스트에서 outer 컨텍스트, inner 컨텍스트로 갈수록 규모는 작아지지만 스코프 체인을 타고 접근 가능한 변수는 늘어난다. inner 함수 내부에서는 inner, outer, 전역 스코프 모두 접근 가능하지만 outer 함수 내부에서는 outer, 전역 스코프에는 접근할 수 있지만 inner에는 접근할 수 없다.

위의 코드를 보면 전역공간과 inner 함수에서 모두 a라는 식별자를 선언하고 있다.

inner 함수 내부에서 a에 접근한다면?

스코프 체인 상의 첫 번째 인자인 inner 스코프의 L.E부터 검색을 시작한다. inner의 L.E에 a가 존재하므로 스코프 체인 검색이 종료된다. 그리고 inner의 L.E 상에 있는 a를 반환한다.
inner에서 a를 선언했기 때문에 inner 내부에서는 전역 공간에서 선언한 a에 접근할 수가 없게 된다. 이를 변수 은닉화(variable shadowing)라고 한다.

전역변수와 지역변수

마지막으로 살펴볼 것은 전역변수(global variable)와 지역변수(local variable)이다. 앞선 예제에서 전역변수는 전역공간에서 선언한 a와 outer이고, 지역변수는 outer 함수에서 선언한 inner와 inner 함수에서 선언한 a이다. 전역 공간에서 선언한 변수를 전역변수라 하고, 함수 내부에서 선언한 변수는 지역변수이다.
위에서 다뤘던 '함수 선언문은 위험하다'에서 전역변수를 사용했기 때문에 문제가 되었는데, 좀 더 안전한 방법으로 소개한 함수 표현식보다도 사실은 지역변수를 사용하는 것이 훨씬 안전하다. 지역변수로 사용하기 위해서는 sum 함수 외부를 함수 X로 감싸야 한다. X 함수 내부에서 선언한 sum 함수의 경우 X 내부에서만 호출할 수 있기 때문에 전역 공간에서는 이에 접근할 수 없게 된다.

전역변수의 사용을 가급적 자제하고 지역변수로 사용하는 습관을 들여야겠다.

profile
하고 싶은 게 많은 사람

0개의 댓글