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()); // 3createCounter 함수는 종료되었지만, 반환된 내부 함수는 여전히 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 내부의 콜백 함수는 컴포넌트가 처음 렌더링 될 때 생성되었다. 이때의 count는 0이었다. 이후 count가 1, 2, 3으로 증가해도, 이 콜백 함수가 가진 "가방" 속의 count는 여전히 0이다.
이것을 해결하려면 의존성 배열에 count를 넣어서 클로저를 재생성하거나, 함수형 업데이트(setCount(prev => prev + 1))를 사용해야 한다.
핵심 정리
useState는 마법이 아니라 자바스크립트의 기본 원리인 클로저를 활용한 패턴이다.
다음에는 이 원리를 바탕으로 브라우저가 화면을 그리는 과정, 즉 렌더링 파이프라인을 이해해보자. 성능 최적화의 첫걸음이다.
🔗 참고 링크
다음 글 예고:
자바스크립트와 메모리 구조를 이해했다면, 이제 브라우저가 이 코드를 어떻게 화면에 픽셀로 바꾸는지 알아야 한다.
"브라우저 렌더링 원리: Reflow와 Repaint를 이해해야 최적화가 보인다." 편에서 계속된다.