makeCounter()가 이미 실행을 끝냈는데, 반환된 함수가 count 변수에 어떻게 접근할 수 있는 걸까요?

함수가 자신이 생성된 스코프의 변수를 "기억"하는 이유는 자바스크립트 엔진이 함수 객체에 렉시컬 환경 참조를 저장하기 때문입니다.

클로저란

클로저(Closure)는 함수와 그 함수가 선언된 렉시컬 환경(Lexical Environment)의 조합 입니다.

쉽게 말하면, 함수가 자신이 만들어진 스코프의 변수를 나중에도 기억하고 접근할 수 있는 것 입니다.

JS
function outer() {
  const message = '안녕하세요';

  function inner() {
    console.log(message); // outer의 변수에 접근
  }

  return inner;
}

const greeting = outer(); // outer 실행 종료
greeting(); // '안녕하세요' — outer가 끝났는데도 message에 접근 가능

outer()가 실행 종료되어 콜 스택에서 제거되었지만, 반환된 inner 함수가 message를 참조하고 있으므로 outer의 렉시컬 환경은 가비지 컬렉션되지 않고 유지됩니다.

렉시컬 환경과 클로저의 관계

자바스크립트 엔진이 함수를 생성할 때, 그 함수 객체에 [[Environment]]라는 내부 슬롯 이 생깁니다. 이 슬롯에 함수가 선언된 시점의 렉시컬 환경 참조가 저장됩니다.

JS
function outer() {
  let count = 0;

  // inner 생성 시 [[Environment]]에 outer의 렉시컬 환경이 저장됨
  function inner() {
    count++;
    return count;
  }

  return inner;
}

inner가 호출될 때의 스코프 체인을 그려보면 이렇습니다.

PLAINTEXT
inner의 렉시컬 환경
  → outer의 렉시컬 환경 (count: 0) ← [[Environment]]로 참조
    → 전역 렉시컬 환경

이 참조 때문에 outer가 종료되어도 렉시컬 환경이 살아 있고, count에 접근할 수 있습니다.

클로저의 활용 패턴

1. 팩토리 함수

같은 로직이지만 설정이 다른 함수를 생성합니다.

JS
function createMultiplier(factor) {
  // factor를 기억하는 클로저
  return function (number) {
    return number * factor;
  };
}

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

console.log(double(5));  // 10
console.log(triple(5));  // 15

doubletriple은 각각 다른 렉시컬 환경을 참조하므로 factor가 다릅니다.

2. 캡슐화 (정보 은닉)

