Notice
Recent Posts
Recent Comments
Link
«   2026/01   »
1 2 3
4 5 6 7 8 9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29 30 31
Tags
more
Archives
Today
Total
관리 메뉴

likeornament 님의 블로그

클로저라는 기반 위에 쌓아 만든 useState 본문

공부/javascript

클로저라는 기반 위에 쌓아 만든 useState

likeornament 2026. 1. 21. 13:53

1. 클로저의 정의와 동작 원리

클로저란 어떤 함수에서 선언한 변수를 참조하는 내부 함수에서만 발생하는 현상이라고 볼 수 있다.

 

var outer = function() {
  var a = 1;
  var inner = function() {
    return ++a;
  };
  return inner;
};
var outer2 = outer();
console.log(outer2()); // 2
console.log(outer2()); // 3
  • outer 함수는 내부에서 정의한 inner 함수를 반환한다.
  • outer 함수의 실행 컨텍스트가 종료되면,
    outer2 변수는 outer가 반환한 inner 함수 객체를 참조하게 된다.
  • 이후 outer2()를 호출하면 곧바로 inner 함수가 실행된다.

여기서 중요한 점은 inner 함수의 실행 컨텍스트를 살펴보자.

inner 함수의 EnvironmentRecord에는 a라는 변수가 존재하지 않는다.

하지만 inner 함수의 OuterEnvironmentReference에는

자신이 선언되었던 위치인 outer 함수의 LexicalEnvironment가 참조로 연결되어 있다.

 

이로 인해 스코프 체이닝이 발생하고,
inner 함수는 이미 실행이 끝난 outer 함수에서 선언된 변수 a에 접근해 연산을 수행할 수 있다.


1-1. 가비지 컬렉터의 메모리 관리 방식

그런데 여기서 한 가지 이상한 점이 눈에 띈다.

 

inner 함수가 실행되는 시점에는 이미 outer 함수의 실행 컨텍스트는 종료된 상태다.

그럼에도 불구하고 어떻게 outer 함수의 LexicalEnvironment에 접근할 수 있는 것일까?

 

이 의문은 가비지 컬렉터의 동작 방식을 이해하면 풀린다.

 

가비지 컬렉터는 어떤 값을 참조하는 변수가 단 하나라도 존재한다면,

그 값은 수집 대상에 포함 시키지 않는다.

 

클로저와 가비지 컬렉션의 메모리 참조 구조

 

  • outer 함수는 실행이 종료되며 inner 함수를 반환한다.
  • 외부 함수인 outer의 실행 컨텍스트는 사라졌지만 반환된 inner 함수는 outer2()를 통해 언젠가 다시 호출될 가능성을 가진다.
  • 해당 호출 시점에는 inner 함수가 자신이 선언될 당시의 스코프인 outer 함수의 LexicalEnvironment를 필요로 하게 된다.

이 때문에 outer 함수의 LexicalEnvironment는 
가비지 컬렉션의 대상에서 제외되고 메모리에 유지된다.

 

결국 위에서 말했던 
"어떤 함수에서 선언한 변수를 참조하는 내부 함수에서만 발생하는 현상"이란,
외부 함수의 LexicalEnvironment가 가비지 컬렉팅되지 않고 유지되는 현상을 의미한다.

 


1-2. useState의 상태 유지 방식에 대한 의문

이렇게 외부 함수의 실행 컨텍스트가 종료되었음에도,
내부 함수가 참조하는 변수는 살아남는다는 클로저의 특성을 공부하다가 
문득 리액트의 useState가 떠올랐다.

 

함수형 컴포넌트는 상태가 바뀔 때마다 매번 재실행되는 함수다.
함수 내부의 지역 변수는 실행이 끝나면 사라지는 게 자바스크립트의 상식인데,

useState의 값은 초기화되지 않고 유지된다.

 

혹시 useState는 클로저를 이용해 컴포넌트 외부의 LexicalEnvironment에 상태를 저장해두고,

컴포넌트가 재실행될 때마다 그 환경에 접근해 값을 꺼내오는 구조 아닐까?


2. 클로저를 활용한 useState 구현 실험

이 의문을 해결하기 위해 곧바로 리액트의 소스 코드를 파고들고 싶었지만,

리액트의 내부 구조는 생각보다 훨씬 방대하고 복잡했다.

 

대신 방향을 바꿨다.

리액트 전체를 이해하려 하지 말고,
useState가 상태를 '유지'하는 최소한의 구조만 직접 만들어보자.

 

