4/1 수요코딩 리액트(2)
Week 5 미니 React.ver2 구현 정리 이번 과제는 기존 React의 핵심 개념인 Component , State , Hooks 를 직접 구현하고, 이를 바탕으로 실제로 동작하는 웹 페이지를 만드는 것이 목표였다. 우리 프로젝트는 단순히 Re
Week 5 미니 React.ver2 구현 정리
이번 과제는 기존 React의 핵심 개념인 Component, State, Hooks를 직접 구현하고, 이를 바탕으로 실제로 동작하는 웹 페이지를 만드는 것이 목표였다. 우리 프로젝트는 단순히 React 문법을 흉내 내는 것이 아니라, FunctionComponent 클래스 + hooks 배열 + Virtual DOM diff/patch를 직접 구현한 뒤 Tic-Tac-Toe 데모 페이지를 그 위에서 동작하게 만든 구조다.
루트 컴포넌트 하나가 모든 상태를 관리하고, 자식 컴포넌트는 props만 받아 렌더링하며, 상태 변경 시 새로운 Virtual DOM을 만든 뒤 이전 트리와 비교해서 바뀐 부분만 실제 DOM에 반영하도록 구현했다.
1. 요구사항 정리
- Component는 반드시 함수형 컴포넌트로 구현
- FunctionComponent 클래스를 직접 만들어
hooks 배열,mount(),update()를 구현 - Hook은 최상위 컴포넌트에서만 사용 가능
- State는 루트 컴포넌트에서만 관리
- 자식 컴포넌트는 props만 사용하는 Stateless Component로 구현
useState,useEffect,useMemo직접 구현- Virtual DOM 생성 후 이전 트리와 비교하고, 변경된 부분만 Patch
- 사용자 입력에 따라 화면이 바뀌는 테스트 페이지 제작
- 외부 프레임워크 없이 JavaScript, HTML, CSS만 사용
2. 프로젝트 전체 구조
src/
lib/
runtime.js // FunctionComponent, hooks, update scheduling
vdom.js // Virtual DOM 생성, diff, patch
tic-tac-toe/
model.js // 게임 상태와 순수 로직
demo/
main.js // 데모 페이지 엔트리
styles.css // 데모 페이지 스타일
tests/
runtime.test.js
vdom.test.js
tic-tac-toe-model.test.js
3. Component 구현 방식
이 프로젝트의 핵심은 FunctionComponent 클래스다. 일반적인 React에서는 함수형 컴포넌트를 React 내부가 관리하지만, 이번 프로젝트에서는 우리가 직접 이 실행 환경을 만들었다.
3-1. FunctionComponent의 역할
hooks[]배열로 state, memo, effect 정보를 저장mount()로 첫 렌더 수행update()로 상태 변경 후 재렌더 수행- 이전 Virtual DOM 트리와 현재 트리를 비교하여 patch 적용
- 렌더 후
useEffect실행
class FunctionComponent {
constructor(renderFn, props = {}) {
this.renderFn = renderFn;
this.props = props;
this.hooks = [];
this.hookIndex = 0;
this.container = null;
this.currentTree = createRootVNode([]);
this.pendingEffects = [];
this.updateScheduled = false;
this.isMounted = false;
}
mount(container) {
this.container = container;
this.renderAndCommit();
}
update(nextProps = this.props) {
this.props = nextProps;
this.renderAndCommit();
}
}
3-2. 자식 컴포넌트는 왜 Stateless인가?
과제 조건에 따라 Hook과 State는 루트 컴포넌트에서만 사용할 수 있게 설계했다. 그래서 App만 상태를 가지고, Board, Square, MoveHistoryPanel 같은 자식 컴포넌트는 오직 props만 받아 화면을 그린다.
function App() {
const [game, setGame] = useState(createInitialGameState);
return h(Board, {
board: getCurrentBoard(game),
onSquareClick: (index) => setGame((currentGame) => playMove(currentGame, index)),
});
}
function Board({ board, onSquareClick }) {
return h(
'section',
{},
...board.map((value, index) =>
h(Square, {
value,
onClick: () => onSquareClick(index),
}),
),
);
}
4. State 구현 방식
상태는 함수 내부 변수에 저장되지 않고, FunctionComponent 인스턴스의 hooks[] 배열에 저장된다. 렌더가 다시 일어나도 이 배열은 유지되기 때문에 상태도 유지된다.
4-1. 왜 상태가 유지되는가?
렌더 시작 시마다 hookIndex를 0으로 초기화하고, Hook을 호출한 순서대로 같은 슬롯을 재사용한다. 그래서 첫 번째 useState는 항상 같은 위치를 쓴다.
function getHook(component, name, expectedKind, createHook) {
const index = component.hookIndex++;
let hook = component.hooks[index];
if (!hook) {
hook = createHook();
component.hooks[index] = hook;
}
return hook;
}
5. Hooks 구현 방식
5-1. useState
useState는 상태 값을 담는 slot을 만들고, 현재 값과 setter를 반환한다. setter는 즉시 렌더하지 않고 queue에 상태 변경 요청을 저장한다.
export function useState(initialValue) {
const component = assertHookAccess('useState');
const hook = getHook(component, 'useState', 'state', () => ({
kind: 'state',
queue: [],
value: resolveInitialValue(initialValue),
}));
flushStateQueue(hook);
const setState = (nextValue) => {
hook.queue.push(nextValue);
component.scheduleUpdate();
};
return [hook.value, setState];
}
5-2. setState는 무엇을 하는가?
- 다음 상태를 queue에 저장
- 루트 update를 예약
- 다음 렌더에서 queue를 반영
- 새 Virtual DOM 생성
- diff 후 patch
5-3. batching
같은 이벤트 안에서 여러 번 호출된 setState는 하나의 microtask로 묶어 처리한다. 즉 여러 상태 변경을 한 번의 재렌더로 합친다.
scheduleUpdate() {
if (this.updateScheduled || !this.container) {
return;
}
this.updateScheduled = true;
queueMicrotask(() => {
this.updateScheduled = false;
this.update(this.props);
});
}
5-4. useMemo
useMemo는 dependency가 바뀌었을 때만 다시 계산된다. 이 프로젝트에서는 승패 계산, 말 개수 계산, 상태 문구 생성에 사용했다.
const result = useMemo(() => calculateResult(board), [board]);
const moveCount = useMemo(() => getMoveCount(board), [board]);
5-5. useEffect
useEffect는 렌더 중 실행되지 않고 commit 후 실행된다. 또한 다시 실행되기 전 이전 cleanup을 먼저 실행하도록 만들었다.
export function useEffect(callback, deps) {
const component = assertHookAccess('useEffect');
const hook = getHook(component, 'useEffect', 'effect', () => ({
kind: 'effect',
cleanup: null,
deps: undefined,
}));
if (shouldRunHook(hook.deps, deps)) {
component.pendingEffects.push({ callback });
hook.deps = [...deps];
}
}
6. Virtual DOM + Diff + Patch 구현
6-1. Virtual DOM 생성
이 프로젝트는 JSX 대신 h() 함수로 Virtual DOM 노드를 생성한다. 실제 DOM을 바로 만드는 것이 아니라, 먼저 UI 구조를 JavaScript 객체로 만든다.
h('button', { onClick: handleClick }, 'Click')
6-2. 초기 렌더
첫 렌더에서는 이전 트리가 없으므로 diff 없이 전체 Virtual DOM을 실제 DOM으로 마운트한다.
if (!this.isMounted) {
mountVNode(this.container, nextTree);
this.isMounted = true;
}
6-3. update 시 diff / patch
상태가 바뀌면 새 트리를 만들고 이전 트리와 비교해서, 변경된 부분만 실제 DOM에 반영한다.
UPDATE_PROPSUPDATE_TEXTINSERT_CHILDREMOVE_CHILDMOVE_CHILD
const operations = patchDom(this.container, this.currentTree, nextTree);
7. 테스트 페이지: Tic-Tac-Toe
데모 페이지는 사용자 입력에 따라 상태가 확실히 바뀌고, Hook과 Virtual DOM 동작을 설명하기 좋은 Tic-Tac-Toe 게임으로 만들었다.
7-1. 상태 변화 흐름
- 사용자가 칸 클릭
Square가 props로 받은onClick실행- 루트
App의setGame()호출 - 새 상태가 queue에 저장
- microtask에서 update 실행
- 새 Virtual DOM 생성
- diff
- patch
- effect 실행
7-2. 자식 컴포넌트에서 루트로 데이터 전달
자식 컴포넌트는 상태를 직접 수정하지 않고, 루트가 내려준 콜백을 호출해서 클릭 정보만 전달한다.
function Square({ index, onClick, value }) {
return h(
'button',
{
onClick,
type: 'button',
},
value || '',
);
}
function Board({ board, onSquareClick }) {
return h(
'section',
{},
...board.map((value, index) =>
h(Square, {
value,
onClick: () => onSquareClick(index),
}),
),
);
}
즉, 클릭된 칸 번호 같은 데이터는 이벤트 인자로 부모에게 전달되고, 실제 state 변경은 루트에서만 일어난다.
8. Component Lifecycle을 이 프로젝트 기준으로 설명하면
| 단계 | 설명 |
|---|---|
| 생성 | new FunctionComponent(App) 시 hooks 배열과 상태 플래그 초기화 |
| Mount | 첫 render 후 Virtual DOM 생성, 실제 DOM 마운트 |
| Update | setState 이후 새 트리 생성, diff, patch |
| Commit | 실제 DOM 반영 완료 |
| Effect | commit 후 useEffect 실행 |
| Cleanup | 다음 effect 실행 전 이전 cleanup 수행 |
9. 테스트 코드로 검증한 내용
- 루트 state 저장 및 DOM 업데이트
- 동일 이벤트 내 여러
setState호출 시 batching useMemo재계산 조건useEffect실행 및 cleanup 순서- 자식 컴포넌트의 Hook 사용 금지
- Virtual DOM diff/patch 정상 동작
- key 기반 자식 이동 처리
- Tic-Tac-Toe 상태 전이와 승패 계산
10. 실제 React와의 차이점
- Hook을 루트 컴포넌트에서만 허용
- Fiber 구조 없음
- Concurrent Rendering 없음
- 우선순위 기반 스케줄링 없음
- Effect 처리 방식이 단순화됨
- Reconciliation이 최소 기능만 구현됨
11. 마무리
이번 과제를 통해 React를 단순히 사용하는 수준이 아니라, 상태가 어떻게 유지되고, 렌더가 어떻게 다시 일어나며, DOM이 왜 전체가 아니라 필요한 부분만 갱신되는지를 직접 구현하며 이해할 수 있었다.
- 함수는 다시 실행되어도 상태는 hooks 배열에 남아 있어야 한다.
setState는 값 변경이 아니라 재렌더 흐름의 시작점이다.- Virtual DOM diff/patch는 DOM 업데이트를 효율적으로 만든다.
결과적으로 이번 프로젝트는 과제 요구사항을 충실히 반영하면서도, 미니 React 런타임의 핵심 개념을 설명할 수 있는 구조로 완성되었다.