참 난해해서 미루고 미뤘던 개념이였다. 이제야 제대로 각 잡고 깊게 공부한다.
클로저는 함수와 그 함수가 선언된 렉시컬 환경과의 조합이다.
클로저가 가능한 이유는 실행 컨텍스트는 제거되어도 렉시컬 환경은 남아있기 때문이다.
클로저를 제대로 이해하기 위해선 용어 몇가지에 대해 제대로 알아야한다. 첫번째는 렉시컬 스코프
이다.
자바스크립트 엔진은 상위 스코프를 결정할 때 함수의 호출 위치가 아닌 함수가 정의되어 있는 위치를 기준으로 결정한다. 이를 렉시컬 스코프 즉, 정적 스코프라고 한다.
함수 코드 평가를 보면 다음과 같은 순서로 진행된다.
2.3의 외부 렉시컬 환경에 대한 참조
에 저장할 참조값, 즉 상위 스코프에 대한 참조
는 함수가 정의된 위치에 따라 결정된다는 말이다.
그렇다면 함수 코드 평가에 따라 어떻게 상위 스코프에 대한 참조가 이루어지고 클로저가 가능한지 설명해보자.
const x = 1;
function outer() {
const x = 10;
const inner = function () {
console.log(x);
};
return inner;
}
const innerFunc = outer();
innerFunc();
모던 자바스크립트 예시이다.
현재 inner 함수는 outer 함수가 콜스택에서 팝되었음에도 불구하고 outer 함수의 x변수에 접근해서 값을 출력하고 있다. inner 함수가 클로저이고 이 말은 즉 inner 함수는 상위 스코프에 대한 정보를 기억하고 있다는 말이다.
어떤 과정에서 이것이 가능한가?
우선 함수 코드 평가 과정대로 전역 실행 컨텍스트가 콜스택에 푸시된다.
그리고 전역 렉시컬 환경을 만들어서 전역환경레코드, 외부렉시컬환경에 대한 참조(전역이기 때문에 참조값이 없음) 등등 다양한 정보들이 만들어지게 된다.
이때 전역환경레코드에는 outer 함수에 대한 정보도 기록이 되는데 outer 함수의 Environment
에는 현재 콜스택에 실행중인 실행컨텍스트의 참조값이 들어가게 된다.
바로 이 Environment
가 나중에 outer 함수가 호출되었을 때 outer 함수의 렉시컬 환경의 외부 렉시컬 환경의 참조값으로 들어가게 되는 값이 된다.
그러니까 내부 함수의 상위 스코프 참조값은 외부 함수가 실행되는 시점에 생성이 되는것이다. 그 정보는 Environment
슬롯에 저장이 되어 있는 것이다.
그리고 outer 함수가 호출되었을 때도 위와 마찬가지의 과정을 거치게 되고 outer 함수의 렉시컬 환경의 환경레코드에 inner 함수가 들어가게 되고 inner 함수의 Environment
슬롯에 현재 콜스택에 실행중인 outer함수의 렉시컬 환경에 대한 참조값이 들어가게 된다.
그리고 ✅ innerFunc(); 바로 이 시점에서는 콜스택에 outer 함수는 나간지 오래다...
하지만 inner함수는 Environment
슬롯에 저장되어 있는 outer 함수의 렉시컬 환경 참조값이 여전히 남아있다. 이것을 기반으로 inner 함수가 콜스택에 들어가고, 렉시컬 환경이 생성되어질 때 렉시컬 환경의 외부 렉시컬 환경의 참조값에 바로 Environment
슬롯에 저장된 값이 들어가게 된다.
그렇기 때문에 outer 함수가 콜스택에 없어져도 inner 함수는 outer 함수의 x에 접근할 수 있는 것이다.
사실 글로 해석하는 것보다 그림으로 표현하는 것이 더 이해하기 쉽지만 그림 한번 보고 넘어가는 것보다 이해한 내용을 글로 풀어써보는 것이 스스로 공부에 더 도움이 될 거 같아 장황하게 풀어써봤다.
그렇다면 클로저는 어떻게 활용될 수 있을까?
클로저는 캡슐화와 정보은닉에 사용될 수 있다.
클로저는 상태가 의도치 않게 변경되지 않도록 안전하게 은닉하고 특정 함수에게만 상태변경을 허용하여 상태를 안전하게 변경하고 유지하기 위해 사용한다.
const counter = (function () {
let num = 0;
return {
increase() {
return ++num;
},
decrease() {
return num > 0 ? --num : 0;
},
};
})();
console.log(counter.increase());
console.log(counter.increase());
console.log(counter.decrease());
console.log(counter.decrease());
counter는 즉시 실행함수이고 딱 한번 실행이 된다. 실행되면서 렉시컬 환경을 생성하게 되고 렉시컬 환경이 생성되는 시점에 반환하는 객체 리터럴이 평가되어 객체가 된다. 그리고 객체 내부의 메서드들도 평가되어진다.
(평가와 실행의 개념의 차이를 이해해야한다.)
이때 increase와 decrease 함수가 평가되는 시점에 두 개의 함수의 Environment에는 현재 콜스택에 들어가있는 counter 즉시 실행 함수의 렉시컬 환경이 참조된다.
따라서 즉시 실행 함수가 딱 한번 실행되고 종료되어도 객체 내부의 메서드들은 즉시 실행함수에서 선언된 num의 값(자유변수)에 다가갈 수 있다. 이것이 클로저이다.
이렇게 클로저를 사용하면 num이라는 변수는 외부에서 직접 접근하지 못하고 변형하지 못한다. 오로지 객체 내부에 정의된 메서드들만이 num을 변경할 수 있다.
그렇다면 함수형 프로그래밍에서는 어떻게 클로저를 활용하는지 예제를 알아보자.
function makeCounter(aux) {
let counter = 0;
return function () {
counter = aux(counter);
return counter;
};
}
function increase(n) {
return ++n;
}
function decrease(n) {
return --n;
}
const increaser = makeCounter(increase);
console.log(increaser());
const decreaser = makeCounter(decrease);
console.log(decreaser());
위의 예제는 makeCounter를 두번 호출하고 있다. 이렇게 되면 각자 반환된 함수는 서로 다른 렉시컬 환경을 생성하기 때문에 counter를 공유할 수 없다.
그렇기 위해서는 makeCounter를 한번 호출해야한다. 그래야 클로저가 동일한 렉시컬 환경을 상위 스코프로 참조할 수 있기 때문이다.
const makeCounter = (function () {
let counter = 0;
return function (aux) {
counter = aux(counter);
return counter;
};
})();
function increase(n) {
return ++n;
}
function decrease(n) {
return --n;
}
console.log(makeCounter(increase));
console.log(makeCounter(decrease));