클로저를 활용해 리액트의 동작을 흉내 낸 아주 작은 리액트를 만들어보기로 했다.


2-1. 외부 변수를 이용한 상태 저장 구조

함수형 컴포넌트는 렌더링이 발생할 때마다 함수가 다시 호출된다.

즉, 함수 내부에서 선언된 지역 변수들은 매번 새로 생성된다.

 

function Component() {
  let count = 0;
}

 

이 구조만 놓고 보면,

함수가 다시 실행될 때 이전 값이 유지되지 않는 것이 당연해 보인다.

 

그런데 리액트의 useState를 사용하면 그렇지 않다.

 

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

 

컴포넌트 함수가 다시 실행되는데도, 
count 값은 초기화되지 않고 이전 값을 그대로 유지한다는 것을 우리는 알고 있다.

 

이 말은 곧,

리액트는 상태 값을 컴포넌트 함수 내부가 아닌, 함수 바깥 어딘가에 저장하고 있다는 뜻이다.

 

그 "어딘가"를 클로저를 이용해 직접 만들어 보기로 했다.

 

const MyReact = (function() {
  let _state; // 상태를 보관할 외부 변수

  return {
    useState(initialValue) {
      // 상태가 아직 없다면 초기값으로 설정
      _state = _state === undefined ? initialValue : _state;

      const setState = (newValue) => {
        _state = newValue;
        console.log("상태 변경됨. 리렌더링 실행");
        // 실제 리액트라면 여기서 컴포넌트를 다시 실행한다.
      };

      return [_state, setState];
    }
  };
})();

 

먼저 즉시 실행 함수를 사용해 한 번만 실행되는 스코프를 만들었다.

 

그리고 그 스코프 안에 _state라는 변수를 선언했다.

 

let _state;

 

이 변수는 컴포넌트 함수 내부에 있지 않고,

전역 변수도 아니며 오직 useStatesetState만 접근할 수 있는 위치에 있다.

 

즉, 클로저로 보호된 상태 저장소가 된다.

 

_state = _state === undefined ? initialValue : _state;

 

useState는 초기값 initialValue를 인자로 받는다.

 

위 코드는 아래 의미를 가진다.

  • _state가 아직 한 번도 설정된 적 없다면 → 초기값 사용
  • 이미 값이 있다면 → 기존 값을 그대로 유지

이로 인해 useState가 여러 번 호출되더라도 상태가 초기화되지 않고 유지된다.

 

const setState = (newValue) => {
  _state = newValue;
  console.log("상태 변경됨. 리렌더링 실행");
};

 

setState는 단순히 외부 변수 _state의 값을 변경하고,
실제 리액트라면 여기서 컴포넌트를 다시 실행했을 것이다.

 

중요한 점은, 
상태 변경은 컴포넌트 함수 내부가 아니라 외부에서 일어난다는 사실이다.

 

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

  return {
    render: () => console.log(`현재 count 값은: ${count} 입니다.`),
    click: () => setCount(count + 1)
  };
}

 

 

위에서 만든 MyReact를 사용하는 Counter 함수다.

 

Counter 함수는 다시 실행될 때마다 useState(0)을 호출하지만,
실제로 상태는 Counter 밖에 있는 _state 변수에 저장되어 있다.

 

console.log("--- 첫 번째 렌더링 ---");
let app = Counter();
app.render(); // 현재 count 값은: 0 입니다.

app.click(); // _state를 1로 변경

console.log("--- 두 번째 렌더링 ---");
app = Counter(); // 컴포넌트 재실행
app.render(); // 현재 count 값은: 1 입니다.

 

실행 흐름을 정리하면 다음과 같다.

  1. 첫 렌더링
    - _state는 아직 없으므로 초기값 0으로 설정
  2. setState 호출
    - _state 값이 1로 변경
  3. 두 번째 렌더링
    - Counter 함수는 다시 실행됨
    - 하지만 _state는 외부 스코프에 남아 있음
    - 따라서 이전 값 1이 그대로 사용됨 

2-2. 실행 컨텍스트와 LexicalEnvironment 분석

앞에서 살펴본 구조는 겉으로 보면 단순하다.

 

하지만 이 코드가 왜 가능한지를 이해하려면

실행 컨텍스트와 LexicalEnvironment 관점에서 다시 볼 필요가 있다.

 

Counter 함수는 렌더링이 발생할 때마다 다시 호출한다.

