티스토리 뷰

Rodrigo Pombo의 Build your own React 블로그 글을 참고해서 리액트의 기본 개념, 기능들을 직접 따라 만들어 보면서, 신경쓰지 못한 부분을 알고 가고자 한다.

알아가는 과정 속에서 원글을 응용하여 직접 돌려보며 확인해볼 수 있는 실습환경을 구성 및 번역 내용이 들어간 프로젝트를 만들었다. learn-react-by-building-ko

이전 글에서는 리액트의 Fiber 개념과 Render 함수 그리고 Commit 단계, Reconciliation 재조정에 대해서 알아보았습니다.

오늘은 리액트의 함수형 컴포넌트 개념과 Hook에 대해서 알아보겠습니다.

똑같이 원글을 따라 가며, Step7~Step8 까지의 내용을 담고 있으며 각 스텝마다 실습을 구성했습니다. 추가적으로 원글에는 없지만, useEffect에 대한 챕터를 따로 추가하여 Step9을 넣었습니다.

Step VII: Function Component(함수형 컴포넌트)

다음으로 추가해야 할 것은 함수 컴포넌트에 대한 지원입니다.

먼저 예제를 변경해 봅시다.
h1 요소를 반환하는 다음과 같은 간단한 함수 컴포넌트를 사용할 것입니다.

JSX를 JS로 변환하면 다음과 같이 됩니다:

/** @jsx Didact.createElement */
// 아래와 같이 함수형 컴포넌트를 정의한 경우
function App(props) {
    return <h1>Hi {props.name}</h1>; // JSX 트랜스폼 규칙: 소문자 시작 -> 문자열 "h1"
}

// jsx의 요소가 아닌 함수형 컴포넌트를 할당한 경우
const element = <App name="foo" />; // JSX 트랜스폼 규칙: 대문자 시작 -> 식별자 App (변수 참조)
const container = document.getElementById('root');
Didact.render(element, container);
// 변환하면, 함수는 그대로 유지되고, 반환하는 jsx는 이전화 동일하게 변환되어 return에 매핑
function App(props) {
    return Didact.createElement('h1', null, 'Hi ', props.name);
}

const element = Didact.createElement(App, {
    name: 'foo',
});

// 참고
function createElement(type, props, ...children) {
    return {
        type, // 함수 컴포넌트의 경우, fiber.type에 할당된다.
        props: {
            ...props,
            children: children.map((child) =>
                typeof child === 'object' ? child : createTextElement(child),
            ),
        },
    };
}

함수 컴포넌트는 두 가지 면에서 다릅니다:

  • 함수 컴포넌트로부터 생성된 fiber는 DOM 노드를 가지지 않습니다. (함수 컴포넌트 JSX에 대응되는 DOM이 없다는 표현, 내부 요소를 타고 갈 수 있게 도와줄 뿐)
  • 그리고 자식(children)들은 props에서 직접 가져오는 것이 아니라, 함수를 실행해서 얻습니다.
function performUnitOfWork(fiber) {
  if (!fiber.dom) {
    fiber.dom = createDom(fiber)
  }
​
  const elements = fiber.props.children
  reconcileChildren(fiber, elements)

// more ...
}

우리는 fiber의 타입이 함수인지 확인하고, 그에 따라 다른 업데이트 함수로 분기합니다.
updateHostComponent에서는 이전과 동일하게 처리합니다.

그리고 updateFunctionComponent에서는 함수를 실행해서 자식들을 가져옵니다.

function performUnitOfWork(fiber) {
  const isFunctionComponent =
    fiber.type instanceof Function
  if (isFunctionComponent) {
    updateFunctionComponent(fiber)
  } else {
    updateHostComponent(fiber)
  }
  if (fiber.child) {
    return fiber.child
  }
  let nextFiber = fiber
  while (nextFiber) {
    if (nextFiber.sibling) {
      return nextFiber.sibling
    }
    nextFiber = nextFiber.parent
  }
}
​
function updateFunctionComponent(fiber) {
  // TODO
}
​
function updateHostComponent(fiber) {
  if (!fiber.dom) {
    fiber.dom = createDom(fiber)
  }
  reconcileChildren(fiber, fiber.props.children)
}

