클로저(closure)는 함수형 프로그래밍 언어에서 등장하는 보편적인 특성이기에 다양한 문헌에서 제각각 클로저를 다르게 설명하고 있습니다. 본질을 깨닫고 나면 쉬운 개념인데도 어딘가 갈증이 해소되지 않는 기분을 느끼기도 쉬운 개념이 바로 클로저입니다.
예시를 통해 클로저가 어떤 상황일때 발생하는 현상인지 살펴봅시다.
var outer = function () {
var a = 1;
var inner = function () {
return ++a;
};
return inner; // not "return inner();"
};
var outer2 = outer();
console.log(outer2());
LexicalEnvironment
에 식별자들(a, inner)에 대한 정보를 저장합니다.a
를 찾기 위해 outerEnvironmentReference
에 지정된 상위 컨텍스트인 outer의 LexicalEnvironment
에 접근해서 식별자 a
를 찾습니다.사실 위 단계에서 이상한 점이 있습니다. inner 함수의 실행 시점(3단계)에는 outer 함수는 이미 종료된 상태(2단계)인데 어떻게 outer 함수의 LexicalEnvironment
에 접근(4단계)할 수 있었을까요?
다른 관점으로 보면, outer 함수가 종료되는 시점(2단계)에 식별자들(a, inner)에 대한 참조를 지웠기에 가비지 컬렉터의 수집 대상이 되었지만 그 이후에도 어떻게 해당 식별자에 접근할 수 있었을까요?
사실 가비지 컬렉터는 어떤 값을 참조하는 변수가 호출될 가능성이 있다면 그 값은 수집 대상에 포함시키지 않습니다. 위에서 outer 함수가 종료되었다고 해도 외부함수인 outer2 함수에 의해 inner 함수가 호출될 가능성이 열렸기 때문에 식별자 a
는 사라지지 않았습니다. 바로 이러한 현상이 클로저입니다.
정리하자면, 클로저란 함수 A에서 선언한 변수 a를 참조하는 내부함수 B를 외부로 전달할 경우, A의 실행 컨텍스트가 종료된 이후에도 변수 a가 사라지지 않는 현상을 말합니다.
변수 a를 다 사용했음에도 불구하고 사라지지 않으면 메모리 누수가 발생합니다. 그렇기 때문에 필요성이 사라진 시점에는 참조 카운트를 0으로 만들어 GC의 수집 대상에 포함시키면 됩니다. 참조 카운트를 0으로 만드는 방법은 식별자에 참조형이 아닌 기본형 데이터(보통 null
이나 undefined
)를 할당하면 됩니다.
var outer = function () {
var a = 1;
var inner = function () {
return ++a;
};
return inner;
};
var outer2 = outer();
console.log(outer2());
outer = null; // 기본형 데이터 할당
Q1. 왜
outer
에 기본형 데이터를 할당했을까?outer2
나inner
에 대해 기본형 데이터를 할당했더라면?
Q2. 함수는 데이터 할당을 어떻게 할까?
var fruits = ['apple', 'banana', 'peach'];
var $ul = document.createElement('ul');
fruits.forEach(function (fruit) { // (A)
var $li = document.createElement('li');
$li.innerText = fruit;
$li.addEventListener('click', function () { // (B)
alert(fruit);
});
$ul.appendChild($li);
});
document.body.appendChild($ul);
(B) 함수가 참조할 예정인 변수 fruit에 대해서는 (A)가 종료된 후에도 GC 대상에서 제외되어 계속 참조 가능합니다. 그러나 (B) 함수의 쓰임새가 콜백 함수에 국한되지 않는 경우라면 반복을 줄이기 위해 외부로 분리하는 편이 나을 수도 있을 것입니다 .
...
var alertFruit = function (fruit) { // (B)
alert(fruit);
};
fruits.forEach(function (fruit) { // (A)
...
$li.addEventListener('click', alertFruit);
...
});
...
alertFruit(fruits[1]);
그런데 여기서 li
를 클릭하면 과일명이 아닌 [object MouseEvent]라는 값이 출력됩니다. 콜백 함수인 (B)의 인자에 대한 제어권을 addEventListener
가 가진 상태이며, 이 함수는 콜백 함수를 호출할 때 첫 번째 인자에 '이벤트 객체'를 주입하기 때문입니다.
이 문제는 bind
메서드를 활용하여 넘겨줄 인자를 직접 지정할 수 있습니다.
...
fruits.forEach(function (fruit) { // (A)
...
$li.addEventListener('click', alertFruit.bind(null, fruit));
...
});
...
다만 bind
함수는 첫 번째 인자(새로 바인딩할 this)는 필수로 입력해야하기 때문에 원래의 this
와 달라질 수 있다는 점, 이벤트 객체가 인자로 넘어오는 순서가 바뀌는 점(2번째 인자로 출력) 점은 감안해야 합니다.
이런 변경사항이 발생하지 않게끔 하기 위해서는 bind
메서드가 아닌 고차함수를 활용하면 됩니다.
...
var alertFruitBuilder = function (fruit) { // (B)
return function () {
alert(fruit); // (C)
};
};
fruits.forEach(function (fruit) { // (A)
...
$li.addEventListener('click', alertFruitBuilder(fruit));
...
});
...
고차함수란 함수를 인자로 받거나 함수를 리턴하는(위 alertFruitBuilder
함수와 같은) 함수입니다.
alertFruitBuilder
함수를 실행하면서 fruit
값을 인자로 전달했고 실행 결과는 다시 함수(C)가 되며 이는 addEventListener
에 콜백 함수로 전달될 것입니다.
이후 언젠가 클릭 이벤트가 발생하면 비로소 함수 (C)의 실행 컨텍스트가 열리면서 인자로 넘어온 fruit
를 outerEnvironmentReference
에 의해 참조할 수 있을 것입니다. 즉 alertFruitBuilder
의 실행 결과로 반환된 함수에는 클로저가 존재합니다.
정보 은닉(information hiding)은 어떤 모듈의 외부 노출을 최소화해서 모듈간의 결합도를 낮추고 유연성을 높이고자 하는 중요한 개념 중 하나입니다. 흔히 public
, private
, protected
로 접근 권한을 부여하지만 Javascript에서는 기본적으로 변수 자체에 이러한 권한을 직접 부여하도록 설계되어 있지 않습니다.
하지만 방법이 없진 않죠! 클로저를 이용하면 함수 차원에서 public
한 값과 private
한 값을 구분하는 것은 가능합니다.
var car = {
fuel: Math.ceil(Math.random() * 10 + 10), // 연료 (L)
power: Math.ceil(Math.random() * 3 + 2), // 연비 (km/L)
moved: 0, // 총 이동거리
run: function () {
var km = Math.ceil(Math.random() * 6); // 랜덤한 주사위 숫자만큼 거리 이동
var wasteFuel = km / this.power; // 소요되는 연료 계산
if (this.fuel < wasteFuel) { // 현재 연료보다 많이 들어가면 "총 이동거리" 출력 후 종료
console.log('이동불가');
return;
}
this.fuel -= wasteFuel; // 이동이 가능하다면 연료 소진 후
this.moved += km; // 이동거리 증가
console.log(km + 'km 이동 (총 ' + this.moved + 'km)');
}
};
위와 같이 자동차 경주 게임을 만든다고 생각해봅시다. car
변수에 객체를 직접 할당했습니다. fuel
과 power
는 무작위로 생성하고, moved
라는 프로퍼티에 총 이동거리를 부여했으며, run
메서드를 실행할 때마다 car
객체의 fuel
, moved
값이 변하게 했습니다.
하지만 fuel
과 moved
, power
의 값을 변경할 수 있는, 조작할 수 있는 방법이 존재합니다.
car.fuel = 10000;
car.power = 100;
car.moved = 1000;
이렇게 게임의 핵심적인 값들을 직접 변경할 수 있으면 게임의 존재 이유가 사라지겠죠. 이런 값들을 방어하기 위해선 객체가 아닌 함수로 만들고, 필요한 멤버만을 return 하게 한다면 공평하게 게임을 진행할 수 있을 것입니다.
var createCar = function () {
var fuel = Math.ceil(Math.random() * 10 + 10); // 연료 (L)
var power = Math.ceil(Math.random() * 3 + 2); // 연비 (km/L)
var moved = 0; // 총 이동거리
return {
get moved () { // getter 만을 부여 (읽기 전용 속성)
return moved;
},
run: function () {
var km = Math.ceil(Math.random() * 6);
var wasteFuel = km / power;
if (fuel < wasteFuel) {
console.log('이동불가');
return;
}
fuel -= wasteFuel;
moved += km;
console.log(km + 'km 이동 (총 ' + moved + 'km). 남은 연료: ' + fuel);
}
};
};
var car = createCar();
그럼 아래와 같이 fuel
, power
, moved
에 대한 값을 변경하지 못하지만 run
에 대한 값은 여전히 변경이 가능합니다.
car.fuel = 1000;
console.log(car.fuel); // 1000
car.run() // 3km 이동(총 3km), 남은 연료: 17.4
car.run = function () { console.log('changed'); };
car.run(); // 'changed'
이런 어뷰징까지 막기 위해서는 Object.freeze
를 사용하여 return 하기 전에 변경할 수 없게끔 조치를 취해야 합니다.
var createCar = function () {
...
var publicMembers = {
get moved () { // getter 만을 부여 (읽기 전용 속성)
return moved;
},
run: function () {
...
}
};
Object.freeze(publicMembers);
return publicMembers;
};
var car = createCar();
freeze 된 객체를 car
에 할당했기 때문에 car
에 할당된 값(run 함수)은 더이상 변경(추가, 수정 및 삭제)이 불가합니다. 이 정도면 충분히 안전한 객체가 되었습니다. 내용을 정리하자면 아래와 같습니다.