코어 자바스크립트 - 실행컨텍스트

31352 단어 JavaScriptJavaScript

🤚 전체적인 내용은 위키북스 코어자바스크립트 도서, 정재남 지음 을 참고로 했고, 부가적인 내용과 생략된 부분이 존재할 수 있다.


1. 실행 컨텍스트(execution context)

실행 컨텍스트는 실행할 코드에 제공할 환경 정보를 모아놓은 객체로, 자바스크르립트의 동적 언어로서의 성격을 가장 잘 파악할 수 있는 개념이다.

실행 컨텍스트와 호출 스택

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

하나의 컨텍스트를 구성할 수 있는 방법으로는 다음과 같다.

  • 전역공간 : 자동으로 생성된다
  • eval() : eval is evil? 아무튼 권장하지 않음
  • 함수 : 가장 일반적인 컨텍스트 구성방법
  • 블록({ ... }로 둘러쌓인 코드 내부로써 ES6부터 지원)

다음 예제 코드를 보며 실행 컨텍스트가 어떻게 생성되고 스택에 쌓이는지 알아보자.

// ------------------- (1) 전역 컨텍스트
var a = 1;
function outer() {
  function inner() {
    console.log(a); // undefined
    var a = 3;
  }
  inner(); // -------- (3)  inner 함수 컨텍스트
  console.log(a); // 1
}
outer(); // ---------- (2) outer 함수 컨텍스트
console.log(a); // 1

  1. 전역 컨텍스트는 자바스크립트 파일이 열리는 순간 자동으로 생기고 활성화된다.
  2. outer 함수를 호출하면 outer에 대한 환경정보를 수집해 outer 실행 컨텍스트 생성 후 호출 스택에 담는다.
  3. outer 함수 내에서 inner 함수를 호출하므로 outer 컨텍스트 실행을 중단하고 inner 컨텍스트 생성 후 실행하게 된다.

✍ 이처럼 자바스크립트 엔진은 해당 컨텍스트에 관련된 코드들을 실행하는데 필요한 환경정보들을 수집해서 실행 컨텍스트 객체에 저장한다.

구체적인 수집정보

그렇다면 실행 컨텍스트 객체에 저장하는 내용은 무엇일까? 이 객체는 자바스크립트 엔진이 활용할 목적으로 생성할 뿐 개발자가 코드를 통해 확인할 수는 없다. 여기에 담기는 정보들은 다음과 같다.

  • VariableEnvironment : 현재 컨텍스트내의 식별자들에 대한 정보 + 외부환경정보 저장. 변경사항은 반영되지 않음.
    • environmentRecord (snapshot)
    • outerEnvironmentReference (snapshot)
  • LexicalEnvironment : 처음에는 VariableEnvironment와 같지만 변경사항이 실시간으로 반영됨
    • environmentRecord
    • outerEnvironmentReference
  • ThisBinding : this 식별자가 바라봐야 할 대상 객체

2. VariableEnvironment

담기는 내용은 LexicalEnvironment와 같지만 최초 실행 시의 스냅샷을 유지한다는 점에서 차이가 있다. 실행 컨텍스트 생성 시 VariableEnvironment에 먼저 정보를 담고, 그대로 LexicalEnvironment에 복사 한 후 주로 LexicalEnvironment를 활용한다.

내부 구성 요소로는 현재 컨텍스트 내의 식별자들에 대한 정보 + 외부 환경 정보가 선언 시점의 스냅샷으로 저장된다. 사실 LexicalEnvironment가 더 중요하게 다뤄지는 편이기 때문에 여기까지만 알면된다.


3. LexicalEnvironment

environmentRecord

environmentRecord에는 현재 컨텍스트와 관련 된 코드의 식별자 정보들이 저장된다. 아래는 그 예시이다.

  • 함수에 지정된 매개변수 식별자
  • 선언한 함수가 있을경우 그 함수자체
  • var로 선언된 변수의 식별자

