thenullpage.com

함수 안에서 만든 변수는 함수가 끝나면 사라진다고 배웠다. 그런데 분명히 끝난 함수의 변수가 멀쩡히 살아서 값을 기억하고 있는 코드를 보고 한참 어리둥절했다. 클로저라는 단어는 그때 검색해봤고, 설명을 읽어도 무슨 소린지 몰라서 그냥 넘겼다. 정작 이해한 건 그것 때문에 버그를 낸 다음이었다.

끝난 함수의 변수가 계속 살아 있었다. 카운터를 만들어보면 바로 보인다.


function makeCounter() {
let count = 0;
return function () {
count = count + 1;
console.log(count);
};
}
const click = makeCounter();
click(); // 1
click(); // 2
click(); // 3


makeCounterclick을 만드는 그 줄에서 이미 끝났다. 보통이라면 count도 같이 정리돼야 하는데, 돌려받은 안쪽 함수가 count를 쓰고 있어서 자바스크립트가 그 변수를 못 버린다. 함수가 자기가 만들어진 자리의 변수를 계속 붙잡고 있는 이 상태를 클로저라고 부른다.

하나 더 만들면 값도 따로 논다. 부를 때마다 count가 새로 생기기 때문이다.


const a = makeCounter();
const b = makeCounter();
a(); // 1
a(); // 2
b(); // 1, a가 두 번 눌린 것과 상관이 없다


카운터를 두 개 만들었는데 숫자가 같이 올라간다면 count를 함수 밖에 뒀다는 뜻이다. 어디에 선언했는지가 곧 몇 개가 생기는지를 정한다.

클로저는 값을 복사해두는 게 아니다. 그때의 값이 아니라 변수 자체를 본다.


let msg = "처음";
function show() {
console.log(msg);
}
msg = "나중";
show(); // 나중, 함수를 만들 때의 값이 아니다


함수를 만든 시점에 값이 찍혀 들어가는 게 아니라, 실행되는 순간 그 변수를 다시 읽는다. 사용자 정보를 담아둔 변수를 나중에 통째로 갈아끼웠더니 미리 등록해둔 함수들이 전부 새 값을 보고 있어서 놀란 적이 있다. 이 성질을 모르면 바로 다음 함정에 걸린다.

버튼 세 개가 전부 같은 번호를 찍었다. 반복문 안에서 함수를 만들면 나오는 그 유명한 함정이다.


for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// 0 1 2가 아니라 3 3 3이 찍힌다


var로 만든 i는 반복문 블록이 아니라 함수 전체에 하나뿐이다. 함수 세 개가 전부 그 하나를 보고 있고, 0.1초 뒤에 실행될 때 i는 이미 3까지 올라간 뒤다. 각자 다른 값을 기억한 게 아니라 같은 변수를 나눠 본 것이다. let으로 바꾸면 반복마다 i가 새로 생긴다.


for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// 0 1 2


목록을 그리면서 항목 번호를 핸들러 안에 넣어뒀는데 뭘 눌러도 마지막 번호만 나오던 게 딱 이거였다. 옛날 코드에는 함수로 한 번 감싸서 그 순간 값을 가둬두는 방식이 남아 있는데, 지금은 let 한 단어면 끝난다.

바깥에서 아예 못 건드리는 변수를 만들 수 있다. 클로저를 일부러 쓰는 대표적인 자리다.


function createCart() {
let items = [];
return {
add(name) {
if (!name) return items.length; // 검증도 여기서 끝낸다
items.push(name);
return items.length;
},
list() {
return [...items];
},
};
}
const cart = createCart();
console.log(cart.add("키보드")); // 1
console.log(cart.list()); // ["키보드"]
console.log(cart.items); // undefined, 밖에서는 손댈 수 없다


itemscreateCart 안에만 있고, 돌려준 두 함수만 그 배열을 안다. 상태를 객체 속성으로 열어두면 언젠가 어디선가 cart.items = [] 같은 줄이 끼어들어 원인을 찾기 어려워진다. 닫아두면 addlist를 거치지 않고는 바꿀 방법이 없다. list가 원본이 아니라 펼친 새 배열을 돌려주는 것도 같은 이유다.

비슷한 함수를 여러 개 찍어낼 때도 쓴다. 매번 넘기던 인자를 미리 고정해두는 식이다.


function makeLogger(tag) {
return (msg) => console.log("[" + tag + "] " + msg);
}
const logApi = makeLogger("API");
const logAuth = makeLogger("AUTH");
logApi("응답 200"); // [API] 응답 200
logAuth("토큰 만료"); // [AUTH] 토큰 만료


tag는 함수를 만들 때 한 번 정해지고 그 뒤로는 바뀔 일이 없다. 호출할 때마다 태그를 같이 넘기던 코드가 있었는데, 이렇게 미리 박아두니 부르는 쪽은 메시지만 신경 쓰면 됐다. 찍어낸 두 함수는 각자 다른 tag를 기억한다.

타이머 변수를 밖에 꺼내놨다가 서로의 예약을 지웠다. 검색창과 창 크기 계산이 같은 변수를 쓰고 있었다.


function debounce(fn, wait) {
let timer; // 만들 때마다 따로 생긴다
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), wait);
};
}
const => console.log("검색", v), 300);
onSearch("자");
onSearch("자바");
onSearch("자바스크립트");
// 0.3초 뒤에 검색 자바스크립트 한 번만 찍힌다


파일 위쪽에 let timer 하나를 두고 여기저기서 쓰면 한쪽이 예약할 때마다 다른 쪽 예약이 취소된다. debounce로 감싸면 만든 함수마다 자기 timer를 갖는다.

클로저가 메모리를 붙잡고 안 놓기도 한다. 안쪽 함수가 쓰는 변수는 그 함수가 살아 있는 동안 같이 남는다.


function attach() {
const rows = new Array(100000).fill("행 데이터");
const count = rows.length; // 필요한 값만 꺼내둔다
document.querySelector("#btn").addEventListener("click", () => {
console.log(count); // 100000
});
}


핸들러 안에서 rows를 직접 썼다면 버튼이 화면에 있는 내내 십만 칸짜리 배열이 통째로 남는다. 숫자 하나만 꺼내두면 나머지는 정리된다. 클로저가 무거워지는 건 대개 이런 식으로 큰 값을 통째로 가둬둘 때다.

돌아보면 클로저는 특별한 문법이 아니라 규칙 하나였다. 함수는 자기가 불린 자리가 아니라 자기가 만들어진 자리의 변수를 본다. 이것만 붙잡고 있으면 카운터도 은닉도 반복문 함정도 결국 같은 얘기다. 그래서 값이 이상하게 나오면 이 변수가 언제 만들어졌고 지금 몇 개인지부터 센다. 하나뿐인데 여럿이 나눠 보고 있으면 거기가 대체로 범인이었다.