자바스크립트의 실행 컨텍스트

코어자바스크립트 정복 02

Posted by Juri on April 27, 2022

책 코어자바스크립트 (위키북스 출판) 을 참고해 작성한 포스팅입니다.

실행 컨텍스트란?

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

1
2
3
4
5
6
7
8
9
10
11
12
// -----------(1)
var a = 1;
function outer() {
    function inner() {
        console.log(a);
        var a = 3;
    }
    inner(); // ----(2)
    console.log(a);
}
outer(); // ----(3)
console.log(a);

처음 자바스크립트를 실행하는 순간 전역 컨텍스트가 콜 스택에 담긴다. 최상단의 공간은 별도의 실행 명령이 없어도 브라우저에서 자동으로 실행하므로 자바스크립트 파일이 열리는 순간 전역 컨텍스트가 활성화된다.

전역 컨텍스트와 관련된 코드들을 순차로 진행하다가 (3)에서 outer함수를 호출하면 자바스크립트 엔진은 outer에 대한 환경 정보를 수집해서 outer 실행 컨텍스트를 생성한 후 콜 스택에 담는다. 콜 스택의 맨 위에 outer 실행 컨텍스트가 놓였으므로 전역 컨텍스트와 관련된 코드의 실행을 중단하고 outer실행 컨텍스트와 관련된 코드 (outer 함수 내부의 코드)들을 순차로 실행한다.

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

inner함수 내부에서 a 변수의 값을 출력하면 inner함수의 실행이 종료되면서 inner실행 컨텍스트가 콜 스택에서 제거된다. 그럼 그 아래에 있던 outer컨텍스트가 맨 위에 놓여지므로 중단했던 (2)의 다음줄부터 이어 실행한다. a 변수의 값을 출력하면 outer함수의 실행이 종료되어 outer 실행 컨텍스트가 콜 스택에서 제거되고 콜 스택에는 전역 컨텍스트만 남는다.

전역 컨텍스트에서 실행을 중단했던 (3) 다음줄부터 이어 실행되고 전역 컨텍스트가 콜 스택에서 제거되면 콜 스택에는 아무것도 없는 상태로 종료된다.

어떤 실행 컨텍스트가 활성화될 때 자바스크립트 엔진은 해당 컨텍스트에 관련된 코드들을 실행하는 데 필요한 환경 정보들을 수집해 실행 컨텍스트 객체에 저장한다. 여기에 담기는 정보는 아래와 같다.

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

VariableEnvironment 와 LexicalEnvironment

실행 컨텍스트를 생성할 때 VariableEnvironment에 정보를 먼저 담은 후, 이를 그대로 복사해 LexicalEnvironment를 만들어 사용한다. 내부는 environmentRecord와 outer-EnvironmentReference로 구성된다.

environmentRecord에는 현재 컨텍스트와 관련된 코드의 식별자 정보가 저장된다. 컨텍스트 내부 전체를 처음부터 끝까지 순서대로 수집한다.

  • 컨텍스트를 구성하는 함수에 지정된 매개변수 식별자
  • 선언한 함수가 있을 경우 그 함수 자체
  • var로 선언된 변수의 식별자 등

코드가 실행되기 전에 변수 정보를 모두 수집하기 때문에 자바스크립트 엔진은 식별자들을 최상단으로 끌어올려놓은 다음 실제 코드를 실행한다고 생각할 수 있다. 이것을 호이스팅(hoisting)이라고 한다.

호이스팅

1
2
3
4
5
6
7
8
function a() {
    console.log(b); // (1)
    var b = "abc";
    console.log(b); // (2)
    function b() {}
    console.log(b); // (3)
}
a();

순서대로 코드를 읽고 출력결과를 예상해보자. (1)은 undefined, (2)는 “abc” (3)은 b 함수가 출력될 것으로 예상된다.

a 함수를 실행하는 순간 a 함수의 실행 컨텍스트가 생성된다. 이 때, 변수명과 함수 선언의 정보를 위로 끌어올린다. 변수는 선언부와 할당부를 나누어 선언부만 끌어올리는 반면 함수 선언은 함수 전체를 끌어올린다. 호이스팅이 끝난 상태에서 함수 선언문은 함수 표현식으로 여길 수 있다.