우리 예제의 경우, 여기서 fiber.type은 App 함수이고, 이를 실행하면 h1 요소를 반환합니다.
그런 다음 자식들을 얻고 나면, reconciliation(재조정)은 이전과 동일한 방식으로 동작하므로 거기서는 아무것도 변경할 필요가 없습니다.

function updateFunctionComponent(fiber) {
    // updateHostComponent와 달리 DOM을 만들 필요가 없고, 자식이 무엇인지 계산하여 다음 fiber를 만들도록 한다.
    const children = [fiber.type(fiber.props)];
    reconcileChildren(fiber, children);
}

// 참고
function reconcileChildren(wipFiber, elements) {
    let index = 0;
    let oldFiber = wipFiber.alternate && wipFiber.alternate.child;
    let prevSibling = null;
    while (index < elements.length || oldFiber != null) {
        const element = elements[index];
        let newFiber = null;

        const sameType = oldFiber && element && element.type == oldFiber.type;
        if (sameType) {
            newFiber = {
                type: oldFiber.type,
                props: element.props,
                dom: oldFiber.dom,
                parent: wipFiber,
                alternate: oldFiber,
                effectTag: 'UPDATE',
            };
        }

        if (element && !sameType) {
            newFiber = {
                type: element.type,
                props: element.props,
                dom: null,
                parent: wipFiber,
                alternate: null,
                effectTag: 'PLACEMENT',
            };
        }

        if (oldFiber && !sameType) {
            oldFiber.effectTag = 'DELETION';
            deletions.push(oldFiber);
        }

        if (oldFiber) {
            oldFiber = oldFiber.sibling;
        }
        if (index === 0) {
            wipFiber.child = newFiber;
        } else {
            prevSibling.sibling = newFiber;
        }
        prevSibling = newFiber;
        index++;
    }
}

우리가 변경해야 할 것은 commitWork 함수입니다.
이제 DOM 노드가 없는 fiber들이 있기 때문에 두 가지를 변경해야 합니다.

첫째, DOM 노드의 부모를 찾기 위해서는 DOM 노드를 가진 fiber를 찾을 때까지 fiber 트리를 위로 거슬러 올라가야 합니다.

그리고 노드를 제거할 때도 DOM 노드를 가진 자식을 찾을 때까지 계속 내려가야 합니다.

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

  // 함수 컴포넌트 사용으로 항상 fiber에 DOM이 있지 않게 되었기 때문에 이제는 DOM이 있는 부모를 찾아서 생성하거나, 제거한 DOM을 반영해줘야 한다.
  let domParentFiber = fiber.parent
  while (!domParentFiber.dom) {
    domParentFiber = domParentFiber.parent
  }
  //  domParent.appendChild(fiber.dom)
  const domParent = domParentFiber.dom
​
  if (
    fiber.effectTag === "PLACEMENT" &&
    fiber.dom != null
  ) {
    domParent.appendChild(fiber.dom)
  } else if (
    fiber.effectTag === "UPDATE" &&
    fiber.dom != null
  ) {
    updateDom(
      fiber.dom,
      fiber.alternate.props,
      fiber.props
    )
  } else if (fiber.effectTag === "DELETION") {
    // DOM을 가진 fiber 자식을 탐색하여 DOM을 제거
    // domParent.removeChild(fiber.dom)
     commitDeletion(fiber, domParent)
  }
​
  commitWork(fiber.child)
  commitWork(fiber.sibling)
}

function commitDeletion(fiber, domParent) {
  if (fiber.dom) {
    domParent.removeChild(fiber.dom)
  } else {
    commitDeletion(fiber.child, domParent)
  }
}

실습

index.js의 요소를 원하는 형태로 수정 후 pnpm step7-1로 트랜스파일 후 pnpm step7-2 로 결과를 확인하세요.

우리는 위 함수형 컴포넌트를 활요할 수 있게 되면서, 재사용 가능한 구조를 얻게 되었습니다.
자원 및 개발 효율성을 높일 수 있게 되었습니다.

