자바스크립트 스코프와 클로저 - 반복문 var 버그와 상태 감추기 (JS 중급 9단원)
이 단원에서 배우는 것
초급 8단원까지 변수와 자료형, 함수, 배열과 객체, DOM 과 이벤트를 다뤘다. 거기까지는 "문법이 이렇게 생겼고 이렇게 쓰면 된다"였다. 중급은 "왜 그렇게 동작하는가"로 넘어가는 구간이고, 그 첫 관문이 스코프와 클로저다. 이 단원부터 16단원까지는 사내 도서 대출 서비스 하나를 계속 손본다. 도서(book)·회원(member)·대출(loan) 세 가지 데이터가 매 단원 그대로 이어진다. 기준 환경은 ES2022 이상(Node.js 18 이상, 2022년 이후 브라우저)이다.
- 스코프 체인이 선언된 위치로 결정된다는 것과, 그래서 클로저가 무엇을 붙잡는지 설명한다.
- 대출번호 발급기·조회 캐시처럼 클로저가 실제로 쓰이는 자리를 직접 만든다.
var반복문에서 콜백이 전부 같은 값을 보는 고전 버그의 원인을 짚고 세 가지 방법으로 고친다.
왜 필요한가
도서 대출 시스템에서 대출번호를 발급한다고 하자. 가장 먼저 떠오르는 코드는 이렇다.
let loanSeq = 0;
function nextLoanId() {
loanSeq += 1;
return 'L2026-' + String(loanSeq).padStart(4, '0');
}
동작은 한다. 문제는 loanSeq 가 밖에 노출돼 있다는 점이다. 같은 파일 어디서든 loanSeq = 0 을 할 수 있고, 나중에 반납번호 발급기를 추가하면 returnSeq, reserveSeq 가 줄줄이 전역에 쌓인다. 누가 언제 그 값을 건드렸는지 추적하려면 파일 전체를 읽어야 한다.
필요한 건 함수만 밖으로 내보내고 그 함수가 쓰는 상태는 안에 가두는 것이다. 자바스크립트에는 이것을 위한 별도 문법이 없다. 대신 함수가 자기가 만들어진 자리의 변수를 계속 붙잡고 있다는 성질을 쓴다. 이 성질을 클로저라고 부른다. 정의를 외워서 쓸모가 생기는 개념이 아니라, 위 문제를 풀다 보면 자연스럽게 도달하는 도구다.
문법과 예제
스코프는 호출 위치가 아니라 선언 위치로 정해진다
변수를 찾을 때 자바스크립트는 "지금 실행 중인 함수 안 → 그 함수가 적혀 있던 바깥 → 그 바깥 → … → 전역" 순서로 올라간다. 이 사슬을 스코프 체인이라 한다. 중요한 건 적혀 있던이다. 어디서 호출됐는지는 아무 상관이 없다.
const branch = '본관';
function printBranch() {
console.log(branch); // 선언 위치 기준으로 바깥을 본다
}
function run() {
const branch = '분관'; // 이건 printBranch 와 무관하다
printBranch();
}
run(); // '본관'
printBranch 는 run 안에서 호출됐지만 run 의 branch 는 보이지 않는다. printBranch 가 적힌 곳의 바깥은 전역이기 때문이다. 이 규칙을 렉시컬 스코프라고 한다. 다음 단원에서 다룰 this 는 정반대로 호출 방식에 따라 달라지는데, 두 규칙이 다르다는 점이 혼란의 출발점이다.
클로저: 함수가 바깥 변수를 데리고 다닌다
이제 발급기를 함수 안에 가둔다.
function createLoanIdGenerator(prefix) {
let seq = 0; // 밖에서 접근할 방법이 없다
return function () {
seq += 1;
return `${prefix}-${String(seq).padStart(4, '0')}`;
};
}
const nextLoanId = createLoanIdGenerator('L2026');
console.log(nextLoanId()); // L2026-0001
console.log(nextLoanId()); // L2026-0002
console.log(nextLoanId()); // L2026-0003
const nextReserveId = createLoanIdGenerator('R2026');
console.log(nextReserveId()); // R2026-0001 ← 서로 다른 seq 를 가진다
createLoanIdGenerator 는 이미 끝났는데 seq 는 왜 살아 있는가. 반환된 함수가 seq 를 참조하고 있으니 그 변수를 담고 있던 환경이 함께 유지되기 때문이다. 그리고 createLoanIdGenerator 를 두 번 호출하면 환경도 두 개 생긴다. nextLoanId 와 nextReserveId 의 seq 는 완전히 별개다.
여러 함수가 하나의 환경을 공유한다
반환값을 객체로 만들면 여러 함수가 같은 상태를 공유하되 밖에서는 그 상태에 손댈 수 없다.
function createLoanDesk(limit) {
const loans = new Map(); // bookId -> memberId
return {
borrow(bookId, memberId) {
if (loans.size >= limit) throw new Error(`대출 한도 ${limit}건 초과`);
if (loans.has(bookId)) throw new Error(`${bookId} 는 이미 대출 중`);
loans.set(bookId, memberId);
return loans.size;
},
giveBack(bookId) {
return loans.delete(bookId);
},
get count() {
return loans.size;
},
list() {
return [...loans.entries()].map(([bookId, memberId]) => ({ bookId, memberId }));
}
};
}
const desk = createLoanDesk(2);
desk.borrow('B001', 'M01');
desk.borrow('B003', 'M01');
console.log(desk.count); // 2
console.log(desk.loans); // undefined ← 밖에서는 보이지 않는다
try {
desk.borrow('B005', 'M02');
} catch (e) {
console.log(e.message); // 대출 한도 2건 초과
}
desk.loans 가 undefined 인 것이 핵심이다. 상태를 읽는 방법은 count 와 list() 뿐이고, 바꾸는 방법은 borrow 와 giveBack 뿐이다. 한도 검사를 건너뛸 수 있는 경로가 없다. 11단원에서 배울 class 의 #private 필드가 같은 일을 문법으로 해 주지만, 클로저 쪽이 더 오래됐고 지금도 라이브러리 내부에서 흔하다.
실무형 예제: 조회 캐시
도서 상세 조회가 느리다고 하자. 같은 bookId 를 반복 조회하는 화면이라면 캐시를 붙인다. 캐시 저장소가 전역에 있으면 테스트마다 오염되므로, 클로저 안에 둔다.
function withCache(loader, { maxSize = 100 } = {}) {
const cache = new Map();
let hit = 0;
let miss = 0;
function get(key) {
if (cache.has(key)) {
hit += 1;
return cache.get(key);
}
miss += 1;
const value = loader(key);
if (cache.size >= maxSize) {
cache.delete(cache.keys().next().value); // 가장 오래된 것부터 버린다
}
cache.set(key, value);
return value;
}
get.stats = () => ({ hit, miss, size: cache.size });
get.clear = () => { cache.clear(); hit = 0; miss = 0; };
return get;
}
const BOOK_DB = {
B001: { id: 'B001', title: '자바스크립트 완벽 가이드', stock: 2 },
B002: { id: 'B002', title: '리팩터링', stock: 0 }
};
const findBook = withCache((id) => {
console.log(` [DB 조회] ${id}`);
return BOOK_DB[id] ?? null;
});
findBook('B001');
findBook('B001');
findBook('B002');
console.log(findBook.stats()); // { hit: 1, miss: 2, size: 2 }
Map 은 삽입 순서를 유지하므로 cache.keys().next().value 가 가장 먼저 들어온 키다. 함수도 객체이므로 get.stats 처럼 프로퍼티를 붙일 수 있다.
실무에서 자주 틀리는 것
1. var 반복문 안에서 만든 콜백이 전부 마지막 값을 본다
도서 목록에 버튼을 달거나 타이머를 거는 코드에서 매번 나온다.
const bookIds = ['B001', 'B002', 'B003'];
const handlers = [];
for (var i = 0; i < bookIds.length; i++) {
handlers.push(function () { return bookIds[i]; });
}
console.log(handlers.map(h => h()));
// [ undefined, undefined, undefined ] ← i 가 전부 3 이다
원인은 var 가 함수 단위 변수라서다. 반복이 세 번 돌아도 i 는 하나뿐이고, 세 함수가 모두 그 하나를 붙잡는다. 반복이 끝난 시점의 i 는 3 이므로 셋 다 3 을 본다.
여기서 오해하기 쉬운 지점이 있다. 클로저가 붙잡는 것은 값이 아니라 변수 자체다. 함수를 만들 때의 값을 복사해 두는 게 아니라, 실행하는 순간 그 변수를 다시 읽는다. 그래서 나중에 바뀐 값이 보인다.
고치는 방법은 세 가지다.
// (1) let — 반복마다 새 바인딩이 생긴다. ES6 이후로는 이게 정답이다.
for (let i = 0; i < bookIds.length; i++) {
handlers.push(() => bookIds[i]);
}
// (2) 즉시 실행 함수로 값을 인자에 복사 — let 이 없던 시절 관용구. 오래된 코드에서 만난다.
for (var j = 0; j < bookIds.length; j++) {
(function (captured) {
handlers.push(function () { return bookIds[captured]; });
})(j);
}
// (3) 애초에 반복문을 쓰지 않는다 — 14단원에서 다룬다.
const handlers3 = bookIds.map((id) => () => id);
주의할 점: let 이 통하는 것은 for (let i = ...) 처럼 반복문 헤더에서 선언했을 때다. for (const id of bookIds) 도 마찬가지로 안전하다. 하지만 반복문 바깥에서 let 으로 선언하고 안에서 대입만 하면 var 와 똑같이 깨진다.
2. 클로저가 큰 데이터를 계속 붙잡아 메모리가 안 빠진다
클로저는 자기가 참조하는 바깥 변수를 살려 둔다. 콜백을 어딘가에 등록해 두면 그 콜백이 참조하는 것들도 함께 산다.
function startLoanPolling(bookList) { // bookList 가 수만 건이라고 하자
const timerId = setInterval(() => {
console.log(`감시 중: ${bookList.length}건`);
}, 1000);
return () => clearInterval(timerId); // 정지 함수를 반드시 돌려준다
}
const stop = startLoanPolling(bigBookList);
// ... 화면을 떠날 때
stop(); // 이걸 부르지 않으면 bigBookList 는 영원히 회수되지 않는다
브라우저에서는 addEventListener 로 등록한 핸들러도 같다. 등록만 하고 removeEventListener 를 안 하면, 그 핸들러가 붙잡은 DOM 노드와 데이터가 그대로 남는다. 콜백을 등록하는 함수를 만들 때는 해제 함수를 반환하는 것을 습관으로 삼는다. 그리고 콜백 안에서 정말 필요한 값만 참조한다. 위 예에서 길이만 쓴다면 const total = bookList.length 로 숫자만 붙잡는 편이 낫다.
3. 선언 전에 접근하면 undefined 가 아니라 에러다
let 과 const 도 호이스팅은 된다. 다만 선언문에 도달하기 전까지는 접근이 금지된 구간(TDZ, temporal dead zone)에 있다.
function report() {
console.log(varCount); // undefined (var 는 이렇게 나온다)
console.log(letCount); // ReferenceError: Cannot access 'letCount' before initialization
var varCount = 1;
let letCount = 2;
}
이게 오히려 좋은 성질이다. var 는 오타로 선언 전에 읽어도 undefined 로 조용히 넘어가고, 문제가 한참 뒤에 터진다. let 은 그 자리에서 멈춘다. 중급부터는 var 를 쓸 이유가 없다. 재할당하면 let, 안 하면 const 다.
4. 클로저를 반복 호출 안에서 새로 만든다
// 나쁨: 렌더링마다 캐시가 새로 생겨 캐시 역할을 전혀 못 한다
function renderBookRow(id) {
const findBook = withCache(loadBookFromDb); // ← 매번 새 Map
return findBook(id);
}
// 좋음: 캐시는 한 번만 만든다
const findBook = withCache(loadBookFromDb);
function renderBookRow(id) {
return findBook(id);
}
클로저를 만드는 함수(createXxx, withXxx)는 모듈 최상단이나 초기화 시점에 한 번만 호출한다. 호출할 때마다 상태가 새로 생긴다는 것이 클로저의 장점이자 함정이다.
스스로 확인하기
- 아래 코드의 출력은 무엇이며, 왜 그런가.
const make = () => { let n = 0; return { up: () => ++n, get: () => n }; }; const a = make(); const b = make(); a.up(); a.up(); b.up(); console.log(a.get(), b.get()); - 도서별 대출 횟수를 세는
createLoanCounter()를 만들어라.count('B001')을 부르면 그 도서의 누적 횟수를 1 늘리고 늘어난 값을 반환하고,count.top()은 가장 많이 대출된bookId를 반환한다. 내부 저장소는 밖에서 보이면 안 된다. - 다음 코드는
['B001', 'B002', 'B003']을 1초 간격으로 출력하려 했으나undefined만 세 번 나온다. 원인을 한 문장으로 쓰고,var를 그대로 둔 채 고쳐라.const ids = ['B001', 'B002', 'B003']; for (var i = 0; i < ids.length; i++) { setTimeout(() => console.log(ids[i]), (i + 1) * 1000); }
정답
2 1.make()를 두 번 호출했으므로n을 담은 환경도 두 개 만들어졌다.a와b는 서로 다른n을 붙잡는다. 같은make함수에서 나왔다는 사실은 상태 공유와 무관하다.function createLoanCounter() { const counts = new Map(); function count(bookId) { const next = (counts.get(bookId) ?? 0) + 1; counts.set(bookId, next); return next; } count.top = () => { let bestId = null; let best = -1; for (const [id, n] of counts) { if (n > best) { best = n; bestId = id; } } return bestId; }; return count; } const count = createLoanCounter(); count('B001'); count('B003'); count('B001'); console.log(count.top()); // B001- 원인:
var i는 반복문 전체에서 변수 하나뿐이라 세 콜백이 같은i를 붙잡고, 타이머가 실행될 때 그 값은 이미 3 이다.var를 유지하며 고치려면 값을 인자로 복사한다.
실무에서는for (var i = 0; i < ids.length; i++) { (function (idx) { setTimeout(() => console.log(ids[idx]), (idx + 1) * 1000); })(i); }var를let으로 바꾸는 것이 정답이다. 이 문제는 즉시 실행 함수 관용구를 읽을 수 있는지 확인하기 위한 것이다.