클로저와 렉시컬 환경 정리
클로저가 "외부 함수의 변수를 기억하는 내부 함수"라는 한 줄 설명만으로는 와닿지 않았다. 모던 자바스크립트 Deep Dive를 따라가며 렉시컬 스코프 → 함수 객체의 내부 슬롯 → 실행 컨텍스트 흐름 순서로 차근차근 짚고 나서야 코드가 왜 그렇게 동작하는지 잡혔다. 그 흐름을 정리해뒀다.
함수가 정의된 위치를 기억하는 이유
자바스크립트의 스코프는 렉시컬 스코프(정적 스코프)다. 함수를 어디서 호출했는지가 아니라, 함수를 어디에 정의했는지에 따라 상위 스코프가 결정된다.
좀 더 정확히는, 렉시컬 환경의 "외부 렉시컬 환경에 대한 참조" 슬롯에 들어갈 값(상위 스코프 참조)이 함수 정의가 평가되는 시점에 함수가 정의된 위치에 의해 결정된다. 호출 시점이 아니라 정의 시점이라는 게 핵심이다.
[[Environment]] 슬롯이 상위 스코프를 들고 다닌다
렉시컬 스코프가 성립하려면 함수는 자신이 어디서 호출되든 자기가 정의된 환경을 기억해야 한다. 그래서 함수 정의가 평가되어 함수 객체가 생성될 때, 그 함수 객체의 내부 슬롯 [[Environment]]에 자신이 정의된 환경, 즉 상위 스코프에 대한 참조를 저장한다.
[[Environment]]에 저장되는 참조는 그 함수가 정의된 시점에 실행 중이던 실행 컨텍스트의 렉시컬 환경을 가리킨다. 함수가 나중에 어디서 호출되든, 이 슬롯이 가리키는 환경을 외부 렉시컬 환경으로 삼아 식별자를 찾는다.
외부 함수가 사라져도 변수가 살아남는다
다음 코드를 돌려보면 outer가 끝났는데도 안쪽 x가 살아 있다.
const x = 1;
function outer() {
const x = 10;
const inner = function () { console.log(x); };
return inner;
}
const innerFunc = outer();
innerFunc(); // 10outer를 호출하면 중첩 함수 inner를 반환하고, outer의 실행 컨텍스트는 실행 컨텍스트 스택에서 팝되어 제거된다. 보통이라면 outer의 지역 변수 x도 함께 생명 주기를 끝내야 한다. 그런데 반환된 innerFunc를 호출하면 여전히 10이 찍힌다. 이게 클로저다.
inner는 자기 [[Environment]]로 outer의 렉시컬 환경을 참조하고 있다. 그래서 outer의 실행 컨텍스트가 제거되어도 그 렉시컬 환경은 가비지 컬렉터가 회수하지 않는다. 참조하는 누군가(여기선 inner)가 살아 있기 때문이다.
이론적으로는 자바스크립트의 모든 함수가 상위 스코프를 기억하므로 전부 클로저라고 부를 수도 있다. 하지만 보통은 상위 스코프의 식별자를 실제로 참조하면서, 외부 함수보다 더 오래 사는 경우만 클로저라 부른다. 상위 스코프의 어떤 식별자도 참조하지 않는 함수나, 외부 함수보다 일찍 소멸되는 함수는 굳이 클로저라 부르지 않는다.
클로저가 참조하는 상위 스코프의 변수를 자유 변수라고 한다. 클로저는 "자유 변수에 묶여 있는(닫혀 있는) 함수"라는 정의가 여기서 나온다.
클로저로 상태를 안전하게 가두기
클로저는 상태를 외부에서 직접 건드리지 못하게 감추고, 정해진 함수에게만 변경을 허용하기 위해 쓴다. 외부 상태 변경과 가변 데이터를 피하고 불변성을 지향하는 함수형 프로그래밍에서 부수효과를 억제하는 도구로 자주 등장한다.
전형적인 카운터 예제로 짚어봤다.
const counter = (function () {
let num = 0;
return {
increase() {
return ++num;
},
decrease() {
return num > 0 ? --num : 0;
}
};
}());
console.log(counter.increase()); // 1
console.log(counter.increase()); // 2
console.log(counter.decrease()); // 1
console.log(counter.decrease()); // 0즉시 실행 함수 안의 num은 바깥에서 직접 접근할 수 없다. counter.increase와 counter.decrease만이 자유 변수로 num을 참조하므로 상태 변경 통로가 두 함수로 제한된다.
독립된 렉시컬 환경 주의
함수를 호출해 다른 함수를 반환받을 때, 반환된 함수는 자기만의 독립된 렉시컬 환경을 갖는다. 같은 상태를 공유하고 싶다면 매번 새 함수로 만들어 호출하면 안 되고, 한 클로저를 그대로 재사용해야 한다.
아래는 하나의 counter 함수가 외부에서 받은 보조 함수(increase, decrease)에 따라 동작을 바꾸도록 짠 형태다. counter 자신은 즉시 실행 함수가 만든 단일 렉시컬 환경에 묶여 있어서, 호출을 여러 번 해도 같은 counter 변수를 갱신한다.
const counter = (function () {
let counter = 0;
return function (aux) {
counter = aux(counter);
return counter;
};
}());
function increase(n) {
return ++n;
}
function decrease(n) {
return --n;
}
console.log(counter(increase)); // 1
console.log(counter(increase)); // 2
console.log(counter(decrease)); // 1
console.log(counter(decrease)); // 0increase와 decrease는 상태를 들고 있지 않고, 상태는 오직 counter 안에서만 산다. 보조 함수를 갈아끼우는 것만으로 동작이 바뀐다.
캡슐화와 정보 은닉
캡슐화는 객체의 상태를 나타내는 프로퍼티와 동작을 나타내는 메서드를 하나로 묶는 것을 말한다. 여기에 더해 특정 프로퍼티나 메서드를 외부에서 보이지 않게 감추는 것을 정보 은닉이라고 한다.
정보 은닉은 외부에 공개할 필요가 없는 구현을 가려서, 적절치 못한 접근으로부터 객체의 상태가 변경되는 것을 막는다. 객체 간의 상호 의존성(결합도)을 낮추는 효과도 같이 얻는다.
자바스크립트 객체의 모든 프로퍼티와 메서드는 기본적으로 외부에 공개되어 있다. 다시 말해 모두 public이다. 그래서 정보 은닉을 흉내 내려면 클로저의 자유 변수처럼 외부 스코프에서 접근 경로 자체가 없는 영역에 상태를 가둬두는 방식이 필요하다. 위의 카운터 예제에서 즉시 실행 함수의 num이 바깥에서 보이지 않는 것도 같은 원리다.