Step VIII: Hooks

마지막 단계입니다. 이제 함수 컴포넌트가 있으니, 상태(state)도 추가해 봅시다.

예제를 클래식한 카운터 컴포넌트로 변경해 봅시다. 클릭할 때마다 상태가 1씩 증가합니다. 여기서 우리는 카운터 값을 가져오고 업데이트하기 위해 Didact.useState를 사용하고 있다는 점에 주목하세요.

const Didact = {
  createElement,
  render,
  useState,
}
​
/** @jsx Didact.createElement */
function Counter() {
  const [state, setState] = Didact.useState(1)
  return (
    <h1 onClick={() => setState(c => c + 1)}>
      Count: {state}
    </h1>
  )
}
const element = <Counter />
const container = document.getElementById("root")
Didact.render(element, container)

예제에서 Counter 함수를 호출하는 부분이 여기입니다. 그리고 그 함수 안에서 useState를 호출하죠.

function updateFunctionComponent(fiber) {
  const children = [fiber.type(fiber.props)]
  reconcileChildren(fiber, children)
}
​
function useState(initial) {
  // TODO
}

함수 컴포넌트를 호출하기 전에 몇 가지 전역 변수를 초기화해야 합니다. 그래야 useState 함수 내부에서 이 변수들을 사용할 수 있습니다.

먼저 작업 중인(work in progress) fiber를 설정합니다. 또한 같은 컴포넌트에서 useState를 여러 번 호출할 수 있도록 fiber에 hooks 배열을 추가합니다. 그리고 현재 hook 인덱스를 추적합니다.

let wipFiber = null
let hookIndex = null
​
function updateFunctionComponent(fiber) {
  wipFiber = fiber
  hookIndex = 0
  wipFiber.hooks = []
  const children = [fiber.type(fiber.props)]
  reconcileChildren(fiber, children)
}

함수 컴포넌트가 useState를 호출하면, 이전 hook이 있는지 확인합니다. hook 인덱스를 사용하여 fiber의 alternate에서 확인합니다. 이전 hook이 있으면, 이전 hook의 상태를 새 hook으로 복사하고, 없으면 상태를 초기화합니다. 그런 다음 새 hook을 fiber에 추가하고, hook 인덱스를 1 증가시키고, 상태를 반환합니다.

function useState(initial) {
  const oldHook =
    wipFiber.alternate &&
    wipFiber.alternate.hooks &&
    wipFiber.alternate.hooks[hookIndex]
  const hook = {
    state: oldHook ? oldHook.state : initial,
  }
​
  wipFiber.hooks.push(hook)
  hookIndex++
  return [hook.state]
}

useState는 상태를 업데이트하는 함수도 반환해야 하므로, action을 받는 setState 함수를 정의합니다 (Counter 예제의 경우, 이 action은 상태를 1 증가시키는 함수입니다). 그 action을 hook에 추가한 큐(queue)에 push 합니다. 그리고 render 함수에서 했던 것과 비슷한 일을 합니다. 새로운 작업 중인 root를 다음 작업 단위(next unit of work)로 설정해서, 작업 루프(work loop)가 새로운 렌더 단계를 시작할 수 있게 하는 거죠.

function useState(initial) {
  // 저장해둔 hook이 있는지 확인 (render 또는 reconcileChildren 과정에서 fiber를 구조화하는 과정에서 새로운 fiber.alternate에 이전 fiber 형상이 저장됨)
  const oldHook =
    wipFiber.alternate &&
    wipFiber.alternate.hooks &&
    wipFiber.alternate.hooks[hookIndex]

  const hook = {
    state: oldHook ? oldHook.state : initial,
    queue: [],
  }
​
  const setState = action => {
    // 즉시 상태를 변경하지 않고, 큐에 Action을 쌓아두고 리렌더 예약
    hook.queue.push(action)
    wipRoot = {
      dom: currentRoot.dom,
      props: currentRoot.props,
      alternate: currentRoot,
    }
    nextUnitOfWork = wipRoot
    deletions = []
  }
​
  // 인덱스 방식으로 여러 개의 hook을 저장
  wipFiber.hooks.push(hook)
  hookIndex++
  return [hook.state, setState]
}

