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만 받으면 됩니다. 이 패턴을 부분 적용(partial application) 이라고 합니다. 다인자 함수를 단일 인자 함수의 체인으로 변환하는 커링 (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가 클로저 안에 유지되므로, 반환된 함수가 이전 결과를 기억합니다.

실전: React Hooks와 클로저

React의 useState, useEffect 등 Hooks는 클로저를 핵심 메커니즘으로 사용합니다. 컴포넌트 함수가 매번 새로 호출되어도 useState가 이전 상태를 "기억"할 수 있는 이유는, Hook 내부에서 클로저가 React의 내부 상태 배열에 대한 참조를 유지하기 때문입니다. 이로 인해 stale closure (오래된 클로저가 이전 렌더의 값을 참조하는 현상) 라는 독특한 버그 패턴도 발생하므로, useEffect의 의존성 배열을 올바르게 관리하는 것이 중요합니다.

V8에서의 클로저

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

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

클로저와 메모리

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

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

다음 단계

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