본문 바로가기

개발

JavaScript 실행 컨텍스트와 관련 개념들

Execution Context는 자바스크립트 코드가 어떻게 실행되는지를 이해하는 데 가장 기본이 되는 개념이다.
그리고 Scope, Hoisting, this, Closure 등 자바스크립트의 중요한 특성으로 꼽히는 것들과 밀접한 관련이 있다. 이미 좋은 자료들이 많이 있지만 한번쯤 스스로 정리를 해야겠다는 생각이 들어 포스트를 작성하게 되었다.

정의

Execution Context(EC)는 자바스크립트 코드가 실행되는 환경을 의미하는 추상적인 개념이다. 여기서 환경은 실행되고 있는 코드가 사용할 수 있는 변수들, this의 값 등을 말한다.

EC는 크게 전역(Global), 함수 호출(Function Call), eval 함수 내 이렇게 세 가지 경우에 생성된다. 보통 eval 함수를 사용할 일은 별로 없으므로 이 포스트에서의 예시는 모두 전역 또는 함수 호출만을 고려하여 작성했다.

 

const g = "hello world";

goodbye();

function goodbye() {
  const f = "goodbye";

  console.log(f);
}

 

위와 같은 코드에서 EC는 1. 전역 EC, 2. goodbye 함수 호출로 생성된 EC, 마지막으로 3. console.log 호출로 생성된 EC 총 세 개가 존재한다고 할 수 있다.

Call Stack

EC는 실행 도중 생성된 다른 EC들까지 모두 실행을 마쳐야 소멸된다. 그래서 코드의 실행 과정을 스택에 EC가 쌓였다가(push) 나가는(pop) 것으로 표현한다. 이 때 그 스택을 Call Stack, Execution Context Stack 등으로 부른다.

 

// *는 실행 중인 EC를 의미함
// STACK = [*전역]
const g = "hello world";

goodbye(); // 함수 호출

function goodbye() {
  // 함수가 실행되었을 때를 가정
  // STACK = [전역, *goodbye] 
  const f = "goodbye";

  console.log(f); // STACK = [전역, goodbye, *console.log]
  // STACK = [전역, *goodbye]
}
// STACK = [*전역]

// STACK = []

 

EC는 상세하게 Variable Environment, Scope Chain, this로 나누어 살펴볼 수 있다.

Variable Environment

Variable Environment는 EC 내의 변수, 함수 선언, arguments로 구성된다. 예를 들어 위 코드에서 각 EC의 Variable Environment는 다음과 같다.

 

전역
- g (변수), goodbye (함수)

goodbye
- f (변수)

console.log
- f (arguments: "goodbye")

변수, 함수 (+Hoisting)

이 때 EC에서 코드가 실행되기 전에 모든 변수와 함수가 Variable Environment에 추가되는데 이것을 Hoisting이라고 한다. 위 예시에서 goodbye 함수 호출이 함수를 선언하는 코드보다 먼저 있음에도 작동하는 것도 이 때문이다.

 

여기서 중요한 점은 Hoisting은 변수와 함수를 코드 실행 전에 선언한 효과만 있다는 것이다. 선언과 동시에 초기화가 제대로 이루어지는 것은 위와 같은 함수 선언 뿐이다. 변수의 Hoisting을 보면 좀 더 이해가 쉽다.

var 변수는 undefined로 초기화가 이루어진다.

 

console.log(foo); // undefined가 출력된다

var foo = "bar";

 

let, const 변수는 초기화가 이루어지지 않는다.

 

console.log(foo); // ReferenceError: Cannot access 'foo' before initialization

const foo = "bar";
console.log(foo); // ReferenceError: foo is not defined

let foo = "bar";

 

그래서 let, const의 경우 'Hoisting에 의해 변수가 선언되었지만 값이 할당되지 않은 동안'을 가리켜 Temporal Dead Zone(TDZ)이라고 부르며 이 때 변수에 접근하면 위처럼 에러가 발생한다.

 

함수 표현식의 경우 변수에 함수를 할당하는 것이므로 변수와 마찬가지로 키워드에 따라 Hoisting된다. 그래서 함수 선언과 달리 선언 코드 이전에 사용하려고 할 경우 에러가 발생한다.

 

예시에서 알 수 있듯이 Hoisting은 함수를 선언 코드 이전에 사용할 수 있게 해주지만, 나머지 경우에서는 주의해야 할 특성이다. 특히 var의 경우 명시적인 에러가 발생하지 않기 때문에 코드가 예기치 못한 결과를 낳을 수 있다.

arguments

다시 Variable Environment로 돌아와서, arguments는 말 그대로 함수 호출 EC 내에서 함수가 호출될 때 넘겨받은 argument들을 의미한다. 함수 내에서 arguments Object를 출력해보는 것도 가능하다.

 

