실행 컨텍스트와 클로저는 자바스크립트에서 가장 중요한 핵심 개념입니다.
실행 컨텍스트를 알아야 스코프를 기반으로
식별자와 식별자에 바인딩된 값을 관리하는 방식과 호이스팅이 발생하는 이유,
클로저의 동작 방식,
그리고 태스크 큐와 함께 동작하는 이벤트 핸들러와 비동기 처리의 동작 방식을 이해할 수 있습니다.
모든 소스코드는 실행이 되기 전에 평가과정을 거치며 코드를 실행하기 위한 준비를 합니다.
자바스크립트 엔진은 소스코드를 2개의 과정(소스코드의 평가 + 소스코드의 실행)을 나누어 처리합니다.
소스코드 평가 과정에서는 실행 컨텍스트를 생성하고,
변수, 함수 등의 선언문만 먼저 실행하여 생성된 변수나 함수 식별자를 키로 실행 컨텍스트가 관리하는 스코프(렉시컬 환경의 환경 레코드)에 등록합니다.
소스코드 평가 과정이 끝나면 비로소 선언문을 제외한 소스코드가 순차적으로 실행되기 시작합니다. 소스코드 실행에 필요한 정보(변수나 함수의 참조)를 실행 컨텍스트가 관리하는 스코프에서 검색해서 취득합니다. 변수 값의 변경 등 소스코드의 실행 결과는 다시 실행 컨텍스트가 관리하는 스코프에 등록됩니다.

코드를 보면서 과정을 이해해볼까요??
var x;
x = 1;
이런 코드가 있다고 가정해보면 자바스크립트 엔진은 이 코드를 2개의 과정으로 나누어 처리합니다.
var x;를 먼저 실행
x=1;만 실행
소스코드 평가 과정에서 var 변수의 경우 변수 선언과 동시에 기본값 undefined를 할당받지만 let, const 변수는 초기화 되지 않은 상태(uninitialized)로 저장됩니다.
이를 호이스팅이라고 말하며, 호이스팅으로 인해 var 변수는 선언 전에 접근해서 값을 얻을 수 있는 반면 let이나 const 변수는 선언 전에 접근할 경우 Reference 에러가 발생하게 됩니다. 이를 일시적 사각지대(TDZ: Temporal Dead Zone)이라고 합니다.
코드가 실행되려면 스코프, 식별자 코드 실행 순서 등의 관리가 필요합니다.
선언에 의해 생성된 모든 식별자(변수, 함수, 클래스 등)를 스코프를 구분하여 등록하고 상태 변화를 지속적으로 관리할 수 있어야 합니다.
스코프 체인을 통해 상위 스코프로 이동하며 식별자를 검색할 수 있어야 하기 때문에
스코프는 중첩 관계에 의해 스코프 체인을 형성해야 하고,
현재 실행 중인 코드의 실행 순서를 변경할 수 있어야 하며
다시 되돌아갈 수도 있어야 합니다.
실행 컨텍스트는 소스코드를 실행하는 데 필요한 환경을 제공하고 코드의 실행 결과를 실제로 관리하는 영역으로,
식별자를 등록하고 관리하는 스코프와 코드 실행 순서 관리를 구현한 내부 매커니즘입니다.
즉, 모든 코드는 실행 컨텍스트를 통해 실행되고 관리됩니다.
식별자와 스코프는 실행 컨텍스트의 렉시컬 환경으로 관리하고
코드 실행 순서는 실행 컨텍스트 스택으로 관리합니다.
코드가 실행되는 시간의 흐름에 따라 실행 컨텍스트 스택에는 실행 컨텍스트가 추가되고 제거됩니다.
const x = 1;
function foo() {
const y = 2;
function bar() {
const z =3;
console.log(x+y+z);
}
bar();
}
foo(); // 6

