코어 자바스크립트(정재남 지음) 를 참고하여 실행 컨텍스트에 대해 공부한 내용을 정리했습니다.
ES6 에서 let 과 const 변수 타입이 나타난 이후로 var 의 사용은 에러를 잡기 힘들단 이유로 지양된다. var로 선언된 변수는 호이스팅이 되기 때문이다. 호이스팅은 무엇을 의미하는 개념일까?
호이스팅을 이해하기 위해선 자바스크립트가 실행될 때 자바스크립트 엔진이 어떻게 변수들을 수집하는지를 알아야 한다.
실행할 코드에 제공할 환경 정보들을 모아놓은 객체이다.
코드를 실행할 때 필요한 환경 정보들을 모아 call stack 에 쌓아 올렸다가, 가장 위에 쌓여있는 컨텍스트와 관련 있는 코드를 실행함으로써 전체 코드의 환경과 순서를 보장한다.
JS 코드를 실행하는 순간 전역 컨텍스트가 가장 먼저 콜 스택에 담기게 되고, 어떤 함수 A를 실행하게 되면 A에 대한 환경 정보를 수집하여 A 실행 컨텍스트를 생성한 후 콜 스택에 담는다. 콜 스택은 가장 상단에 있는 실행 컨텍스트의 코드를 실행한다.
실행 컨텍스트가 활성화 될 때 JS 엔진은 해당 컨텍스트에 관련된 환경 정보를 수집해서 실행 컨텍스트 객체에 저장한다. 여기에 담기는 정보들은 다음과 같다.
VariableEnvironment
코드가 가장 처음 실행 될 때 담기는 LexicalEnvironment 의 스냅샷, 변경사항은 저장되지 않는다.
LexicalEnvironment
변경 사항이 실시간으로 반영되는 환경 정보
ThisBinding
this 식별자가 바라봐야할 객체 대상
LexicalEnvironment 의 내부는 다음으로 구성돼 있다.
현재 컨텍스트와 관련된 코드의 식별자 정보들이 저장된다. 함수 자체나 var로 선언된 변수의 식별자 등이 수집대상에 속한다. 컨텍스트 내부 전체를 처음부터 끝까지 쭉 훑어나가며 순서대로 수집한다.
변수 정보를 수집하는 과정을 모두 마쳤더라도 코드는 아직 실행되기 전의 상태이다. 코드가 실행되기 전임에도 자바스크립트 엔진은 이미 해당 환경에 속한 코드의 변수명들을 모두 알고 있게 된다.
여기서 실제로 식별자들을 최상단으로 끌어올리는 것은 아니지만, 마치 실제로 식별자들을 코드의 최상단에 끌어올린 것처럼 코드를 실행하게 된다. 이를 호이스팅이라고 한다.
var 는 호이스팅 때문에 쓰지 않는 것을 권장한다고 한다. 하지만 실제론 let, const 도 호이스팅이 된다. 어떤 차이가 있는 걸까?
답은 TDZ 에 있다. Temporal Dead Zone 의 약자로, 시간상 사각지대라는 뜻이다. var 는 선언과 동시에 초기화가 undefined 로 이뤄지지만 let, const는 선언과 초기화가 따로 이뤄지기 때문에 참조 에러가 난다.
1. var 변수의 호이스팅
function a(x) {
console.log(x); // 1
var x;
console.log(x); // 2
var x = 2;
console.log(x); // 3
}
a(1);
/*
호이스팅의 개념을 제외하고 본다면
1 - 1
2 - undefined
3 - 2
가 출력될 것으로 예상된다.
*/
function a() {
var x; // 매개변수는 모두 위로 끌어올려진 것처럼 동작하게 된다
var x;
var x;
x = 1;
console.log(x); // 1 - 1
console.log(x); // 2 - 1
x = 2;
console.log(x); // 3 - 2
}
a(1);
2. 함수 호이스팅
// 코드를 다음과 같이 작성하면 함수는 어떻게 호이스팅 되는가?
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;
}
// 다음과 같이 호이스팅된다.
var sum = function sum(a, b) { // 함수 선언문은 전체를 호이스팅 한다.
return a + b;
}
var multiply; // 변수(함수 표현식)는 선언부만 호이스팅한다.
console.log(sum(1, 2));
console.log(multiply(3, 4)); // [ERROR] multiply is not a function
multiply = function (a, b) { // 할당부는 그 자리에 있다.
return a * b;
}
함수 선언식의 경우 같은 이름으로 선언한 경우 이후 선언한 함수가 이전의 함수를 덮어버리기 때문에 위험성이 높다. 하지만 함수 표현식으로 정의한다면 선언부만을 호이스팅하게 되므로 위험이 적다.
// 함수 선언식의 경우
function sum(a, b) {
return x + y;
}
console.log(sum(1, 2)); // 마지막에 선언된 sum 함수로 동작한다
function sum(a, b) {
return 'x' + 'x' + 'y' + 'y';
}
// 함수 표현식의 경우
var sum = function(a, b) {
return x + y;
}
console.log(sum(1, 2)); // 이전에 선언된 함수로 출력
var sum = function(a, b) {
return 'x' + 'x' + 'y' + 'y';
}
console.log(sum(1, 2)); // 이후 선언된 함수로 출력
스코프는 식별자에 대한 유효범위를 의미한다. 어떤 블록 내부에서 선언한 변수는 오직 블록 내부에서만 접근할 수 있다.
ES5 까지는 전역공간을 제외하고 오직 함수에 의해서만 스코프가 생성되었는데 ES6 에서는 let, const, class 등의 블록에 대해서도 스코프가 생성된다. 둘을 구분하기 위해 함수 스코프, 블록 스코프라는 용어를 사용한다.
var 는 함수 레벨 스코프라고 하고, let, const 는 블록 레벨 스코프라고 한다. 둘의 차이점이 무엇일까?
함수 레벨 스코프
var 는 함수의 스코프만을 범위로 인정한다. 따라서 if, for, while 등의 일반적인 코드 블록은 스코프로 인정하지 않는다.
var x = 1;
if (true) {
var x = 10;
}
console.log(x); // 10
블록 레벨 스코프
if, for, while 등 일반적인 모든 코드 블록에 대해서 스코프를 인정한다. 따라서 아래 코드에서 전역적으로 선언된 x 와, 코드 블록 내에 선언된 x 는 다르게 취급한다.
let x = 1;
if (true) {
let x = 10;
console.log(x); // 10
}
console.log(x); // 1
식별자의 유효범위를 안에서부터 바깥으로 차례로 검색해나가는 것을 의미한다.
1. LexicalEnvironment
LexicalEnvironment 의 내부는 다음으로 구성돼 있다.
2. 스코프 체인의 동작원리
outer-EnvironmentReference 는 현재 호출된 함수가 선언될 당시의 렉시컬 환경을 참조한다. 예를 들어 전역 환경에서 A 함수를 선언한 경우 A 의 outer-EnvironmentReference는 전역 환경을 참조한다. 이처럼 outer-EnvironmentReference는 연결리스트의 형태를 띤다.
찾고자 하는 식별자가 현재 environmentRecord 에 없으면 outer-EnvironmentReference 에 있는 environmentRecord 로 넘어가 식별자를 찾는다.
결론부터 얘기하자면, 클로저란 어떤 함수 A 에서 선언한 변수 a를 참조하는 내부함수 B를 외부로 전달할 경우 A의 실행 컨텍스트가 종료된 이후에도 변수 a가 사라지지 않는 현상을 의미한다.
❗
1. 클로저란, 함수를 선언할 때 만들어지는 유효범위가 사라진 이후에도 호출할 수 있는 함수이다.
2. 클로저란, 이미 생명 주기가 끝난 외부 함수의 변수를 참조하는 내부 함수이다.
outer 함수 내부에 inner 컨텍스트가 활성화된 시점에 inner는 outer의 렉시컬 환경에도 접근이 가능하다. 대게 outer 함수의 실행 컨텍스트가 종료되기 이전에 inner 함수의 실행 컨텍스트가 종료되며 이후 별도로 inner 함수를 호출할 수 없다.
그럼 outer 함수가 종료된 이후에도 inner 함수를 사용하려면 어떻게 해야할까?
inner 함수 자체를 반환값에 포함시켜줌으로써 해결할 수 있다.
const outer = function() {
let a = 1;
const inner = function() {
return ++a;
}
return inner; // inner 함수의 결과값이 아닌 inner 함수 자체를 반환
}
const outer2 = outer();
console.log(outer2()); // 2
그 이유는 가비지 컬렉터의 동작 방식 때문이다. 가비지 컬렉터는 어떤 값을 참조하는 변수가 하나라도 있으면 그 값은 수집 대상에 포함시키지 않는다. inner 함수는 outer2 를 실행함으로써 언젠가 호출될 가능성이 있으므로 가비지 컬렉터의 수집 대상에서 제외된다.
가비지 컬렉션에 수집 대상에서 제외되는 메모리들은 메모리 누수를 발생시킬 위험이 있으나, 현재는 거의 발견하기 힘들어졌으므로 의도대로 설계한 스코프에 대한 관리법을 알아야 한다.
outer2 = null;
위의 예시에선 outer2가 inner 함수를 참조하고 있으므로 함수 참조를 끊는다.
var a = 0;
var intervalid = null;
var inner = function() {
if (++a >= 10) {
clearInterval(intervalId);
inner = null; // inner 식별자의 함수 참조를 끊는다.
}
}
intervalId = setInterval(inner, 1000);
var count = 0;
// ... 이벤트 추가 코드
var clickHandler = function() {
if (count >= 10) {
button.removeEventListener('click', clickHandler);
clickHandler = null; // clickHandler 식별자의 함수 참조를 끊는다.
}
}
setInterval이나 eventListener 같은 비동기 함수의 경우 콜백 함수의 내부에서 식별자의 함수 참조를 끊는다. (각 코드는 어떤 함수 안에 있다고 가정)