이때마다 자바스크립트 엔진은 새로운 실행 컨텍스트를 생성한다.

 

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

 

이 실행 컨텍스트 안에는
count, setCount와 같은 지역 식별자들이 새롭게 바인딩된다.

 

따라서 count라는 변수는 매 렌더링마다 항상 새롭게 만들어진다.

 

하지만 _state는 다르다.

 

let _state;

 

_stateCounter 함수 내부에 선언된 변수가 아니다.

 

이 변수는 MyReact를 생성한 즉시 실행 함수의 실행 결과로 만들어진 환경,
MyReact의 LexicalEnvironment에 존재한다.

 

LexicalEnvironment는 프로그램 시작 시 한 번 생성되고 이후에도 계속 유지된다.

 

그래서 Counter가 몇 번 다시 실행되든 _state는 영향을 받지 않는다.

 

const MyReact = (function () {
  let _state;

  return {
    useState(initialValue) {
      ...
    }
  };
})();

 

그리고 useStateMyReact의 LexicalEnvironment 안에서 선언된 함수다.
따라서 이 함수는 자신이 선언된 시점의 환경을 기억한다.

 

즉, useStateCounter의 실행 컨텍스트를 참조하는 것이 아니라,

_state가 존재하는 MyReact의 LexicalEnvironment를 클로저로 캡처하고 있다.

 

정리해 보면 구조는 다음과 같다.

  • Counter
    - 렌더링마다 새로운 실행 컨텍스트 생성
    - count는 매번 새로 만들어짐
  • MyReact
    - 한 번 생성된 LexicalEnvironment 유지
    - _state는 이 환경에 계속 남아 있음
  • useState
    - 자신의 상위 LexicalEnvironment를 클로저로 참조
    - 그래서 항상 동일한 _state에 접근 가능

이 차이 때문에 함수는 다시 실행되지만 상태는 유지되는 구조가 만들어진다.


2-3. 클로저를 통한 메모리 점유와 데이터 보존

일반적인 함수라면 실행이 끝나는 순간,
실행 컨텍스트와 함께 지역 변수도 모두 사라진다.

 

하지만 앞선 예제에서는 상황이 다르다.

useState라는 내부 함수가 _state를 참조하고 있고,
그 함수는 MyReact 밖으로 반환되어 언제든 다시 호출될 수 있는 상태로 남아 있다.

 

이 차이는 가비지 컬렉터의 관점에서 보면 명확하다.

누군가 나를 참조하고 있다면, 나는 수집되지 않는다.

 

useState가 외부로 노출되어 다시 호출될 수 있는 한,

_state 역시 가비지 컬렉션 대상에서 제외되고 메모리에 유지된다.

 

결국 컴포넌트가 재실행된다는 것은 상태를 새로 만드는 과정이 아니라,
클로저를 통해 이미 존재하는 LexicalEnvironment에 다시 접근하는 과정에 가깝다.

 

여기까지 오면 처음의 질문으로 돌아오게 된다.

useState는 클로저를 이용해 
컴포넌트 외부의 LexicalEnvironment에 상태를 저장해 두고,
컴포넌트가 재실행될 때마다 그 환경에 접근하는 구조 아닐까?

 

적어도 이 작은 실험 안에서는,

그 가설이 틀리지 않았다는 걸 확인할 수 있었다.


3. 클로저만으로는 부족하다

앞선 실험을 통해 한 가지는 분명해졌다.

 

useState의 핵심은 상태를 함수 외부의 LexicalEnvironment에 두고,

클로저를 통해 그 값을 참조하는 구조라는 점이다.

 

하지만 우리가 만든 MyReact에는 
이 설명을 끝까지 밀어붙이기에는 한계를 가진다.

 

바로 상태를 저장하는 변수가 _state 하나뿐이라는 점이다.

 

const [count, setCount] = useState(0);
const [text, setText] = useState("");
const [isOpen, setIsOpen] = useState(false);

 

현실의 리액트에서는 한 컴포넌트 안에서 여러 개의 useState를 동시에 사용할 수 있다.

 

그리고 각 상태는 서로 섞이지 않고,
항상 정확한 값으로 유지된다.

 

하지만 이 동작을 단순히

"외부 변수 하나 + 클로저"라는 구조만으로는 설명하기 어렵다.

 

그렇다면 리액트는 이 여러 상태들을 어디에,

그리고 어떤 기준으로 저장하고 찾아낼까?

 

