클로저, 단순한 개념

얼마 전 면접에서 클로저의 개념에 대해 질문을 받았다.

프론트엔드 면접에서 단골로 나오는 질문 중 하나지만, 단순히 암기할 개념은 아니라고 생각한다.
질문 자체는 간단해 보이지만, 면접관의 의도를 생각해보면
실제로는 자바스크립트의 실행 컨텍스트, 스코프 체인, 메모리 구조에 대한 이해까지 확인하려는 질문인 경우가 많다.

클로저는 자바스크립트의 실행 컨텍스트와 메모리 관리를 이해하는 데 있어 핵심적인 역할을 한다.

어느 정도 개념은 알고 있었지만, 이번 기회에 클로저가 어떤 구조를 기반으로 동작하고,
실제 코드에서 어떤 이슈를 만들 수 있는지를 구조적으로 정리해보고 싶다.

클로저는 흔히 외부 변수를 기억하는 함수라고 할 수 있다.

그리고 이 구조 때문에 자바스크립트에서는 가비지 컬렉터(GC)가 변수들을 정리하지 못하고 메모리가 남게 되는 경우가 발생한다.

정의나 동작 방식은 익숙할 수 있지만, 실제로 이 개념이 어떤 구조를 통해 가능해지는지,
그리고 React의 상태 관리나 메모리 관리 이슈와 어떻게 연결되는지를 설명하려면 더 깊은 이해가 필요하다.

이번 글에서는 클로저의 핵심인 렉시컬 환경 구조,
그리고 그것이 자바스크립트의 메모리 관리와 React의 useState와 어떤 방식으로 연결되는지를 정리해본다.


렉시컬 환경(Lexical Environment)이란

자바스크립트는 코드가 실행될 때 실행 컨텍스트(Execution Context)를 만들고,
그 내부에 렉시컬 환경(Lexical Environment)을 구성한다.

렉시컬 환경은 다음 두 요소로 구성된다:

  • 환경 레코드(Environment Record): 현재 스코프의 식별자(name → value) 저장소
  • 외부 렉시컬 환경 참조(Outer Lexical Environment Reference): 상위 스코프에 대한 참조
function outer() {
  let count = 10;
  return function inner() {
    console.log(count); // outer의 x에 접근
  };
}

이 구조에서 inner는 자신이 선언될 당시의 외부 환경(outer)을 참조한다.
outer 함수의 실행이 종료된 이후에도 inner 함수가 외부 변수 x를 계속 사용할 수 있는 이유가 여기에 있다.


클로저가 변수를 유지하는 구조

[ inner() ]
 └─ [[Environment]] → outer()의 렉시컬 환경 참조

inner 함수는 외부 스코프를 참조하고 있는 상태로 남는다.
outer 함수는 이미 종료되었지만, 내부 환경은 여전히 메모리에 유지된다.
이는 클로저의 특징이자, 메모리 누수 가능성과 연결되는 부분이다.


클로저 구조 시각화

렉시컬 환경이 어떻게 연결되고 참조가 유지되는지는 시각적으로 보면 더 명확하다.

closure

  • outer 함수 실행 시 count 변수가 포함된 환경이 생성된다.
  • inner 함수는 outer의 환경을 [[Environment]]로 참조하고 있다.
  • 이 참조가 존재하는 한, count는 GC에 의해 수거되지 않고 메모리에 남아 있다.

React useState와 클로저의 관계

React의 useState도 클로저 기반의 동작을 한다.

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

function handleClick() {
  setCount(count + 1);
}

여기서 handleClick 함수는 count 변수를 클로저로 기억한다.
이때 기억된 count는 렌더링 당시의 값이다. 이후 상태가 변경되어도 클로저 내부 값은 갱신되지 않기 때문에,
setTimeout이나 비동기 로직에서 오래된 상태값을 참조하는 문제가 생길 수 있다.

setTimeout(() => {
  setCount(count + 1); // 오래된 count 사용
}, 1000);

함수형 업데이트로 해결

setCount((prev) => prev + 1);

prev는 현재 상태값을 항상 인자로 전달받기 때문에, 클로저가 아닌 최신 상태를 기준으로 동작한다.
이 방식이 보다 안전하다.

useState를 직접 구현해보면?

function createState(initialValue) {
  let value = initialValue;

  function get() {
    return value;
  }

  function set(newValue) {
    value = newValue;
  }

  return [get, set];
}

const [getCount, setCount] = createState(0);

console.log(getCount()); // 0
setCount(getCount() + 1);
console.log(getCount()); // 1

이 구조에서 getCountsetCountvalue를 클로저로 기억하고 있으며,
외부에서는 직접 접근할 수 없다. 즉, React의 useState처럼
상태 값을 은닉하고 클로저로 유지하는 방식이다.


메모리 누수와 클로저

클로저가 메모리 누수와 연결되는 대표적인 사례는 다음과 같다.

function attachEvent() {
  let bigData = new Array(1000000).fill("data");
  document.getElementById("btn").addEventListener("click", () => {
    console.log(bigData[0]);
  });
}

이 코드에서는 이벤트 리스너 내부 함수가 bigData를 클로저로 참조하고 있다.
DOM 요소가 제거되더라도 해당 참조가 남아 있어, GC는 bigData를 수거하지 못한다.

해결 방법

  • 참조 제거: bigData = null
  • removeEventListener 명시적 호출
  • React에서는 useEffect의 클린업 함수 사용

정리

클로저는 외부 변수를 기억하는 함수라는 정의를 넘어서,
실제로는 렉시컬 환경 구조, 실행 컨텍스트, 메모리 해제 시점 등과 깊은 관련이 있다.

React의 상태 관리와도 밀접하게 연결되며, 실무에서는 클로저로 인한 예상치 못한 버그나 메모리 누수 현상이 자주 발생한다.

단순히 정의를 아는 수준을 넘어서, 어떻게 동작하는지, 어떤 문제를 만들 수 있는지, 그리고 어떻게 방지할 수 있는지를 이해하는 것이 중요하다.


참고