add(3, 5); // 8

function add(p1, p2) {
  console.log(arguments); // Arguments(2) [3, 5, callee: ƒ, Symbol(Symbol.iterator): ƒ]

  return p1 + p2;
}

Scope Chain

Scope

Scope는 변수 또는 함수가 유효한 범위를 의미한다. 자바스크립트에는 전역(Global), 함수(Function), Block 세 종류의 Scope가 존재한다.

 

const g = "hello world"; // 전역 Scope
console.log(g); // "hello world"

if(true) {
  const b = "block"; // Block Scope
  console.log(b); // "block"
}

// console.log(b); <- Reference error

foo(); // "bar";

// bar(); <- Reference error

function foo() {
  bar();

  function bar() { // Function Scope
    console.log("bar");
  }
}

 

위와 같이 자신보다 안쪽 Scope의 변수, 함수에는 접근할 수 없다. 이 때 변수, 함수가 선언된 위치가 안과 밖의 기준이 된다. 이것을 Lexical Scoping이라고 하는데, 조금 뒤에 이어서 설명할 것이다.

 

어떤 Scope에 속하게 되는 것을 'scoped'라고 표현한다면 함수 선언은 Strict Mode에서는 Block Scoped, strict mode가 아닐 때는 Function Scoped이다. let, const 변수는 Block Scoped / var 변수는 Function Scoped이다. 만약 Block 안에 let 또는 const가 선언되었다면 그 Block 내에서만 사용할 수 있지만 var 변수는 그렇지 않다는 의미다.

 

if(true) {
  var hello = "hello world";
  const bye = "bye";
}

console.log(hello); // "hello world"

console.log(bye); // Reference error

 

Hoisting과 Scope를 고려했을 때 1. 항상 Strict mode에서, 2. 변수 선언은 변수를 사용할 범위를 고려하여 Scope 가장 상단에, 3. var 변수는 사용하지 않는 것이 에러를 방지하는 좋은 습관이라고 할 수 있겠다.

Chain

위에서 자신보다 안쪽 Scope에는 접근할 수 없다고 설명했는데, 반대로 바깥 Scope에는 접근이 가능하다. 예를 들어 a라는 변수를 출력하는 코드가 실행 중일 때 현재 Scope에 a가 없다면, 바깥 Scope에서 a를 찾는다. 이 과정이 전역 Scope에까지 연쇄적으로 일어나는데, 이것을 Scope Chain이라고 한다.

 

const a = "hello world";

foo(); // "hello world"

function foo() {
  console.log(a); // Scope에 a가 없으므로 바깥 Scope인 전역에서 a를 찾아 출력
}

 

const a = "hello world";

foo(); // "goodbye"

function foo() {
  const a = "goodbye";

  console.log(a); // Scope에 a가 있으므로 그대로 출력
}

Lexical Scoping

중요한 것은 Scope Chain이 코드의 실행 순서와는 무관하게 선언된 위치에 따라 결정된다는 것이다.

 

// STACK = [*전역: { a, foo, baz }]
const a = "hello world";

foo();

function foo() {
  // [전역: { a, foo, baz }, *foo: { b, bar }]
  const b = "goodbye";
  bar();

  function bar() {
    // [전역: { a, foo, baz }, foo: { b, bar }, *bar: { c }]
    const c = "hi again";

    baz(); // 함수 baz가 Scope에 없으므로 바깥 Scope인 foo에서 찾음
          // foo에도 baz가 없으므로 전역 Scope에서 baz를 찾아 호출
  }
}

function baz() {
  // [전역: { a, foo, baz }, foo: { b, bar }, bar: { c }, *baz: { d }]
  const d = "bye again";

  console.log(a); // 변수 a가 Scope에 없으므로 바깥 Scope인 전역 Scope에서 찾아 출력
  console.log(b); // Reference Error: 변수 b가 Scope에 없고, 바깥 Scope인 전역 Scope에도 없으므로 Error 발생
}

 

예시에서 baz가 실행될 때 변수 b를 선언하는 foo 함수는 이미 콜 스택에 쌓인 상태였다. 하지만 설명한대로 Scope Chain은 실행 순서가 아니라 선언된 위치에 의해 결정되므로 baz의 바깥 Scope는 전역 Scope가 된다. 자신의 Scope와 모든 바깥 Scope에 b가 존재하지 않기 때문에 에러가 발생할 것이다.

this

this는 EC 내에서 사용할 수 있는 특별한 키워드다. 전역 EC에서 this는 항상 window object를 가리킨다. 함수 호출 EC에서는 상황에 따라 this의 값이 달라진다.