이 궁금증에서부터 
클로저만으로는 설명할 수 없는 리액트의 다음 구조를 알게 됐다.


3-1. Fiber 노드와 훅의 호출 순서

리액트는 상태를 변수 이름으로 구분하지 않는다.

대신 훅이 호출되는 순서를 기준으로 각 상태를 관리한다.

 

이 방식이 가능하려면,
컴포넌트마다 상태를 일관되게 보관하고 다시 찾아올 수 있는 구조가 필요하다.

그 역할을 하는 것이 Fiber 노드다.

 

실제 리액트에서는 각 함수형 컴포넌트마다 하나의 Fiber 객체가 존재하고,

그 내부에 훅 정보들이 연결 리스트 형태로 저장된다.

 

개념적으로 보면 아래 구조에 가깝다.

Fiber
 └─ hooks
     ├─ state #1 (count)
     ├─ state #2 (text)
     └─ state #3 (isOpen)

 

컴포넌트가 처음 렌더링될 때,

리액트는 useState가 호출될 때마다 이 리스트에 새로운 노드를 하나씩 추가한다.

 

이후 리렌더링이 발생하면,

리액트는 같은 순서로 훅을 다시 호출하면서 이미 만들어진 훅 리스트를 앞에서부터 하나씩 순회한다.

그리고 "지금 호출된 useState는 리스트의 몇 번째 노드인가?"를 기준으로 
자신의 상태를 정확히 찾아간다.

 

여기서 중요한 점은,
리액트가 훅을 구분하는 유일한 기준이 호출 순서라는 사실이다.

 

이제 리액트 공식 문서에서 늘 강조하던 규칙이 이해가 되기 시작한다.

"Hooks는 조건문이나 반복문 안에서 호출하지 마세요"

 

이 규칙은 단순한 스타일 가이드가 아니다.

클로저 + 순서 기반 상태 관리라는 구조에서 필연적으로 따라오는 제약인 것이었다.

 

만약 이런 코드가 있다고 가정해보자.

if (flag) {
  useState(0);
}
useState(1);

 

렌더링마다 flag 값이 바뀌면 훅 호출 순서 역시 달라진다.

 

그러는 순간, 
리액트가 관리하던 훅 리스트의 순서와 현재 렌더링에서 호출되는 순서가 어긋나게 된다.

 

그 결과 리액트는 "이 useState가 어느 상태를 의미하는지"를 더이상 알 수 없게 된다.

 

즉, 훅 순서 규칙은 리액트가 클로저로 유지하고 있는 상태 구조를
안정적으로 참조하기 위한 전제 조건인 셈이다.


3-2. 클로저와 리액트 상태 관리 시스템의 관계

이제 처음의 질문을 다시 떠올려보자.

useState는 클로저를 이용해 상태를 유지하는 걸까?

 

이에 대한 답은 다음에 가깝다.

그렇다.
하지만 클로저만으로는 충분하지 않다.

 

클로저는 상태가 선언된 환경을 메모리에 남겨 두는 역할을 한다.

즉, 상태를 '살려두는' 기반을 제공한다.

 

리액트는 그 위에 Fiber라는 구조를 얹고,

훅 호출 순서를 규칙으로 삼아 여러 상태를 안전하게 저장하고 정확히 찾아가는 시스템을 만든다.

 

다시 말해, 
클로저는 기반이고 리액트는 그 위에 관리 전략을 얹는다.

 

useState는 자바스크립트의 기본 원리 위에 
리액트가 설계한 상태 관리 시스템이 결합된 결과물이었다.


4. 이 글을 마치며

이 글에서 살펴본 useState의 동작은 어떤 새로운 개념에서 비롯된 것이 아니었다.

  • 함수가 선언될 당시의 환경을 기억하는 클로저
  • 참조가 남아 있으면 수집되지 않는 가비지 컬렉션
  • 함수 호출마다 새로 만들어지는 실행 컨텍스트

우리가 이미 알고 있던 이 자바스크립트의 기본 원리들이
리액트의 렌더링 모델과 맞물리면서 useState라는 추상화가 만들어진 것이다.

 

처음에는
"왜 값은 바뀌는데 함수는 과거에 머무를까?"라는 의문에서 출발했지만,

그 질문을 끝까지 따라가 보니 답은 하나였다.

 

리액트는 자바스크립트의 원리를 새로 만들지 않았다.

그 원리들을 아주 잘 활용하고 있었을 뿐이다.