클로저에 대하여
이상한 Javascript 문제
1function makeCounter() {
2 let count = 0;
3
4 return function () {
5 return ++count;
6 };
7}
8
9const counter = makeCounter();
10counter(); // 1
11counter(); // 2
12counter(); // 3
13위의 코드에서 makeCounter는 이미 실행이 끝났습니다.
counter를 또 호출한다면, count 변수가 다시 선언되어 항상 같은 결과가 출력될 것 같습니다.
하지만 counter()를 호출할 때마다 count가 계속 증가합니다. 왜 이렇게 동작하는걸까요?
이 문제를 해결하려면, 클로저를 이해해야 합니다.
클로저(Closure)란?
클로저란, 함수가 선언된 시점의 주변환경(Lexical Environment)을 기억하는 현상을 말합니다.
좀 더 풀어서 설명하면, 클로저는 자신의 스코프 안에 없는 변수라도 스코프 체인을 통해 알 수 있는 변수라면, 선언 시점에 해당 변수를 기억합니다. 그리고 이렇게 기억된 변수는 클로저에 의한 참조가 유지되는 한 가비지 컬렉터에 의해 수집되지 않습니다.
MDN에서는 클로저를 "함수와 함수가 선언될 당시의 주변환경(LexicalEnvironment)의 조합" 이라고 표현합니다.
코드로 이해하기
1function func1() {
2 // 이 변수가 클로저에 의해 참조되는 변수.
3 let a = 1;
4
5 function func2() {
6 console.log(++a);
7 }
8
9 return func2;
10}
11
12const test = func1();
13
14test(); // 2
15test(); // 3
16func1은 이미 실행이 끝났습니다. 그런데 왜 a가 소멸되지 않을까요?
핵심은 여기 있습니다.
test는func2를 참조하고 있습니다.func2는func1의 실행 컨텍스트에 있는 변수a를 참조하고 있습니다.- 값을 참조하는 변수가 남아있다면, 가비지 컬렉터는 해당 값을 소멸시키지 않습니다.
따라서 func1의 실행이 끝나도 a는 살아있고, test()를 호출할 때마다 a의 값이 유지됩니다.
실전 예제
예제 1: 카운터 — 상태 은닉
클로저를 사용하면 외부에서 직접 접근할 수 없는 변수를 만들 수 있습니다.
1const counter = function () {
2 let count = 0;
3
4 function changeCount(number) {
5 count += number;
6 }
7
8 return {
9 increase: function () {
10 changeCount(1);
11 },
12 decrease: function () {
13 changeCount(-1);
14 },
15 show: function () {
16 console.log(count);
17 },
18 };
19};
20
21const counterClosure = counter();
22counterClosure.increase();
23counterClosure.show(); // 1
24counterClosure.decrease();
25counterClosure.show(); // 0
26count변수는 외부에서 직접 접근할 수 없습니다.- 오직
increase,decrease,show를 통해서만 간접적으로 접근 가능합니다. increase,decrease,show함수들이count와changeCount에 대한 클로저입니다.- 이렇게 클로저를 통해 상태 은닉을 구현할 수 있습니다.
예제 2: 잘못된 이벤트 바인딩 — 클로저 함정
클로저를 이해하지 못하면 흔히 빠지는 함정입니다.
1<button id="target1">button</button>
2<button id="target2">button</button>
3<button id="target3">button</button>
4<button id="target4">button</button>
5<button id="target5">button</button>
6
7<script>
8 for (var i = 1; i <= 5; i++) {
9 document.getElementById('target' + i).onclick = function () {
10 console.log(i); // 항상 6이 출력된다!
11 };
12 }
13</script>
14어떤 버튼을 눌러도 6이 출력됩니다. 이유는 이렇습니다.
- 호이스팅에 의해, 실행 컨텍스트를 생성할 때 변수 i가 끌어올려집니다.
- 콜백함수 안의 i는 끌어올려진 변수 i를 참조합니다. 모든 버튼의 콜백에서 같은 변수를 참조하는 상황입니다.
- 버튼을 눌러 콜백이 호출되는 시점엔, 변수 i에는 6이 할당되어 있습니다. 따라서 어떤 버튼을 누르건 같은 변수를 참조하고, 항상 6이 출력됩니다.
- 각각의 콜백에서 바인딩 시점의 i 값을 사용하게 하려면 어떻게 해야 할까요?
해결 방법: let 사용
1for (let i = 1; i <= 5; i++) {
2 document.getElementById('target' + i).onclick = function () {
3 console.log(i); // 각각 1, 2, 3, 4, 5 출력
4 };
5}
6- var 대신 let을 사용하면 됩니다.
- var과 다르게 let은 블록 스코프입니다. for 루프에서 let을 사용하는 경우, 반복마다 독립된 i를 갖게 됩니다.
- 따라서, 각각의 콜백은 독립된 i를 클로저로 기억하게 됩니다.
- 덕분에 각 콜백은 서로 다른 i 값을 출력하게 됩니다.
예제 3: 디바운스
클로저를 사용하면 디바운스(debounce)를 쉽게 구현할 수 있습니다. 디바운스 함수는 타이머가 종료되어야 콜백을 호출하고, 그 전에 디바운스 함수가 재호출되면 타이머를 리셋시킵니다. 이렇게 하여 콜백이 빈번하게 호출되는 문제를 방지할 수 있습니다. 주로 자동완성 기능을 구현할 때 사용합니다.
1const input = document.querySelector('input');
2const div = document.querySelector('div');
3
4const debounce = function (eventName, callback, wait) {
5 let timeoutId = null;
6
7 return function (event) {
8 console.log('debounce invoked >>> ', eventName);
9 clearTimeout(timeoutId); // 클로저가 기억하는 timeoutId 초기화
10 timeoutId = setTimeout(() => callback(event), wait);
11 };
12};
13
14const onKeyDown = function (e) {
15 console.log('onKeyDown >>> ', e);
16 div.innerText = e.target.value;
17};
18
19// 이벤트 이름, 콜백, 대기 시간을 미리 넘겨서 부분 적용 함수를 만든다
20input.addEventListener('keydown', debounce('keydown', onKeyDown, 500));
21- 반환된 함수는
timeoutId와eventName을 클로저로 기억합니다. - 키 입력이 500ms 내에 반복되면 타임아웃을 초기화하므로, 빈번한 콜백 호출을 막습니다.
- 이렇게 클로저를 사용하면 전역변수를 사용하지 않고도 디바운스를 쉽게 구현할 수 있습니다.
클로저 메모리 관리
클로저는 변수를 계속 메모리에 유지합니다. 더 이상 사용하지 않는 클로저는 참조를 끊어줘야 합니다.
1let test = func1();
2test();
3test();
4
5test = null; // 참조를 끊어 GC가 수집할 수 있도록 한다
6정리
- 클로저는 함수 선언 시점의 주변환경(LexicalEnvironment)을 기억하는 현상입니다.
- 클로저가 살아 있는 한, 클로저가 기억하는 주변환경은 가비지 컬렉터에 의해 수집되지 않습니다.
- 상태 은닉, 이벤트 바인딩, 디바운스 등 다양한 패턴에 활용됩니다.
- 사용이 끝난 클로저는 참조를 끊어 메모리 누수를 방지해야 합니다.
마치며
이번 포스트에선 클로저에 대해 알아보았습니다. 클로저는 자바스크립트의 스코프와 실행 컨텍스트를 이해하는 데 있어 핵심적인 개념입니다. 긴 글 읽어주셔서 감사합니다.