위 코드의 소스코드 평가/실행 과정을 알아볼까요?
- 전역 코드 평가/실행 : 전역 변수 x와 함수 foo를 전역 실행 컨텍스트에 등록 ➡️ 실행되면서 x에 값이 할당되고 foo가 호출
- foo 함수 코드의 평가/실행 : 전역 코드 실행이 중단되고 foo 함수 실행 컨텍스트를 생성하고 실행 컨텍스트에 push ➡️ 지역 변수 y와 중첩 합수 bar가 foo 함수 실행 컨텍스트에 등록 ➡️ foo가 실행되며 y에 값이 할당되고 bar가 호출
- bar 함수 코드의 평가/실행 : foo 함수 코드 실행이 중단되고 bar 함수 실행 컨텍스트를 생성하고 실행 컨텍스트 스택에 push ➡️ 지역 변수 z가 함수 bar 실행 컨텍스트에 등록 ➡️ bar가 실행되며 z에 값이 할당되고
console.log메서드를 호출 ➡️ bar 종료- foo 함수로 복귀 : 다시 foo 함수로 이동 ➡️ bar 함수 실행 컨텍스트를 실행 컨텍스트 스택에서 pop하여 제거 ➡️ foo 종료
- 전역 코드로 복귀 : 다시 전역 코드로 이동 ➡️ foo 함수 실행 컨텍스트를 실행 컨텍스트 스택에서 pop하여 제거 ➡️ 전역 실행 컨텍스트도 실행 컨텍스트 스택에서 pop되어 실행 컨텍스트 스택에는 아무것도 남지 않게 됨
실행 컨텍스트 스택은 코드의 실행 순서를 관리합니다.
소스코드가 평가되면 실행 컨텍스트가 생성되고 실행 컨텍스트 스택의 최상위에 쌓입니다.
실행 컨텍스트 스택의 최상위에 존재하는 실행 컨텍스트는 언제나 현재 실행 중인 코드의 실행 컨텍스트입니다.
실행 컨텍스트 스택이 코드의 실행 순서를 관리한다면,
렉시컬 환경은 스코프와 식별자를 관리합니다.
렉시컬 환경을 통해 상위 스코프에 대한 참조를 기록할 수 있습니다.

키와 값을 갖는 객체 형태의 스코프를 생성하여 식별자를 키로 등록하고 식별자에 바인딩된 값을 관리합니다.
렉시컬 환경은 스코프를 구분하여 식별자를 등록하고 관리하는 저장소 역할을 하는 렉시컬 스코프의 실체입니다.
실행 컨텍스트는 LexicalEnvironment 컴포넌트와 VaraiableEnvironment 컴포넌트로 구성됩니다.
생성 초기에 LexicalEnvironment 컴포넌트와 VariableEnvironment 컴포넌트는 하나의 동일한 렉시컬 환경을 참조합니다.
VariableEnvironment 컴포넌트를 위한 새로운 렉시컬 환경을 생성하여 생성하고,
VariableEnvrionment 컴포넌트와 LexicalEnvrionment 컴포넌트는 내용이 달라지는 경우도 있습니다.
VariableEnvironment(변수 환경)은 LexicalEnvironment(렉시컬 환경)과 거의 동일합니다. ES6에서 이 두 컴포넌트의 차이점은 렉시컬 환경은 함수 선언 및 변수(let, const) 바인딩을 저장하는데 사용되는 반면 변수 환경은 변수(var) 바인딩만 저장한다는 점입니다.

렉시컬 환경은 환경 레코드와 외부 렉시컬 환경에 대한 참조로 구성됩니다.
실행 컨텍스트의 구조는 크게 LexicalEnvironment, VariableEnvironment, this 바인딩으로 이루어져있습니다. 그리고 LexicalEnvironment와 VariableEnvironment는 다시 EnvironmentRecode와 OuterEnvironmentReference로 구성됩니다.
var x = 1;
const y = 2;
function foo (a) {
var x =3;
const y=4;
function bar(b) {
const z =5;
console.log(a+b+x+y+z);
}
bar(10);
}
foo(20); //42

