클로저와 렉시컬 환경

함수 실행이 끝난 뒤에도 내부 변수가 유지되는 이유. 함수가 정의된 렉시컬 환경에 대한 참조를 유지하기 때문이며, 카운터·모듈 패턴·이벤트 핸들러가 이 구조를 사용한다.

Seobway · · 8분

문제 상황

일반적으로 함수가 반환되면 그 함수의 지역 변수는 더 이상 접근할 수 없다. 다음 코드는 예외다.

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

const next = makeCounter();
console.log(next()); // 1
console.log(next()); // 2   (makeCounter 는 이미 반환됨)

makeCounter() 는 첫 호출에서 반환됐지만 count 는 유지되고, 호출할 때마다 값이 증가한다.


동작 원리

스코프에서 다룬 것처럼 함수의 바깥 스코프는 호출 위치가 아니라 정의 위치로 결정된다.
함수 객체는 생성될 때 자신이 정의된 렉시컬 환경(Lexical Environment)에 대한 참조를
내부 슬롯 [[Environment]] 에 저장한다.

makeCounter 의 실행 컨텍스트는 반환과 함께 콜 스택에서 제거된다. 그러나 반환된 화살표 함수가
makeCounter 호출 시 생성된 환경 레코드(count 가 저장된 곳)를 참조하고 있으므로, 이 환경 레코드는
가비지 컬렉션 대상이 되지 않는다. 가비지 컬렉터는 도달 가능한 참조가 없는 객체만 회수한다.

인터랙티브 다이어그램: makeCounter 반환 후 count 가 유지되는 과정 (단계별로 재생할 수 있다)

  1. makeCounter() 를 호출한다. 실행 컨텍스트가 콜 스택에 push 된다.
  2. 호출과 함께 count = 0 을 저장하는 환경 레코드가 생성된다.
  3. 화살표 함수를 생성해 반환한다. 이 함수의 [[Environment]] 는 위 환경 레코드를 가리킨다.
  4. makeCounter 의 실행 컨텍스트는 콜 스택에서 제거되지만, 환경 레코드는 참조가 남아 유지된다.
  5. next() 를 호출하면 [[Environment]] 를 통해 같은 count 에 접근한다.
  6. 다시 호출하면 같은 count 가 증가한다.

환경 레코드는 호출마다 새로 생성된다.

const a = makeCounter();
const b = makeCounter();
a(); a();   // 1, 2
b();        // 1   (a 와 독립)

makeCounter 를 호출할 때마다 별도의 환경 레코드가 만들어지므로 a 와 b 는 서로 다른 count 를 참조한다.


사용 사례

외부에서 직접 접근할 수 없는 변수를 만든다. 클래스의 private 필드(#)와 비슷한 효과를 낸다.

function makeAccount(initial) {
  let balance = initial;              // 외부에서 직접 접근 불가
  return {
    deposit: (v) => (balance += v),
    get: () => balance,
  };
}

const acc = makeAccount(1000);
acc.deposit(500);
acc.get();        // 1500
acc.balance;      // undefined (반환 객체의 속성이 아님)


예제

makeCounter 를 두 번 호출해 생성된 두 카운터가 독립적으로 동작하는지 확인한다.

import { useState } from "react";

function makeCounter() {
  let count = 0;              // 호출마다 새 환경 레코드에 생성된다
  return () => ++count;
}

const a = makeCounter();
const b = makeCounter();

export default function App() {
  const [log, setLog] = useState([]);
  const push = (m) => setLog((prev) => [...prev, m]);

  return (
    <div>
      <button onClick={() => push(`a() → ${a()}`)}>a 증가</button>{" "}
      <button onClick={() => push(`b() → ${b()}`)}>b 증가</button>{" "}
      <button onClick={() => setLog([])}>지우기</button>
      <ul>{log.map((l, i) => <li key={i}>{l}</li>)}</ul>
    </div>
  );
}

a 를 여러 번 호출해도 b 는 1부터 시작한다. 두 함수가 서로 다른 환경 레코드를 참조하기 때문이다.


자주 하는 실수


정리

클로저는 함수와 그 함수가 정의된 렉시컬 환경의 조합이다. 함수가 환경 레코드를 참조하는 동안
그 안의 변수는 유지되며, 외부 함수를 호출할 때마다 새 환경 레코드가 생성된다.

참고

  1. [1] Closures — MDN Web Docs
  2. [2] Scope — MDN Web Docs 용어 사전
  3. [3] Environment Records — ECMAScript 사양

관련 글