React|언어와 웹의 본질

useState가 값을 기억하는 원리: 클로저(Closure)와 실행 컨텍스트 완벽 이해하기

11
useState가 값을 기억하는 원리: 클로저(Closure)와 실행 컨텍스트 완벽 이해하기

리액트 개발자라면 하루에 수십 번도 더 쓰는 코드가 있다.

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

우리는 너무나 당연하게 setCount를 호출하면 count가 증가하고, 컴포넌트가 다시 렌더링 되어도 그 값이 유지될 것이라 믿는다.

그런데 잠깐, 자바스크립트의 기본 동작 원리를 생각해보면 이것은 마법에 가깝다. 함수형 컴포넌트는 말 그대로 '함수'다. 함수가 다시 호출(리렌더링)되면 내부의 변수는 초기화되어야 정상이다.

도대체 useState는 어떻게 함수가 종료된 후에도 값을 '기억'하고 있는 걸까?

그 비밀은 자바스크립트의 핵심 개념인 클로저(Closure)에 있다.

함수는 기억상실증에 걸려 있다

자바스크립트의 함수 실행 과정을 먼저 살펴보자. 크리스가 간단한 카운터 함수를 만들었다.

// bad-counter.js
function Counter() {
  let count = 0; // 지역 변수
  count += 1;
  return count;
}

console.log(Counter()); // 1
console.log(Counter()); // 1 (어라? 2가 되어야 하는데?)

아무리 호출해도 결과는 항상 1이다.

이유는 실행 컨텍스트(Execution Context) 때문이다. 함수가 호출되면 실행 컨텍스트가 생성되고, 함수가 종료되면 이 컨텍스트와 함께 내부 변수(count)도 메모리에서 사라진다(Garbage Collected). 즉, 함수는 태생적으로 "상태를 유지할 수 없는" 존재다.

기억을 보존하는 타임캡슐, 클로저

이 문제를 해결하기 위해 자바스크립트는 클로저(Closure)라는 기능을 제공한다.

MDN의 정의에 따르면 "클로저는 함수와 그 함수가 선언된 렉시컬 환경(Lexical Environment)의 조합"이다. 말이 어렵다. 쉽게 비유하면 "함수가 생성될 당시의 외부 변수들을 가방(Backpack)에 싸서 들고 다니는 것"과 같다.

이제 크리스는 코드를 조금 바꿨다.

// closure-counter.js
function createCounter() {
  let count = 0; // 외부 함수의 변수 (자유 변수)

  return function() {
    count += 1; // 내부 함수가 외부 변수를 참조함
    return count;
  };
}

const counter = createCounter(); // 내부 함수를 반환받음

console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3

createCounter 함수는 종료되었지만, 반환된 내부 함수는 여전히 count 변수에 접근할 수 있다. 외부 함수가 죽어도(종료되어도), 내부 함수가 살아있다면 그가 참조하는 변수는 죽지 않는다. 이것이 클로저의 핵심이다.

리액트 훅(Hook) 직접 구현해보기

리액트의 useState도 정확히 이 원리를 사용한다. 리액트 모듈 내부에 우리가 모르는 "진짜 변수 저장소"가 있고, useState는 그 저장소에 접근할 수 있는 클로저다.

아주 단순화한 MyReact를 만들어보자.

// MyReact.js
const MyReact = (function() {
  let _val; // 1. 함수 외부의 변수 (상태 저장소)

  function useState(initialValue) {
    // 값이 없으면 초기값 할당 (첫 렌더링)
    const state = _val !== undefined ? _val : initialValue;

    // 2. setState는 _val을 수정하는 클로저다
    const setState = (newValue) => {
      _val = newValue;
      render(); // 상태가 바뀌면 리렌더링 트리거
    };

    return [state, setState];
  }

  function render() {
    console.log('렌더링 되었습니다.');
  }

  return { useState, render };
})();

// 사용 예시
const [count, setCount] = MyReact.useState(0); // count: 0
setCount(1); // _val 업데이트 -> 1
const [count2, setCount2] = MyReact.useState(0); // count2: 1 (기억하고 있다!)

MyReact는 IIFE(즉시 실행 함수)로 실행되어 _val이라는 변수를 클로저 공간에 숨겨둔다.

우리가 컴포넌트에서 useState를 호출할 때마다, 이 함수는 자신의 외부에 있는 _val을 참조하여 값을 반환하거나 수정한다.

컴포넌트 함수(Counter)가 리렌더링 되어 새로 호출되더라도, MyReact 모듈 안에 있는 _val은 그대로 살아있기 때문에 상태가 유지되는 것이다.

참고: 실제 리액트는 하나의 변수(_val)가 아니라, 배열과 **인덱스(hookIndex)**를 사용하여 여러 개의 useState를 관리한다. 이것이 우리가 "훅은 최상위 레벨에서만, 순서대로 호출해야 한다"는 규칙을 지켜야 하는 이유다.

주의할 점: Stale Closure (상한 클로저)

클로저는 강력하지만, "생성될 당시의 환경"을 기억한다는 점 때문에 실수를 유발하기도 한다. 리액트 개발자를 괴롭히는 Stale Closure 문제다.

// stale-example.js
function Component() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    // 이 함수는 '최초 렌더링 시점'의 count(0)를 기억하는 클로저다.
    const timer = setInterval(() => {
      console.log(count); // 계속 0만 출력된다!
    }, 1000);

    return () => clearInterval(timer);
  }, []); // 의존성 배열이 비어있음

  // ...
}

setInterval 내부의 콜백 함수는 컴포넌트가 처음 렌더링 될 때 생성되었다. 이때의 count0이었다. 이후 count1, 2, 3으로 증가해도, 이 콜백 함수가 가진 "가방" 속의 count는 여전히 0이다.

이것을 해결하려면 의존성 배열에 count를 넣어서 클로저를 재생성하거나, 함수형 업데이트(setCount(prev => prev + 1))를 사용해야 한다.

핵심 정리

useState는 마법이 아니라 자바스크립트의 기본 원리인 클로저를 활용한 패턴이다.

  • 실행 컨텍스트: 함수가 종료되면 내부 변수는 사라진다. 그래서 일반 함수는 상태를 기억하지 못한다.
  • 클로저(Closure): 함수가 선언될 때의 외부 환경(Lexical Environment)을 기억하는 기능이다.
  • useState의 원리: 리액트 모듈 내부에 선언된 변수를 useState가 클로저를 통해 참조하고 수정한다. 그래서 리렌더링이 되어도 값이 사라지지 않는다.
  • 다음에는 이 원리를 바탕으로 브라우저가 화면을 그리는 과정, 즉 렌더링 파이프라인을 이해해보자. 성능 최적화의 첫걸음이다.


    🔗 참고 링크

  • MDN - Closures
  • React Hooks: Not Magic, Just Arrays

  • 다음 글 예고:

    자바스크립트와 메모리 구조를 이해했다면, 이제 브라우저가 이 코드를 어떻게 화면에 픽셀로 바꾸는지 알아야 한다.

    "브라우저 렌더링 원리: Reflow와 Repaint를 이해해야 최적화가 보인다." 편에서 계속된다.

    댓글 (0)

    0/1000
    댓글을 불러오는 중...