글 목록으로 돌아가기

Jungle / Everyday

4/1 수요코딩 리액트(2)

Week 5 미니 React.ver2 구현 정리 이번 과제는 기존 React의 핵심 개념인 Component , State , Hooks 를 직접 구현하고, 이를 바탕으로 실제로 동작하는 웹 페이지를 만드는 것이 목표였다. 우리 프로젝트는 단순히 Re

임재환
임재환 2026년 4월 5일 · 5분 읽기 · 수정 2026년 4월 5일
4/1 수요코딩 리액트(2)

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_PROPS
  • UPDATE_TEXT
  • INSERT_CHILD
  • REMOVE_CHILD
  • MOVE_CHILD
const operations = patchDom(this.container, this.currentTree, nextTree);

7. 테스트 페이지: Tic-Tac-Toe

데모 페이지는 사용자 입력에 따라 상태가 확실히 바뀌고, Hook과 Virtual DOM 동작을 설명하기 좋은 Tic-Tac-Toe 게임으로 만들었다.

7-1. 상태 변화 흐름

  1. 사용자가 칸 클릭
  2. Square가 props로 받은 onClick 실행
  3. 루트 AppsetGame() 호출
  4. 새 상태가 queue에 저장
  5. microtask에서 update 실행
  6. 새 Virtual DOM 생성
  7. diff
  8. patch
  9. 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 런타임의 핵심 개념을 설명할 수 있는 구조로 완성되었다.