컨텍스트 내부 전체를 처음부터 끝까지 쭉 훑어나가며 순서대로 수집하게 된다. 이 때문에 생기는 특이한 동작이 바로 호이스팅이다.

호이스팅(hoisting)

위와 같이 변수 정보를 수집하는 과정을 마쳤다면, 코드가 실행되기 전임에도 불구하고 자바스크립트 엔진은 이미 해당 환경에 속한 코드의 변수명들을 모두 알고 있게 된다.

여기서 호이스팅이라는 개념이 등장한다. 호이스팅은 끌어올리다라는 의미의 hoist와 ing을 붙여 만든 동명사로 식별자들을 최상단으로 끌어올려놓은듯한 동작을 비유적으로 표현한 것이다. 아래 예제를 통해 호이스팅에 대한 알아보자.

// 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, undefined, 2로 예측하지 않았을까? 하지만 실제 결과는 1, 1, 2이다.

코드변경 #1

이해를 돕기위해 위 코드를 조금 바꿔보도록 하겠다. 매개변수를 변수 선언 및 할당과 같다고 간주하여 변환한 상태이다. 결과는 동일하다.

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

a();

코드변경 #2

마지막으로 코드를 조금만 더 수정해보자. 호이스팅을 하면 사실 아래 코드와 같다.

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

a();

✍ 즉, 호이스팅은 변수명만을 끌어올리고 할당과정은 원래 자리에 그대로 남겨두기 때문에 어떤 식별자가 있는지는 알고 있어도 어떤 값을 가지는지는 모른다.

함수 선언에 대한 호이스팅

다음 코드의 결과도 한번 예측해보자.

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

a();

음... undefined, 'bbb', function b() {}가 아닐까 예상했었는데 틀렸다. 위 코드를 호이스팅을 적용해서 변환시켜보도록 하겠다.

function a() {
  var b; // ------------------- 수집 대상 1(변수 선언) 의 선언 부분
  function b() {} // ---------- 수집 대상 2(함수 선언) 의 전체

  console.log(b); // ---------- (1)
  b = 'bbb'; // --------------- 수집 대상 1의 할당 부분
  console.log(b); // ---------- (2)
  console.log(b); // ---------- (3)
}
a();

특이한점은 변수는 선언부와 할당부를 나누어 선언부만 끌어올리는 반면 함수선언은 함수 전체를 끌어올린다는 것이다. 덕분에 함수를 선언한 위치와 무관하게 그 함수를 실행할 수 있어서 많은 혼란을 야기했다는 지적도 있다.

함수선언문과 함수표현식

자바스크립트에서는 함수를 선언하는 형태는 한가지가 아니다. (아이구 복잡해라🤦‍♂️)

  • 함수선언문 : 함수 정의부만 존재하고 별도의 할당 명령이 없는 것. 함수선언문은 전체를 호이스팅 함.
  • 함수표현식 : 정의한 함수를 별도의 변수에 할당하는 것
    • 기명 함수표현식 : 함수명을 정의함(거의 안쓰임)
    • 익명 함수표현식 : 함수명을 정의하지 않음(권장)
function a() { /* ... */ } // 함수 선언문. 함수명 a가 곧 변수명
// var a = function a() { /* ... */ } 와 동일
a(); // 실행 가능

var b = function () { /* ... */ } // (익명) 함수 표현식. 변수명 b가 곧 함수명
b(); // 실행 가능

var c = function d() { /* ... */ } // (기명) 함수 표현식. 변수명은 c, 함수명은 d
c(); // 실행 가능
d(); // 실행 불가능.

여기서 중요한 점은 함수선언문은 자바스크립트 엔진에 의해 함수표현식으로 형태가 변경된다고 보면 된다.

차이를 알아보자

그렇다면 함수 선언문과 함수 표현식은 실질적인 차이가 존재할까? 🤔 존재한다. 다음 코드를 살펴보자.

