다크 모드
Chapter 02.5. [선택·심화] 50줄로 직접 만드는 미니 React 렌더러와 Hooks 엔진
"React의
useState는 내부적으로 어떻게 이전 상태를 기억하고 컴포넌트를 다시 실행할까? 왜 훅을if문 안에서 부르면 React가 비명을 지를까? React 없이 순수 TypeScript 50줄로useState와useEffect코어를 직접 만들어 터미널에서 돌려 보면, 훅은 더 이상 마법이 아니다."
📋 이 장에서 처음 만나는 개념 — 모르는 문법이 나오면 0장 준비운동에서 찾아볼 수 있습니다.
| 개념 | 한 줄 요약 | 설명 위치 |
|---|---|---|
npx tsx | TypeScript 파일을 브라우저 없이 Node.js에서 바로 실행하는 도구 | 2.5.1 🆕 박스 |
hooks[] 슬롯 배열과 hookIndex | 함수형 컴포넌트가 호출 순서대로 상태를 저장·복원하는 메모리 구조 | Step 1~3 |
튜플 반환 타입 [T, (val: T | ((prev: T) => T)) => void] | 값과 변경 함수를 길이 2인 배열로 반환하는 TypeScript 타입 표기 | Step 3 🆕 박스 |
Object.is(a, b) | 이전 상태와 새 상태가 같은지 판별하는 표준 비교 함수 | Step 3 |
의존성 배열 비교 deps.every(...) | useEffect가 이전 렌더링의 의존성 배열과 달라졌는지 검사하는 원리 | 2.5.3 Step 6 |
| 훅의 규칙 (Rules of Hooks) | 왜 조건문·반복문 안에서 훅을 호출하면 상태 인덱스가 밀려 대참사가 나는가 | 2.5.4 |
TIP
🏁 이 장의 시작 파일
이 장은 BoardApp.tsx를 고치지 않습니다. 이 장에서 손대는 파일: frontend/src/practice/mini-react.ts
NOTE
이 장은 선택입니다. 2장을 읽었다면 3장 이후 실습에 필요한 지식은 모두 갖췄습니다. "원리를 손으로 만져 봐야 직성이 풀린다"는 분, 혹은 3장 이후에 훅의 동작이나 클로저가 헷갈릴 때 돌아와서 하십시오. 소요 시간은 25~30분 정도입니다.
2.5.1 실습 준비: useState는 도대체 어디에 값을 저장하는가?
함수형 컴포넌트(BoardApp())는 그저 평범한 자바스크립트 함수일 뿐입니다.
일반 함수는 실행이 끝나고 return하는 순간 내부의 지역 변수가 스택 메모리에서 모두 증발합니다.
그렇다면 컴포넌트가 리렌더링되어 함수가 처음부터 다시 실행될 때, React는 도대체 어디서 아까 저장해 둔 posts와 title 값을 되찾아오는 걸까요?
비밀은 **컴포넌트 함수 바깥(실제 React에서는 각 컴포넌트의 Fiber 노드 내부)에 있는 훅 슬롯 배열(hooks[])과 호출 순서 포인터(hookIndex)**에 있습니다!
파일 위치와 실행 방법
- 작성할 파일 경로:
frontend/src/practice/mini-react.ts - 실행 명령어: 터미널의
frontend/디렉터리에서 아래 명령을 실행하면 브라우저 없이 Node.js에서 TypeScript 코드가 바로 실행됩니다.
bash
cd frontend
npx tsx src/practice/mini-react.ts
# (또는 package.json에 등록된 단축 명령어):
npm run practice:react🆕 처음 보는 문법: npx tsx
tsx: TypeScript 파일을 미리 빌드하지 않고 Node.js에서 즉시 실행하는 도구입니다. 실습 환경의package.json에 이미 설치되어 있습니다.- 이 실습은 브라우저가 아니라 터미널에서 결과를 확인합니다. React 라이브러리 없이 순수 자바스크립트만으로 훅 엔진의 원리를 보기 위해서입니다.
2.5.2 Step 1~5 미니 React 엔진(render, useState) 만들기와 터미널 검증
이제 에디터로 frontend/src/practice/mini-react.ts를 열고, 기존 내용을 비운 뒤 1단계부터 타이핑해 봅시다.
[Step 1] 훅 슬롯 배열(hooks)과 현재 순서 포인터(hookIndex) 선언
typescript
// frontend/src/practice/mini-react.ts
// Chapter 02.5 실습: 50줄로 밑바닥부터 만드는 React Hooks & 렌더러 엔진
// 1. 훅들의 상태값을 호출 순서대로 저장할 메모리 슬롯 배열과 인덱스 포인터
const hooks: any[] = [];
let hookIndex = 0;
// 2. 현재 렌더링 중인 컴포넌트 함수를 기억할 변수
let currentComponent: (() => any) | null = null;[Step 2] 컴포넌트를 실행하고 포인터를 0으로 되감는 render() 구현
컴포넌트가 처음 마운트되거나 상태가 바뀌어 리렌더링될 때마다 hookIndex를 반드시 0으로 초기화한 뒤 컴포넌트 함수를 호출합니다. 그래야 첫 번째 useState가 항상 hooks[0]을, 두 번째 useState가 항상 hooks[1]을 찾아갈 수 있습니다!
typescript
// 3. 컴포넌트를 실행(Render)하여 가상 DOM 결과를 출력하는 미니 렌더러
export function render<T>(Component: () => T): T {
currentComponent = Component;
hookIndex = 0; // 🌟 핵심: 렌더링을 시작할 때마다 포인터를 0번 슬롯으로 되감는다!
const vdom = Component();
console.log('🎨 [DOM 렌더링 결과]:', vdom);
return vdom;
}[Step 3] useState() 구현 (함수형 업데이트 prev => ...까지 완벽 지원!)
typescript
type Updater<T> = T | ((prev: T) => T);
// 4. 우리가 매일 쓰는 useState의 진짜 밑바닥 구현체
export function useState<T>(
initialValue: T
): [T, (nextOrUpdater: Updater<T>) => void] {
// 중요: setState 클로저가 '자신의 슬롯 번호'를 영원히 기억하도록 지역 상수에 캡처해 둔다!
const slotIndex = hookIndex;
// 최초 렌더링(슬롯이 비어 있음)이면 초기값을 넣고, 리렌더링이면 기존 슬롯 값을 그대로 쓴다
if (hooks[slotIndex] === undefined) {
hooks[slotIndex] = initialValue;
}
const setState = (nextOrUpdater: Updater<T>) => {
const prevValue = hooks[slotIndex] as T;
// 함수(prev => prev + 1)가 들어왔으면 이전 값을 넣어 호출하고, 아니면 값 그대로 사용
const nextValue =
typeof nextOrUpdater === 'function'
? (nextOrUpdater as (prev: T) => T)(prevValue)
: nextOrUpdater;
// Object.is로 비교해 실제로 값(또는 참조)이 달라졌을 때만 리렌더링 발동!
if (!Object.is(prevValue, nextValue)) {
hooks[slotIndex] = nextValue;
console.log(
`\n📢 상태 변경 감지 (슬롯 #${slotIndex}: ${JSON.stringify(prevValue)} -> ${JSON.stringify(nextValue)}) -> 리렌더링 실행!`
);
if (currentComponent) {
render(currentComponent);
}
} else {
console.log(
`\n💤 값 변화 없음 (슬롯 #${slotIndex}: Object.is 동일) -> 리렌더링 스킵!`
);
}
};
const state = hooks[slotIndex] as T;
hookIndex++; // 🌟 다음 훅 호출을 위해 포인터를 1 증가시킨다!
return [state, setState];
}🆕 처음 보는 문법: 튜플 반환 타입과 slotIndex 클로저 캡처
[T, (nextOrUpdater: Updater<T>) => void]: 일반 배열(T[])과 달리 "첫 번째 요소는 반드시 값T이고, 두 번째 요소는 변경 함수다"라고 자리별 타입을 고정한 튜플(Tuple) 타입입니다. 덕분에const [title, setTitle] = useState('초기값')으로 구조 분해할 때title은string,setTitle은 함수로 정확히 추론됩니다.- 왜
hooks[hookIndex]를 바로 안 쓰고const slotIndex = hookIndex;에 담아 두나요?:hookIndex는 다음 훅이 불릴 때마다0 → 1 → 2로 계속 변하는 전역 변수입니다. 나중에 사용자가 버튼을 클릭해setState를 호출했을 때 그setState가 자신이 몇 번 슬롯 주인이었는지 기억하려면,useState가 실행되던 그 순간의 번호를 지역 상수slotIndex에 복사(클로저 캡처)해 두어야 합니다!
[Step 4] 게시판 미니 컴포넌트로 시뮬레이션 코드 작성
파일 하단에 테스트 코드를 이어서 작성합니다:
typescript
// ==========================================
// 🧪 [테스트 실행 구역]
// ==========================================
function BoardMiniApp() {
const [title, setTitle] = useState('jQuery 레거시의 종말');
const [likeCount, setLikeCount] = useState(0);
return {
UI: `<article><h2>${title}</h2><span>좋아요: ${likeCount}</span></article>`,
actions: {
changeTitle: (newTitle: string) => setTitle(newTitle),
clickLike: () => setLikeCount((prev) => prev + 1)
}
};
}
console.log('🚀 [Chapter 02.5] 미니 React 엔진 테스트 시작!\n');
console.log('=== [1] 최초 마운트 ===');
let app = render(BoardMiniApp);
console.log('\n=== [2] 좋아요 클릭 (0 -> 1) ===');
app.actions.clickLike();
console.log('\n=== [3] 제목 변경 ===');
app.actions.changeTitle('React 19.3 아키텍처 완성');
console.log('\n=== [4] 같은 제목 다시 대입 (Object.is 방어 확인) ===');
app.actions.changeTitle('React 19.3 아키텍처 완성');[Step 5] 터미널에서 실행 및 검증
파일을 저장하고 터미널에서 실행해 보십시오:
bash
cd frontend
npx tsx src/practice/mini-react.ts[실제 콘솔 출력 결과]:
text
🚀 [Chapter 02.5] 미니 React 엔진 테스트 시작!
=== [1] 최초 마운트 ===
🎨 [DOM 렌더링 결과]: {
UI: '<article><h2>jQuery 레거시의 종말</h2><span>좋아요: 0</span></article>',
actions: { changeTitle: [Function: changeTitle], clickLike: [Function: clickLike] }
}
=== [2] 좋아요 클릭 (0 -> 1) ===
📢 상태 변경 감지 (슬롯 #1: 0 -> 1) -> 리렌더링 실행!
🎨 [DOM 렌더링 결과]: {
UI: '<article><h2>jQuery 레거시의 종말</h2><span>좋아요: 1</span></article>',
actions: { changeTitle: [Function: changeTitle], clickLike: [Function: clickLike] }
}
=== [3] 제목 변경 ===
📢 상태 변경 감지 (슬롯 #0: "jQuery 레거시의 종말" -> "React 19.3 아키텍처 완성") -> 리렌더링 실행!
🎨 [DOM 렌더링 결과]: {
UI: '<article><h2>React 19.3 아키텍처 완성</h2><span>좋아요: 1</span></article>',
actions: { changeTitle: [Function: changeTitle], clickLike: [Function: clickLike] }
}
=== [4] 같은 제목 다시 대입 (Object.is 방어 확인) ===
💤 값 변화 없음 (슬롯 #0: Object.is 동일) -> 리렌더링 스킵!놀랍지 않습니까? 단 40여 줄의 코드로:
title은 슬롯#0에,likeCount는 슬롯#1에 정확히 분리 저장되었습니다.clickLike()를 부르면 슬롯#1만1로 바뀌고BoardMiniApp()이 다시 실행되며, 슬롯#0의 제목은 그대로 유지되었습니다.- 똑같은 제목을 다시 넣었을 때는
Object.is가 이를 막아 불필요한 리렌더링을 건너뛰었습니다!
2.5.3 Step 6: useEffect()와 클린업 직접 추가하기
1장과 2장에서 배운 useEffect(callback, deps)도 똑같은 hooks[] 슬롯 배열을 씁니다!
슬롯에 **"이전에 넘겼던 의존성 배열(deps)과 클린업 함수(cleanup)"**를 저장해 두었다가, 다음 렌더링 때 새 deps와 비교해 하나라도 달라졌으면 이전 클린업을 부르고 새 콜백을 실행하면 됩니다.
mini-react.ts의 useState 정의 바로 아래(테스트 코드 위)에 useEffect를 추가해 봅시다:
typescript
// 5. useEffect: 의존성 배열(deps)이 바뀌었을 때만 부수 효과를 실행하고 이전 클린업을 호출한다
export function useEffect(
callback: () => void | (() => void),
deps?: any[]
): void {
const slotIndex = hookIndex;
const prevSlot = hooks[slotIndex] as
| { deps?: any[]; cleanup?: void | (() => void) }
| undefined;
// 이전 렌더링의 deps와 현재 deps 비교 (하나라도 다르면 true)
const hasChanged =
!prevSlot ||
!deps ||
!prevSlot.deps ||
deps.some((dep, i) => !Object.is(dep, prevSlot.deps![i]));
if (hasChanged) {
// 1) 이전 클린업 함수가 있었다면 먼저 실행! (타이머 해제, 요청 취소 등)
if (prevSlot && typeof prevSlot.cleanup === 'function') {
prevSlot.cleanup();
}
// 2) 새 이펙트 콜백을 실행하고 반환된 클린업 함수와 새 deps를 슬롯에 저장
const cleanup = callback();
hooks[slotIndex] = { deps, cleanup };
}
hookIndex++;
}그리고 BoardMiniApp 컴포넌트 안에 useEffect 두 개를 추가해 테스트해 보십시오:
typescript
function BoardMiniApp() {
const [title, setTitle] = useState('jQuery 레거시의 종말');
const [likeCount, setLikeCount] = useState(0);
// 슬롯 #2: 마운트 시 딱 1번만 실행되는 이펙트 (빈 배열 [])
useEffect(() => {
console.log('⚡ [useEffect #2] 최초 마운트 시 API 1회 호출!');
}, []);
// 슬롯 #3: likeCount가 바뀔 때만 실행되는 이펙트
useEffect(() => {
console.log(`⚡ [useEffect #3] 좋아요 수가 ${likeCount}개로 변경됨!`);
return () => {
console.log(`🧹 [Cleanup #3] 이전 좋아요(${likeCount}) 이펙트 정리!`);
};
}, [likeCount]);
// ...다시 npx tsx src/practice/mini-react.ts를 실행하면:
- 최초 마운트 때는
[useEffect #2]와[useEffect #3]이 모두 실행됩니다. - 좋아요를 클릭하면(
0 -> 1)[Cleanup #3]이 먼저 실행된 뒤[useEffect #3]이 실행되고, 빈 배열[]인[useEffect #2]는 다시 실행되지 않습니다! - 제목만 바꿨을 때는
likeCount가 그대로이므로 두useEffect모두 조용히 넘어갑니다!
2.5.4 왜 "훅을 조건문(if) 안에서 쓰면 안 되는가?"의 해답
방금 우리가 짠 mini-react.ts 코드를 보면 React 공식 문서의 제1계명인 **"훅의 규칙(Rules of Hooks): 절대 조건문·반복문 안에서 훅을 호출하지 말라"**가 왜 존재하는지 단 1초 만에 이해됩니다.
React는 훅의 변수 이름(title, likeCount)을 알지 못합니다. 오직 **호출된 순서(hookIndex: 0, 1, 2...)**만으로 각 상태를 구분합니다!
만약 누군가 다음과 같이 조건문 안에 첫 번째 useState를 넣었다고 상상해 보십시오:
typescript
let isFirstRender = true;
function BrokenComponent() {
if (isFirstRender) {
// 첫 렌더링 때만 실행됨 -> 슬롯 #0에 '초기 제목' 저장
const [title] = useState('초기 제목');
}
// 첫 렌더링 때는 슬롯 #1에 0 저장
// 🚨 하지만 두 번째 렌더링(isFirstRender = false) 때는 위 if문을 건너뛰므로
// 이 useState가 첫 번째로 호출되어 hookIndex가 0이 된다!!
const [likeCount] = useState(0);
console.log('현재 좋아요 수:', likeCount); // 💥 숫자 0 대신 '초기 제목' 문자열이 튀어나옴!
}조건에 따라 훅 하나가 건너뛰어지는 순간, 그 뒤에 줄 서 있던 모든 useState와 useEffect의 슬롯 번호가 한 칸씩 앞으로 밀리면서 남의 상태를 덮어쓰는 대참사가 일어납니다!
실제 React는 각 Fiber 노드에 훅들을 연결 리스트로 매달아 두고, 이전 렌더링 때와 훅의 개수나 순서가 달라지면 즉시 Error: Rendered more/fewer hooks than during the previous render 에러를 던져 화면을 멈춥니다.
2.5.5 실제 React 19.3 엔진과의 차이
원리를 50줄에 담기 위해 몇 가지를 단순화했습니다.
🔬 심화(건너뛰어도 됨): 미니 엔진이 생략한 실제 React의 기능들
- 전역 배열 대신 Fiber 노드별 연결 리스트: 우리 미니 엔진은 전역 배열
hooks[]하나만 두어서 컴포넌트 하나만 그릴 수 있습니다. 실제 React는 각 컴포넌트 인스턴스(Fiber 노드)의memoizedState속성에 훅들을 연결 리스트(hook.next)로 저장하므로 수천 개의 컴포넌트가 각자의 독립된 훅 슬롯을 가집니다. - 비동기 자동 배치 스케줄러: 우리 미니 엔진은
setState안에서render()를 동기적으로 즉시 호출했지만, 실제 React는 변경 요청을 큐(Queue)에 쌓아 두었다가 이벤트 핸들러가 끝난 뒤 한 번만render()를 호출합니다. useEffect의 실행 타이밍: 우리 미니 엔진은 렌더링 도중에useEffect콜백을 바로 실행했지만, 실제 React는 컴포넌트 함수가 JSX를 반환하고 브라우저 DOM 반영(Commit)까지 모두 끝난 직후에 모아 둔useEffect콜백들을 실행합니다.
2.5.6 [직접 해보기] 실습 미션 💪
미션: 미니 React 엔진에 useRef를 5줄로 직접 추가해 보기!
1.5장에서 배운 useRef(initialValue)는 어떻게 구현할 수 있을까요?useRef는 { current: initialValue } 객체를 슬롯에 한 번 만들어 넣어 두고, 이후 렌더링에서는 그 객체를 그대로 돌려주기만 하면 됩니다(.current를 바꿔도 render()를 호출하지 않음!).
mini-react.ts에 export function useRef<T>(initialValue: T): { current: T } 함수를 직접 구현해 보십시오.
💡 미션 정답 코드 확인하기
typescript
// 6. useRef: { current } 박스를 슬롯에 보관하고, 값을 바꿔도 리렌더링하지 않는다!
export function useRef<T>(initialValue: T): { current: T } {
const slotIndex = hookIndex;
if (hooks[slotIndex] === undefined) {
hooks[slotIndex] = { current: initialValue };
}
const refObj = hooks[slotIndex] as { current: T };
hookIndex++;
return refObj;
}단 7줄입니다! useState와 똑같이 hooks[slotIndex] 슬롯에 저장되지만, setState 없이 { current } 객체 참조를 그대로 반환하므로 .current 속성을 아무리 바꿔도 render()가 호출되지 않고 렌더링 간에 값은 영구히 보존됩니다. useState와 useRef가 사실은 같은 슬롯 배열에 사는 형제였다는 사실이 한눈에 보입니다!
2.5.7 이번 장 핵심 요약 및 다음 단계 🚀
핵심 3줄 요약
- 함수형 컴포넌트의
useState와useEffect상태는 함수 바깥(Fiber 노드의 훅 슬롯 배열)에 **호출 순서 인덱스(hookIndex)**를 기준으로 저장됩니다. - 렌더링이 시작될 때마다
hookIndex가0으로 초기화되므로, 매 렌더링마다 훅이 정확히 같은 순서로 호출되어야 자신의 슬롯을 찾아갈 수 있습니다(훅의 규칙). setState는Object.is로 이전 값과 새 값을 비교해 다를 때만 슬롯을 갱신하고 컴포넌트를 다시 렌더링하며,useEffect는 슬롯에 저장된 이전deps배열과 새deps배열을 비교해 달라졌을 때만 이전 클린업을 실행한 뒤 새 콜백을 실행합니다.
바뀐 파일
| 파일 | 무엇이 바뀌었나 |
|---|---|
frontend/src/practice/mini-react.ts | render, useState, useEffect 미니 엔진 구현 및 터미널 시뮬레이션 완료 |
셀프 체크 — 답을 먼저 떠올린 뒤 펼쳐 보십시오.
render()함수 첫 줄에서hookIndex = 0;을 빼먹으면 두 번째 렌더링 때 어떤 일이 벌어질까요?useState와useRef는 둘 다 컴포넌트가 리렌더링되어도 값을 잃어버리지 않고 기억합니다. 두 훅의 내부 구현 차이는 무엇인가요?useEffect(fn, [])처럼 두 번째 인자에 빈 배열[]을 넘기면 왜 최초 마운트 때 딱 1번만 실행되고 이후 리렌더링에서는 실행되지 않나요?
셀프 체크 정답
- 첫 렌더링에서 훅 2개를 써서
hookIndex가2가 된 상태로 남으므로, 두 번째 렌더링에서는 기존 슬롯#0,#1을 읽지 못하고 엉뚱한 슬롯#2,#3을 새로 만들어 모든 상태가 초기값으로 리셋되어 버립니다. - 둘 다
hooks[slotIndex]슬롯에 값을 보관하지만,useState는 값을 바꾸는 전용setState함수를 통해render()를 재호출하는 반면,useRef는{ current: initialValue }객체 참조만 반환하여.current를 직접 수정해도render()를 호출하지 않습니다. - 첫 렌더링 때는
prevSlot이 없으므로 실행되어 슬롯에deps: []가 저장됩니다. 이후 리렌더링에서는 빈 배열[]에 대해deps.some(...)이 항상false(변한 요소 없음)를 반환하므로hasChanged가 거짓이 되어 건너뜁니다.
다음 챕터로 이어지는 질문
이제 render()가 호출될 때마다 컴포넌트 함수가 새로 실행되어 그 시점의 state를 꺼내 온다는 사실을 손으로 확인했습니다.
그렇다면 **"컴포넌트 함수가 실행될 때마다 매번 그 순간의 지역 변수 스냅샷이 새로 만들어진다"**는 뜻인데, 만약 첫 번째 렌더링 때 만들어진 타이머(setInterval)나 비동기 함수가 나중에 실행되면서 첫 번째 렌더링의 옛날 posts 변수를 계속 붙잡고 있다면 어떻게 될까요?
이어지는 Chapter 03에서는 React 실무에서 가장 악명 높은 오래된 클로저(Stale Closure) 함정을 BoardApp.tsx에서 직접 터뜨려 보고, 함수형 업데이트, React 19.2+의 useEffectEvent, 그리고 실시간 검색 디바운스와 AbortController로 완벽히 제압해 보겠습니다.