Method와 일반 함수 호출

함수가 어떤 Object의 property, 즉 method로 실행된 경우 this는 그 Object를 가리킨다.

 

const cat = {
  age: 3,
  printAge: function() {
    console.log(this.age);
  },
};

cat.printAge(); // 3

 

한편 일반적인 함수 호출의 경우 this는 strict mode에서는 undefined를 값으로 갖고, strict mode가 아닐 때는 window object를 가리킨다.

 

'use strict';

foo();

function foo() {
  console.log(this); // undefined

  bar();

  function bar() {
    console.log(this); // undefined
  }
}

 

bar 함수가 foo 함수 내에서 호출되어 this가 함수 foo를 가리킬 것이라고 생각하기 쉬운데, method로 실행된 것이 아니기 때문에 undefined를 값으로 가진다. 다음과 같이 함수를 호출해도 마찬가지 이유로 this는 undefined다.

 

'use strict';

const cat = {
  age: 3,
  printAge: function() {
    console.log(this.age);
  },
};

const foo = cat.printAge;

foo(); // TypeError: Cannot read property 'age' of undefined

addEventListener

addEventListener의 콜백 함수에서 this는 해당 DOM Element를 가리킨다.

 

<!DOCTYPE html>
<html>
  <head>
    <!-- 생략 -->
  </head>
  <body>
    <h1>What is this?</h1>
    <script>
      const h1 = document.querySelector("h1");

      h1.addEventListener("click", function() {
        console.log(this); // <h1>What is this?</h1>
      });
    </script>
  </body>
</html>

new

new 키워드를 사용했을 경우 새로운 Object가 만들어지고, class의 constructor와 생성자 함수에서 this는 그 Object를 가리킨다.

 

// class
class Cat {
  constructor(age) {
    this.age = age;
  }
}

const myCat = new Cat(3);
console.log(myCat); // Cat {age: 3}

// function
function Dog(age) {
  this.age = age;
}

const myDog = new Dog(5);
console.log(myDog); // Dog {age: 5}

Arrow Function

Arrow function에서의 this는 함수가 실행될 때 결정되지 않고, 바로 바깥 Scope의 this를 자신의 this값으로 갖는다(Lexical this라고도 함). 여태까지의 설명은 일반 함수 선언 기준이며 만약 Arrow function이었다면 결과가 달라진다. 이 때문에 Arrow function을 정확히 이해하지 못한 상태로 사용한다면 예상치 못한 에러가 발생하기 쉬우므로 주의가 필요하다.

 

const foo = {
  bar: function() {
    console.log(this);
  },

  baz: () => {
    console.log(this);
  },
}

foo.bar(); // {bar: ƒ, baz: ƒ}

foo.baz(); // Window { ... }

 

bazfoo의 method으로 실행되었지만 this는 foo가 아니라 그 바깥 Scope인 전역 Scope의 this, 즉 window Object를 가리키는 것을 확인할 수 있다. addEventListener 예시에서 콜백 함수를 Arrow function으로 바꿔보면 마찬가지로 window Object가 출력된다.

bind, call

bindcall은 명시적으로 함수의 this값을 정할 수 있는 함수 메소드다. 함수 뒤에 .bind(something)를 호출하면 그 함수에서 this가 something을 가리키도록 '바인딩'된 새로운 함수를 리턴한다. 또 .call(something)을 호출하면 this를 something으로 바인딩하여 함수가 실행된다. .call(something, arg1, arg2, ...)처럼 argument를 넘겨주는 것도 가능하다.

 

const cat = {
  age: 3,
  printAge: function() {
    console.log(this.age);
  },
};

const addOne = function () {
  this.age += 1;
}

cat.printAge(); // 3

addOne.call(cat);
cat.printAge(); // 4

const addOneToCat = addOne.bind(cat);
addOneToCat();

cat.printAge(); // 5

 

*Arrow function에는 bind, call 등이 적용되지 않는다. (항상 lexical this)

마무리

한창 자바스크립트를 공부할 때(2021년 여름?) 정리했던 내용인데, 일하면서 바닐라JS로 뭔가를 만들 일이 없어서 다시 보니 약간 낯선(?) 느낌이기도 하고 새삼 자바스크립트는 참 오묘하다... 꾸준히 공부하지 않으면 안 되겠다.

'개발' 카테고리의 다른 글

Gatsby 블로그: Pagination 추가하기  (0) 2022.11.30
Gatsby 블로그: 포스트에 카테고리 추가하기  (0) 2022.11.30
Gatsby로 블로그 만들기  (0) 2022.11.30
HTTP 세션과 쿠키  (0) 2022.11.30
JavaScript 클로저  (0) 2022.11.30