클로저는 자바스크립트 고유의 개념이 아닌, 함수를 일급 객체로 취급하는 함수형 프로그래밍 언어에서 사용되는 중요한 특성이다.
MDN에서 클로저에 대해 다음과 같이 정의하고 있다.
A Closure is the combination of a function and the lexical environment within which that function was declared.
(클로저는 함수와 그 함수가 선언된 렉시컬 환경과의 조합이다.)
클로저를 이해하려면 자바스크립트가 변수의 유효범위를 지정하는 방식인 렉시컬 스코프에 대해 알아볼 필요가 있다.
자바스크립트 엔진은 함수를 어디서 호출했는지가 아니라 함수를 어디에 정의했는지에 따라 상위 스코프를 결정한다. 이를 렉시컬 스코프(정적 스코프)라 한다.
앞서 23장 "실행 컨텍스트" 파트에서 스코프의 실체는 실행 컨텍스트의 렉시컬 환경이라고 설명한 바 있다. 그리고 이 렉시컬 환경은 자신의 외부 렉시컬 환경에 대한 참조를 통해 상위 렉시컬 환경과 연결된다.
따라서 상위 스코프를 결정한다
는 것의 의미는 곧 외부 렉시컬 환경에 대한 참조에 저장된 참조값을 결정한다
는 의미와 동일하며, 이 값은 함수 정의가 평가되는 시점에 함수가 정의된 위치가 어디인지에 따라 결정된다. 이것이 바로 렉시컬 스코프다.
[[Environment]]
그렇다면 호출되는 위치가 다름에도 불구하고 렉시컬 스코핑이 가능한 이유는 무엇일까? 바로 함수 정의가 평가되어 함수 객체가 생성될 때부터 함수 객체의 내부 슬롯 [[Environment]]
에 자신이 정의된 환경, 즉 상위 스코프의 참조를 저장하기 때문이다. 그리고 이 시점은 아래 코드에서 전역 코드 평가 시점에 해당된다.
const x = 1;
function foo() {
const x = 10;
bar();
}
function bar() {
console.log(x);
}
foo();
bar();
따라서 foo
함수 내에서 bar
를 호출하든 전역에서 bar
를 호출하든 관계없이 bar
함수가 정의된 위치는 변하지 않으므로 bar
함수 객체의 내부 슬롯 [[Environment]]
에는 상위 스코프에 해당하는 전역 렉시컬 환경을 자신이 존재하는 한 기억하게 된다.
이후 함수가 호출되어 함수 코드가 평가될 때 비로소 함수 렉시컬 환경의 외부 렉시컬 환경에 대한 참조에 함수 객체의 내부 슬롯 [[Environment]]
에 저장된 렉시컬 환경의 참조가 할당된다.
📚 정리
상위 스코프
= 함수 객체 생성 시[[Environment]] 내부 슬롯에 저장된 렉시컬 환경의 참조
= 함수 객체 생성 시현재 실행 중인 실행 컨텍스트
= 함수가 호출될 때함수 렉시컬 환경의 외부 렉시컬 환경에 대한 참조
이쯤에서 클로저가 무엇인지에 대해서 다시 한 번 생각해보자.
const x = 1;
function outer() {
const x = 10;
const y = 20;
const inner = function() { console.log(x); };
return inner;
}
const innerFunc = outer(); // ①
innerFunc(); // ②
①번에서 outer
함수는 중첩 함수 inner
를 반환하고 생명 주기를 마감한다. 따라서 outer
함수의 지역 변수 x
는 더 이상 유효하지 않는 것처럼 보이지만 ②번을 실행하면 다시 부활이라도 한 듯 지역 변수 x
의 값인 10
이 출력된다.
이처럼
하고 있는 경우 이 중첩 함수를 클로저
라고 부르며, 두 조건 중 하나라도 만족하지 않는 경우에는 클로저라고 부르지 않는 것이 일반적이다.
다만, 클로저인 중첩 함수가 상위 스코프의 식별자 중 일부만 참조하는 경우 대부분의 모던 브라우저는 최적화를 통해 클로저가 참조하고 있는 식별자만을 기억한다.
또한, 클로저에 의해 참조되는 상위 스코프의 변수를 자유 변수라고 부르며, 이를 통해 클로저를 자유 변수에 의해 닫혀있는/묶여있는
함수라고 해석할 수 있다.
①번에서 outer
함수의 실행이 종료되면 실행 컨텍스트 스택에서 Outer Execution Context
는 제거된다.
하지만 outer Lexical Environment
의 경우 inner
함수의 [[Environment]]
내부 슬롯에 의해 참조되고 있고 inner
함수는 전역 변수 innerFunc
에 의해 참조되고 있기 때문에 소멸되지 않는다. (가비지 컬렉터는 누군가가 참조하고 있는 공간을 함부로 해제하지 않는다.)
따라서 ②번이 실행되면 inner Lexical Environment
의 외부 렉시컬 환경에 대한 참조에 inner
함수 객체의 [[Environment]]
내부 슬롯에 저장된 outer Lexical Environment
의 참조가 바인딩되어 outer
함수의 식별자를 참조하고 값을 변경할 수 있게 된다.
클로저는 상태가 의도치 않게 변경되지 않도록 상태를 안전하게 은닉하고 특정 함수에게만 상태 변경을 허용하기 위해 사용한다.
함수가 호출될 때마다 호출된 횟수를 누적하여 출력하는 카운터를 예시로 들어보자. 호출된 횟수(num
)가 바로 안전하게 변경하고 유지해야 할 상태다.
let num = 0;
const increase = function() {
return ++num;
}
console.log(increase()); // 1
console.log(increase()); // 2
console.log(increase()); // 3
num
을 전역 변수로 선언했기 때문에 언제든지 누구나 접근하여 변경할 수 있다.
-> 안전하게 변경하고 유지하기 위해서는 increase
함수만이 num
을 참조하고 변경할 수 있도록 해야 한다.
const increase = function() {
let num = 0;
return ++num;
}
console.log(increase()); // 1
console.log(increase()); // 1
console.log(increase()); // 1
이제 increase
함수만이 num
변수를 참조하고 변경할 수 있지만, 함수가 호출될 때마다 num
이 다시 선언되어 0
으로 초기화되는 문제가 발생한다.
-> 이전 상태를 유지할 수 있도록 클로저를 활용한다.
const increase = (function () {
let num = 0;
return function () {
return ++num;
};
}());
console.log(increase()); // 1
console.log(increase()); // 2
console.log(increase()); // 3
IIFE는 호출된 이후 소멸하지만 IIFE의 렉시컬 환경은 반환되는 함수의 [[Environment]]
내부 슬롯이 참조하고 반환되는 함수는 전역 변수 increase
가 참조하므로 소멸하지 않는다.
IIFE는 한 번만 실행되기 때문에 increase
가 호출될 때마다 별개의 렉시컬 환경이 생성되지 않는다. 따라서 단 하나의 자유 변수를 공유할 수 있게 된다.
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
IIFE의 반환값이 객체 리터럴인 경우, 객체 리터럴은 별도의 스코프를 생성하지 않기 때문에 객체 리터럴에 추가된 여러 개의 메서드가 하나의 자유 변수를 공유하도록 할 수 있다.
함수를 인수로 전달받아 함수를 반환하는 고차 함수 makeCounter
를 예로 들어보자. 이 함수는 자유 변수 counter
를 기억하는 클로저를 반환한다.
function makeCounter(predicate) {
let counter = 0;
return function() {
counter = predicate(counter);
return counter;
};
}
function increase(n) { return ++n; }
function decrease(n) { return --n; }
const increaser = makeCounter(increase); // ①
console.log(increaser()); // 1
console.log(increaser()); // 2
const decreaser = makeCounter(decrease); // ②
console.log(decreaser()); // -1
console.log(decreaser()); // -2
makeCounter
함수는 ①, ②에 의해 총 2번 호출되는데 문제는 호출될 때마다 별개의 렉시컬 환경이 만들어지기 때문에 increaser
와 decreaser
에 할당된 함수의 자유 변수 counter
는 서로 연동되지 않는다.
const counter = (function() {
let counter = 0;
return function(predicate) {
counter = predicate(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)); // 0
따라서 단 한번만 실행되는 IIFE + IIFE가 반환하는 함수에 보조 함수를 전달함으로써 자유 변수 counter
가 공유되도록 할 수 있다.
캡슐화
는 객체의 상태를 나타내는 프로퍼티와 프로퍼티를 참조하고 조작할 수 있는 동작인 메서드를 하나로 묶는 것을 말한다.
캡슐화는 객체의 특정 프로퍼티와 메서드를 감출 목적으로 사용하기도 하는데 이를 정보 은닉
이라 한다. 정보 은닉을 통해 외부에 공개될 필요가 없는 부분을 감추어 정보를 보호하고 객체 간의 상호 의존성, 즉 결합도를 낮추는 효과를 가져올 수 있다.
대부분의 객체지향 언어는 접근 제한자를 선언하여 공개 범위를 한정할 수 있지만, JS의 경우 접근 제한자를 제공하지 않는다. 즉, JS에서 객체의 모든 프로퍼티와 메서드는 기본적으로 public
이다.
const Person = (function() {
let _age = 0; // private
function Person(name, age) {
this.name = name; // 인스턴스의 프로퍼티는 public
_age = age;
}
Person.prototype.sayHi = function() {
console.log(`Hi! My name is ${this.name}. I am ${_age}.`);
};
return Person;
}());
const me = new Person('Lee', 20);
me.sayHi(); // Hi! My name is Lee, I am 20.
console.log(me.name, me._age); // Lee undefined
const you = new Person('Kim', 30);
you.sayHi(); // Hi! My name is Lee, I am 20.
console.log(you.name, you._age); // Kim undefined
하지만 IIFE 내에서 변수를 선언할 경우 인스턴스 및 IIFE 외부에서 접근할 수 없는 private
변수가 된다. private
변수는 클로저인 sayHi
프로토타입 메서드에 의해서만 참조될 수 있다.
하지만 이 경우에도 생성자 함수가 여러 개의 인스턴스를 생성할 경우 num
변수의 상태가 유지되지 않는 문제가 발생한다.
const me = new Person('Lee', 20);
const you = new Person('Kim', 30);
me.sayHi(); // Hi! My name is Lee. I am 30.
이는 Person.prototype.sayHi
가 IIFE가 호출될 때 단 한 번 실행되는 클로저이기 때문에 발생하는 현상이다. 따라서 어떤 인스턴스로 호출하더라도 Person.prototype.sayHi
의 상위 스코프는 단 하나의 IIFE의 렉시컬 환경으로 고정된다.
다행히도 클래스에 private
필드를 정의할 수 있는 새로운 표준 사양이 제안되어 있는데, 자세한 내용은 25장 "클래스"에서 알아보도록 하자.
var funcs = [];
for (var i = 0; i < 3; i++) {
funcs[i] = function() { return i; };
}
for (var j = 0; j < funcs.length; j++) {
console.log(funcs[j]());
}
console.log(funcs[j]())
의 결과가 0 1 2
가 될 것으로 기대했으나, 실제로는 3 3 3
이 출력되는데, 그 이유는 var
가 함수 레벨 스코프를 따르기 때문이다.
for문에서 var
키워드로 선언한 i
는 전역 변수가 되고 i
에는 순서대로 0
, 1
, 2
, 그리고 최종적으로 3
이 할당이 된다. 따라서 funcs
배열의 요소로 추가한 함수를 호출하면 전역 변수 i
를 참조하여 3
이 3번 호출된다.
이 문제를 클로저를 사용해서 해결할 수 있다.
var funcs = [];
for (var i = 0; i < 3; i++) {
funcs[i] = (function(id) {
return function() { return id; };
}(i));
}
for (var j = 0; j < funcs.length; j++) {
console.log(funcs[j]());
}
IIFE는 매개변수 id
에 i
를 할당하고 그 매개변수를 참조하는 중첩 함수를 반환한 후 종료한다. IIFE는 총 3번 호출되는데, 호출마다 할당된 id
의 값이 다른 IIFE 렉시컬 환경이 생성되고 이 렉시컬 환경은 funcs[i]
에 바인딩된 중첩 함수의 [[Environment]]
에 의해 참조되므로 사라지지 않게 된다.
위 예제는 var
키워드로 선언한 변수가 전역 변수가 되기 때문에 발생하는 현상이므로, ES6의 let
키워드를 사용하여 이 같은 번거로움을 해결할 수 있다.
const funcs = [];
for (let i = 0; i < 3; i++) {
funcs[i] = function() { return i; };
}
for (let i = 0; i < funcs.length; i++) {
console.log(funcs[i]());
}
let
키워드는 블록 레벨 스코프를 따르므로 for
문의 코드 블록이 반복 실행될 때마다 새로운 렉시컬 환경이 생성되고 여기의 환경 레코드에 바인딩되는 i
의 값에는 반복할 당시의 상태를 마치 스냅숏을 찍는 것처럼 저장된다.
단, 이는 반복문의 코드 블록 내부에서 함수를 정의할 때 의미가 있으며 만약 함수 정의가 없는 경우 반복문이 생성하는 새로운 렉시컬 환경은 아무도 참조하지 않기에 가비지 컬렉션의 대상이 된다.