Post

Vanila JS 실행 컨텍스트

목적

자바스크립트에서도 코드 실행 순서나, 그 원리에 대해 깊게 이해하기 위해서 실행 컨텍스트에 대해서 알아야한다. 따라서, 그 내용을 담아보았다.

본 내용은 전체적으로, 코드가 실행될 때의 규칙이나 원리에 대해서 다룬 내용이다. 기본 문법도 숙지가 잘 안되었다면 지금은 적절하지 않은 주제다.

실행 컨텍스트 구성 요소

먼저, 실행 컨텍스트 또한 객체로서 아래와 같은 property 를 갖는다.

  1. VariableEnvironment, VE a. 현재 컨텍스트 내의 식별자 정보(=record, 즉 let a = 0; 에서 let a; 만 수집) -> 호이스팅에서 다루어 볼것이다. b. 외부 환경 정보(=outer, 스코프에 대한 정보로 호이스팅 다음에 다룰 것이다.) c. 선언 시점 LexicalEnvironment 의 snapshot -> 컨텍스트 생성 시점의 정보를 가짐.

  2. LexicalEnvironment, LE a. VariableEnvironment 와 동일한 것인데, Lexical 은 변경사항을 실시간으로 반영. -> snapshot 을 기반으로 정보를 생성하고, 이후 변동이 생기면 Lexical 을 업데이트함.

  3. ThisBindings a. this 식별자가 바라보는 객체

VE, LE

VE 와 LE 는 99% 동일하다. 단지 LE 는 변경사항을 기록하는 용도가 더 있다는 것.

여기서, VE 가 식별자 정보를 가진다고 했는데 여기에는 함수에 지정된 매개변수의 식별자나 함수 그 자체, let 이나 const 등으로 선언된 변수의 식별자 등이 포함된다.

이 때, 컨텍스트에 포함되는 코드를 처음 부터 끝까지 순차적으로 살펴보며 수집하는 것이다.

호이스팅

호이스팅은 실제로 동작하는 방식이 아니고, 방금 말한 VE 가 식별자 등을 수집하는 과정의 이해를 돕기위해 사용하는 개념이라고 한다. mdn 을 보면, 선언할 때 사용하는 식별자가 최상단으로 위치가 옮겨지는 것 처럼 보이기 때문에 호이스팅이라고 한단다.

식별자가 최상단으로 옮겨지는 것 같다는게 당최 무슨말인가????

아래 코드를 보면 이해가 될 것이다.

1
2
3
4
5
6
const x = 1;
{
  // const x; -> Hoisting
  console.log(x); // ReferenceError: can't access lexical declaration 'x' before initialization
  const x = 2;
}

나는 const x 를 두 번이나 가능하다는 것이 좀 신기했다. 그래서 따로 찾아봤는데, 정리가 필요할 것 같다. const keyword 를 var 로 변경해보면, 1 이 정상적으로 출력된다. 그런데, let 으로 바꾸면 마찬가지로 참조에러가 발생한다. 여기에서는 let 이나 const 가 블록 스코프를 가지기 때문에 그렇다고 이해해주길 바란다.

아무튼 이때, console.log(x) 에서는 오류가 발생하지 않고 그 아래에서 문제가 생길 거라고 예상했는데 뜬금없이 console.log(x) 에서 오류가 발생했다.

위에서 아래로 실행이 된다면, console.log(x) 는 전역 변수 x 를 먼저 참조해야하는 것 같은데 초기화가 안된 변수에 접근하려고 한다는 에러를 뱉는게 아닌가!

초기화가 안됐다라는 말은, const x 와 같은 상황일 때 사용할 수 있다.

그런데, 아까 VE 가 식별자 정보를 수집할 때 const x = 1; 에서 const x 와 같은 부분만 수집한다고 했다. 그래서 아직 초기화가 되지 않은 상황에 console.log(x) 에서 x 를 참조하려고 하니 문제가 생기는 것이다.

이는 블록 스코프와 호이스팅을 동시에 알 수 있는 좋은 예시인 것 같다.

함수 선언문, 함수 표현식

여기서 짚고 넘어가야할 것이 있다.

