Ray Book
실행 컨텍스트와 스코프

클로저

함수가 반환된 후에도 살아있는 스코프 — 클로저의 원리와 실용 패턴을 시각화합니다

javascriptclosurescopefunction

스코프는 사라지지 않을 수 있다

이전 글에서 함수의 스코프는 함수 실행이 끝나면 사라진다고 했습니다. 하지만 항상 그렇지는 않습니다.

function makeCounter() {
  let count = 0;
  return function increment() {
    count++;
    return count;
  };
}

const counter = makeCounter();

makeCounter()는 실행이 끝나고 반환합니다. 하지만 반환된 increment 함수가 count를 참조하고 있습니다. 이 경우 count가 속한 스코프는 사라지지 않습니다.

이것이 클로저 (Closure) 입니다.

클로저의 정의

클로저는 함수와 그 함수가 선언된 렉시컬 환경의 조합이다. — MDN

좀 더 실용적으로 말하면:

클로저 = 함수 + 그 함수가 접근할 수 있는 외부 변수

함수가 외부 스코프의 변수를 참조하면, 그 스코프는 함수가 살아있는 한 가비지 컬렉터에 의해 회수되지 않습니다.

클로저 동작 과정

아래 시각화에서 makeCounter가 반환된 후에도 스코프가 유지되는 과정을 단계별로 확인하세요.

코드
1function makeCounter() {
2 let count = 0;
3 return function increment() {
4 count++;
5 return count;
6 };
7}
8const counter = makeCounter();
9counter(); // 1
10counter(); // 2
스코프
Global
makeCounterfunction
makeCounter()
count0
makeCounter()가 호출되면 count = 0이 생성됩니다.

핵심:

  • makeCounter()가 반환된 후에도 count 변수의 스코프가 살아있습니다
  • counter()를 호출할 때마다 같은 count에 접근합니다 — 복사본이 아니라 원본
  • 스코프 옆의 CLOSURE 표시가 클로저로 유지되는 스코프를 나타냅니다

각 호출은 독립된 클로저를 만든다

같은 함수를 여러 번 호출하면 매번 독립된 스코프 가 생성됩니다.

코드
1function makeCounter() {
2 let count = 0;
3 return function() { return ++count; };
4}
5const a = makeCounter();
6const b = makeCounter();
7a(); // 1
8a(); // 2
9b(); // 1 ← a와 독립!
스코프
Global
afunction
makeCounter() #1 스코프CLOSURE
count0
캡처된 변수
acount (#1)
첫 번째 makeCounter() 호출로 a가 생성됩니다. a는 자신만의 count (#1) 를 클로저로 캡처합니다.

ab는 각각 자신만의 count를 가집니다. 이것이 클로저가 강력한 이유입니다 — 함수 호출마다 독립된 상태 를 만들 수 있습니다.

클로저는 변수를 캡처한다, 값이 아니라

흔한 실수:

function makeCallbacks() {
  const callbacks = [];
  for (var i = 0; i < 3; i++) {
    callbacks.push(function() {
      console.log(i);
    });
  }
  return callbacks;
}

const fns = makeCallbacks();
fns[0](); // 3 (0이 아님!)
fns[1](); // 3
fns[2](); // 3

세 함수 모두 같은 i 변수 를 클로저로 캡처합니다. i의 현재 을 캡처하는 것이 아니라 변수 자체를 캡처하므로, 루프가 끝난 후 i = 3을 세 함수 모두 보게 됩니다.

해결법 — let을 사용하면 반복마다 새 스코프가 생깁니다:

for (let i = 0; i < 3; i++) {
  callbacks.push(function() {
    console.log(i); // 각각 0, 1, 2
  });
}

실용 패턴

데이터 은닉

function createUser(name) {
  let loginCount = 0;

  return {
    getName() { return name; },
    login() { loginCount++; },
    getLoginCount() { return loginCount; },
  };
}

const user = createUser("Ray");
user.login();
user.login();
user.getLoginCount(); // 2
// user.loginCount → undefined (외부에서 직접 접근 불가)

loginCount는 클로저 안에 감춰져 있어 외부에서 직접 접근하거나 변경할 수 없습니다. 오직 반환된 메서드를 통해서만 상호작용할 수 있습니다.

부분 적용

function multiply(a) {
  return function(b) {
    return a * b;
  };
}

const double = multiply(2);
const triple = multiply(3);

double(5);  // 10
triple(5);  // 15

multiply(2)를 호출하면 a = 2가 클로저에 캡처됩니다. 반환된 함수는 b만 받으면 됩니다. 이 패턴을 커링 (currying) 이라고도 합니다.

메모이제이션

function memoize(fn) {
  const cache = new Map();
  return function(...args) {
    const key = JSON.stringify(args);
    if (cache.has(key)) return cache.get(key);
    const result = fn(...args);
    cache.set(key, result);
    return result;
  };
}

const expensiveCalc = memoize((n) => {
  // 오래 걸리는 계산...
  return n * n;
});

expensiveCalc(42); // 계산 실행
expensiveCalc(42); // 캐시에서 즉시 반환

cache가 클로저 안에 유지되므로, 반환된 함수가 이전 결과를 기억합니다.

V8에서의 클로저

엔진 시리즈에서 다뤘던 V8의 관점에서 보면:

  • 클로저가 캡처하는 변수는 Context 라는 별도의 객체에 저장됩니다
  • 캡처되지 않는 변수는 스택에 있다가 함수 반환 시 사라집니다
  • V8은 컴파일 시점에 어떤 변수가 클로저에 의해 캡처되는지 분석하여, 필요한 변수만 Context에 할당합니다 — 모든 변수를 유지하지 않습니다

클로저와 메모리

클로저는 스코프를 유지하므로 메모리를 사용합니다. 주의할 점:

  • 더 이상 필요 없는 클로저의 참조를 null로 설정하면 GC가 스코프를 회수할 수 있습니다
  • 이벤트 리스너에 클로저를 등록했다면 removeEventListener로 정리하세요
  • 같은 스코프의 다른 클로저가 큰 변수를 참조하고 있으면, 의도치 않게 메모리가 유지될 수 있습니다

다음 단계

클로저는 스코프를 이해한 뒤에야 자연스럽게 이해됩니다. 다음 글에서는 또 다른 혼란의 원인인 호이스팅 을 실행 컨텍스트의 관점에서 정확하게 다루겠습니다 — var, let, const, function의 호이스팅이 왜 각각 다르게 동작하는지 살펴봅니다.