console.log(sum(1, 2)); // 3
console.log(multiply(3, 4)); // 🚨 에러

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

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

sum(1, 2)는 3이 정상적으로 나오지만 multiply(3, 4)는 에러가 출력된다. 위의 코드를 호이스팅을 적용해서 코드를 바꿔보도록 하겠다.

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;
};

✍ 함수선언문은 함수선언, 초기화, 할당을 한번에 하는 반면, 함수표현식은 함수 호이스팅이 아닌 변수 호이스팅을 진행하여 변수 생성 및 초기화와 할당이 분리되어 진행된다.

함수선언문의 위험성

보통의 언어라면 선언한 후에야 호출할 수 있다는 편이 훨씬 자연스럽다. 따라서 함수선언문은 큰 혼란을 일으키는 원인이 되기도 한다.

만약 개발자 A씨가 sum 함수를 100번째 줄에 선언했다. 그런데 새로 입사한 B씨는 같은 파일의 sum 함수를 5000번째 줄에 새로 선언했다. 자바스크립트를 잘 모르는 B씨는 자신이 작성한 sum함수가 5000번째 줄 이후에만 영향을 줄거라고 생각해서 배포한다. 🤦‍♂️

근데 만약 A와 B모두 sum함수를 함수 표현식으로 정의했다면? 의도대로 잘 동작했을것이다.

✍ 여기서 말하고 싶은것은 함수선언문보다 상대적으로 함수표현식이 안전하다는 것이다.


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

스코프(scope)

스코프란 식별자에 대한 유효 범위를 말한다. 대부분의 프로그래밍 언어는 블록 레벨 스코프를 따른다. 하지만 자바스크립트는 함수레벨 스코프를 따른다.

함수 레벨 스코프란 함수 코드 블록 내에서 선언된 변수는 함수 코드 블록 내에서만 유효하고 함수 외부에서는 유효하지 않다(참조할 수 없다)는 것이다. 단, ECMAScript 6부터 도입된 let, const를 사용하면 블록 레벨 스코프를 사용할 수 있다.

var x = 0;
{
  var x = 1;
  console.log(x); // 1
}
console.log(x); // 1

let y = 0;
{
  let y = 1;
  console.log(y); // 1
}
console.log(y); // 0

스코프 체인(scope chain)

식별자에 대한 유효 범위를 안에서 부터 바깥으로 차례로 검색해 나가는 것. 이를 가능케 하는것이 LexicalEnvironment의 두번째 수집 자료인 outerEnvironmentReference 이다.

다음 코드의 흐름에 따라 좀 더 구체적으로 알아보도록 하자.

var a = 1;
var outer = function() {
  var inner = function() {
    console.log(a); // (1)
    var a = 3;
  };
  inner();
  console.log(a); // - ✅ (2) : 스코프 체인 발생
}
outer();
console.log(a); // --- (3)

  1. 전역 컨텍스트가 활성화 된다.
    1.1 전역 컨텍스트의 environmentRecord에 {a, outer} 식별자 정보가 저장된다.
    1.2 outerEnvironmentReference에는 아무것도 담기지 않는다.

  2. outer 함수가 실행된다. 이에 따라 전역 컨텍스트 코드는 10번째 줄에서 임시중단되고, outer 실행 컨텍스트가 활성화 되면서 2번째 줄로 이동한다.
    2.1 outer 실행 컨텍스트의 environmentRecord에는 {inner} 식별자 정보를 저장한다.
    2.2 outerEnvironmentReference에는 outer 함수가 실행될 당시의 LexicalEnvironment가 담긴다.

  3. 7번째 줄에 inner 함수가 실행된다. 이에 따라 outer 실행 컨텍스트는 임시중단되며, inner 실행 컨텍스트가 활성화된다.
    3.1 inner 실행 컨텍스트의 environmentRecord에 {a} 식별자 정보를 저장한다.
    3.2 outerEnvironmentReference에는 inner함수가 실행될 당시의 LexicalEnvironment가 담긴다.

  4. inner 함수 실행이 종료되면 inner 실행 컨텍스트가 콜 스택에서 제거되고, 바로 아래 outer 실행 컨텍스트가 활성화 된다.

  5. ✅ 이 과정에서 console.log(a); // - (2) 이부분이 중요한데, 식별자 a에 접근하는 과정은 먼저 활성화된 실행 컨텍스트의 LexicalEnvironment에 접근해서 environmentRecord에 a가 있는지 찾아보고 없으면 outerEnvironmentReference에 있는 environmentRecord로 넘어가는 식으로 계속 검색한다. 따라서 결과는 1이 된다.

  6. outer 함수 실행이 종료되면 역시 마찬가지로 outer 실행 컨텍스트가 콜 스택에서 제거되며, 전역 컨텍스트가 다시 활성화된다. 그리고 최종 코드까지 실행이 완료되면 전역 컨텍스트도 제거된다.