하지만 우리는 아직 action을 실행하지는 않았습니다. action 실행은 다음번에 컴포넌트를 렌더링할 때 합니다. 이전 hook 큐에서 모든 action을 가져와, 그것들을 하나씩 새 hook의 상태에 적용합니다. 그래서 상태를 반환할 때는 이미 업데이트된 상태가 됩니다. 이게 전부입니다. 우리만의 React 버전을 만들어 냈습니다.

function useState(initial) {
    const oldHook =
        wipFiber.alternate &&
        wipFiber.alternate.hooks &&
        wipFiber.alternate.hooks[hookIndex];
    const hook = {
        state: oldHook ? oldHook.state : initial,
        queue: [],
    };

    // 이전에 저장되어 있던 action 큐를 실행하여 상태 반영하여 state를 반환
    const actions = oldHook ? oldHook.queue : [];
    actions.forEach((action) => {
        hook.state = action(hook.state);
    });

    const setState = (action) => {
        hook.queue.push(action);
        wipRoot = {
            dom: currentRoot.dom,
            props: currentRoot.props,
            alternate: currentRoot,
        };
        nextUnitOfWork = wipRoot;
        deletions = [];
    };
    wipFiber.hooks.push(hook);
    hookIndex++;
    return [hook.state, setState];
}

실습

index.js에 원하는 hook을 사용하여 컴포넌트를 구성하고 pnpm step8-1로 트랜스파일 후 pnpm step8-2로 확인해보세요.

위 글을 통해서 hook이 동작하는 매커니즘(hook index)을 직접 확인하면서, 왜 React에서 hook을 사용할 때 조건문/반복문 안에서 hook 사용을 제한하고 있는 부분이 자연스럽게 이해되었습니다.

에필로그 (Rodrigo Pombo)

| 아래 내용은 원문의 글쓴이의 에필로그입니다.

이 포스트의 목표 중 하나는 React가 어떻게 동작하는지 이해하도록 돕는 것뿐만 아니라, React 코드베이스에 더 깊이 파고들기 쉽게 만드는 것입니다. 그래서 우리는 거의 모든 곳에서 React와 동일한 변수명과 함수명을 사용했습니다. 예를 들어, 실제 React 앱의 함수 컴포넌트 중 하나에 브레이크포인트를 추가하면 콜 스택(call stack)에 다음과 같은 것들이 보일 겁니다:

  • workLoop
  • performUnitOfWork
  • updateFunctionComponent

우리는 React의 많은 기능과 최적화를 포함시키지 않았습니다. 예를 들어, React가 다르게 처리하는 것들 몇 가지를 살펴보면:

  • Didact에서는 렌더 단계(render phase) 동안 전체 트리를 순회합니다. 반면 React는 몇 가지 힌트와 휴리스틱(heuristics)을 따라서, 아무것도 변경되지 않은 서브트리 전체를 건너뜁니다.
  • 우리는 커밋 단계(commit phase)에서도 전체 트리를 순회합니다. React는 effect가 있는 fiber들만을 담은 연결 리스트(linked list)를 유지하고, 그 fiber들만 방문합니다.
  • 새로운 work in progress 트리를 만들 때마다, 우리는 각 fiber마다 새로운 객체를 생성합니다. React는 이전 트리들의 fiber를 재활용합니다.
  • 렌더 단계 중에 새로운 업데이트를 받으면 Didact는 작업 중인 트리를 버리고 루트부터 다시 시작합니다. React는 각 업데이트에 만료 타임스탬프(expiration timestamp)를 태깅하고, 이를 사용해 어떤 업데이트가 더 높은 우선순위를 갖는지 결정합니다.
  • 그 외에도 많이 있습니다…

쉽게 추가할 수 있는 기능들도 몇 가지 있습니다:

  • style prop에 객체 사용하기
  • children 배열 평탄화(flatten)하기
  • useEffect hook
  • key를 이용한 재조정(reconciliation)