함수 또한 호이스팅의 대상이라고 했는데, 함수도 종류에 따라 호이스팅이 달라진다.

함수를 처음 배웠을 때, 함수를 변수에 대입할 수도 있다는 것을 배웠을 것이다. 만약, 모르고 있다면 된다는 걸 알아두길!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 함수 선언문
function sayHi() {
  console.log(`Hi!`);
}

// 함수 표현식
const sayHi = function () {
  console.log(`Bye!`);
};

// 사실 이렇게 할 거면, 위에 있는 두 개중 하나 쓰면 되지 않을까??
const sayHi = function func() {
  console.log(`Bye!`);
};

이렇게 세 가지 정도 있다.

이때, 함수 선언문과 표현식의 호이스팅에서 차이가 발생한다.

함수 선언문을 사용하게 되면(function 키워드로만 정의) 함수 전체가 호이스팅 된다. 이와 달리, 함수 표현식을 사용하게 되면 const sayHi 만 따로 호이스팅이 된다. 이후 순서에 따라 sayHi 에 할당이 될 것이다.

“근데 이게 무슨 문제냐?” 라고 묻는다면 대답해드리는게 인지상정!

그런데, 프로젝트 규모가 커져서 나중에 동일한 이름이지만 다른 기능을 하는 함수를 정의했다고 치자.

1
2
3
4
5
6
7
8
9
10
11
12
// 함수 선언문
function sayHi() {
  console.log(`Hi!`);
}

sayHi(); // Bye!

function sayHi() {
  console.log(`Bye!`);
}

sayHi(); // Bye!

이 코드는 모두 Bye! 만을 출력하게 된다.

보기에는 Hi! 와 Bye! 를 출력할 거라고 생각했는데 두 번째로 정의한 sayHi() 만 실행되고 있다!

그래서 함수 표현식을 쓰면 어떻게 될까?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 함수 선언문
function sayHi() {
  console.log(`Hi!`);
}

console.log(sayHi);

sayHi();

const sayHi = function () {
  console.log(`Bye!`);
};
// Uncaught SyntaxError: redeclaration of function sayHi

console.log(sayHi);

sayHi();

이렇게 하면 사실 오류가 발생한다. 이미 선언된 으름을 다시 사용하려고 하기 때문이다. 표현식으로 선언하면, 중복되는 이름의 함수를 선언할 경우에는 막아줄 수가 있다.

그래서 호이스팅을 통해 배운 것은 함수 표현식을 쓰라는 것!

outerEnvironmentReference, 스코프

스코프는 식별자의 유효 범위라고 말할 수 있다. 이미 블록 스코프니 뭐니 언급했지만, 함수 스코프인지 블록 스코프인지 구분하는 것은 미뤄두고 아래 예시를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let a = 1;

