자바스크립트 핵심 (1): 실행 문맥 (execution context)

Lee Yechan·2023년 7월 9일
0

Language - javascript

목록 보기
1/8
post-thumbnail

주의: scope chain은 ES3 이후로 사용되지 않는 표현이다. 그 대신 outer lexical environmental chain 등의 단어로 표현할 수 있겠으나, 더욱 쉬운 이해를 위해 scope chain이라는 표현을 그대로 사용한다.


실행 문맥

자바스크립트의 동작을 본질적으로 이해하기 위해서는 실행 문맥, scope(유효범위), prototype에 대한 이해가 필요하다.

이 글에서는 그 중에서 실행 문맥과 scope에 대해 살펴본다.


  • 호이스팅(hoisting)? 그거 var로 선언된 변수가 위로 올라가는 거 아니야?
  • 클로저(closure)? 예전에 이해했던 것 같은데 설명은 못하겠어.
  • this? 복잡해. 왜 이렇게 만들어놓은 거야?

라는 생각을 가지고 있다면, 이 글을 통해 hoisting, closure, this에 대한 더 깊은 이해를 할 수 있을 것이다.


Scoping의 예시

var a = "A";
function f() {
  var b = "B";
  function g() {
    var c = "C";
		console.log(a + b + c);
	}
	g();
}

f();

위와 같은 상황을 생각해보자.

자바스크립트를 접하지 않았더라도, 다른 프로그래밍 언어를 접해본 사람이라면 “ABC”가 콘솔로 출력될 것임을 예측할 수 있을 것이다.

변수 탐색은 가장 내부 스코프에서 시작해서 외부로 진행된다.

따라서 전역 범위에 위치한 변수 a와, 함수 f() 안에 위치한 변수 b, 그리고 함수 g() 안에 위치한 지역변수 c를 활용해 console.log(a + b + c);를 수행하는 것이 가능하다.


자바스크립트의 실행 문맥과 실행 과정

어떻게 이런 일이 가능하도록 구현했을까?

이를 위해 자바스크립트의 실행 문맥(execution context)에 대해 이해해보자.

자바스크립트 엔진은 우리가 쓴 자바스크립트 코드를 실행 문맥으로 변환한다.

실행 문맥이란 실행 가능한 코드가 실제로 실행되고 관리되는 영역으로, 실행에 필요한 모든 정보를 컴포넌트 여러 개가 나눠 관리하도록 만들어져 있다.

// 실행 문맥
ExecutionContext = {
  // 렉시컬 환경 컴포넌트
  // 함수 또는 블록의 유효 범위 안에 있는 식별자와 그 결과값이 저장되는 곳.
  // 식별자 : 가리키는 값 을 키와 값의 쌍으로 바인드하여 기록함.
  LexicalEnvironment: {
    // 환경 레코드
    // 유효 범위 안에 포함된 식별자를 기록하고 실행하는 영역
    EnvironmentRecord: {
      // 선언적 환경 레코드
      // 실제로 함수, 변수, catch 문의 식별자와 실행 결과가 저장되는 영역
      DeclarativeEnvironmentRecord: {},
      // 객체 환경 레코드
      // 객체의 참조에서 데이터를 읽거나 씀
      ObjectEnvironmentRecord: {},
    },
    // 외부 렉시컬 환경 참조
    // 유효 범위 너머의 범위에 대한 레퍼런스
    OuterLexicalEnvironmentReference: {},
  },
  // 디스 바인딩 컴포넌트
  ThisBinding,
};

실행 문맥은 위와 같은 구조를 가지고 있는데, 요약하면 실행 문맥은 크게

  • 함수, 변수 등을 저장하는 영역
  • 객체의 참조를 저장하는 영역
  • 유효 범위 너머의 참조를 저장하는 영역
  • this를 저장하는 영역

등으로 이뤄진다고 할 수 있다.

이러한 실행 문맥은 함수가 실행될 때 새로 만들어져 stack 영역에 push되며, 이를 통해 위에서 보았던 scope chaining이 가능하게 된다.


var a = "A";
function f() {
  var b = "B";
  function g() {
    var c = "C";
		console.log(a + b + c);
	}
	g();
}

f();

실행 문맥을 더 자세히 설명하기 위해, 위 코드를 자바스크립트 인터프리터가 해석한다고 가정해보자.


코드를 실제로 읽기 전, 자바스크립트 인터프리터가 코드 해석을 위한 준비를 하기 위해 다음과 같은 과정이 필요하다.

자바스크립트 인터프리터는 처음으로 lexical environment(렉시컬 환경) 타입의 전역 환경을 생성한다. 그 다음으로 전역 객체를 생성한 뒤, 전역 환경의 객체 환경 레코드에 전역 객체의 참조를 대입한다. 웹브라우저의 경우 전역 객체가 window이다.


