민혁이의 IT스토리

[Mini React 만들기 #4] - 함수형 컴포넌트와 상태관리 본문

혼자 공부해서 개발까지/JavaScript

[Mini React 만들기 #4] - 함수형 컴포넌트와 상태관리

FE_Minhyuk 2026. 3. 22. 17:12

Intro


이전 3편에서는 가상 DOM을 활용하여 변경된 속성과 이벤트만 영리하게 갈아끼우는 '업데이트 파이프라인(Reconciliation & Diffing)'을 완성했습니다. 이제 리액트는 불필요한 DOM 조작을 피해 아주 빠르고 가볍게 렌더링을 수행할 수 있게 되었습니다.

하지만 치명적인 한계가 남아있습니다. 지금까지는 <div>, <h1> 같은 일반 HTML 태그(Host Component)만 다룰 수 있었습니다. 복잡한 웹 애플리케이션을 만들기 위해 코드를 재사용 가능한 블록으로 쪼개는 일, 즉 <App /> 같은 '함수형 컴포넌트(Function Component)'를 사용할 수 없는 반쪽짜리 리액트였죠.

또한, 함수형 컴포넌트 안에서 값이 변할 때마다 화면이 스스로 업데이트 되게 만드는 State도 필요합니다. 이번 4편에서는 우리의 미니 리액트 엔진이 함수형 컴포넌트를 이해할 수 있도록 구조를 개선하고, 컴포넌트에게 상태를 기억할 수 있는 뇌(useState)를 이식해 보겠습니다.


핵심 개념


이번 렌더링 업데이트 과정을 관통하는 세 가지 핵심 개념은 다음과 같습니다.

1. Babel의 마법과 함수의 실행

우리가 JSX로 <App name="민혁" />이라고 작성하면, 바벨은 이것을 React.createElement(App, { name: "민혁" })으로 번역합니다.

이때 아주 중요한 차이가 발생합니다.

  • <div />를 쓸 때 fiber.type"div"라는 문자열이었습니다.
  • <App />을 쓸 때 fiber.type은 문자열이 아니라 App 연산 로직을 담은 '함수 그 자체(Function)'가 됩니다.

브라우저는 뜬금없이 "App 함수를 HTML로 만들어줘"라고 하면 에러를 뿜으며 죽어버립니다. 따라서 우리는 들어온 type이 함수인지 일반 태그인지 먼저 구분해야 합니다. 만약 함수라면, 해당 함수를 한 번 실행(호출)하여 그 함수가 뱉어내는 진짜 HTML 태그(자식 요소)들을 끄집어내는 과정이 선행되어야 합니다.

 

 

2. 가상의 존재: 실제 DOM이 없는 노드

함수형 컴포넌트는 자바스크립트의 추상적인 개념일 뿐, 브라우저 화면에 대응되는 실제 물리적 DOM 노드(div, span 등)를 갖지 않습니다.

따라서 화면에 무언가를 붙일 때(appendChild), 무작정 내 부모(fiber.parent.dom)를 찾아가면 안 됩니다. 내 부모가 <App />이라면 실제 DOM이 비어있기 때문입니다. 우리는 진짜 물리적인 DOM을 가지고 있는 진짜 조상(예: <div id="root">)을 만날 때까지 가상 트리 위로 거슬러 올라가는 탐색 로직을 추가해야 합니다. (삭제할 때도 마찬가지로 진짜 DOM을 가진 자식까지 파고 들어가야 합니다.)

3. 클로저 방어막: 상태 저장소 (Hooks 배열)

함수형 컴포넌트 안에 let count = 0이라고 변수를 선언해 봤자, 컴포넌트가 렌더링(다시 실행) 될 때마다 변수는 매번 0으로 초기화되어 버립니다.

과거의 상태를 오래된 클로저(Stale Closure)에 빼앗기지 않고 온전히 보존하기 위해, 우리는 현재 작업 중인 Fiber(wipFiber)의 허리춤에 hooks라는 배열을 매달아 둡니다. 그리고 화면의 변경을 예약하는 스위치(setState)를 제공하여, 사용자가 스위치를 누르면 렌더링 엔진을 강제로 깨워 변경된 최신 상태를 반영한 화면을 처음부터 다시 그리도록(requestIdleCallback) 설계합니다.


구현


1. performUnitOfWork : 갈림길 나누기

이제 type이 함수인지 문자열인지에 따라 작업 처리 방식을 두 갈래로 나눕니다.

// 역할: Fiber 하나를 처리하고, "다음 처리할 Fiber"를 반환하는 함수
function performUnitOfWork(fiber) {
  // 1. 함수형 컴포넌트인지 일반 HTML 태그인지 확인합니다.
  const isFunctionComponent = fiber.type instanceof Function;

  // 2. 타입에 따라 다르게 업데이트를 진행합니다.
  if (isFunctionComponent) {
    updateFunctionComponent(fiber);
  } else {
    updateHostComponent(fiber);
  }

  // ... (탐색 순서 반환 로직은 동일)
}

함수형 컴포넌트 처리를 담당하는 updateFunctionComponent 에서는 함수 그 자체(fiber.type)를 직접 실행하여 그 결괏값을 자식(children)으로 받아옵니다. 이때, 추후 useState에서 현재 컴포넌트의 위치를 알 수 있도록 전역 변수(wipFiber, hookIndex)를 세팅해 주는 준비 작업도 함께 거칩니다.

let wipFiber = null;
let hookIndex = null;

function updateFunctionComponent(fiber) {
  // [Hooks 준비물] 상태(state)를 함수에 매달아두기 위해 현재 Fiber를 전역에 기억해둡니다.
  wipFiber = fiber;
  hookIndex = 0;
  wipFiber.hooks = [];

  // 핵심: 함수 자체를 실행(호출)해야만 그 안의 진짜 HTML 리턴값을 받을 수 있습니다!
  const children = [fiber.type(fiber.props)];
  reconcileChildren(fiber, children);
}

2. commitWork & commitDeletion : 진짜 DOM 찾기 여행

함수형 컴포넌트는 물리적인 DOM(fiber.dom)을 가지지 않습니다. 따라서 화면에 그릴 때, 진짜 DOM을 가지고 있는 조상을 만날 때까지(while) 트리 위를 거슬러 올라가야 안전하게 화면에 요소를 붙일 수 있습니다.

function commitWork(fiber) {
  if (!fiber) return;

  //  주의: 함수형 컴포넌트는 실제 브라우저 DOM 노드가 없습니다!
  // 진짜 DOM을 가진 할아버지/증조할아버지를 만날 때까지 거슬러 올라가야 합니다.
  let domParentFiber = fiber.parent;
  while (!domParentFiber.dom) {
    domParentFiber = domParentFiber.parent;
  }
  const domParent = domParentFiber.dom;

  // 상태에 맞춰 appendChild, updateDom 등을 수행...

  // 상황 3. 화면에서 지워야 할 때 (DELETION)
  else if (fiber.effectTag === "DELETION") {
    // 지울 때도 마찬가지로 진짜 DOM을 찾아 파고드는 별도 함수를 호출합니다.
    commitDeletion(fiber, domParent);
  }

  // ... (재귀 호출 로직)
}

3. useState : 내 상태 기억하기

대망의 핵심인 상태 관리 훅입니다. 이전 렌더링(과거)에서 킵해둔 hooks 배열이 있다면 그 상태를 물려받고, 없으면 초기값을 세팅해 줍니다.


// Hooks 상태 관리의 핵심: useState 구현하기

function useState(initial) {
  // 1. 내 과거 모습(alternate)에서 예전의 나(hookIndex 위치)를 꺼내봅니다.
  const oldHook =
    wipFiber.alternate &&
    wipFiber.alternate.hooks &&
    wipFiber.alternate.hooks[hookIndex];

  const hook = {
    state: oldHook ? oldHook.state : initial,
    queue: [], // setState가 여러 번 불릴 것에 대비한 대기열
  };

  // 2. 예약되어 있던 밀린 상태 변경 작업들을 한꺼번에 처리합니다.
  const actions = oldHook ? oldHook.queue : [];
  actions.forEach(action => {
    hook.state = typeof action === "function" ? action(hook.state) : action;
  });

  // 3. 컴포넌트에게 건네줄 상태 변경 스위치(setState) 조작부
  const setState = action => {
    hook.queue.push(action); // 당장 바꾸지 않고 대기열에 예약

    // 화면 전체를 "처음부터 다시 그려랏!" 하고 requestIdleCallback 루프를 깨웁니다.
    wipRoot = {
      dom: currentRoot.dom,
      props: currentRoot.props,
      alternate: currentRoot, 
    };
    nextUnitOfWork = wipRoot;
    deletions = [];
  };

  // 4. 세팅된 훅을 컴포넌트 허리춤에 달아주고 인덱스를 올려 다음 훅을 대비합니다.
  wipFiber.hooks.push(hook);
  hookIndex++;

  return [hook.state, setState];
}

이 코드를 통해, 우리는 setCount 함수를 부를 때마다 조용히 잠들어있던 렌더링 엔진(nextUnitOfWork)에 먹이를 주어 강제로 깨울 수 있게 되었습니다!


마무리


축하합니다! 이제 우리의 미니 리액트 엔진은 단순히 정적인 태그만 찍어내던 깡통 엔진을 벗어났습니다. 재사용 가능한 '함수형 컴포넌트'를 온전히 렌더링하며, 사용자의 클릭에 반응해 화면을 스스로 다시 그리는 '기억(State)과 생명'을 얻게 되었습니다.

우리는 지금 리액트가 화면을 다루는 가장 본질적인 사이클을 완성했습니다.
다음 5편에서는 마지막으로 화면이 렌더링된 직후에 별도로 통신이나 타이머 작업을 예약할 수 있는 부수 효과, 즉 useEffect 훅을 마저 이식해 보겠습니다.