1
2
3
4
5
6
7
8
9
function a() {
    var b;
    var b = function b() {};

    console.log(b); // (1)
    b = "abc";
    console.log(b); // (2)
    console.log(b); // (3)
}
  1. 2번째 줄에서 변수 b를 선언한다. 이 때 메모리에서 저장할 공간을 미리 확보하고 확보한 공간의 주소값을 변수 b에 연결한다.
  2. 3번째 줄에서 다시 변수 b를 선언하고 함수 b를 선언된 변수 b에 할당한다. 이미 선언된 변수 b가 있으므로 선언은 무시한다. 함수는 별도의 메모리에 담기고 그 함수가 저장된 주소값을 b와 연결된 공간에 저장한다.
  3. 4번째 줄에서 변수 b에 할당된 함수 b를 출력한다. (1)
  4. 6번째 줄에서 변수 b에 “abc”를 할당한다. b와 연결된 메모리 공간에 문자열 “abc”가 담긴 주소값으로 덮어씌운다.
  5. 7번째 줄과 8번째 줄에서 b를 출력한다. (2)(3)

실제 출력값은 (1)은 b함수 (2)는 “abc” (3)은 “abc” 로 호이스팅을 고려하지 않았을 때의 예상 출력값과 전혀 다르다.

함수 선언문(function declaration)과 함수 표현식(function expression)

함수 선언문은 function 정의부만 존재하고 별도의 할당 명령이 없는 반면에 함수 표현식은 정의한 function을 별도의 변수에 할당한다. 함수 선언문의 경우 반드시 함수명이 정의되어야 하고 함수 표현식은 그렇지 않다.

함수를 정의하는 세 가지 방식은 아래와 같다.

1
2
3
4
5
6
7
8
function a() {}
a();

var b = function () {};
b();

var c = function d() {};
c();

둘의 실질적인 차이점을 알아보자.

1
2
3
4
5
6
7
8
9
10
11
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;
};

호이스팅이 완료된 상태

1
2
3
4
5
6
7
8
9
10
11
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;
};

함수 선언문은 전체를 호이스팅한 반면 함수 표현식은 변수 선언부만 호이스팅했다. 여기서 함수 선언문과 함수 표현식의 차이가 발생한다.

  1. 1번째 줄에서 메모리 공간을 확보하고 확보된 공간의 주소값을 변수 sum에 연결한다.
  2. 4번째 줄에서 메모리 공간을 확보하고 확보된 공간의 주소값을 변수 multiply에 연결한다.
  3. 다시 1번째 줄에서 sum함수를 메모리 공간에 저장하고 그 주소값을 변수 sum의 공간에 할당한다,
  4. 5번째 줄에서 sum을 실행하면 3이 나온다.
  5. 6번째 줄에서 multiply에 값이 할당되어 있지 않아 ‘multiply is not a function’ 이라는 에러 메시지가 출력된다.
  6. 에러로 인해 뒤의 코드는 실행되지 않은 채 런타임이 종료된다.

함수 선언문으로 작성한 sum 함수는 선언 전에 호출에도 문제 없이 실행된다. 위에서 아래로 글을 읽는 인간의 입장에서 어색함을 느낄 수 있으며 혼란을 일으키는 원인이 될 수 있다. 예를 들어, 전역 공간에서 같은 이름의 함수를 중복해서 선언하면 선언된 함수들이 위로 끌어올려지는 과정에서 나중에 할당한 값이 먼저 할당한 값을 덮어씌우는 일이 발생한다. 의도한 것과 다른 결과값이 나와도 에러가 발생하지 않아 디버깅에 문제가 생길 수 있다. 만약 같은 상황에서 함수를 함수 표현식으로 정의했다면 의도대로 잘 작동했을뿐만 아니라 에러가 검출되 빠르게 디버깅할 수 있을 것이다.

협업 시엔 전역공간에 함수를 선언하거나 함수를 중복 선언하는 경우는 없도록 조심한다.

스코프, 스코프 체인, outerEnvironmentReference