위 설명을 코드로 표현하면 다음과 같다.

Global_ExecutionContext = {
  LexicalEnvironment: {
    EnvironmentRecord: {
      DeclarativeEnvironmentRecord: {},
      ObjectEnvironmentRecord: {
				bindObject: [window],
			},
    },
    OuterLexicalEnvironmentReference: null,
  },
  ThisBinding: window,
};

그 후, 자바스크립트 인터프리터는 최상위 레벨의 전역 변수와 함수를 읽는다.

var a = "A";
function f() {
  ...
}

f();
Global_ExecutionContext = {
  LexicalEnvironment: {
    EnvironmentRecord: {
      DeclarativeEnvironmentRecord: {
				a : "A",
			},
      ObjectEnvironmentRecord: {
				bindObject: [window, f],
			},
    },
    OuterLexicalEnvironmentReference: null,
  },
  ThisBinding: window,
};

f();와 같이 함수를 실행하면, f를 실행하기 위한 실행 문맥을 새로 만들어, stack에 push한다. 이때에도 함수 안에 선언된 지역 변수와 중첩함수의 참조를 환경 레코드에 저장한다. 저장을 마친 뒤 함수 안에 있는 코드를 실행한다.

function f() {
  var b = "B";
  function g() {
    ...
	}
	g();
}
Global_ExecutionContext = {
  Global_LexicalEnvironment: {
    EnvironmentRecord: {
      DeclarativeEnvironmentRecord: {
				a : "A",
			},
      ObjectEnvironmentRecord: {
				bindObject: [window, f],
			},
    },
    OuterLexicalEnvironmentReference: null,
  },
  ThisBinding: window,
};

F_ExecutionContext = {
  F_LexicalEnvironment: {
    EnvironmentRecord: {
      DeclarativeEnvironmentRecord: {
				b : "B",
			},
      ObjectEnvironmentRecord: {
				bindObject: [g],
			},
    },
    OuterLexicalEnvironmentReference: Global_LexicalEnvironment
  },
  ThisBinding: window,
};

마찬가지로 g();를 실행하면, 실행 문맥을 새로 만든 뒤, 함수 안에 선언된 지역 변수와 중첩함수의 참조를 환경 레코드에 저장한다. 저장을 마친 뒤 함수 안에 있는 코드를 실행한다.

function g() {
  var c = "C";
	console.log(a + b + c);
}
Global_ExecutionContext = {
  Global_LexicalEnvironment: {
    EnvironmentRecord: {
      DeclarativeEnvironmentRecord: {
				a : "A",
			},
      ObjectEnvironmentRecord: {
				bindObject: [window, f],
			},
    },
    OuterLexicalEnvironmentReference: null,
  },
  ThisBinding: window,
};

F_ExecutionContext = {
  F_LexicalEnvironment: {
    EnvironmentRecord: {
      DeclarativeEnvironmentRecord: {
				b : "B",
			},
      ObjectEnvironmentRecord: {
				bindObject: [g],
			},
    },
    OuterLexicalEnvironmentReference: Global_LexicalEnvironment
  },
  ThisBinding: window,
};

G_ExecutionContext = {
  G_LexicalEnvironment: {
    EnvironmentRecord: {
      DeclarativeEnvironmentRecord: {
				c : "C",
			},
      ObjectEnvironmentRecord: {
				bindObject: [],
			},
    },
    OuterLexicalEnvironmentReference: F_LexicalEnvironment
  },
  ThisBinding: window,
};

이때, console.log(a + b + c);를 실행할 때 c는 G의 실행 문맥 안에서 찾을 수 있으나, ab는 찾을 수 없다.

이와 같이 지금의 실행 문맥에서 식별자를 찾을 수 없을 경우, 차례차례로 외부 환경의 참조를 통해(outer lexical environment reference) 바깥 단계에 식별자가 있는지 확인한다.

F_LexicalEnvironment에는 a는 찾을 수 없지만, b는 찾을 수 있다.

a를 찾기 위해 Global_LexicalEnvironment에 접근하면, 식별자 a를 찾을 수 있다.

따라서 이러한 방식으로 g 함수 내에서 지역 변수가 아닌 ab에 접근할 수 있다.


그래서 저게 왜 중요한데?

실행 문맥은 Scope chain을 구현하기 위한 중요한 이론적 배경이며, 위와 같은 자바스크립트의 평가 및 실행 과정을 통해 자바스크립트의 여러 기능들과 특성들이 설명될 수 있다.

Hoisting

hoisting(호이스팅)이란 코드가 실행하기 전 변수선언/함수선언 이 해당 스코프의 최상단으로 끌어 올려진 것 같은 현상을 말한다.

예를 들어 다음과 같은 상황이 있다고 가정해보자.

