...
클로저(closure) 란?
클로저는 반환된 내부함수가 자신이 선언됐을 때의 환경(Lexical environment)인 스코프를 기억하여, 만일 자신이 선언됐을 때의 환경(스코프) 밖에서 호출되어도 스코프에 접근할 수 있는 함수를 말한다.
function outerFunc() {
var x = 10;
var innerFunc = function () { console.log(x); };
innerFunc();
}
outerFunc(); // 10
- 함수 outerFunc 내에서 내부함수 innerFunc가 선언되고 호출되었다.
- 이때 내부함수 innerFunc는 자신을 포함하고 있는 외부함수 outerFunc의 변수 x에 접근할 수 있다.
- 이는 함수 innerFunc가 함수 outerFunc의 내부에 선언되었기 때문이다.
스코프는 함수를 호출할 때가 아니라 함수를 어디에 선언하였는지에 따라 결정된다.
이를 렉시컬 스코핑(Lexical scoping)라 한다.
위 예제의 함수 innerFunc는 함수 outerFunc의 내부에서 선언되었기 때문에 함수 innerFunc의 상위 스코프는 함수 outerFunc이다.
따라서 함수 innerFunc가 함수 outerFunc의 내부에 선언된 내부함수이므로 함수 innerFunc는 자신이 속한 렉시컬 스코프(전역, 함수 outerFunc, 자신의 스코프)를 참조할 수 있다.
만일 함수 innerFunc가 전역에 선언되었다면 함수 innerFunc의 상위 스코프는 전역 스코프가 된다.
이것을 실행 컨텍스트 의 관점에서 설명해보면 다음과 같다.
1) innerFunc 함수 스코프(함수 자신의 스코프를 가리키는 활성 객체) 내에서 변수 x를 검색한다. 검색이 실패하였다.
2) innerFunc 함수를 포함하는 외부 함수 outerFunc의 스코프(함수 outerFunc의 스코프를 가리키는 함수 outerFunc의 활성 객체)에서 변수 x를 검색한다. 검색이 성공하였다.
이번에는 내부함수 innerFunc를 함수 outerFunc 내에서 호출하는 것이 아니라 반환하도록 변경해 보자.
function outerFunc() {
var x = 10;
var innerFunc = function () { console.log(x); };
return innerFunc;
}
/**
* 함수 outerFunc를 호출하면 내부 함수 innerFunc가 반환된다.
* 그리고 함수 outerFunc의 실행 컨텍스트는 소멸한다.
*/
var inner = outerFunc();
inner(); // 10
함수 outerFunc는 내부함수 innerFunc를 반환하고 생을 마감했다.
즉, 함수 outerFunc는 실행된 이후 콜스택(실행 컨텍스트 스택)에서 제거되었으므로 함수 outerFunc의 변수 x 또한 더이상 유효하지 않게 되어 변수 x에 접근할 수 있는 방법은 달리 없어 보인다.
그러나 위 코드의 실행 결과는 변수 x의 값인 10이다.
이미 life-cycle이 종료되어 실행 컨텍스트 스택에서 제거된 함수 outerFunc의 지역변수 x가 다시 부활이라도 한 듯이 동작하고 있다.
눈에는 보이지 않지만 뭔가 특별한 일이 일어나고 있는 것이다.
이처럼 자신을 포함하고 있는 외부함수보다 내부함수가 더 오래 유지되는 경우, 외부 함수 밖에서 내부함수가 호출되더라도 외부함수의 지역 변수에 접근할 수 있는데 이러한 함수를 클로저(Closure)라고 부르는 것이다.
클로져의 활용
상태 유지
클로져가 가장 유용하게 사용되는 상황은 현재 상태를 기억하고 변경된 최신 상태를 유지하는 것이다.
- closure 라는 이름의 함수를 만들고 toggle 변수에 함수를 실행한 리턴값인 내부 함수를 반환한다.
반환한 함수는 자신이 생성됐을 때의 렉시컬 환경(Lexical environment)에 속한 변수 isShow를 기억하는 클로저다. 클로저가 기억하는 변수 isShow는 box 요소의 표시 상태를 나타낸다. - 클로저를 이벤트 핸들러로서 이벤트 프로퍼티에 할당했다.
이벤트 프로퍼티에서 이벤트 핸들러인 클로저를 제거하지 않는 한 클로저가 기억하는 렉시컬 환경의 변수 isShow는 소멸하지 않는다.
다시 말해 현재 상태를 기억한다. - 버튼을 클릭하면 이벤트 프로퍼티에 할당한 이벤트 핸들러인 클로저가 호출된다.
이때 .box 요소의 표시 상태를 나타내는 변수 isShow의 값이 변경된다.
변수 isShow는 클로저에 의해 참조되고 있기 때문에 유효하며 자신의 변경된 최신 상태를 게속해서 유지한다.
<!DOCTYPE html>
<html>
<body>
<button class="toggle">toggle</button>
<div class="box" style="width: 100px; height: 100px; background: red;"></div>
<script>
var box = document.querySelector('.box');
var toggleBtn = document.querySelector('.toggle');
function closure() {
var isShow = false;
// ① 클로저를 반환
return function () {
box.style.display = isShow ? 'block' : 'none';
// ③ 상태 변경
isShow = !isShow;
};
}
var toggle = closure(); // 클로저 반환
// ② 이벤트 프로퍼티에 클로저를 할당
toggleBtn.onclick = toggle;
</script>
</body>
</html>
이처럼 클로저는 현재 상태(위 예제의 경우 .box 요소의 표시 상태를 나타내는 isShow 변수)를 기억하고 이 상태가 변경되어도 최신 상태를 유지해야 하는 상황에 매우 유용하다.
만약 자바스크립트에 클로저라는 기능이 없다면 상태를 유지하기 위해 전역 변수를 사용할 수 밖에 없다.
그러나 전역 변수는 언제든지 누구나 접근할 수 있고 변경할 수 있기 때문에 많은 부작용을 유발해 오류의 원인이 되므로 사용을 억제해야 한다.
전역 변수의 사용 억제
버튼이 클릭될 때마다 클릭한 횟수가 누적되어 화면에 표시되는 카운터를 만들어보자.
이 예제의 클릭된 횟수가 바로 유지해야할 상태이다.
// 카운트 상태를 유지하기 위한 전역 변수
var counter = 0;
function increase() {
return ++counter;
}
Button.onclick = function () {
count.innerHTML = increase();
};
위 코드는 잘 동작하지만 오류를 발생시킬 가능성을 내포하고 있는 좋지 않은 코드다.
변수 counter는 전역 변수이기 때문에 언제든지 누구나 접근할 수 있고 변경할 수 있다. 이는 의도치 않게 값이 변경될 수 있다는 것을 의미한다.
만약 누군가에 의해 의도치 않게 전역 변수 counter의 값이 변경됐다면 이는 오류로 이어진다.
따라서 변수 counter는 카운터를 관리하는 increase 함수가 관리하는 것이 바람직하다.
전역 변수 counter를 increase 함수의 지역 변수로 바꾸어 의도치 않은 상태 변경을 방지해보자.
function increase() {
// 카운트 상태를 유지하기 위한 지역 변수
var counter = 0;
return ++counter;
}
Button.onclick = function () {
count.innerHTML = increase(); // 무조건 1이 반환 된다
};
전역변수를 지역변수로 변경하여 의도치 않은 상태 변경은 방지했다.
하지만 increase 함수가 호출될 때마다 지역변수 counter를 0으로 초기화하기 때문에 언제나 1이 표시된다.
다시 말해 변경된 이전 상태를 기억하지 못한다.
이전 상태를 기억하도록 클로저를 사용하여 이 문제를 해결해보자.
var increase = (function() { // (function() { ... })(); 즉시실행함수
var counter = 0;
return function(){ // 클로저
return ++counter;
}
})();
Button.onclick = function () {
count.innerHTML = increase(); // 1 .. 2 .. 3
};
스크립트가 실행되면 즉시실행함수(immediately-invoked function expression)가 호출되고,
변수 increase에는 함수 function () { return ++counter; }가 할당된다.
이 함수는 자신이 생성됐을 때의 렉시컬 환경(Lexical environment)을 기억하는 클로저다.
즉시실행함수는 호출된 이후 소멸되지만 즉시실행함수가 반환한 함수는 변수 increase에 할당되어 inclease 버튼을 클릭하면 클릭 이벤트 핸들러 내부에서 호출된다.
이때 클로저인 이 함수는 자신이 선언됐을 때의 렉시컬 환경인 즉시실행함수의 스코프에 속한 지역변수 counter를 기억한다.
따라서 즉시실행함수의 변수 counter에 접근할 수 있고 변수 counter는 자신을 참조하는 함수가 소멸될 때가지 유지된다.
변수 counter는 외부에서 직접 접근할 수 없는 자바의 private 변수와 비슷하므로 전역 변수를 사용했을 때와 같이 의도되지 않은 변경을 걱정할 필요도 없기 때문이 보다 안정적인 프로그래밍이 가능하다.
변수의 값은 누군가에 의해 언제든지 변경될 수 있어 오류 발생의 근본적 원인이 될 수 있다.
상태 변경이나 가변(mutable) 데이터를 피하고 불변성(Immutability)을 지향하는 함수형 프로그래밍에서 부수 효과(Side effect)를 최대한 억제하여 오류를 피하고 프로그램의 안정성을 높이기 위해 클로저는 적극적으로 사용된다.
클로져 실전 예제)
함수형 프로그래밍에서 클로저를 활용
function makeCounter(predicate) {
var counter = 0; // 카운트 상태를 유지하기 위한 자유 변수
// 클로저를 반환
return function () {
counter = predicate(counter);
return counter;
};
}
// 보조 함수
function increase(n) {
return ++n;
}
// 보조 함수
function decrease(n) {
return --n;
}
const increaser = makeCounter(increase);
/*
= function () {
counter = function increase(counter) {
return ++counter;
}
return counter;
};
*/
console.log(increaser()); // 1
console.log(increaser()); // 2
// increaser 함수와는 별개의 독립된 렉시컬 환경을 갖기 때문에 카운터 상태가 연동하지 않는다.
const decreaser = makeCounter(decrease);
console.log(decreaser()); // -1
console.log(decreaser()); // -2
함수 makeCounter는 보조 함수를 인자로 전달받고 함수를 반환하는 고차 함수이다.
함수 makeCounter가 반환하는 함수는 자신이 생성됐을 때의 렉시컬 환경인 함수 makeCounter의 스코프에 속한 변수 counter을 기억하는 클로저다.
위 예제에서 변수 increaser와 변수 decreaser에 할당된 함수는 각각 자신만의 독립된 렉시컬 환경을 갖기 때문에 카운트를 유지하기 위한 자유 변수 counter를 공유하지 않아 카운터의 증감이 연동하지 않는다.
따라서 독립된 카운터가 아니라 연동하여 증감이 가능한 카운터를 만들려면 렉시컬 환경을 공유하는 클로저를 만들어야 한다.
정보의 은닉
생성자 함수 Counter를 생성하고 이를 통해 counter 객체를 만들어보자.
function Counter() {
// 카운트를 유지하기 위한 자유 변수
var counter = 0; // this.counter로 안했기 때문에 new 생성자로 초기화해도 이 변수는 객체에 담기지 않는다.
// 클로저
this.increase = function () {
return ++counter;
};
// 클로저
this.decrease = function () {
return --counter;
};
}
const counter = new Counter(); // counter객체는 increase decrease함수 두개만 갖게 된다.
console.log(counter.increase()); // 1
console.log(counter.decrease()); // 0
생성자 함수 Counter는 increase, decrease 메소드를 갖는 인스턴스를 생성한다.
이 메소드들은 모두 자신이 생성됐을 때의 렉시컬 환경인 생성자 함수 Counter의 스코프에 속한 변수 counter를 기억하는 클로저이며 렉시컬 환경을 공유한다.
생성자 함수가 함수가 생성한 객체의 메소드는 객체의 프로퍼티에만 접근할 수 있는 것이 아니며 자신이 기억하는 렉시컬 환경의 변수에도 접근할 수 있다.
이때 생성자 함수 Counter의 변수 counter는 this에 바인딩된 프로퍼티가 아니라 그냥 변수다.
counter가 this에 바인딩된 프로퍼티라면 생성자 함수 Counter가 생성한 인스턴스를 통해 외부에서 접근이 가능한 public 프로퍼티가 되지만
생성자 함수 Counter 내에서 선언된 변수 counter는 생성자 함수 Counter 외부에서 접근할 수 없다.
생성자 함수 Counter가 생성한 인스턴스의 메소드인 increase, decrease는 클로저이기 때문에 자신이 생성됐을 때의 렉시컬 환경인 생성자 함수 Counter의 변수 counter에 접근할 수 있다.
이러한 클로저의 특징을 사용해 클래스 기반 언어의 private 키워드를 흉내낼 수 있다.
자주 발생하는 클로저 실수
var arr = [];
for (var i = 0; i < 5; i++) {
arr[i] = function () { return i; };
}
for (var j = 0; j < arr.length; j++) {
console.log(arr[j]()); // 5 5 5 5 5
}
배열 arr에 5개의 함수가 할당되고 각각의 함수는 순차적으로 0, 1, 2, 3, 4를 반환할 것으로 기대하겠지만 결과는 그렇지않다.
for 문에서 사용한 변수 i는 전역 변수이기 때문이다.
var i= 110;
for (var i = 0; i < 5; i++) {
}
console.log(i) // 5
이러한 문제를 클로저를 사용해 바르게 동작하는 코드로 만들어보자.
var arr = [];
for (var i = 0; i < 5; i++){
arr[i] = (function (id) { // ②
return function () {
return id; // ③
};
}(i)); // ①
}
for (var j = 0; j < arr.length; j++) {
console.log(arr[j]());
}
- 배열 arr에는 즉시실행함수에 의해 함수가 반환된다.
- 이때 즉시실행함수는 i를 인자로 전달받고 매개변수 id에 할당한 후 내부 함수를 반환하고 life-cycle이 종료된다. 매개변수 id는 자유변수가 된다.
- 배열 arr에 할당된 함수는 id를 반환한다. 이때 id는 상위 스코프의 자유변수이므로 그 값이 유지된다.
위 예제는 자바스크립트의 함수 레벨 스코프 특성으로 인해 for 루프의 초기문에서 사용된 변수의 스코프가 전역이 되기 때문에 발생하는 현상이다.
사실 ES6의 let 키워드를 사용하면 이와 같은 문제는 말끔히 해결된다.
const arr = [];
for (let i = 0; i < 5; i++) {
arr[i] = function () {
return i;
};
}
for (let i = 0; i < arr.length; i++) {
console.log(arr[i]());
}
또는 함수형 프로그래밍 기법인 고차 함수를 사용하는 방법이 있다.
이 방법은 변수와 반복문의 사용을 억제할 수 있기 때문에 에플리케이션의 오류를 줄이고 가독성을 좋게 만든다.
const arr = new Array(5).fill(); // Array.prototype.fill() 인자를 배열 인수로
// [undefined, undefined, undefined, undefined, undefined]
// fill()을 안붙일경우 -> [empty x 5]
arr.forEach((v, i, array) => array[i] = () => i); // 각 배열원소에 클로저 함수(외부 i 참조)를 넣는다.
arr.forEach(f => console.log(f())); // 0 1 2 3 4
// 각 배열원소에 있는 익명함수들이 각각 외부 변수 i를 각각 따로따로 독립적으로 참조하고 있다.
클로저는 함수안의 내부함수로서 외부함수의 변수를 참조 할 수있는 놈이다.
따라서 클로저를 만들려면, 먼저 외부함수를 만들고 변수를 선언한다.
그리고 클로저 함수가 그 변수를 참조하도록 하면, 마치 static 정적변수나 private 변수를 만들수 있게 된다.
여기서 클로저 지역 변수는 함수내에서 선언해도 되고, 아규먼트로 지정해도 된다.
이 글이 좋으셨다면 구독 & 좋아요
여러분의 구독과 좋아요는
저자에게 큰 힘이 됩니다.