const outer = function () {
  const inner = function () {
    // let a : Hoisting
    console.log(a); // undefined
    let a = 3;
  };

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

outer();

console.log(a); // 1

각각 console.log(a) 의 결과가 이해가 되는가?? 그럼 오늘은 이만 하산해도 좋다.

여기에서는 3 이 출력이 될수가 없다. 실행 컨텍스트는 콜 스택을 그려보면 알게되는데, 다음과 같다.

inner()
outer()
전역환경

이러한 스택이 만들어진다. 이때, inner() 함수에서는 outer() 의 컨텍스트와 전역환경 컨텍스트에 접근이 가능하다. 스택이 만들어질 때, 먼저 들어온 환경들은 LE 가 존재하기 때문.

이렇게 계층적 구조로 하위 컨텍스트가 상위 컨텍스트로 접근할 수 있는 것을 scope chain 이라고 한다.

ThisBindings

마지막으로, this 에 대해서 다루어 보자.

다른 프로그래밍 언어에서는 this keyword 는 클래스로 생성한 인스턴스를 말한다. -> 이 말은 나중에 다시 풀어서 써보자.

그런데, JS 에서 this 는 단순히 클래스의 인스턴스만을 말하지 않는다.

this 역시 실행 컨텍스트 생성시 결정이 되는데, this bind 가 일어난다고 말한다. 그렇다면, this 역시 각 컨텍스트마다 다르기에 어떤 것을 가리키는지 유의해야한다.

함수나 메소드의 예시를 들면, 둘의 this 는 명백히 다르다.

함수와 메소드의 차이는 아래 코드를 통해 확인해보자.

1
2
3
4
5
function func() {
  console.log(this); // window or global
}

func();
1
2
3
4
5
6
7
8
9
10
const obj = {
  outer: function () {
    const func = function () {
      console.log(this); // obj, { func: [Function: func] }
    };
  }
};

obj.func();
// obj[`func`]();

func() 는 호출할 때 부터 명백히 차이가 있다.

바깥에 정의한 첫 번째 func()this 가 전역 객체를 가리키고 있다. 브라우저 환경이라면, window 객체가 this 가 될 것이고 node 환경이면 global 이 될 것이다.

두 번째 func() 는 obj 를 통해 호출을 하게 된다. 이때 this 를 출력하면, obj 를 가리킬 것이다.

실행을 할때, 함수이름() 으로 호출하는지 객체.함수이름() 으로 호출하는지가 관건이다.

그런데, 만약 아래와 같은 상황에서 this 는 어떤 것을 가리키고 있을까?

1
2
3
4
5
6
7
8
const obj = {
  outer: function () {
    const func = function () {
      console.log(this);
    };
    func(); // ???
  }
};

이때는, 전역 객체가 출력이 된다. 객체 내의 메소드인데 그 방식으로 호출하지 않아서 this 는 전역객체를 바라본다.

이것이 직관적이지 못했기 때문인지, 해결하기위해 등장한 것이 arrow function 이라고 한다!!! 처음 배울 때는 익숙하지도 않고 불편하다고 생각했는데, 아주 큰 차이점이 있었다.

arrow function 을 사용하면, 실행 컨텍스트가 생성될 때 this binding 과정 자체가 없다고 한다. 그렇기 때문에, 아래처럼 사용하면 이미 상위 실행 컨텍스트에서 binding 된 this 에 접근이 가능해진다!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// As is
const obj = {
  outer: function () {
    console.log(`1 : `, this); // obj, { outer: [Function: outer] }

    const func = function () {
      console.log(`2 : `, this); // global or window
    };
    func(); // ???
  }
};

// To be
const obj = {
  outer: function () {
    console.log(`1 : `, this); // obj, { outer: [Function: outer] }

    const func = () => {
      console.log(`2 : `, this); // obj, { outer: [Function: outer] }
    };

    func(); // obj
  }
};

obj.outer();

또 하나의 예시로, 콜백함수 또한 전역객체를 바라보게 된다. forEach method 를 예시로 보자.

1
2
3
4
5
6
const arr = new Array();
arr.push(1);

arr.forEach(function abc() {
  console.log(this); // window
});

화살표함수를 사용하지 않았음에도 위 예시에서 this 는 전역 객체를 바라보고 있다. 이런 메소드는 콜백함수를 호출할 때 this binding 이 별도로 이루어지지 않기 때문이다.

그러나, 콜백함수에게 this bidning 을 직접 해주는 경우가 하나 있다! addEventListener() 의 경우, 우리가 이벤트를 추가해주는 대상을 바라보도록 설정을 해준다.

1
2
3
4
5
const div = document.querySelector("div");

const handleClick = () => {};

div.addEventListener("click", handleClick);

이렇게 지정해줄 경우, addEventListener() 는 this binding 을 해준다.

정리

  1. 실행 컨텍스트가 생성 될때, 3가지 구성요소(VariableEnvironment, LexicalEnvironment, Thisbinding)들이 만들어진다.
  2. VE 와 LE 는 식별자(record) 와 외부 환경(outer) 정보를 가진다.
  3. VE 는 실행 컨텍스트 생성 시의 snapshot 이며, LE 는 실시간으로 변화하는 정보를 담는 역할이다.
  4. This 는 속한 스코프에 따라 달라진다.
  5. 콜백함수도 마찬가지로, this binding 을 별도로 해주지 않으면 전역 객체를 가리킨다.
This post is licensed under CC BY 4.0 by the author.