위 코드를 보면서 그림을 해석해볼까요??
먼저 전역 소스코드에 foo 함수가 있고, foo 함수 내부에는 중첩 함수 bar가 있습니다.
따라서 실행 컨텍스트 스택에는 Global ➡️ foo ➡️ bar 순서로 상위에 쌓게 되었습니다.
LexicalEnvironment를 각각 갖고 있고 환경 레코드와 외부 렉시컬 환경에 대한 참조로 이루어져있습니다.
1️⃣ Global Execution Context
GlobalExecutionContext는 가장 아래에 위치하고 Outer는 null입니다.
이때 BindingObject는 "전역 객체 생성"에서 생성된 객체로, 전역 코드 평가 과정에서 var 키워드로 선언한 전역 변수가 BindingObject를 통해 전역 객체의 프로퍼티가 되었습니다.
2️⃣ foo 호출 -> foo Execution Context
foo 함수가 호출되면 새로운 실행 컨텍스트가 쌓입니다.
a는 20으로 들어가고, var x = 3;으로 호이스팅 시 undefined를 갖다가 실행될 때 3이 할당됩니다.
foo Lexical Environment 안에 함수 환경 레코드를 생성하고 매개변수, arguments 객체, 함수 내부에서 선언한 지역 변수와 중첩 함수를 등록하고 관리합니다.
a: 20
arguments: { 0: 20, length: 1, callee: foo }
x: 3
y: 4
bar: <function object>
함수 환경 레코드의 [[ThisValue]] 내부 슬롯에 this가 바인딩됩니다.
foo(20);으로 일반 함수를 호출했기 때문에 전역 객체인 window를 참조합니다.
또 전역에서 호출했기 때문에 Outer Lexical Environment는 전역을 참조합니다.
자밥스크립트 엔진은 함수 정의를 평가하여 함수 객체를 생성할 때 현재 실행 중인 실행 컨텍스트의 렉시컬 환경, 즉 함수의 상위 스코프를 함수 객체의 내부 슬롯 [[Environment]]에 저장합니다. 함수 객체의 내부 슬롯 [[Environment]]가 바로 렉시컬 스코프를 구현하는 메커니즘입니다.
3️⃣ bar 호출 -> bar Execution Context
b = 10, const z = 5, console.log(a + b + x + y + z)가 실행됩니다.
여기서 주의할 점은 스코프 체인을 따라 접근합니다.
스코프 체인의 상위 스코프, 즉 외부 렉시컬 환경에 대한 참조가 가리키는 곳으로 이동하여 식별자를 검색합니다. console 식별자는 전역 렉시컬 환경에 있으며, 객체 환경 레코드의 BindingObject를 통해 전역 객체에서 찾을 수 있습니다.
a → bar에 없음 → foo에서 찾음 → 20
b → bar에 있음 → 10
x → bar에 없음 → foo에서 찾음 → 3
y → bar에 없음 → foo에서 찾음 → 4
z → bar에 있음 → 5
결국 20 + 10 + 3 + 4 + 5 = 42가 됩니다.
bar의 Outer Lexical Environment는 bar를 호출한 foo를 참조합니다.
4️⃣ bar 종료
bar 함수 실행 컨텍스트가 pop되어 foo 실행 컨텍스트가 실행 중인 실행 컨텍스트가 됩니다.
bar 함수 실행 컨텍스트가 제거되었다고 해서 bar 함수 렉시컬 환경까지 즉시 소멸되는 것은 아닙니다.
렉시컬 환경은 실행 컨텍스트에 의해 참조되기는 하지만 독립적인 객체입니다.
객체를 포함한 모든 값은 누군가에 의해 참조되지 않을 때 비로소 가비지 컬렉터에 의해 메모리 공간의 확보가 해제되어 소멸합니다.
bar 함수 실행 컨텍스트가 소멸되었다 하더라도 만약 bar 렉시컬 환경을 누군가 참조하고 있다면 bar 함수 렉시컬 환경은 소멸하지 않습니다.
그렇다면 언제 소멸되죠?
클로저를 참조하는 곳이 더 이상 없고 참조 카운트가 0이 되면 가비지 컬렉션 대상이 되어 소멸됩니다.
5️⃣ foo 종료
foo 함수 실행 컨텍스트가 pop되어 제거되고 전역 실행 컨텍스트가 실행 중인 실행 컨텍스트가 됩니다.
6️⃣ 전역 코드 실행 종료
남은 전역 코드가 없으므로 전역 실행 컨텍스트도 실행 컨텍스트 스택에서 pop되어 실행 컨텍스트 스택에는 아무것도 남아있지 않습니다.
let x = 1;
if (true) {
let x = 10;
console.log(x); // 10
}
console.log(x); // 1

