클로저란 함수와 그 함수가 선언된 렉시컬 환경과의 조합이라고 한다. 이게 무슨 뚱딴지 같은 소리일까.... 이 어이없는 설명을 이해하기 위해서 먼저 "렉시컬 환경"에 대해서 다시 한번 생각해보자.
렉시컬 환경은 실행 컨텍스트의 컴포넌트이다. 변수, 함수 선언 및 스코프 체인에 대한 정보를 저장하는 역할을 수행한다. 각 함수 또는 블록의 실행 컨텍스트가 생성될 때 같이 생성되며, 그 안에 변수 및 함수에 대한 참조와 스코프 체인 정보가 저장된다. 렉시컬 환경은 크게 1)환경 레코드와 2)외부 렉시컬 환경에 대한 참조로 구성된다.
렉시컬 스코프는 변수 및 함수 식별자가 어떤 범위 내에서 유효한지를 결정하는 메커니즘이다. 정적 스코프의 특징은 함수나 블록을 어디에서 선언했는지에 따라 스코프가 결정된다는 점이다. 해당 스코프에 찾고자 하는 식별자가 없을 경우 스코프 체인을 통해 외부 렉시컬 환경으로 거슬러 올라가 추적한다.
👉 렉시컬 환경이 렉시컬 스코프에 대한 정보를 저장하고 있는 구조!!
위 예제 코드를 보자. 함수 foo와 bar 모두 전역에서 정의된 함수이다. 자바스크립트는 함수의 스코프를 결정할 때 어디에서 정의했는지에 따른 렉시컬 스코프로 상위 스코프를 결정한다고 했으므로, 두 함수의 상위 스코프는 모두 전역 스코프이다.
함수를 어디에서 호출했는지 는 함수의 상위 스코프를 결정하는데 아무런 영향도 주지 못한다.
이처럼 코드 블록 안에 스코프가 어떻게 정의되어 있고 또 해당 스코프의 상위 스코프는 어디인지와 같은 관련한 정보들이 모두 저장되어 있는 곳, 그 실체가 바로 렉시컬 환경이다.
렉시컬 환경의 구성 요소 중 하나인 외부 렉시컬 환경에 대한 참조를 통해 상위 렉시컬 환경과 연결이되며 이것이 우리가 기존에 알고 있는 스코프 체인이다.
함수가 정의된 위치와 호출되는 위치는 다를 수 있다.
고로 렉시컬 스코프가 가능하기 위해서는 함수는 자신이 호출되는 환경과는 상관없이 자신이 정의된 위치, 즉 상위 스코프를 기억해야 한다. 이를 위해 함수는 자신의 내부 슬롯 [[Environment]]에 자신이 정의된 환경, 즉 상위 스코프의 참조를 저장한다.
이 때 자신의 내부 슬롯에 [[Environment]]에 저장된 상위 스코프의 참조는 현재 실행중인 실행 컨텍스트의 렉시컬 환경을 가리킨다.
예를 들어 전역에서 정의된 함수 선언문은 전역 코드가 평가되는 시점에 함수 객체를 생성하므로, 이 떄 생성되는 함수 객체의 내부 슬롯 [[Environment]] 에는 전역 코드가 평가되는 시점에 렉시컬 환경인 전역 렉시컬 환경이 참조로 저장되는 것이다.
bar() 함수 객체가 생성되는 시점은, 전역 코드가 평가되는 시점으로, bar 함수의 내부 슬롯 [[Environment]]에는 전역 실행컨텍스트의 렉시컬 환경이 참조값으로 저장된다.
😤 다시 정리해보자. 이것만 이해하면 된다
1. 전역 코드에서 함수선언문 또는 변수 선언문을 평가할 때 함수 선언문이 있다면 함수 객체를 생성함
2. 이 때 함수 객체 내부에 [[Environment]] 슬롯에는 실행중인 실행 컨텍스트의 렉시컬 환경이 저장됨.(중첩함수가 아닌 이상 당연히 전역 렉시컬 환경)
3. 함수가 호출이 될 때 함수 내부로 코드의 제어권이 이동되면서 함수 코드를 평가하기 시작함.
4. 이 때 함수 실행컨텍스트, 렉시컬 환경, 외부 렉시컬 환경에 대한 참조 결정(스코프 체인) 등등 순차적으로 진행됨
5. (4)번에서 외부 렉시컬 환경에 대한 참조값을 결정할 때 (2)번에 내부 슬롯 [[Environment]]에 저장되어 있던 값을 할당함.
짜잔 나 렉시컬 스코프🎉🎉
위에 코드를 보면 outer 함수를 호출할 시 outer 함수는 중첨 함수 inner 함수를 반환하고 생명 주기를 마감한다.(컨텍스트 스택에서 제거(pop)된다.)
근데 이상한 점이 있다. innerFunc()가 호출되는 시점은 outer 함수의 생명주기가 모두 끝난 시점인데 outer 함수의 지역 변수 x가 마치 부활이라도 한듯이 다시 참조된다.
이처럼 아래 두가지 조건을 충족하는 중첩 함수를 우리는 클로저라고 부르기로 약속했다.
1. 외부 함수보다 중첩 함수가 더 오래 살아있을 경우
2. 중첩 함수가 이미 생명 주기가 종료된 외부 함수의 변수를 참조할 경우
이것이 가능한 이유를 이해하기 위해서는 단 한가지 개념만 알고 있으면 된다. 그것은 outer 함수의 실행 컨텍스트가 종료되었다고 해서 함수의 렉시컬 환경까지 소멸되는 것은 아니라는 사실이다.
위 두개의 사진을 보면 알 수 있듯이 제거 된 것은 좌측 최상단 outer 함수의 실행 컨텍스트지, inner 함수가 참조하고 있는 outer 함수의 렉시컬 환경 은 그대로 살아있다.
또한 inner 함수 역시 전역 공간의 innerFunc에 의해 참조되고 있기 때문에 가비지 컬렉터의 대상이 되지 않아 살아있을 수 있다.
함수가 살아있고 죽는 여부는 메모리에서 해제됐냐 해제되지 않았냐에 여부이다. 가비지 컬렉터는 누군가가 참조하고 있는 메모리라면 함부로 해제하지 않는다.
처음에 뚱딴지 소리처럼 들렸던 "클로저의 정의"를 다시 한번 해석해보자.
"그 함수가 선언된 렉시컬 환경은" 곧 해당 함수가 정의될 때 실행되는 실행 컨텍스트의 렉시컬 환경, 쉽게 이야기하면 상위 스코프를 의미한다.
그렇다. 클로저는 자신의 상위 스코프의 실행컨텍스트가 종료가 되어도 렉시컬 환경은 기억하고 있기에 어디서 함수를 호출하든 상관없이 상위 스코프의 식별자를 참조할 수도, 식별자에 바인딩 된 값을 변경할 수도 있다는 특징을 위에서는 "렉시컬 환경과의 조합"이라고 함축적으로 이야기한 것이다.
함수가 생성될 때 해당 함수의 렉시컬 환경을 기억하여, 함수가 외부 스코프의 변수에 접근할 수 있는 특성을 "클로저"라고 의미한다면 모든 함수는 자신의 상위 스코프를 기억하므로 모두 클로저라고 할 수 있는가??
정답은 아니다. 위에 주장은 어디까지 클로저에 대한 개념을 강조한 말일 뿐이며 반드시 아래 두가지 조건을 모두 충족해야지만 우리는 일반적으로 클로저라고 명명한다. 아래 두가지 조건을 반드시 기억하자. 다시 강조해도 아쉬울게 없다.
1. 외부 함수보다 중첩 함수가 더 오래 살아있을 경우
2. 중첩 함수가 이미 생명 주기가 종료된 외부 함수의 변수를 참조할 경우
함수가 호출될 때마다 호출된 횟수를 누적하여 출력하는 카운터(증가) 함수이다. 위 코드의 호출된 횟수(num)는 안전하게 변경되고 유지되어야 한다. 이유는 당연히 누군가에 의해 의도치 않게 카운트 상태가 조작될 경우 개발자에 의도와 맞지 않는 결과물을 출력하기 때문이다.
문제는 num이라는 변수가 전역 공간에 있기 때문에 즉, public하기 때문에 어디서든 참조가 가능하고 이는 즉 암묵적 결합을 허용하는 위험한 행위이다.
아래와 같이 바꾸어보겠다.
안전하게 지켜야 하는 변수 num을 전역공간에서 지역 공간으로 바꾸어주었따. 이제 num 변수의 상태는 increase 함수만이 변경할 수 있게 되었다.
하지만 또 다른 문제가 발생하는데 increase 함수가 호출될 때마다 지역 변수 num은 다시 선언되기에 0으로 초기화 된다. 이러면 카운터 함수가 의미가 없게 된다.
num의 값을 개발자의 의도와 맞게 이전 ++num된 상태를 유지하기 위해 클로저를 사용해보자.
const increase = (function () {
let num = 0;
//클로저 함수, 외부 렉시컬 환경의 변수 num을 참조하는 모습이다.
return function () {
return ++num;
};
}());
console.log(increase()); // 1
console.log(increase()); // 2
console.log(increase()); // 3
즉시 실행 함수는 한 번만 실행되므로 increase가 호출될 때마다 num 변수가 재차 초기화 되는 일이 없을 것이고 또한 num 변수는 외부에서 직접 접근할 수 없는 은닉된 private 변수와 같이 작동하므로 의도되지 않은 값의 변경을 막아 줄 수 있다.
이처럼 클로저는 상태가 의도치 않게 변경되지 않도록 안전하게 은닉하고 특정 함수에게만(위 예시에서는 increase 함수) 상태 변경을 허용하여 상태를 안전하게 변경하고 유지하기 위해 사용된다.
캡슐화란 객체의 상태를 나타내는 프로퍼티와 프로퍼티를 참조하고 조작하는 동작인 메서드를 하나로 묶는 것을 의미한다. 캡슐 화는 객체의 특정 프로퍼티나 메서드를 감출 목적으로 사용하기도 하는데 이를 정보 은닉이라고 한다.
var secretHolder = (function () {
let secret = 'mySecret'; // 외부에서 접근할 수 없는 블록 스코프 변수
// 객체를 반환하여 메서드에 접근할 수 있도록 함
return {
getSecret: function () {
return secret;
},
setSecret: function (newSecret) {
secret = newSecret;
},
showMessage: function () {
if (secret === 'open sesame') {
console.log('안녕하세요! 비밀 메시지를 표시합니다.');
} else {
console.log('비밀번호가 틀렸습니다.');
}
},
};
})();
// 비밀번호 설정 및 메시지 확인
console.log(secretHolder.getSecret()); // 출력: "mySecret"
secretHolder.setSecret('open sesame');
secretHolder.showMessage(); // 출력: "안녕하세요! 비밀 메시지를 표시합니다."
secret
변수를 IIFE에 가두어 private 변수로 선언한 모습이고 해당 객체는 객체의 메서드를 통해서만 접근하거나 수정할 수 있다.
이처럼 클로저를 활용하면 객체의 데이터를 외부에 접근으로부터 보호할 수 있다.