주의: scope chain은 ES3 이후로 사용되지 않는 표현이다. 그 대신 outer lexical environmental chain 등의 단어로 표현할 수 있겠으나, 더욱 쉬운 이해를 위해 scope chain이라는 표현을 그대로 사용한다.
자바스크립트의 동작을 본질적으로 이해하기 위해서는 실행 문맥, scope(유효범위), prototype에 대한 이해가 필요하다.
이 글에서는 그 중에서 실행 문맥과 scope에 대해 살펴본다.
var
로 선언된 변수가 위로 올라가는 거 아니야?라는 생각을 가지고 있다면, 이 글을 통해 hoisting, closure, this에 대한 더 깊은 이해를 할 수 있을 것이다.
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,
};
실행 문맥은 위와 같은 구조를 가지고 있는데, 요약하면 실행 문맥은 크게
등으로 이뤄진다고 할 수 있다.
이러한 실행 문맥은 함수가 실행될 때 새로 만들어져 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의 실행 문맥 안에서 찾을 수 있으나, a
와 b
는 찾을 수 없다.
이와 같이 지금의 실행 문맥에서 식별자를 찾을 수 없을 경우, 차례차례로 외부 환경의 참조를 통해(outer lexical environment reference) 바깥 단계에 식별자가 있는지 확인한다.
F_LexicalEnvironment
에는 a
는 찾을 수 없지만, b
는 찾을 수 있다.
a
를 찾기 위해 Global_LexicalEnvironment
에 접근하면, 식별자 a
를 찾을 수 있다.
따라서 이러한 방식으로 g
함수 내에서 지역 변수가 아닌 a
와 b
에 접근할 수 있다.
실행 문맥은 Scope chain을 구현하기 위한 중요한 이론적 배경이며, 위와 같은 자바스크립트의 평가 및 실행 과정을 통해 자바스크립트의 여러 기능들과 특성들이 설명될 수 있다.
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(클로저)란 근처에서 만들어진 변수를 캡처하는 함수이다.
예를 들어 다음과 같은 코드를 살펴보자.
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 함수가 이미 실행을 멈췄는데도 불구하고 지역변수인 greeting
과 name
에 접근이 가능하다.
이는 앞서 살펴보았던 실행 문맥의 특성 때문에 가능하다.
function () {
console.log(greeting + name + "!");
};
Anonymous_ExecutionContext = {
LexicalEnvironment: {
EnvironmentRecord: {
DeclarativeEnvironmentRecord: {},
ObjectEnvironmentRecord: {},
},
OuterLexicalEnvironmentReference: sayHelloTo_LexicalEnvironment,
},
ThisBinding: window,
};
sayHelloToYechan
과 sayHelloToJames
를 실행할 때, 실행 문맥의 구조는 다음과 같다.
위의 익명함수가 sayHelloTo 함수 내에서 선언되었기 때문에 outer lexical environment reference에 sayHelloTo
의 lexical environment가 참조로써 저장되는 것이다.
따라서 return된 익명함수 function () { console.log(greeting + name + "!"); }
의 범위 내에서는 greeting
도 name
도 찾아볼 수 없지만, sayHelloTo
의 lexical environment 참조를 통해 greeting
과 name
식별자를 찾을 수 있는 것이다.
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”로 저장된) 별도의 다른 실행 문맥을 기반으로 함수를 실행하게 된다.
그런데 name
과 greeting
과 같은 변수에 어떻게 접근하는지는 이해했어도, name
과 greeting
이 접근 전까지 어떻게 소멸하지 않은 것인지 궁금할 수 있을 것이다.
이는 가비지 컬렉터의 동작 때문이다.
위와 같은 경우에서,
sayHelloTo
는 중첩된 익명함수의 참조를 반환한다. 이로 인해 전역 범위에 선언된 전역 변수 sayHelloToYechan
과 sayHelloToJames
가 익명함수를 참조하게 된다.sayHelloTo
의 지역변수 name
과 greeting
을 참조한다.그 결과 전역 변수 sayHelloToYechan
과 sayHelloToJames
가 외부 함수 sayHelloTo
의 렉시컬 환경 컴포넌트를 간접적으로 참조하게 되므로 가비지 컬렉션의 대상이 되지 않는다.
따라서 sayHelloTo
의 실행이 끝나서 호출자에게 제어권이 넘어간다고 하더라도 외부 함수의 렉시컬 환경 컴포넌트가 메모리에서 지워지지 않게 되어 name
과 greeting
에 접근할 수 있었던 것이다.
this
값javascript에서 this는 하나로 고정된 값이 아닌 동적인 값으로, 이 때문에 때로는 자바스크립트 프로그램이 예상치 못한 행동을 보여주기도 한다.
function foo() {
var a = 10;
console.log(this.a);
}
foo(); // undefined
일례로, 10
을 출력하기 위해 위와 같이 자바스크립트 코드를 짤 수 있지만, 실제로 위 프로그램은 undefined를 반환한다.
Java, C++와 같은 언어에서 this가 해당 코드를 실행하는 클래스의 인스턴스를 가리켰다면, 자바스크립트에서는 ‘함수가 호출되었을 때 그 함수가 속해 있던 객체’를 가리키기 때문이다.
정리하면, this
는
이다.
다음은 다양한 상황에서 this
가 무슨 객체를 가리키는지 정리한 것이다.
console.log(this); // window { ... }
this
function f() { console.log(this); }
f(); // Window { ... }
this
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"}
this
(apply
와 call
메서드로 호출한 함수 안에 있는 this
)apply
와 call
메서드를 사용하면 함수를 호출할 때 this
가 가리키는 객체를 명시적으로 바꿀 수 있다.이처럼 hoisting과 closure, this는 자바스크립트를 이해하는 데에 있어 까다로울 수 있는 개념이지만, 자바스크립트 인터프리터가 어떻게 동작하는지 이해한다면 더욱 쉽게 그 개념을 이해할 수 있다.
모던 자바스크립트 입문 (이소 히로시, 길벗출판사, 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)