if문의 코드 블록 내에서 let 키워드로 변수가 선언되었습니다. 따라서 if문의 코드 블록이 실행되면 if문의 코드 블록을 위한 블록 레벨 스코프를 생성해야 합니다.
새롭게 생성된 if문의 코드 블록을 위한 렉시컬 환경의 외부 렉시컬 환경에 대한 참조는 if문이 실행되기 이전의 전역 렉시컬 환경을 가리킵니다.
이후 블록의 실행이 종료되면 if문의 코드 블록이 실행되기 이전의 렉시컬 환경으로 되돌립니다. 해당 블록의 렉시컬 환경은 파기되고, 실행 컨텍스트의 렉시컬 환경 참조는 상위 환경을 다시 가리키게 됩니다.
만약 for문의 코드 블록 내에서 정의된 함수가 있다면 이 함수의 상위 스코프는 for문의 코드블록이 생성한 렉시컬 환경입니다.
이때 함수의 상위 스코프는 for문의 코드 블록이 반복해서 실행될 때마다 식별자의 값을 유지해야 합니다. 이를 위해 for문의 코드 블록이 반복해서 실행될 때마다 독립적인 렉시컬 환경을 생성하여 식별자의 값을 유지합니다.
위에서 실행 컨텍스트 안에는 this 바인딩이 있다고 이야기했었는데요,
this 바인딩이 무엇인지도 알아보겠습니다.
동작을 나타내는 메서드는 자신이 속한 객체의 상태, 즉 프로퍼티를 참조하고 변경할 수 있어야 합니다.
이때 메서드가 자신이 속한 객체의 프로퍼티를 참조하려면 먼저 자신이 속한 객체를 가리키는 식별자를 참조할 수 있어야 합니다.
this는 자신이 속한 객체 또는 자신이 생성할 인스턴스를 가리키는 자기참조변수입니다.
자신이 속한 객체 또는 자신이 생성할 인스턴스의 프로퍼티나 메서드를 참조할 수 있습니다.
자바스크립트 엔진에 의해 암묵적으로 생성되며 코드 어디서든 참조가 가능합ㄴ디ㅏ.
this 바인딩은 this와 this가 가리킬 객체를 바인딩하는 것으로,
함수 호출방식, 즉 함수가 어떻게 호출되었는지에 따라 동적으로 결정됩니다.
렉시컬 스코프와 this 바인딩은 결정 시기가 다르다.
함수의 상위 스코프를 결정하는 방식인 렉시컬 스코프는 함수 정의가 평가되어 함수 객체가 생성되는 시점에 상위 스코프를 결정하지만, this 바인딩은 호출 시점에 결정됩니다.
// this 바인딩은 함수 호출 방식에 따라 동적으로 결정
const foo = function () {
console.dir(this);
};
// 동일한 함수도 다양한 방식으로 호출 가능
// 1. 일반 함수 호출
// foo 함수를 일반적인 방식으로 호출
// foo 함수 내부의 this는 전역 객체 window를 가리킴
foo(); // window
// 2. 메서드 호출
// foo 함수를 프로퍼티 값으로 할당하여 호출
// foo 함수 내부의 this는 메서드를 호출한 객체 obj를 가리킴
const obj = { foo };
obj.foo(); // obj
// 3. 생성자 함수 호출
// foo 함수를 new 연산자와 함께 생성자 함수로 호출
// foo 함수 내부의 this는 생성자 함수가 생성한 인스턴스를 가리킴
new foo(); // foo {}
// 4. Function.prtotype.apply/call/bind 메서드에 의한 간접 호출
// foo 함수 내부의 this는 인수에 의해 결정
const bar = {name: 'bar'};
foo.call(bar); // bar
foo.apply(bar); // bar
foo.bind(bar)(); // bar
| 함수 호출 방식 | this 바인딩 |
|---|---|
| 일반 함수 호출 | 전역객체 |
| 메서드 호출 | 메서드를 호출한 객체 |
| 생성자 함수 호출 | 생성자 함수가 미래에 생성할 인스턴스 |
| Function.prototype.apply/call/bind 메서드에 의한 간접 호출 | Function.prototype.apply/call/bind 메서드에 첫번째 인수로 전달한 객체 |
메서드 내에서 정의한 중첩 함수 또는 메서드에게 전달한 콜백함수가 일반 함수로 호출될 때는 어떻게 되나요?
var value = 1; const obj = { value: 100, foo() { console.log("foo's this: ", this); // {value: 100, foo: f} // 콜백함수 내부의 this에는 전역 객체가 바인딩 setTimeout(function() { console.log("callback's this: ", this); // window console.log("callback's this.value: ", this.value); // 1 }, 100); } }; obj.foo();메서드 내의 중첩 함수 또는 콜백 함수의 this가 전역 객체를 바인딩하게 됩니다. 그렇지만 이는 문제가 있는데요..
중첩 함수 또는 콜백 함수는 외부 함수를 돕는 헬퍼 함수의 역할을 하므로 외부 함수의 일부 로직을 대신하는 경우가 대부분입니다.
외부 함수인 메서드와 중첩함수 또는 콜백 함수의 this가 일치하지 않는다는 것은 중첩함수 또는 콜백함수를 헬퍼함수로 동작하기 어렵습니다.따라서 아래 방법을 사용할 수 있는데요..
1️⃣ 변수 that에 this 바인딩을 할당하여 콜백 함수 내부에서 this 대신 that을 참조하기
2️⃣ this를 명시적으로 바인딩할 수 있는Function.prototype.apply,Function.prototype.call,Function.prototype.bind제공하기var value = 1; const obj = { value: 100, foo() { setTimeout(function() { console.log(this.value); }.call(this), 100); // this를 obj로 바인딩 } }; obj.foo(); // 1003️⃣ 화살표 함수를 사용해서 this 바인딩을 일치시키기
var value = 1; const obj = { value: 100, foo() { // 화살표 함수 내부의 this는 상위 스코프의 this를 가리킴 setTimeout(()=> console.log(this.value), 100); // 100 } }; obj.foo();➡️ 화살표 함수는 자신만의 this를 가지지 않기 때문에 상위 스코프 this를 사용합니다.
클로저는 함수를 일급 객체로 취급하는 함수형 프로그래밍 언어 등에서 사용되는 둥요한 특성으로
자바스크립트 고유의 개념은 아닙니다.
클로저는 함수와 그 함수가 선언된 렉시컬 환경과의 조합입니다.
const x = 1;
function outerFunc() {
const x = 10;
innerFunc();
}
function innerFunc() {
console.log(x); // 1
}
outerFunc();
이 같은 현상이 발생하는 이유는 자바스크립트가 렉시컬 스코프를 따르는 프로그래밍 언어이기 때문입니다.
자바스크립트 엔진은 함수를 어디서 호출했는가 아니라 함수를 어디에 정의했는지에 따라 상위 스코프를 결정하고, 이를 렉시컬 스코프라고 합니다.
실행 컨텍스트에서 살펴보았듯이 스코프의 실체는 실행 컨텍스트의 렉시컬 환경입니다.
이 렉시컬 환경은 자신의 외부 렉시컬 환경에 대한 참조를 통해 상위 렉시컬 환경과 연결되고,
이것이 바로 스코프 체인입니다.
렉시컬 환경의 "외부 렉시컬 환경에 대한 참조"에 저장할 참조값, 즉 상위 스코프에 대한 참조는 함수 정의가 평가되는 시점에 함수가 정의된 환경(위치)에 의해 결정됩니다.
함수가 정의된 환경과 호출된 환경은 다를 수 있습니다. 따라서 렉시컬 스코프가 가능하려면 함수는 상위 스코프를 기억해야 합니다. 이를 위해 함수는 자신의 내부 슬롯 [[Environment]]에 자신이 정의된 환경(상위 스코프의 참조)를 저장합니다.
const x = 1;
function foo() {
const x = 10;
// 상위 스코프는 함수 정의 환경에 따라 결정
// 함수 호출 위치와 상위 스코프는 아무런 관계가 없다.
bar();
}
// 함수 bar는 자신의 상위 스코프, 즉 전역 렉시컬 환경을 [[Environment]]에 저장하여 기억
function bar() {
console.log(x);
}
foo();
bar();

foo, bar 모두 전역 코드가 평가되는 시점에 평가되어 함수 객체를 생성하고 전역 객체 window의 메서드가 됩니다. 이때 생성된 함수 객체의 내부 슬롯 [[Environment]]에는 함수 정의가 평가된 시점, 즉 전역 코드 평가 시점에 실행 중인 실행 컨텍스트의 렉시컬 환경인 전역 렉시컬 환경의 참조가 저장됩니다.
함수 코드 평가 과정
- 함수 실행 컨텍스트 생성
- 함수 렉시컬 환경 생성
2.1. 함수 환경 레코드 생성
2.2. this 바인딩
2.3. 외부 렉시컬 환경에 대한 참조 결정
➡️ 함수 렉시컬 환경의 구성 요소인 외부 렉시컬 환경에 대한 참조에는 함수 객체의 내부 슬롯 [[Environment]]에 저장된 렉시컬 환경의 참조가 할당됩니다.
즉, 함수 객체의 내부 슬롯 [[Environment]]에 저장된 렉시컬 환경의 참조는 바로 함수의 상위 스코프를 의미합니다.
const x = 1;
function outer() {
const x = 10;
const inner = function () { console.log(x); };
return inner;
}
// outer 함수를 호출하면 중첩 함수 inner를 반환
// outer 함수의 실행 컨텍스트는 실행 컨텍스트 스택에서 pop되어 제거
const innerFunc = outer();
innerFunc(); // 10
outer 함수의 실행 컨텍스트가 제거되었으므로 전역 변수의 값인 1을 출력할 것 같지만,
outer 함수의 지역 변수가 부활이라도 한듯이 동작합니다.
이처럼 외부 함수보다 중첩 함수가 더 오래 유지되는 경우 중첩 함수는 이미 생명주기가 종료한 외부함수의 변수를 참조할 수 있습니다. 이러한 중첩 함수를 클로저라고 합니다.


outer 함수를 호출하면 outer 함수의 렉시컬 환경이 생성되고 앞서 outer 함수 객체의 [[Environemt]] 내부 슬롯에 저장된 전역 렉시컬 환경을 outer 함수 렉시컬 환경의 "외부 렉시컬 환경에 대한 참조"에 할당합니다.
그리고 중첩 함수 inner가 평가되는데, 이때 중첩함수 inner는 자신의 [[Environment]] 내부 슬롯에 현재 실행 중인 실행 컨텍스트의 렉시컬 환경, 즉 outer 함수의 렉시컬 환경을 상위 스코프로서 저장합니다.
outer 함수의 실행 컨텍스트가 제거되어도 outer 함수의 렉시컬 환경은 유지됩니다.

outer 함수가 반환한 inner 함수를 호출하면 inner 함수의 실행 컨텍스트가 생성되고 실행 컨텍스트 스택에 push됩니다. 그리고 렉시컬 환경의 외부 렉시컬 환경에 대한 참조에는 inner 함수 객체의 [[Environment]] 내부 슬롯에 저장되어 있는 참조값이 할당됩니다.
이처럼 중첩 함수 inner 내부에서 상위 스코프를 참조할 수 있으므로 상위 스코프의 식별자를 참조할 수 있고 식별자의 값을 변경할 수도 있습니다.
그렇다면 모든 중첩함수가 클로저인가요??
상위 스코프의 어떤 식별자도 참조하지 않는 경우 대부분의 모던 브라우저는 최적화를 통해 상위 스코프를 기억하지 않습니다. 참조하지도 않는 식별자를 기억하는 것은 메모리 낭비이기 때문입니다.
클로저는 중첩 함수가 상위 스코프의 식별자를 참조하고 있고 중첩 함수가 외부 함수보다 더 오래 유지되는 경우에 한정하는 것이 일반적입니다.
클로저는 탕구리..?
![]()
![]()
조금 난해할 수도 있겠지만 클로저는 포켓몬 탕구리에 비유해볼 수 있겠는데요..!
탕구리는 어미의 뼈를 쓰고 살아갑니다. 클로저도 마찬가지로 자신을 만들어준 외부 함수는 이미 종료되어 사라졌지만, 그 "외부 함수의 변수"를 머리에 이고 살아갑니다.
1. 어미가 사망함 ➡️ 외부 함수가 실행 컨텍스트 스택에서 제거됨
2. 어미의 뼈를 머리에 씀 ➡️ 외부 함수의 렉시컬 환경을 [[Environment]] 슬롯에 저장
3. 어미와의 연결을 끊지 않음 ➡️ 외부 함수의 변수(스코프)를 참조하며 계속 사용함
4. 외로움 속에서도 과거를 간직함 ➡️ 스코프 체인 속에서도 변수 값을 기억하고 참조함코드를 보면서 비유해볼까요..?
function outer() { const memory = "어미의 뼈"; return function inner() { console.log(`탕구리는 ${memory}를 머리에 쓰고 있다`); }; } const tanguri = outer(); // 외부 함수는 끝났지만, tanguri(); // "탕구리는 어미의 뼈를 머리에 쓰고 있다"outer 함수는 이미 종료됐지만, 탕구리가 죽은 어미의 뼈를 지닌 것처럼 inner 함수는 여전히 memory라는 변수에 접근하고 있습니다.
클로저는 상태를 안전하게 변경하고 유지하기 위해 사용합니다.
즉, 상태를 안전하게 은닉하고 특정 함수에게만 상태 변경을 허용하기 위해 사용합니다.
const increase = (function () {
let num = 0;
// 클로저
return function () {
// 카운트 상태를 1만큼 증가
return ++num;
};
}());
console.log(increase()); // 1
console.log(increase()); // 2
increase 변수에 할당한 함수는 자신이 정의된 위치에 의해 결정된 상위 스코프인 즉시 실행 함수의 렉시컬 환경을 기억하는 클로저입니다.
즉시 실행함수는 호출된 이후 소멸되지만 즉시 실행 함수가 반환한 클로저는 increase 변수에 할당되어 호출됩니다.
즉시 실행 함수가 반환한 클로저는 카운트 상태를 유지하기 위한 자유 변수 num을 언데 어디서 호출하든지 참조하고 변경할 수 있습니다.
한번만 실행되기 때문에 num 변수가 재차 초기화될 일은 없고, 외부에서 직접 접근할 수 없기 때문에 안정적인 프로그래밍이 가능합니다.
이처럼 클로저는 상태가 의도치 않게 변경되지 않도록 안전하게 은닉하고 특정 함수에게만 상태 변경을 허용하여 상태를 안전하게 변경하고 유지하기 위해 사용합니다.
function Person(name, age) {
this.name = name; //public
let _age = age; //private
this.sayHi = function () {
console.log(this.name, age);
};
}
const me = new Person('imddoy', "02");
me.sayHi(); // imddoy02
console.log(me.name); // imddoy
console.log(me._age); // undefined
name은 public하지만 _age는 Person 생성자 함수의 지역 변수이므로 Person 생성자 함수 외부에서 참조하거나 변경할 수 없습니다.
즉, _age 변수는 private합니다.
하지만 sayHi 메서드는 인스턴스 메서드이기 때문에 Person 객체가 생성될 때마다 중복으로 생성됩니다.
Person.prototype.sayHi로 프로토타입 메서드로 변경하면 중복 생성이 방지되지만,
단 한 번 생성되는 클로저이기 때문에 여러 개의 인스턴스를 생성할 경우 _age 변수의 상태가 변경되어 유지 되지 않는 문제가 있습니다.
이처럼 자바스크립트는 정보 은닉을 완전하게 지원하지 않습니다.
ES6의 Symbol 또는 WeakMap을 사용하여 흉내도 낼 수 있지만 근본적인 해결이 되지는 않습니다.
ES2019에서는 해쉬 # prefix 를 추가해 private class 필드를 선언할 수 있게 되었습니다.
class Person {
name; // public 필드
#age; // private 필드
constructor(name, age) {
this.name = name;
this.#age = age;
}
sayHi() {
console.log(this.name, this.#age);
}
getAge() {
return this.#age;
}
setAge(age) {
this.#age = age;
}
}
const me = new Person("imddoy", "02");
me.sayHi(); // imddoy02
console.log(me.name); // imddoy
console.log(me.#age); // SyntaxError: Private field '#age' must be declared in an enclosing class
사실 우리는 클로저를 많이 사용하고 있었습니다.
1️⃣ useState는 상태 저장 위치를 기억하는 클로저입니다.
지난 React 상태 글에서도 useState의 구조를 알아보았는데요,
클로저를 통해 함수가 선언될 당시 값을 기억하며 사용하는 원리입니다.
🔗 지난 블로그 보러가기 - useState 더 알아보기
// 이해를 돕기 위한 예시 코드로 실제 코드와는 차이가 있습니다.
function useState(initVal) {
let _val = initVal;
const state = () => _val;
const setState = newVal => { // 클로저로 이전 상태를 기억
_val = newVal;
}
return [state, setState]
}
2️⃣ useEffect는 실행 시점에는 이미 렌더 타이밍의 스코프를 기억하는 콜백 클로저입니다.
useEffect는 컴포넌트가 렌더링될 때마다 새로운 함수를 생성하고, 이 함수는 해당 렌더링 시점의 props와 state 값을 기억합니다.
useEffect(() => {
// 선언될 당시의 count 값을 기억
console.log(`현재 카운트: ${count}`);
// 1초 후에 알림 표시
const timer = setTimeout(() => {
alert(`useEffect 내부에서 기억한 count 값: ${count}`);
}, 1000);
// 클린업 함수도 마찬가지로 클로저
return () => {
clearTimeout(timer);
console.log(`클린업: ${count} 값을 가진 이펙트가 정리됨`);
};
}, [count]);
useEffect의 콜백 함수가 실행될 때, 그 함수는 생성된 시점의 상태 값(count)을 기억한다는 것입니다.
만약 count를 1로 변경한 후 즉시 다시 2로 변경하면, React는 각 렌더링에 대해 별도의 useEffect를 실행하고, 각 이펙트는 자신이 생성된 렌더링 시점의 count 값을 "기억"합니다.
클로저는 React의 함수형 프로그래밍의 핵심입니다. 각 렌더링은 자신만의 props와 state의 "스냅샷"을 가지며, useEffect와 같은 훅은 이 스냅샷 내에서 클로저를 통해 동작합니다.
Stale Closure
위 코드에서는 timer에 할당하고 ClearTimeout을 사용해 다시 클로저로 감싼 함수를 실행하기 때문에 stale 문제가 발생하지 않습니다.
하지만 아래처럼 작성하면 setTimeout의 내부 함수는 useEffect가 정의될 당시의 count를 클로저로 캡처하기 때문에 계속 초기 렌더의 count를 기억합니다. 따라서 나중에 호출될 때 최신 상태가 아니라 오래된 상태를 참조하는 문제가 발생합니다.useEffect(() => { setTimeout(() => { console.log("count:", count); // 오래된 값 출력 가능 }, 1000); }, []);
지금까지 자바스크립트의 핵심인 실행 컨텍스트와 클로저를 알아보았습니다.
모든 코드의 실행은 실행 컨텍스트에서 시작되고, 모든 변수의 참조는 렉시컬 환경에서 이루어지며, 모든 비동기 처리는 클로저에 의해 이어집니다.
비동기 처리가 왜 클로저..?
비동기적으로 나중에 실행되는 콜백 함수가 실행 시점에 이전 스코프에 접근할 수 있는 이유는,
콜백 함수가 선언된 시점의 렉시컬 환경을 클로저로 기억하기 때문입니다.
읽어주셔서 감사합니다 :)
wow 2주치의 아티클 쓰시느라 완전 고생 많으셨어요...🥹
중간에 탕구리가 있길래 으오잉...? 했는데, 탕구리 비유 너무 신박하네요! 😂 클로저가 외부 함수의 변수를 기억하는 개념이 탕구리가 어미의 뼈를 머리에 쓰고 다니는 모습과 연결되다니… 이젠 클로저 볼 때마다 탕구리가 떠오를 것 같아요! 좋은 비유 감사합니다. 🙌
또이님 덕분에 막연하게 어렵게만 느껴졌던 클로저가 좀 친숙해졌네요 ㅎㅎ
(근데 탕구리...어디서 많이 들어봤는데 어딜까요...)
고생 많으셨습니다!