스코프란 식별자에 대한 유효범위이다. 스코프를 안에서부터 바깥으로 차례대로 검색하는 것을 스코프 체인이라고 하며 이를 가능하게 하는 것이 바로 LexicalEnvironment의 두번째 수집 자료인 outerEnvironmentReference이다.

outerEnvironmentReference는 현재 호출된 함수가 선언될 당시의 LexicalEnvironment를 참조한다. 선언 행위가 실제로 일어날 수 있는 시점은 콜 스택 상에서 어떤 실행 컨텍스트가 활성화된 상태일 때뿐이다.

예를 들어, A 함수 내부에 B 함수를 선언하고 다시 B 함수 내부에 C 함수를 선언한 경우, 함수 C의 outerEnvironmentReference는 함수 B의 LexicalEnvironment를 참조한다. 함수 B의 LexicalEnvironment에 있는 outerEnvironmentReference는 함수 A의 LexicalEnvironment를 참조한다. 이처럼 outerEnvironmentReference는 연결리스트 형태를 띠고 계속 찾아 올라가면 마지막에는 전역 컨텍스트의 LexicalEnvironment가 있을 것이다.

이런 구조적 특성에 의해 여러 스코프에서 동일한 식별자를 선언한 경우 스코프 체인 상에서 가장 먼저 발견된 식별자에만 접근가능하다.

1
2
3
4
5
6
7
8
9
10
11
var a = 1;
var outer = function () {
    var inner = function () {
        console.log(a);
        var a = 3;
    };
    inner();
    console.log(a);
};
outer();
console.log(a);
  1. 실행된 순간 전역 컨텍스트가 활성된다. 전역 컨텍스트의 environmentRecord에 { a, outer } 식별자를 저장한다.
  2. 전역 스코프에 있는 변수 a에 1을, outer에 함수를 할당한다.
  3. outer 함수를 호출한다. outer 실행 컨텍스트가 활성된다.
  4. outer 실행 컨텍스트의 environmentRecord에 { inner } 식별자를 저장한다. outerEnvironment에는 outer 함수가 선언될 당시의 LexicalEnvironment가 담긴다. outer 함수는 전역 공간에서 선언됐으므로 전역 컨텍스트의 LexicalEnvironment를 참조복사한다. 이를 [ GLOBAL, { a, outer } ]라고 표기한다. 첫번째는 실행 컨텍스트의 이름, 두번째는 environmentRecord 객체다.
  5. outer 스코프에 있는 변수 inner에 함수를 할당한다.
  6. inner함수를 호출한다. inner 실행 컨텍스트가 활성된다.
  7. inner 실행 컨텍스트의 environmentRecord에 { a } 식별자를 저장한다. outerEnvironment에는 inner 함수가 선언될 당시의 LexicalEnvironment가 담긴다. inner 함수는 outer 함수 내부에서 선언됐으므로 outer 함수의 LexicalEnvironment, 즉 [ outer, { inner } ]를 참조복사한다.
  8. 4번째 줄에서 식별자 a에 접근한다. 현재 활성된 inner 컨텍스트의 environmentRecord에서 a를 검색한다. a가 발견됐으나 아직 할당된 값이 없어 undefined를 출력한다.
  9. 5번째 줄에서 inner 스코프에 있는 변수 a에 3을 할당한다.
  10. 6번째 줄에서 inner 함수 실행이 종료되고 inner 실행 컨텍스트가 콜 스택에서 제거된다. outer 실행 컨텍스트가 다시 활성된다.
  11. 8번째 줄에서 식별자 a에 접근한다. 활성화된 실행 컨텍스트의 LexicalEnvironment의 environmentRecord에 a가 있는지 찾아보고 없으면 outerEnvironmentReference에 있는 environmentRecord로 넘어가는 식으로 계속해서 검색한다.

전역 공간에서는 전역 스코프에서 생성된 변수에만 접근할 수 있다. outer 함수 내부에서는 outer 및 전역 스코프에서 생성된 변수에 접근할 수 있지만 inner 스코프 내부에서 생성된 변수에는 접근하지 못한다.

전역 스코프에서 선언한 aouter는 전역변수(global variable), outer 함수 내부에서 선언한 inner와 inner 함수 내부에서 선언한 a는 지역변수(local variable)이라고 할 수 있다.