자바스크립트 고급 #1 클로저와 스코프
자바스크립트 고급 시리즈 시작입니다. 기초/중급에서 다룬 도구들을 자유롭게 쓸 수 있게 됐다면, 이제는 그 도구들이 왜 그렇게 동작하는지를 들여다볼 차례입니다.
총 7편으로 구성됩니다.
- #1 클로저와 스코프 ← 이번 글
- #2 this 바인딩과 호출 패턴
- #3 프로토타입 체인
- #4 이벤트 루프와 태스크
- #5 메모리 모델과 GC
- #6 Symbol, WeakRef, Proxy
- #7 모듈 시스템 깊이
이번 글은 자바스크립트의 가장 중요한 개념 중 하나 — 클로저.
스코프란 — 변수가 보이는 범위 #
먼저 스코프(scope) 의 의미를 정리합시다. 스코프는 변수가 어디서 보이는지를 규정합니다.
{
const a = 10;
console.log(a); // 10
}
console.log(a); // ✗ ReferenceError
{와 } 안에서 선언한 변수는 그 안에서만 보입니다. 기초 #2 에서 본 let/const의 블록 스코프가 이것입니다.
함수 스코프 + 블록 스코프 #
자바스크립트는 두 가지 스코프를 가집니다.
- 함수 스코프 —
var와function선언이 따름. 함수 단위로 범위 결정. - 블록 스코프 —
let/const가 따름.{...}단위.
새 코드는 let/const를 쓰니 거의 블록 스코프만 신경 쓰면 됩니다.
렉시컬 스코프 — 어디 적었느냐로 결정 #
자바스크립트는 렉시컬(lexical) 스코프를 따릅니다. 변수가 어디서 보이는지가 함수가 어떻게 호출됐느냐가 아니라 어디 정의됐느냐로 결정됩니다.
const message = '바깥';
function inner() {
console.log(message); // '바깥' — 자기 위치에서 보이는 변수
}
function outer() {
const message = '안쪽';
inner(); // 어디서 호출하든 결과는 같음
}
outer(); // '바깥'
inner가 outer 안에서 호출됐지만, inner의 message는 자기가 정의된 위치에서 보이는 바깥 변수를 가리킵니다. 호출 위치가 아니라 정의 위치가 기준입니다.
클로저 — 함수가 자기 스코프를 들고 다님 #
기초 #4 에서 살짝 봤던 카운터 예제.
function createCounter() {
let count = 0;
return function() {
count = count + 1;
return count;
};
}
const counter = createCounter();
counter(); // 1
counter(); // 2
counter(); // 3
createCounter가 끝났는데도 그 안의 count가 살아 있습니다. 반환된 함수가 자기가 만들어진 환경(스코프)을 끌고 다니기 때문입니다. 이걸 클로저(closure)라 부릅니다.
정확한 정의 #
클로저는 함수와, 그 함수가 만들어진 렉시컬 환경의 조합
함수 자체만이 아니라, 그 함수가 참조하는 외부 변수들까지 묶어서 부르는 이름입니다. 자바스크립트의 모든 함수는 사실 클로저입니다 — 다만 외부 변수를 안 쓰는 함수는 의미가 약할 뿐.
클로저가 만들어 내는 것 — private 상태 #
function createCounter() {
let count = 0;
return {
increment() { count++; return count; },
decrement() { count--; return count; },
get value() { return count; },
};
}
const a = createCounter();
const b = createCounter();
a.increment(); // 1
a.increment(); // 2
b.increment(); // 1 — a와 무관
console.log(a.value); // 2
console.log(b.value); // 1
a.count; // undefined — 외부에서 직접 접근 불가
각 호출마다 새로운 클로저가 만들어져 count를 따로 가집니다. 외부에서 a.count로 접근할 수 없습니다 — 모듈 패턴의 private 상태가 이렇게 만들어집니다.
ES2022의 #필드 (중급 #1) 가 등장하기 전, 자바스크립트의 private은 거의 클로저로 만들어졌습니다.
콜백과 클로저 — 가장 자주 만나는 패턴 #
비동기 콜백, 이벤트 핸들러는 사실상 클로저의 응용입니다.
function attachHandlers(buttons) {
buttons.forEach((btn, i) => {
btn.addEventListener('click', () => {
console.log(`${i}번째 버튼 클릭`);
});
});
}각 핸들러가 자기 차례의 i 값을 기억합니다. 함수가 살아 있는 동안 그 시점의 i도 같이 살아있습니다.
옛날의 함정 — var와 클로저
#
ES2015 이전 자바스크립트의 가장 유명한 함정.
function attachOld(buttons) {
for (var i = 0; i < buttons.length; i++) {
buttons[i].addEventListener('click', function() {
console.log(`${i}번째 버튼`);
});
}
}이 코드는 모든 버튼이 마지막 i 값을 출력합니다. var가 함수 스코프라 모든 콜백이 같은 i를 공유했기 때문입니다.
let으로 바꾸면 해결됩니다.
for (let i = 0; i < buttons.length; i++) {
buttons[i].addEventListener('click', function() {
console.log(`${i}번째 버튼`);
});
}let은 블록 스코프라 매 반복마다 새 i가 만들어져 각 콜백이 다른 i를 캡처합니다. let/const가 등장한 큰 이유 중 하나가 정확히 이 부분이었습니다.
클로저로 만드는 패턴들 #
1) 부분 적용 (Partial Application) #
function add(a, b) {
return a + b;
}
function partial(fn, ...preset) {
return function(...rest) {
return fn(...preset, ...rest);
};
}
const add5 = partial(add, 5);
add5(3); // 8
add5(10); // 15
preset을 클로저가 기억해 둡니다. 함수형 프로그래밍의 흔한 도구입니다.
2) 메모이제이션 (Memoization) #
function memoize(fn) {
const cache = new Map();
return function(arg) {
if (cache.has(arg)) return cache.get(arg);
const result = fn(arg);
cache.set(arg, result);
return result;
};
}
const slowSquare = (n) => {
console.log(`계산: ${n}`);
return n * n;
};
const fastSquare = memoize(slowSquare);
fastSquare(5); // 계산: 5 → 25
fastSquare(5); // 25 (캐시)
fastSquare(3); // 계산: 3 → 9
cache Map이 클로저로 살아남습니다. React의 useMemo, lodash의 memoize 모두 같은 아이디어입니다.
3) 디바운스 / 쓰로틀 #
function debounce(fn, delay) {
let timer;
return function(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
const handleSearch = debounce((query) => {
fetch(`/api/search?q=${query}`);
}, 300);
input.addEventListener('input', (e) => handleSearch(e.target.value));timer 변수가 호출 사이에 살아 있어 이전 타이머를 취소합니다. 입력이 멈춘 뒤 300ms가 지나야 실제 호출이 나갑니다.
함정 — 너무 오래 살아남는 클로저 #
클로저가 외부 변수를 캡처하면 그 변수가 함수가 살아 있는 동안 GC 되지 않습니다.
function setupHeavy() {
const huge = new Array(1_000_000).fill(0); // 큰 배열
return function() {
console.log('hi');
};
}
const fn = setupHeavy(); // huge가 fn에 의해 살아남음 (필요 없는데도)
위 함수는 huge를 사용하지 않는데도 클로저가 캡처해서 메모리에 살아있습니다. 모던 자바스크립트 엔진은 사용하지 않는 변수는 캡처하지 않으려 노력 하지만, 의도적으로 큰 객체를 빠르게 풀어주려면 직접 null로 해 주는 게 안전합니다.
function setupHeavy() {
let huge = new Array(1_000_000).fill(0);
process(huge);
huge = null; // 더 이상 필요 없으면 풀기
return function() { console.log('hi'); };
}이 내용은 #5 메모리 모델 에서 더 자세히 다룹니다.
IIFE 다시 보기 — 옛 모듈 패턴 #
기초 #4 에서 IIFE를 잠시 봤습니다. 클로저 관점에서 다시 보면, 모듈 시스템이 없던 시절의 private 스코프 격리가 이거였습니다.
const counter = (function() {
let count = 0;
return {
increment() { return ++count; },
decrement() { return --count; },
get value() { return count; },
};
})();
counter.increment(); // 1
counter.count; // undefined — 못 봄
ES Modules가 등장한 뒤로는 거의 안 쓰지만, 옛 라이브러리 코드에 자주 등장합니다.
TDZ (Temporal Dead Zone) #
let/const에는 한 가지 흥미로운 동작이 있습니다.
console.log(x); // ✗ ReferenceError
let x = 10;선언 줄 이전에 접근하면 에러가 납니다. var는 undefined 였는데 let은 에러를 던집니다.
이걸 임시 사각 지대(Temporal Dead Zone) 라 부릅니다. 변수가 선언되는 줄 이전에는 “존재하지만 사용 불가” 상태입니다. 실수로 선언 전에 사용하는 사고를 막아 줍니다.
마무리 #
이번 글에서 정리한 내용:
- 스코프 — 변수가 보이는 범위 (블록 / 함수 스코프)
- 자바스크립트는 렉시컬 스코프 — 정의 위치 기준
- 클로저 = 함수 + 그 함수의 렉시컬 환경
- private 상태, 콜백, 부분 적용, 메모이제이션, 디바운스 모두 클로저
var가 만드는 옛 함정 —let이 해결- 클로저는 캡처한 변수의 메모리를 살려둠 — 큰 객체는 명시적으로 풀기
- TDZ —
let/const는 선언 전 접근 시 에러
다음 글(#2 this 바인딩과 호출 패턴)에서는 자바스크립트의 또 다른 헷갈리는 개념 — this가 호출 방식에 따라 어떻게 결정되는지, call/apply/bind의 의미와 화살표 함수의 차이를 다룹니다.