이 중 어떤 기능이든, 또는 다른 기능들을 Didact에 추가하시면 GitHub 저장소에 풀 리퀘스트를 보내주세요. 다른 사람들도 볼 수 있도록 말이죠. 읽어주셔서 감사합니다! Build your own React, Rodrigo Pombo

Step IX: useEffect (번외)

8단계에서는 글의 원작자가 useEffect를 만들어보는 것을 추천하여 개인적으로 진행해본 내용입니다.

React의 useEffect는 아래와 같은 형식으로, 사이드 이펙트를 줘야하는 경우 사용합니다. 첫번째 인자로, 동작에 대한 함수를 받고 두번째 인자로 의존성 배열을 받습니다. 의존성 배열과 상관 없이 컴포넌트가 마운트되면 실행됩니다. 이후 의존성 배열이 변경되면 함수를 다시 실행시킵니다.

function App() {
    Didact.useEffect(() => {
        const interval = setInterval(() => {
            setDateNum(() => Date.now());
        }, 1000);

        return () => clearInterval(interval);
    }, []);

    Didact.useEffect(() => {
        setAdd((a) => a + 1);
    }, [dateNum]);
}

우리는 useState와 같은 매커니즘을 활용합니다. hook을 인덱스 기반으로 똑같이 저장하고, useEffect에서 이전과 비교가 필요한 의존성 배열을 저장합니다. 그리고 렌더링 이후 이펙트가 실행되도록 합니다.

const Didact = {
    createElement,
    createTextElement,
    render,
    useState,
    useEffect,
};

function useEffect(fn, deps) {
    const oldHook =
        wipFiber.alternate &&
        wipFiber.alternate.hooks &&
        wipFiber.alternate.hooks[hookIndex];

    const hasChanged = oldHook
        ? !deps || deps.some((dep, i) => dep !== oldHook.deps[i])
        : true;

    const hook = {
        tag: 'effect',
        deps,
        effect: hasChanged ? fn : null,
        cleanup: oldHook ? oldHook.cleanup : null,
    };

    wipFiber.hooks.push(hook);
    hookIndex++;
}

위의 useEffect는 hook에 effect 함수를 저장만 하고 실제로 호출하지는 않습니다. React의 useEffect는 렌더링 → DOM 반영(commit) → paint 이후에 비동기적으로 실행되기 때문입니다. 우리도 commit phase가 끝난 다음 effect를 모아 실행하도록 만듭니다.

function commitRoot() {
    deletions.forEach(runCleanupsOnUnmount); // 언마운트되는 fiber의 cleanup 먼저
    deletions.forEach(commitWork);
    commitWork(wipRoot.child);
    const root = wipRoot;
    requestAnimationFrame(() => {
        setTimeout(() => runEffects(root.child), 0); // paint 이후로 미루기
    });
    currentRoot = wipRoot;
    wipRoot = null;
}

requestAnimationFrame 콜백은 다음 paint 직전에 실행되고, 그 안에서 setTimeout(..., 0)으로 다음 task로 넘기면 paint가 끝난 뒤에 effect가 실행됩니다. 즉 "render → commit(DOM 반영) → paint → effect" 순서가 보장됩니다.

runEffects는 fiber 트리를 순회하면서 tag === 'effect'이고 새로 실행될 effect가 있는 hook을 찾아 호출합니다. 이전 cleanup이 있었다면 먼저 호출하고, effect 반환값이 함수면 다음 cleanup으로 저장해 둡니다.

function runEffects(fiber) {
    if (!fiber) return;
    if (fiber.hooks) {
        fiber.hooks
            .filter((h) => h && h.tag === 'effect' && h.effect)
            .forEach((h) => {
                if (h.cleanup) h.cleanup();
                const cleanup = h.effect();
                h.cleanup = typeof cleanup === 'function' ? cleanup : null;
            });
    }
    runEffects(fiber.child);
    runEffects(fiber.sibling);
}