✍ 정리해보면 스코프 체인이란 식별자가 현재 컨텍스트에 없다면 outerEnvironmentReference 를 통해 외부의 컨텍스트에 있는지 알아보며 있다면 해당 식별자를 사용할 수 있도록 동작을 한다.


변수 은닉화

다시 한번 정리해보면 전역 컨텍스트 -> outer 컨텍스트 -> inner 컨텍스트 순으로 규모가 작아지는 반면 스코프체인을 타고 접근 가능한 변수의 수는 늘어난다.

즉, 전역공간에서는 전역 스코프에서 생성된 변수에만 접근할 수 있고, outer 함수 내부에서는 outer 및 전역 스코프에서 생성된 변수에만 접근할 수 있는 것. inner함수내부에서는 inner, outer, 전역 스코프 모두에 접근할 수 있다.

변수 은닉화란 상위 스코프에 선언되어 있지만, 현재 스코프에 이미 선언된 경우에는 값이 할당되어 있지 않다고 하더라도 현재 스코프의 변수를 우선으로 한다는 규칙을 말한다. 예를 들어 위에서 inner 함수 내부에서 a에 접근하려고 하면 전역에서의 a가 아닌 inner 스코프의 LexicalEnvironment부터 검색하기 때문에 inner 상의 a를 반환하게 되는 동작을 하는 것과 같다.


크롬 브라우저 환경에서 스코프 확인해보기

아래 코드를 크롬 개발자 도구에서 콘솔에다가 실행시켜보자.

var a = 1;
var outer = function() {
  var b = 2;
  var inner = function() {
    // console.log(b); // 
    console.dir(inner);
  };
  inner();
}
outer();

특이하게 함수 내부에서 실제로 호출할 외부 변수들의 정보만 보여주는 것을 알 수 있다. 즉 여기서는 inner이란 정보뿐이 나오지 않는다.


이번에는 주석처리되어있는 console.log(b);를 주석 해제한 다음 실행시켜보면 어떻게 되는지 보자. 결과를 보면 위에서는 없었던 b가 생긴것을 알 수 있다.

이런 결과가 나타나는 이유는 브라우저 성능 향상을 위해서이다. 즉, inner에서 b와 a에도 접근할 수 있다는 사실까지는 굳이 보여주지 않는다는 것이 된다.


전역변수와 지역변수

  • 전역변수 : 전역 공간에서 선언한 변수. 전역 스코프에서 선언한 a와 outer이 해당
  • 지역변수 : 함수 내부에서 선언한 변수. outer함수 내부에서 선언한 inner과, inner함수 내부에서 선언한 a가 해당

코드 안전성을 위해 가급적 번역변수 사용을 최소화하는 것이 좋다. 대표적인 방법으로는 함수 사용, 즉시실행함수 활용, 모듈패턴 등이 있다.

좋은 웹페이지 즐겨찾기