console.log(text);  // undefined
text = 'hello!';
var text;
console.log(text);  // hello!

f();  // ff
g();  // Uncaught TypeError TypeError: g is not a function
function f() {
  console.log('ff');
}
var g = function() {
  console.log('gg');
}

위 코드에서는 text가 선언되기 이전에 console.log로 출력을 하고 있고, f가 선언되기 이전에도 f를 호출하고 있다.

일반적으로는 이런 행위는 에러를 발생시키지만, 자바스크립트에서는 text의 출력과 f의 호출을 하는 데에 에러가 발생하지 않는다.


그 이유는 hoisting 현상 때문인데, 실제로 위 코드는 다음와 같이 해석된다.

var text;
function f() {
  console.log('ff');
}
var g;

console.log(text);  // undefined
text = 'hello!';
console.log(text);  // hello!

f();  // ff
g();  // Uncaught TypeError TypeError: g is not a function
g = function() {
  console.log('gg');
}

이는 자바스크립트 실행 과정에서 실마리를 찾을 수 있는데, 자바스크립트는 해당 scope에 위치한 모든 변수와 함수를 코드 실행 전에 실행 문맥에 저장하기 때문이다.

따라서 console.log(text);f();를 실행하기 전 이미 자바스크립트가 변수 text와 함수 f의 존재를 알고 있기 때문에 에러가 발생하지 않는 것이다.

var로 선언한 변수는 초기 값이 undefined가 되므로 console.log(text);의 실행 결과는 undefined가 되고, 함수는 객체 환경 레코드에 참조로 저장되므로 f();의 실행 결과는 “ff”가 된다.

Closure

closure(클로저)란 근처에서 만들어진 변수를 캡처하는 함수이다.


예를 들어 다음과 같은 코드를 살펴보자.

function sayHelloTo(name) {
  var greeting = "Hello, ";
  return function () {
    console.log(greeting + name + "!");
  };
}
var sayHelloToYechan = sayHelloTo("Yechan");
var sayHelloToJames = sayHelloTo("James");

sayHelloToYechan(); // Hello, Yechan!
sayHelloToJames(); // Hello, James!

보통, 함수의 지역 변수의 생명 주기는 함수의 바디에 국한된다. 즉, 함수가 종료되면 지역 변수가 소멸하는 것이다.

하지만 위에서는 sayHelloTo 함수가 이미 실행을 멈췄는데도 불구하고 지역변수인 greetingname에 접근이 가능하다.

이는 앞서 살펴보았던 실행 문맥의 특성 때문에 가능하다.


function () {
    console.log(greeting + name + "!");
};
Anonymous_ExecutionContext = {
  LexicalEnvironment: {
    EnvironmentRecord: {
      DeclarativeEnvironmentRecord: {},
      ObjectEnvironmentRecord: {},
    },
    OuterLexicalEnvironmentReference: sayHelloTo_LexicalEnvironment,
  },
  ThisBinding: window,
};

sayHelloToYechansayHelloToJames를 실행할 때, 실행 문맥의 구조는 다음과 같다.

위의 익명함수가 sayHelloTo 함수 내에서 선언되었기 때문에 outer lexical environment reference에 sayHelloTo의 lexical environment가 참조로써 저장되는 것이다.

따라서 return된 익명함수 function () { console.log(greeting + name + "!"); }의 범위 내에서는 greetingname도 찾아볼 수 없지만, sayHelloTo의 lexical environment 참조를 통해 greetingname 식별자를 찾을 수 있는 것이다.


function sayHelloTo(name) {
  var greeting = "Hello, ";
  return function () {
    console.log(greeting + name + "!");
  };
}
SayHelloTo_ExecutionContext = {
  LexicalEnvironment: {
    EnvironmentRecord: {
      DeclarativeEnvironmentRecord: {
				name: "Yechan"
				greeting: "Hello, "
			},
      ObjectEnvironmentRecord: {},
    },
    OuterLexicalEnvironmentReference: Global_LexicalEnvironment,
  },
  ThisBinding: window,
};

Anonymous_ExecutionContext = {
  LexicalEnvironment: {
    EnvironmentRecord: {
      DeclarativeEnvironmentRecord: {},
      ObjectEnvironmentRecord: {},
    },
    OuterLexicalEnvironmentReference: sayHelloTo_LexicalEnvironment,
  },
  ThisBinding: window,
};

sayHelloToYechan();을 실행할 때에는 위와 같은 구조의 실행 문맥을 기반으로 name과 greeting을 찾아 함수를 실행하게 되고, sayHelloToJames();를 실행할 때에는 (name이 “James”로 저장된) 별도의 다른 실행 문맥을 기반으로 함수를 실행하게 된다.


그런데 namegreeting과 같은 변수에 어떻게 접근하는지는 이해했어도, namegreeting이 접근 전까지 어떻게 소멸하지 않은 것인지 궁금할 수 있을 것이다.