자바스크립트에는 private 키워드가 없지만(클래스 # 필드 제외), 클로저로 유사한 효과를 낼 수 있습니다.

JS
function createCounter() {
  let count = 0; // 외부에서 직접 접근 불가

  return {
    increment() {
      return ++count;
    },
    decrement() {
      return --count;
    },
    getCount() {
      return count;
    }
  };
}

const counter = createCounter();
console.log(counter.increment()); // 1
console.log(counter.increment()); // 2
console.log(counter.getCount());  // 2
console.log(counter.count);       // undefined — 직접 접근 불가

count는 클로저를 통해서만 접근할 수 있으므로, 외부에서 마음대로 변경할 수 없습니다.

3. 커링 (Currying)

여러 인자를 받는 함수를 하나씩 받는 함수의 체인으로 변환합니다.

JS
// 일반 함수
function add(a, b) {
  return a + b;
}

// 커링된 함수 — 클로저 활용
function curriedAdd(a) {
  return function (b) {
    return a + b; // a를 클로저로 기억
  };
}

const add5 = curriedAdd(5);
console.log(add5(3)); // 8
console.log(add5(7)); // 12

4. 이벤트 핸들러에서의 상태 유지

JS
function setupButton(buttonId) {
  let clickCount = 0;

  document.getElementById(buttonId).addEventListener('click', () => {
    clickCount++; // 클로저로 clickCount 유지
    console.log(`${buttonId} 클릭 횟수: ${clickCount}`);
  });
}

setupButton('btn-a');
setupButton('btn-b');
// 각 버튼이 독립적인 clickCount를 가짐

반복문과 클로저 — 스코프 차이가 드러나는 핵심 사례

문제: var와 setTimeout

JS
for (var i = 0; i < 3; i++) {
  setTimeout(function () {
    console.log(i);
  }, 1000);
}
// 출력: 3, 3, 3

var는 함수 스코프이므로 루프 전체에서 하나의 i를 공유 합니다. setTimeout 콜백이 실행되는 시점에는 루프가 이미 끝나서 i는 3입니다.

해결 1: let 사용

JS
for (let i = 0; i < 3; i++) {
  setTimeout(function () {
    console.log(i);
  }, 1000);
}
// 출력: 0, 1, 2

let은 블록 스코프이므로 매 반복마다 새로운 바인딩 이 생깁니다. 각 콜백이 자신만의 i를 캡처합니다.

해결 2: IIFE로 클로저 생성

ES2015 이전에는 이렇게 해결했습니다.

JS
for (var i = 0; i < 3; i++) {
  (function (j) {
    setTimeout(function () {
      console.log(j);
    }, 1000);
  })(i); // 현재 i를 j로 복사
}
// 출력: 0, 1, 2

IIFE가 호출될 때마다 새로운 스코프가 생기고, j에 현재 i 값이 복사됩니다.

해결 3: setTimeout의 세 번째 인자

JS
for (var i = 0; i < 3; i++) {
  setTimeout(function (j) {
    console.log(j);
  }, 1000, i); // 세 번째 인자가 콜백의 파라미터로 전달됨
}
// 출력: 0, 1, 2

세 가지 해결법은 모두 "각 반복에서 별도의 스코프를 만든다"는 공통 원리를 가집니다.

클로저와 메모리

메모리 누수 주의점

클로저가 참조하는 렉시컬 환경은 가비지 컬렉션되지 않습니다. 불필요한 참조가 남아 있으면 메모리 누수가 발생할 수 있습니다.

JS
function createHeavyClosure() {
  // 큰 데이터
  const largeData = new Array(1000000).fill('데이터');

  return function () {
    // largeData를 사용하지 않지만 렉시컬 환경에 있으므로 참조됨
    console.log('hello');
  };
}

const fn = createHeavyClosure();
// largeData가 해제되지 않음 — 엔진 최적화에 따라 다를 수 있음

모던 자바스크립트 엔진(V8 등)은 실제로 참조하는 변수만 유지 하는 최적화를 수행합니다. 하지만 eval을 사용하거나 디버거가 붙어 있으면 최적화가 무효화될 수 있습니다.

메모리 누수를 방지하는 방법

JS
function setupHandler() {
  let cache = loadExpensiveData();

  const handler = function (event) {
    // cache 사용
    process(cache, event);
  };

  // 핸들러 제거 시 참조도 해제
  return {
    handler,
    cleanup() {
      cache = null; // 명시적으로 참조 해제
    }
  };
}

const { handler, cleanup } = setupHandler();
element.addEventListener('click', handler);

// 나중에 정리
element.removeEventListener('click', handler);
cleanup();

DOM 참조와 클로저

JS
function attachHandler() {
  const element = document.getElementById('button');

  element.addEventListener('click', function () {
    // element를 클로저로 참조 — DOM 노드가 해제되지 않을 수 있음
    console.log(element.textContent);
  });
}

이벤트 핸들러 내에서 DOM 요소를 참조하면, 해당 요소가 DOM에서 제거되어도 가비지 컬렉션되지 않을 수 있습니다. removeEventListener로 정리하는 습관이 중요합니다.

실무에서 자주 보는 클로저 패턴

디바운스 (Debounce)

JS
function debounce(fn, delay) {
  let timerId = null; // 클로저로 타이머 ID 유지

  return function (...args) {
    clearTimeout(timerId);
    timerId = setTimeout(() => {
      fn.apply(this, args);
    }, delay);
  };
}

// 검색 입력에 디바운스 적용
const debouncedSearch = debounce((query) => {
  console.log(`검색: ${query}`);
}, 300);

메모이제이션 (Memoization)

JS
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.apply(this, args);
    cache.set(key, result);
    return result;
  };
}

const expensiveCalc = memoize((n) => {
  console.log('계산 중...');
  return n * n;
});

expensiveCalc(5); // '계산 중...' → 25
expensiveCalc(5); // 캐시에서 반환 → 25

모듈 패턴

ES 모듈이 없던 시절, 클로저로 모듈을 구현했습니다.

JS
const Module = (function () {
  // private 변수와 함수
  let privateVar = 0;

  function privateMethod() {
    return privateVar++;
  }

  // public API
  return {
    increment: privateMethod,
    getValue() {
      return privateVar;
    }
  };
})();

Module.increment();
console.log(Module.getValue()); // 1
console.log(Module.privateVar); // undefined

주의할 점

의도하지 않은 변수 캡처로 인한 메모리 누수

클로저가 참조하는 렉시컬 환경에 큰 데이터가 포함되어 있으면, 클로저가 살아 있는 한 해당 데이터도 GC 대상이 되지 않습니다. 모던 엔진(V8)은 실제로 참조하는 변수만 유지하는 최적화를 수행하지만, eval이나 디버거가 개입하면 최적화가 무효화될 수 있습니다.

DOM 이벤트 핸들러에서의 순환 참조

이벤트 핸들러 내에서 DOM 요소를 클로저로 참조하면, 해당 요소가 DOM에서 제거되어도 GC되지 않을 수 있습니다. removeEventListener로 핸들러를 해제하는 습관이 중요합니다.

반복문 + var에서 하나의 변수를 공유하는 문제

var는 함수 스코프이므로 루프 전체에서 하나의 변수를 공유합니다. setTimeout 같은 비동기 콜백과 결합하면 의도와 다른 결과가 나옵니다. let으로 선언하거나, IIFE로 각 반복의 값을 캡처하면 해결됩니다.

정리

항목설명
클로저란함수와 그 함수가 선언된 렉시컬 환경의 조합
동작 원리함수 객체의 [[Environment]] 슬롯이 렉시컬 환경을 참조
활용 패턴팩토리, 캡슐화, 커링, 디바운스, 메모이제이션, 모듈 패턴
메모리 주의불필요한 참조가 남으면 GC 대상이 되지 않음
반복문 해결let 사용, IIFE, setTimeout 세 번째 인자
댓글 로딩 중...