function runCleanupsOnUnmount(fiber) {
    if (!fiber) return;
    if (fiber.hooks) {
        fiber.hooks
            .filter((h) => h && h.tag === 'effect' && h.cleanup)
            .forEach((h) => h.cleanup());
    }
    runCleanupsOnUnmount(fiber.child);
    runCleanupsOnUnmount(fiber.sibling);
}

여기까지 작성하고 위 예제를 돌려보면, 1초 후 시계가 한 번만 갱신되고 멈추는 현상을 만날 수 있습니다. 원인은 useEffect가 아니라 useState 쪽 클로저에 있습니다.

useState는 매 렌더마다 새 hook 객체와 새 queue: [] 배열을 만듭니다. 그리고 setState 클로저는 그 렌더의 queue를 캡처합니다. button onClick처럼 매 렌더마다 새로 생성되는 클로저에서는 문제가 없지만, setInterval 콜백처럼 첫 렌더의 setState를 오래 붙들고 있는 경우 이전 queue 참조에 계속 넣습니다. 따라서 다음 렌더에서 alternate의 hooks로 계속 참조가 전해지지 못해서 setInterval의 action이 전해지지 못해 해당 현상이 발생하게 됩니다.

해결책은 queue 배열을 매 렌더마다 새로 만들지 말고 첫 렌더의 배열을 계속 재사용하는 것입니다. 그러면 setDateNum 클로저가 잡고 있는 큐가 영원히 살아 있는 큐가 됩니다.

function useState(initial) {
    const oldHook =
        wipFiber.alternate &&
        wipFiber.alternate.hooks &&
        wipFiber.alternate.hooks[hookIndex];
    const queue = oldHook ? oldHook.queue : []; // ← 같은 reference를 이어 받음
    const hook = {
        state: oldHook ? oldHook.state : initial,
        queue,
    };

    // 큐를 비우면서 동시에 action들을 꺼내 상태에 반영
    const actions = queue.splice(0);
    actions.forEach((action) => {
        hook.state = action(hook.state);
    });

    const setState = (action) => {
        queue.push(action); // 항상 같은 queue에 push
        wipRoot = {
            dom: currentRoot.dom,
            props: currentRoot.props,
            alternate: currentRoot,
        };
        nextUnitOfWork = wipRoot;
        deletions = [];
    };
    wipFiber.hooks.push(hook);
    hookIndex++;
    return [hook.state, setState];
}

실습

index.js에 원하는 useEffect을 사용하여 컴포넌트를 구성하고 pnpm step9-1로 트랜스파일 후 pnpm step9-2로 확인해보세요.

원문 프로젝르를 마치며

위 과정을 통해서 기본적으로 React가 어떻게 개발의 편의성을 높여 DX를 제공했는지, 그리고 브라우저 렌더링을 어떻게 제어하고 성능 최적화를 진행했는지. 그 외에 개념들을 전체적으로 실습과 함께 확인 할 수 있는 기회가 되었습니다.

특히 Fiber 개념을 도입하여, 데이터를 구조화하여 작업을 단위를 쪼개어 DFS(깊이 우선 탐색) 형식으로 저장하고 이를 메인 스레드를 차단하지 않고, 작업을 부분적으로 진행한다는 점이 인상깊게 느껴졌습니다.

각 단계를 지나면서 평소에 그냥 넘기던 것들이 하나씩 풀렸습니다. useState를 조건문 안에서 쓰면 안 된다는 건 그냥 린터 규칙으로 외우고 있었는데, hookIndex로 순서를 기억하는 구조를 직접 만들어보니 왜 안 되는지가 바로 이해됐습니다. setState가 즉시 반영되지 않는 것도, action을 queue에 쌓고 다음 렌더에 처리하는 코드를 직접 짜고 나니 버그가 아니라 설계라는 게 납득됐습니다.

실무에서도 달라진 게 있습니다. Reconciliation 구조를 알고 나니 key prop이나 React.memo를 쓸 때 "이게 맞나?" 하는 감이 아니라 왜 필요한지 근거를 갖고 판단하게 됐고, DevTools Profiler의 콜스택에서 performUnitOfWork, commitRoot 같은 이름이 보여도 이제는 낯설지 않습니다.

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/07   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함