← 글 목록

notes

24장. 클로저

모던 JS Deep Dive — 클로저의 렉시컬 환경·상태 은닉 핵심 정리.

참고: 이웅모 《모던 자바스크립트 Deep Dive》 24장 · [MDN Closures](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Closures)

함수가 자기 정의 위치의 환경을 들고 다니는 현상. “끝날 때 다 죽는다”가 아니라, 누군가 참조하는 환경 레코드는 산다. 23의 외부 렉시컬 환경 참조가 주인공이다.

flowchart LR
  DEF[함수 정의 평가] --> ENV["[[Environment]] = 그 위치 LE"]
  CALL[나중에 호출] --> SEARCH[식별자 검색]
  SEARCH --> ENV
  ENV --> OUTER[외부 환경으로 이어짐]

24.1 렉시컬 스코프

식별자 해석은 정의 위치의 스코프 체인을 따른다. 호출 위치가 아니다. 클로저는 이 규칙의 자연스러운 결과다.

const x = 1;
function outer() {
  const x = 10;
  function inner() {
    console.log(x); // 10
  }
  return inner;
}
const f = outer();
f(); // 여전히 10 — 호출 장소가 전역이어도

24.2 함수 객체의 [[Environment]]

함수가 평가·생성될 때, 자신이 태어난 렉시컬 환경을 [[Environment]] 내부 슬롯에 저장한다. 호출 시 함수 실행 컨텍스트의 “외부 렉시컬 환경 참조”가 이 슬롯을 본다.

그래서 함수는 코드 + 출생지의 환경 포인터다.

24.3 클로저와 렉시컬 환경

좁은 정의: 외부 함수보다 오래 살아남아 외부 상태를 참조하는 함수. 넓은 감각: 모든 함수는 [[Environment]]를 가지므로 “닫혀 있다”. 다만 실무에서 말하는 클로저는 외부 지역이 수명 연장되는 패턴이다.

function makeCounter() {
  let n = 0;
  return function () {
    n += 1;
    return n;
  };
}
const c1 = makeCounter();
const c2 = makeCounter();
console.log(c1(), c1(), c2()); // 1 2 1 — 환경이 각각

WHY: n은 makeCounter가 끝난 뒤에도 c1/c2가 붙잡아 산다. 두 카운터는 다른 환경.

24.4 클로저의 활용

  • 콜백·이벤트 핸들러가 바깥 변수 사용
  • 함수형: partial / curry / once
  • 모듈 패턴(14): 비공개 상태 + 공개 API
  • React 등에서 이펙트·핸들러가 props/state를 캡처 (stale closure 주의)
function once(fn) {
  let called = false;
  let result;
  return function (...args) {
    if (!called) {
      called = true;
      result = fn.apply(this, args);
    }
    return result;
  };
}
// 부분 적용
function multiply(a, b) { return a * b; }
function partial(fn, a) {
  return function (b) { return fn(a, b); };
}
const double = partial(multiply, 2);
console.log(double(5)); // 10

24.5 캡슐화와 정보 은닉

외부에서 직접 못 만지는 상태를 클로저로 가둔다. class #private(25) 이전의 고전 무기.

const Person = (function () {
  let age = 0; // 약식 — 인스턴스별이 아님에 주의
  return function (name) {
    this.name = name;
    this.getAge = () => age;
    this.setAge = (n) => { age = n; };
  };
})();

인스턴스마다 비공개가 필요하면 생성자 안에서 let을 두고 메서드를 그 스코프에 닫거나, WeakMap/# 필드를 쓴다.

function Person(name) {
  let age = 0;
  this.name = name;
  this.getAge = () => age;
  this.setAge = (n) => { age = n; };
}

24.6 자주 발생하는 실수

var 루프 + 클로저

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
// 3 3 3 — i 하나
for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
// 0 1 2 — 반복별 바인딩

과도한 캡처·누수

큰 객체를 클로저가 붙잡으면 GC가 못 걷는다. 핸들러 제거·nulling·약한 참조(WeakRef 노트) 검토.

stale closure

오래된 값을 닫아 둔 콜백이 최신 state를 못 봄. 의존 배열·ref·함수형 업데이트로 해결하는 패턴이 프레임워크마다 있다.

예제 — 왜 이렇게 쳐 봤냐

function createStore(initial) {
  let state = initial;
  const listeners = new Set();
  return {
    getState: () => state,
    setState: (next) => {
      state = typeof next === 'function' ? next(state) : next;
      listeners.forEach((l) => l(state));
    },
    subscribe: (l) => {
      listeners.add(l);
      return () => listeners.delete(l);
    },
  };
}

WHY: state·listeners가 클로저로 숨고, API만 공개. 미니 스토어.

헷갈리던 장면 / 함정

  • 클로저 = 버그 라는 미신 — 도구다. 실수는 수명·공유·stale
  • 화살표/일반 함수 this와 클로저를 한 문제로 섞기
  • “모든 외부 변수를 복사한다” — 참조로 환경을 붙잡음
  • 디버거에서 Closure 스코프에 불필요한 값이 보이는 것 = 캡처 단서

표

질문 → 자답

Q. 클로저를 없앨 수 있나? A. 함수가 있는 한 [[Environment]]는 있다. “안 쓰는” 건 외부 상태를 캡처하지 않는 설계.

Q. 메모리 누수인가? A. 필요 없는 참조를 붙잡으면 누수. 필요한 상태 유지면 정상.

Q. class private과 차이? A. #은 인스턴스별 필드 문법. 클로저는 스코프 기반. 둘 다 은닉 수단.

웹 보강 메모

MDN Closures의 “practical closures”와 루프 예제를 다시 치고, 레포에서 addEventListener 클로저·구독 해제가 짝인지 검색했다.

실무로 바로

  • 루프 콜백: let/const
  • 구독·리스너: unsubscribe 반환
  • 큰 데이터: 필요한 스칼라만 캡처
  • 모듈 경계: ESM + 클로저/#

“모든 함수가 클로저” vs “실무 클로저”

스펙 감각으로는 함수마다 [[Environment]]가 있으니 닫혀 있다. 대화·코드리뷰에서 클로저라고 부르는 지점은 대개 외부 지역 상태가 수명 연장되며 유용/위험한 패턴이다. 둘 다 틀리지 않다. 문맥을 밝히자.

반환하지 않아도 클로저

function setup(btn) {
  let clicks = 0;
  btn.addEventListener('click', () => {
    clicks += 1;
    console.log(clicks);
  });
}

리스너가 살아 있는 한 setup의 clicks 환경도 산다. return이 없어도 클로저.

커링 스케치

const curry2 = (fn) => (a) => (b) => fn(a, b);
const add = (a, b) => a + b;
const add10 = curry2(add)(10);
console.log(add10(5)); // 15

각 화살표가 바깥 a를 붙잡는다. 일급(18) + 클로저.

디버깅 팁

  • 개발자 도구 Scope에 Closure로 보이는 객체 = 붙잡은 환경
  • 불필요한 키가 보이면 캡처 범위를 줄일 단서
  • 리스너 누수 의심 시 getEventListeners(브라우저) / 구독 카운트

정보 은닉 선택지 비교

여기서 잠깐 💁‍♂️

25 class는 프로토타입·은닉·상속을 문법으로 묶는다. 클로저는 그 아래 수명 모델이다.