이는 가비지 컬렉터의 동작 때문이다.

위와 같은 경우에서,

  • 외부 함수 sayHelloTo는 중첩된 익명함수의 참조를 반환한다. 이로 인해 전역 범위에 선언된 전역 변수 sayHelloToYechansayHelloToJames가 익명함수를 참조하게 된다.
  • 그리고 익명함수는 외부 함수 sayHelloTo의 지역변수 namegreeting을 참조한다.

그 결과 전역 변수 sayHelloToYechansayHelloToJames가 외부 함수 sayHelloTo의 렉시컬 환경 컴포넌트를 간접적으로 참조하게 되므로 가비지 컬렉션의 대상이 되지 않는다.

따라서 sayHelloTo의 실행이 끝나서 호출자에게 제어권이 넘어간다고 하더라도 외부 함수의 렉시컬 환경 컴포넌트가 메모리에서 지워지지 않게 되어 namegreeting에 접근할 수 있었던 것이다.


this

javascript에서 this는 하나로 고정된 값이 아닌 동적인 값으로, 이 때문에 때로는 자바스크립트 프로그램이 예상치 못한 행동을 보여주기도 한다.

function foo() {
  var a = 10;
  console.log(this.a);
}

foo(); // undefined

일례로, 10을 출력하기 위해 위와 같이 자바스크립트 코드를 짤 수 있지만, 실제로 위 프로그램은 undefined를 반환한다.

Java, C++와 같은 언어에서 this가 해당 코드를 실행하는 클래스의 인스턴스를 가리켰다면, 자바스크립트에서는 ‘함수가 호출되었을 때 그 함수가 속해 있던 객체’를 가리키기 때문이다.


정리하면, this

  • 함수가 호출되었을 때 그 함수가 속해 있던 객체의 참조이며,
  • 실행 문맥의 this binding(디스 바인딩) 컴포넌트가 참조하는 객체

이다.

다음은 다양한 상황에서 this가 무슨 객체를 가리키는지 정리한 것이다.


console.log(this); // window { ... }
  • 최상위 레벨 코드의 this
    • 전역 객체를 가리킨다.
    • 실행 문맥이 초기화될 때 그 안의 this binding 컴포넌트가 전역 환경을 가리키도록 초기화되기 때문이다.

function f() { console.log(this); }
f(); // Window { ... }
  • 직접 호출한 함수 안에 있는 this
    • 전역 객체를 가리킨다.
    • 코드 앞에 객체가 없으므로 this binding 컴포넌트가 전역 객체를 가리키기 때문이다.

var btn = document.querySelector('#btn')
btn.addEventListener('click', function () {
  console.log(this); //#btn
});
  • 이벤트 처리기 안에 있는 this
    • 이벤트가 발생한 요소 객체(이벤트 처리기가 등록된 객체)를 가리킨다.

function Person(name) {
  this.name = name;
}
var name = 'James'; 
var yechan = new Person('Yechan');
 
console.log(yechan.name); // Yechan

  • 생성자 함수 안에 있는 this, 생성자의 prototype 메서드 안에 있는 this
    • 그 생성자로 생성한 객체를 가리킨다.
function getThis() {
  console.log(this);
}
getThis(); // Window { ... }
 
var obj = {
  prop: "abc",
};
 
getThis.call(obj); // {prop: "abc"}

  • 명시적 binding을 한 this (applycall 메서드로 호출한 함수 안에 있는 this)
    • 함수 객체의 applycall 메서드를 사용하면 함수를 호출할 때 this가 가리키는 객체를 명시적으로 바꿀 수 있다.

정리

이처럼 hoisting과 closure, this는 자바스크립트를 이해하는 데에 있어 까다로울 수 있는 개념이지만, 자바스크립트 인터프리터가 어떻게 동작하는지 이해한다면 더욱 쉽게 그 개념을 이해할 수 있다.


reference

모던 자바스크립트 입문 (이소 히로시, 길벗출판사, 2021)

함수형 자바스크립트 (마이클 포거스, 한빛미디어, 2014)

자바스크립트 실행컨텍스트#1 - 환경 레코드 (https://roseline.oopy.io/dev/javascript-back-to-the-basic/environment-record)

Executable Code and Execution Contexts (ECMAScript® 2024 Language Specification, 2023, https://tc39.es/ecma262/#sec-environment-records)

[JavaScript] 호이스팅(Hoisting)이란? (https://hanamon.kr/javascript-호이스팅이란-hoisting/)

[JS] 알쏭달쏭 자바스크립트 this 바인딩 (https://seungtaek-overflow.tistory.com/21)

[JS] 자바스크립트에서의 this (https://nykim.work/71)

profile
이예찬

0개의 댓글