클로저는 자바스크립트에서 이름만 들어도 겁먹는 단어 1위쯤 된다. 그런데 막상 정체를 알고 나면 별거 아니고, 오히려 매일 나도 모르게 쓰고 있었다는 걸 깨닫는다. 나는 클로저를 어렵게 설명한 글만 여러 개 읽다가 더 헷갈렸는데, 결국 작은 코드를 직접 돌려보고 나서야 "아 이거였어" 하고 풀렸다. 그래서 이번 편은 설명을 짧게 하고 코드를 많이 돌린다. 여기 나오는 결과는 전부 실제로 실행해 찍은 것이다.


클로저는 함수가 자기 바깥 변수를 기억하는 것이다. 함수 안에서 또 함수를 만들면, 안쪽 함수는 자기를 감싼 바깥 함수의 변수를 쓸 수 있다. 여기까지는 당연해 보인다. 놀라운 건 바깥 함수가 실행을 끝내고 사라진 뒤에도, 안쪽 함수는 그 변수를 계속 기억한다는 점이다. 안쪽 함수가 바깥 변수를 감싸 안고 나온다고 해서 클로저(closure, 감싸 안음)라 부른다. 말로는 아리송하니 바로 코드로 보자.


카운터가 값을 기억한다. 숫자를 세는 함수를 만든다. 바깥 함수 안에 count를 두고, 그 값을 올려 돌려주는 안쪽 함수를 반환한다.


function makeCounter() {
let count = 0;
return function() {
count += 1;
return count;
};
}
const counter = makeCounter();
console.log(counter());
console.log(counter());
console.log(counter());


실행 결과다.


1
2
3


여기가 클로저의 핵심이다. makeCountercounter를 만들어 주고 이미 실행이 끝났다. 보통 함수가 끝나면 그 안의 지역 변수는 사라진다. 그런데 count는 안 사라지고 1, 2, 3으로 계속 이어진다. 반환된 안쪽 함수가 count를 감싸 안고 나왔기 때문이다. counter가 살아 있는 한 count도 그 함수만 아는 곳에 살아남아 값을 유지한다.


클로저는 호출마다 따로 생긴다. makeCounter를 두 번 부르면 각각 자기만의 count를 가진 독립된 클로저가 만들어진다. 서로 값을 공유하지 않는다.


const a = makeCounter();
const b = makeCounter();
console.log(a());
console.log(a());
console.log(b());


실행 결과다.


1
2
1


a를 두 번 불러 2까지 갔는데, b를 처음 부르면 다시 1부터다. acountbcount가 완전히 다른 변수라는 뜻이다. 함수를 부를 때마다 새 방이 하나씩 생기고, 그 방의 변수는 그 방에서 나온 함수만 안다. 이 독립성 덕에 클로저로 상태를 여러 개 안전하게 굴릴 수 있다.


바깥에서 못 건드리는 비공개 상태를 만든다. 클로저의 진짜 쓸모가 여기 있다. 변수를 함수 안에 가둬두면, 정해준 창구로만 값을 바꿀 수 있고 바깥에서 직접 손댈 수가 없다. 잔액을 다루는 계좌를 보자.


function makeAccount(start) {
let balance = start;
return {
deposit(amount) { balance += amount; return balance; },
getBalance() { return balance; }
};
}
const acc = makeAccount(1000);
console.log(acc.deposit(500));
console.log(acc.getBalance());
console.log(acc.balance);


실행 결과다.


1500
1500
undefined


depositgetBalance는 클로저라 balance를 읽고 바꿀 수 있다. 그런데 바깥에서 acc.balance로 직접 꺼내려 하면 undefined가 나온다. balance는 반환된 객체의 속성이 아니라 함수 안에 갇힌 변수라, 밖에서는 존재조차 보이지 않는다. 잔액을 함부로 못 바꾸게 잠그면서 정해진 메서드로만 다루게 하는 것, 이게 클로저로 만드는 비공개 상태다.


함수를 찍어내는 공장을 만든다. 바깥 변수를 다르게 주면서 안쪽 함수를 여러 개 만들면, 서로 다른 값을 기억하는 함수들이 나온다. 곱하기 함수를 찍어내 보자.


function multiplier(factor) {
return function(n) {
return n * factor;
};
}
const double = multiplier(2);
const triple = multiplier(3);
console.log(double(10));
console.log(triple(10));


실행 결과다.


20
30


doublefactor가 2인 걸, triple은 3인 걸 각자 기억한다. 같은 multiplier에서 나왔지만 감싸 안은 값이 달라 다르게 동작한다. 비슷한 함수를 조금씩 다르게 여러 개 만들 때 이 패턴이 아주 요긴하다. 세율을 다르게 붙이는 계산기, 접두어를 다르게 다는 문자열 함수처럼, 뼈대는 같고 설정값만 다른 함수들을 이렇게 한 틀에서 찍어낸다. 매번 비슷한 함수를 손으로 복사해 쓰던 걸 클로저 하나로 접는 셈이다.


반복문 속 클로저는 옛날 문법에서 사고를 쳤다. 클로저를 배울 때 반드시 만나는 함정이 있다. 반복문에서 함수를 여러 개 만들어 담고 나중에 부르면, 기대와 다른 값이 나오던 문제다. 옛날 변수 선언인 var로 짜보자.


const funcs = [];
for (var i = 0; i < 3; i++) {
funcs.push(function() { return i; });
}
console.log(funcs[0](), funcs[1](), funcs[2]());


실행 결과다.


3 3 3


0, 1, 2를 기대했는데 셋 다 3이 나온다. var로 만든 i는 반복문 전체가 공유하는 변수 하나라, 세 함수가 전부 같은 i를 감싸 안았다. 반복이 다 끝난 뒤 i는 3이 돼 있고, 그 하나를 셋이 똑같이 들여다보니 다 3이다. 나는 이 버그를 처음 만났을 때 컴퓨터가 고장 난 줄 알았다. 함수를 만든 시점의 i가 박제될 거라 착각한 건데, 클로저는 값을 복사해 두는 게 아니라 변수 자체를 계속 들여다본다는 걸 몰라서 생긴 오해였다.


let으로 바꾸면 해결된다. 반복마다 변수를 새로 만드는 let을 쓰면, 각 함수가 그 회차의 독립된 변수를 감싸 안는다.


const funcs = [];
for (let j = 0; j < 3; j++) {
funcs.push(function() { return j; });
}
console.log(funcs[0](), funcs[1](), funcs[2]());


실행 결과다.


0 1 2


이번엔 0, 1, 2가 제대로 나온다. let은 반복 회차마다 j를 새로 만들어서, 첫 함수는 0인 j를, 둘째는 1인 j를 각각 따로 감싸 안는다. 예전엔 이 함정을 피하려고 온갖 우회 기법을 썼는데, 지금은 var 대신 let을 쓰면 그냥 풀린다. 이것 하나만으로도 let을 기본으로 쓸 이유가 충분하다.


정리하면 클로저는 안쪽 함수가 자기를 감싼 바깥 변수를 기억하고, 바깥 함수가 끝난 뒤에도 그 변수를 살려두는 성질이다. 카운터처럼 값을 이어가고, 계좌처럼 상태를 잠그고, 곱셈기처럼 함수를 찍어내는 게 전부 이 하나의 원리다. 어렵게 느껴지면 작은 코드를 직접 돌려 값이 어떻게 이어지는지 눈으로 보는 게, 설명 열 줄보다 빠르다.