클로저(Closure)는 함수형 프로그래밍 언어에서 사용되는 보편적인 특성이다. JavaScript의 고유한 특성이 아니기 때문에 ECMA Spec에서 하나의 주제로 제대로 설명되어 있지 않는다. 그러므로 MDN에 기술된 내용으로 처음에 살펴보아야 한다.
클로저는 함수와 함수가 선언된 Lexical Environment와의 상호관계에 따른 현상이다. 클로저를 설명하는 책, 블로그 글, 강의 등에서 일반적으로 외부 함수(변수)와 내부 함수의 구조를 실행컨텍스트와 곁들여 설명한다. 내부 함수에서 외부 변수를 참조하는 경우에만 Lexical Environment와의 상호관계가 만들어진다.
이전에 작성한 자바스크립트 - Scope, Hoisting 편에서 자바스크립트는 렉시컬 스코프를 따르며, 식별자에서 사용된 키워드(var, let, const)에 따라 추가적으로 함수 레벨 스코프와 블록 레벨 스코프를 따른다고 정리하였다.
렉시컬 스코프는 자바스크립트 엔진이 함수를 어디에 정의했는지에 따라서 상위 스코프를 결정하는 것이다. 자바스크립트는 실행 컨텍스트 내부에 OuterLexicalEnvironmentReference를 통해 상위 LexicalEnvironment와 연결되는 스코프 체인 구조를 가진다.
따라서, Lexical Scope를 실행 컨텍스트 관점에서 보면 LE의 'OLER'에 저장되는 참조 값(즉, 상위 스코프에 대한 참조 값)은 함수가 선언(정의)된 스코프에 의해 결정된다는 것이다.
함수가 정의(선언)된 환경과 호출(실행)되는 환경은 다를 수 있다. 따라서 Lexical Scope가 가능하려면 함수는 호출 시점이 아닌 정의된 스코프를 기억해야한다. 함수가 정의된 스코프를 기억하기 위해서 함수는 내부 슬롯 [[Environment]]에 함수 자신이 정의된 환경을 저장해야한다.
ES5 상세에서는 Function Objects를 생성할 때 함수가 속한 스코프를 함수 내부의 [[Scope]] 프로퍼티에 설정하고, 함수가 호출되었을 때 [[Scope]] 프로퍼티를 사용하는 메커니즘을 가진다
ES6 상세에서는 Function Objects의 내부 슬롯에 [[Scope]]가 없어지고, Type이 Environment Record인 [[Environment]]가 존재한다. 위 설명을 번역하고 설명해보자면 함수 객체의 [[Environment]] 내부 슬롯에는 함수의 정의(선언)가 평가될 시점에서 실행 중인 실행 컨텍스트의 Lexical Environment 컴포넌트 내부의 Outer Lexical Environment Reference가 저장된다. 한마디로 [[Environment]]에는 상위 스코프의 참조가 저장된다는 뜻이다.
함수 객체의 내부 슬롯 [[Environment]]로 인해 자바스크립트의 함수 선언(정의) 위치에 따라 상위 스코프가 결정되는 Lexical Scope가 이뤄지는 것이다. 함수 정의에 연결된 영구적인 데이터 공간을 만든다는 뜻이다.
외부 함수의 변수를 참조하는 내부 함수의 예제이다.
let a = 10;
function outer() {
let a = 20;
const inner = function() {
console.log(a++);
}
return inner;
}
const innerFn = outer();
innerFn(); // 20
innerFn(); // 21
outer 함수를 호출하면 (outer()) outer 함수는 내부 중첩 함수인 inner를 반환하고 생명 주기(life cycle)을 종료한다. 위에서 언급한 ECMAScript 최신 상세에서 Function Objects의 Internal Slot 중 [[Environment]]는 함수가 감싸진 ER(closed over된 Environment Record)를 담는다고 하였다.
일반적으로 실행 컨텍스트는 함수가 호출될 때 생성되고 함수가 종료될 때 없어진다. 하지만 위의 코드를 실행해보면 outer함수는 종료되었지만 outer 함수의 지역 변수인 a는 innerFn를 호출할 때마다 증가된 값으로 출력된다.
inner함수는 outer함수 내부에서 정의(선언)되었으므로, 렉시컬 스코프 규칙에 따라 inner 함수의 상위 스코프는 outer 함수의 Lexical Environment가 된다. inner함수의 [[Environment]] 내부 슬롯에는 현재 실행 중인 outer 함수의 Lexical Environment를 가리킨다.
outer 함수의 실행 컨텍스트는 콜 스택에서 제거되지만 outer 함수의 Lexical Environment까지 소멸되는 것은 아니다. outer 함수의 Lexical Environment는 inner 함수의 [[Environment]] 내부 슬롯에 참조되고 있고, inner 함수는 전역 변수 innerFn에 의해 참조되고 있으므로 가비지 컬렉션(Garbage Collection)에 의해 제거되지 않는다.
클로저는 어떤 실행 컨텍스트 A에서 선언한 변수 a를 참조하는 내부 함수 B를 A의 외부로 전달하는 경우에 A가 종료되어 실행 컨텍스트가 콜스택에서 제거된 이후에도 변수 a가 사라지지 않는 현상이다. 쉽게 말해서 내부 함수 B를 외부에서 할당하면 컨텍스트 A의 지역변수가 사라지지 않는 현상이다.
(function() {
var a = 1;
var intervalId = null;
var inner = function() {
console.log(a++);
if (a === 11) {
clearInterval(intervalId);
}
};
intervalId = setInterval(inner, 300);
})();
// 1부터 10까지 300ms 주기로 출력된다.
이와 같이 내부 함수를 외부로 전달하는 방식에는 콜백 함수도 존재한다.
window의 메소드인 setInterval의 첫 번째 전달인자인 콜백함수 inner 내부에서 지역변수를 참조한다.
클로저는 자바스크립트의 어렵지만 강력한 기능이다. 함수의 내부슬롯 [[Environment]]으로 함수가 선언될 때 정의된 환경을 저장하는 로컬 메모리를 갖는다. 함수가 갖는 메모리는 memoize역할을 하게 되며, 함수마다 독립적인 메모리를 원할 때 코드를 모듈 패턴으로 코딩할 때 장점이 된다. 실제로 회사의 커다란 규모 프로젝트에서 자바스크립트로 개발할 때 전역 메모리를 오염시켜 예기치 못한 버그가 발생할 확률이 높아진다. 모듈 패턴에 따라 클로저를 잘 사용하면 독립적이고 보호받는 메모리 공간(closed over variable environment, lexical scope, closure)을 두어 이를 활용하고, 전역 메모리의 변이를 방지할 수 있다.
클로저에 대한 단점을 찾아보면 메모리 누수라고 나오는데, 개발자가 설계한대로 메모리 소모에 대한 부분을 관리하면 문제될 것이 없다. 클로저는 의도적으로 컨텍스트 내 지역 변수를 메모리를 소모하도록 한다. 이를 해소하기 위해서는 식별자에 보통 null이나 undefined를 할당하여 참조를 없앰으로서 GC가 메모리를 해제하도록 세팅한다.
클로저의 본질은 메모리르 계속 차지하는 개념이므로, 더이상 클로저가 필요없는 경우 메모리가 차지 않도록 관리해야한다. 일반적인 함수의 클로저나 setInterval, eventListener의 콜백함수의 클로저 모두 식별자에 null을 할당하여 참조를 끊는다.
function outer() {
var count1 = 0;
var count2 = 0;
function plus(m) {
count1 += m;
console.log(count1);
} // -- (3)
return plus;
} // -- (1) -- (4)
let fn = outer(); // -- (2) -- (5)
fn(2); // -- (6)
fn(3); // -- (7)
위 코드는 클로저를 충분히 설명할 수 있는 코드이다.
(1) ~ (7) 부분에 대해 콜 스택, 실행 컨텍스트와 더불어 설명해보겠다.
(1) outer 함수가 선언되었다. 함수 선언문이므로, outer 변수에 function outer() { ... }
함수 전문이 할당된다.
fn 식별자는 let 키워드 변수이며, 호이스팅은 되었지만 초기화가 되지 않은 상태이다.
(2) outer 함수가 실행되었다. 이 때 outer()의 리턴 값이 어떤 값이든 fn에 할당될 것이다.
outer 함수가 실행되어 outer 함수 실행 컨텍스트가 생성되며 콜 스택에 함수 실행컨텍스트가 push된다.
outer 함수 내부에 count1, count2, plus 식별자가 ER에 등록되었다. 모두 undefined이다.
(3) outer 함수 실행 컨텍스트 내 count1, count2, plus 식별자가 해결되어, 0, 0, plus 함수 전문이 할당되어 있는 상태이다.
(4) outer 함수는 plus 식별자를 반환하고 종료된다.
위 설명에서 이상한 점이 느껴지지 않는가?
코드를 실행하면 0으로 초기화된 count1 변수 값이 +2, +3되어 2와 5가 출력된다.
하지만, 실행 컨텍스트의 구조를 보니 fn 함수 실행 컨텍스트에서 증가시킬 count1의 행방을 찾을 수 없다.
fn 함수의 상위 스코프 즉, 글로벌에도 count1이 없는데
왜 위와 같은 코드가 정상적으로 실행되며, 에러가 없고, 더군다나 count1 변수의 숫자가 증가되는지 일반적으로는 알 수가 없다.
위 설명 중에 이상한 부분부터 다시 설명을 할 것이다. 이 부분이 빠지게 되었다.
(4) 위에서 설명한 함수 객체의 내부 슬롯 [[Environment]]의 개념이 이 때 등장한다. 위 그림과 같이 [[Environment]] 에는 내부 함수에서 엮여있는 (외부 컨텍스트의 ER 내부에 내부 함수 로직과 상호작용하는) 지역 변수의 참조가 들어간다. 즉, 실질적인 Lexical Scope의 참조가 들어간다는 뜻이다.
그렇기 때문에 함수가 어디서 호출하는지가 아닌 어디서 선언, 정의되어 있는지에 따라 접근할 수 있는 변수가 결정되는 것이다.
(5) outer 함수가 종료된 후 반환된 plus 함수는 글로벌의 fn 변수에 할당된다.
우리는 단순히 함수 선언문을 변수에 할당하고, 해당 변수를 콘솔로그한 결과 함수 전문만 할당되는지 알고 있었다. 하지만 함수 객체의 내부 슬롯 [[Environment]]에 다른 스코프와 엮여있는 숨겨진 묶음도 같이 존재한다.
함수에서 식별자를 찾는 방법은 다음과 같다.
먼저, 현재 본인의 스코프 내 LE의 ER에서 식별자를 찾는다. 그 다음에 실행 컨텍스트 내 Outer Lexical Environment Reference를 보며 상위 스코프의 식별자를 바로 해결할 줄 알았지만, 그 사이에 중간 단계가 하나 더 있다. 바로 함수 객체의 내부 슬롯의 [[Environment]]를 보는 것이다.
그렇기 때문에 내부 슬롯의 count 값이 0에서부터 +2되어 2 값이 출력되고, fn의 파라미터 값으로 3을 넣어 +3되어 5 값이 출력되는 것이다.