JavaScript 클로저가 상태를 보존하는 원리

JavaScript 클로저가 상태를 보존하는 원리

클로저는 함수가 선언된 위치의 변수 환경을 기억하는 성질이다. 콜백과 훅, 모듈 패턴에서 자연스럽게 사용되지만 상태가 예상보다 오래 살아 있거나 오래된 값을 참조하는 문제도 만든다.

한눈에 보기

  • 함수는 호출 위치가 아니라 선언 위치의 렉시컬 환경을 참조한다.
  • 클로저로 비공개 상태와 설정을 보존할 수 있다.
  • 오래된 상태를 캡처하는 stale closure와 불필요한 메모리 보존을 주의한다.

목차

함수가 환경을 기억한다

function createCounter() {
  let count = 0;

  return function increment() {
    count += 1;
    return count;
  };
}

const counter = createCounter();
counter(); // 1
counter(); // 2

createCounter 호출은 끝났지만 반환된 함수가 count를 참조하므로 해당 환경은 유지된다. 전역 변수 없이 각 counter가 독립적인 상태를 갖는다.

상태를 캡슐화하는 예

function createApiClient(baseUrl, token) {
  return async function request(path) {
    return fetch(baseUrl + path, {
      headers: { Authorization: "Bearer " + token },
    });
  };
}

호출할 때마다 baseUrl과 token을 전달하지 않아도 된다. 의존성을 명시적으로 주입하면서 외부에서 직접 바꾸지 못하게 할 수 있다.

오래된 값을 참조하는 문제

비동기 콜백은 등록 당시의 값을 기억한다. React effect나 타이머에서 상태가 바뀌었는데 콜백은 과거 값을 계속 보는 문제가 대표적이다.

해결 전에 의도를 확인한다

최신 값이 필요한지, 등록 당시의 스냅샷이 필요한지 먼저 정한다. 무조건 ref나 전역 상태로 바꾸면 시간에 따른 의미가 더 불분명해질 수 있다.

반복문에서 클로저가 보여주는 차이

var는 함수 스코프를 사용하므로 반복문이 만든 콜백들이 같은 바인딩을 공유한다.

const callbacks = [];

for (var index = 0; index < 3; index += 1) {
  callbacks.push(() => index);
}

console.log(callbacks.map((callback) => callback()));
// [3, 3, 3]

콜백이 실행될 때 반복문은 끝났고 공유하는 index 값은 3이다. let은 반복마다 새 렉시컬 바인딩을 만들어 기대한 결과를 낸다.

const callbacks = [];

for (let index = 0; index < 3; index += 1) {
  callbacks.push(() => index);
}

console.log(callbacks.map((callback) => callback()));
// [0, 1, 2]

이 예제의 핵심은 let을 외우는 것이 아니라 콜백이 어느 바인딩을 기억하는지 추적하는 것이다.

팩토리 함수로 의존성을 보존하기

클로저는 작은 모듈을 만들 때도 유용하다. 다음은 실제 프로젝트와 무관한 가상의 재시도 client다.

export function createJsonClient({ baseUrl, fetchImpl, logger }) {
  async function request(path, options = {}) {
    const response = await fetchImpl(new URL(path, baseUrl), {
      ...options,
      headers: {
        accept: "application/json",
        ...options.headers,
      },
    });

    if (!response.ok) {
      logger.warn("request failed", { path, status: response.status });
      throw new Error(`HTTP ${response.status}`);
    }

    return response.json();
  }

  return { request };
}

request 함수는 baseUrl, fetchImpl, logger를 기억한다. 전역 singleton을 사용하지 않아 테스트에서는 가짜 의존성을 주입할 수 있다.

const calls = [];
const client = createJsonClient({
  baseUrl: "https://example.invalid",
  fetchImpl: async (url) => {
    calls.push(url.toString());
    return new Response(JSON.stringify({ ok: true }));
  },
  logger: { warn() {} },
});

await client.request("/health");
예제 코드

도메인과 URL은 클로저와 의존성 주입을 설명하기 위한 가상 예시다.

stale closure는 왜 생길까

클로저가 변수를 항상 “옛값으로 복사”하는 것은 아니다. 어떤 바인딩을 캡처했는지와 렌더마다 새로운 함수가 만들어지는지를 함께 봐야 한다.

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      console.log(count);
    }, 1000);

    return () => clearInterval(timer);
  }, []);

  return <button onClick={() => setCount((value) => value + 1)}>{count}</button>;
}

첫 렌더에서 만든 effect 콜백은 그 렌더의 count 바인딩을 참조한다. 의존성 배열이 비어 있어 effect가 다시 등록되지 않으므로 로그에는 초기 값이 계속 보인다.

해결 방법은 목적에 따라 다르다.

메모리 수명도 함께 생각하기

클로저가 큰 객체를 참조하고 그 함수가 이벤트 리스너에 오래 등록되어 있으면 객체도 해제되지 않는다.

function attachPreview(button, largeDocument) {
  const handleClick = () => renderPreview(largeDocument);
  button.addEventListener("click", handleClick);

  return () => {
    button.removeEventListener("click", handleClick);
  };
}

cleanup 함수를 호출하지 않으면 DOM과 largeDocument가 예상보다 오래 유지될 수 있다. 클로저가 나쁜 것이 아니라 클로저를 보유한 객체의 수명을 관리해야 한다.

결론

클로저는 값을 복사해 두는 마법이 아니라 함수와 렉시컬 환경의 연결이다. 상태의 수명과 어떤 시점의 값을 참조해야 하는지 함께 설계한다.

관련 노트