클로저 — 함수가 붙잡고 있는 변수

함수가 끝났는데도 그 안의 변수가 살아 있는 이유. 카운터·모듈·이벤트 핸들러가 전부 이것으로 돈다.

Seobway · · 8분

왜 이게 필요한가

함수가 끝나면 그 안의 변수는 사라진다. 그런데 이건 안 사라진다.

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

const next = makeCounter();
console.log(next()); // 1
console.log(next()); // 2   ← makeCounter 는 이미 끝났는데?

makeCounter() 는 첫 줄에서 끝났다. 그런데 count 는 계속 살아서 값을 기억한다.
왜 안 사라지는가가 이 글의 전부다.


어떻게 동작하는가

스코프에서 봤듯 함수는 자기가 쓰여 있는 자리의 바깥을 본다.
그 "바깥"은 함수가 만들어질 때 정해져서, 함수와 함께 붙어 다닌다.

그래서 makeCounter 의 실행은 끝나도, 돌려준 화살표 함수가 count 를 붙잡고 있는 한
count 를 담은 상자는 버려지지 않는다. 자바스크립트 엔진은 아무도 참조하지 않는 것만
정리하기 때문이다.

인터랙티브 다이어그램: makeCounter 가 끝나도 count 가 남는 이유 (단계별로 재생할 수 있다)

  1. makeCounter() 를 호출한다. 콜 스택에 올라간다.
  2. 그 안에서 count = 0 을 담을 스코프가 만들어진다.
  3. 화살표 함수를 만들어 돌려준다. 이 함수는 위 스코프를 붙잡은 채로 나간다.
  4. makeCounter 는 끝나 콜 스택에서 사라진다. 그런데 스코프는 남는다.
  5. next() 를 부르면 붙잡아 둔 count 를 그대로 쓴다.
  6. 다시 부르면 같은 count 가 이어서 증가한다.

여기서 헷갈리기 쉬운 것 하나. 한 번 호출 = 한 개의 상자다.

const a = makeCounter();
const b = makeCounter();
a(); a();   // 1, 2
b();        // 1   ← a 와 무관하다

makeCounter 를 부를 때마다 새 스코프가 만들어지므로 ab 는 서로 다른 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 사양

관련 글