(번역) 400줄의 코드로 나만의 리액트 만들기 (부제: 리액트의 원리에 대한 심층 연구)

Ricki
14 min readAug 19, 2024

--

원문: Build Your Own React.js in 400 Lines of Code

리액트 19의 베타 버전이 출시되었습니다. 리액트 18과 비교하면 사용자 친화적인 API가 많이 추가되었지만, 핵심 원칙은 크게 바뀌지 않았습니다. 여러분은 리액트를 꽤 오랫동안 사용해 왔을 텐데, 내부적으로 어떻게 작동하는지 알고 계신가요?

이 글에서는 비동기 업데이트를 지원하고 작업을 중단할 수 있는 리액트를 약 400줄의 코드로 구현하는 방법을 알아보겠습니다. 이는 리액트의 많은 고수준 API가 사용하는 핵심 기능입니다. 최종 결과는 다음과 같습니다.

저는 리액트 공식 사이트에서 제공되는 틱택토 실습 예제를 사용했고, 잘 작동하는 것을 확인할 수 있습니다.

현재 제 깃허브 에 호스팅되어 있으며, 온라인 버전 에서 직접 시도해 볼 수도 있습니다.

JSX와 createElement

mini-react.ts 의 원리를 살펴보기 이전에, JSX가 무엇을 나타내는지 이해하는 것이 중요합니다. 우리는 JSX를 사용하여 DOM을 그리고, 자바스크립트 로직을 쉽게 적용할 수 있습니다. 하지만 브라우저는 JSX를 이해하지 못하기 때문에, JSX는 브라우저가 이해할 수 있는 자바스크립트로 컴파일됩니다.

저는 이 예제에서 바벨을 사용하였지만, 여러분은 물론 다른 빌드 도구들을 사용할 수 있고 생성되는 내용은 비슷할 것입니다.

보시다시피 React.createElement를 호출하며, 다음과 같은 옵션을 제공합니다.

  1. type: div와 같은 현재 노드의 타입을 나타냅니다.
  2. config: {id "test"}와 같은 현재 요소 노드의 속성을 나타냅니다.
  3. children: 자식 요소로, 여러 요소일 수도 있고, 단순 텍스트이거나 React.createElement로 생성된 더 많은 노드일 수 있습니다.

리액트를 오래 사용해 오신 분들은 리액트 18 이전에는 JSX를 올바르게 작성하기 위해 import React from 'react';가 필요했다는 것을 기억하실 겁니다. 리액트 18부터는 이것이 더 이상 필요하지 않아 개발자 경험이 향상되었지만, 내부적으로는 여전히 React.createElement가 호출됩니다.

간소화된 리액트를 구현하기 위해, Vitereact({ jsxRuntime: 'classic' })로 설정하여 JSX를 직접 React.createElement 구현으로 컴파일해야 합니다.

그 다음 우리의 리액트를 구현할 수 있습니다.

// Text 요소는 특별한 처리가 필요합니다. 
const createTextElement = (text: string): VirtualElement => ({
type: 'TEXT',
props: {
nodeValue: text,
},
});

// 자체 자바스크립트 자료구조를 만듭니다.
const createElement = (
type: VirtualElementType,
props: Record<string, unknown> = {},
...child: (unknown | VirtualElement)[]
): VirtualElement => {
const children = child.map((c) =>
isVirtualElement(c) ? c : createTextElement(String(c)),
);

return {
type,
props: {
...props,
children,
},
};
};

Render

다음으로, 앞서 만든 데이터 구조를 기반으로 JSX를 실제 DOM에 렌더링하는 간소화된 버전의 렌더 함수를 구현합니다.

// DOM 프로퍼티들을 업데이트합니다. 
// 단순하게 하기 위해, 모든 이전의 프로퍼티들을 제거하고 다음 프로퍼티들을 추가합니다.
const updateDOM = (DOM, prevProps, nextProps) => {
const defaultPropKeys = 'children';

for (const [removePropKey, removePropValue] of Object.entries(prevProps)) {
if (removePropKey.startsWith('on')) {
DOM.removeEventListener(
removePropKey.substr(2).toLowerCase(),
removePropValue
);
} else if (removePropKey !== defaultPropKeys) {
DOM[removePropKey] = '';
}
}

for (const [addPropKey, addPropValue] of Object.entries(nextProps)) {
if (addPropKey.startsWith('on')) {
DOM.addEventListener(addPropKey.substr(2).toLowerCase(), addPropValue);
} else if (addPropKey !== defaultPropKeys) {
DOM[addPropKey] = addPropValue;
}
}
};

// 노드 타입에 기반하여 DOM을 생성합니다.
const createDOM = (fiberNode) => {
const { type, props } = fiberNode;
let DOM = null;

if (type === 'TEXT') {
DOM = document.createTextNode('');
} else if (typeof type === 'string') {
DOM = document.createElement(type);
}

// 생성 이후 프로퍼티에 기반하여 업데이트합니다.
if (DOM !== null) {
updateDOM(DOM, {}, props);
}

return DOM;
};

const render = (element, container) => {
const DOM = createDOM(element);
if (Array.isArray(element.props.children)) {
for (const child of element.props.children) {
render(child, DOM);
}
}

container.appendChild(DOM);
};

여기에 온라인 구현 링크 가 있습니다. 여전히 JSX를 한 번만 렌더링하므로 상태 업데이트를 처리하지 않습니다.

Fiber 아키텍처와 동시성 모드

Fiber 아키텍처와 동시성 모드는 주로 다음 문제를 해결하기 위해 개발되었습니다. 전체 요소 트리를 재귀적으로 순회하는 과정이 시작되면 이를 중단할 수 없어, 메인 스레드가 장시간 차단될 가능성이 있었습니다. 이로 인해 사용자 입력이나 애니메이션 같은 우선순위가 높은 작업들이 제때 처리되지 못할 수 있었습니다.

React Conf 2024

소스 코드에서 작업은 작은 단위로 나뉩니다. 브라우저가 유휴 상태가 될 때마다 이런 작은 작업 단위들을 처리하고, 메인 스레드의 제어권을 반환해서 브라우저가 우선 순위 높은 작업에 빠르게 반응할 수 있도록 합니다. 작업의 모든 작은 단위가 완료되면 결과를 실제 DOM에 반영합니다.

React Conf 2024

실제 리액트에서는 useTransition이나 useDeferredValue 같은 제공된 API를 사용해서 업데이트의 우선순위를 명시적으로 낮출 수 있습니다.

요약하자면, 여기서 핵심은 메인 스레드의 제어권을 반환하는 방법과 작업을 관리 가능한 단위로 나누는 방법입니다.

requestIdleCallback

requestIdelCallback은 실험적인 API로 브라우저가 유휴 상태일 때 콜백을 호출합니다. 이 API는 모든 브라우저 에서 지원되지는 않습니다. 리액트는 이 API를 스케쥴러 패키지 에서 사용되는데, 이 패키지는 작업 우선순위 업데이트 기능 등을 포함하여 requestIdleCallback보다 복잡한 스케쥴링 로직을 가지고 있습니다.

하지만 이 아티클에서는 비동기 중단 가능성만 고려할 것이므로, 이것이 리액트를 모방한 기본적인 구현입니다.

// 보강된 requestIdleCallback.
((global: Window) => {
const id = 1;
const fps = 1e3 / 60;
let frameDeadline: number;
let pendingCallback: IdleRequestCallback;
const channel = new MessageChannel();
const timeRemaining = () => frameDeadline - window.performance.now();

const deadline = {
didTimeout: false,
timeRemaining,
};

channel.port2.onmessage = () => {
if (typeof pendingCallback === 'function') {
pendingCallback(deadline);
}
};

global.requestIdleCallback = (callback: IdleRequestCallback) => {
global.requestAnimationFrame((frameTime) => {
frameDeadline = frameTime + fps;
pendingCallback = callback;
channel.port1.postMessage(null);
});
return id;
};
})(window);

주요 포인트들에 대해 간단하게 설명하겠습니다.

왜 MessageChannel을 사용하나요?

주로 각 라운드의 단위 작업을 처리하기 위해 매크로 태스크를 사용합니다. 하지만 왜 매크로 태스크일까요?

이는 메인 스레드의 제어권을 넘겨주기 위해 매크로 태스크를 사용해야 하기 때문입니다. 이렇게 하면 브라우저가 이 유휴 시간 동안 DOM을 업데이트하거나 이벤트를 받을 수 있습니다. 브라우저는 DOM 업데이트를 별도의 작업으로 처리하므로, 이 시간 동안에는 자바스크립트가 실행되지 않습니다.

메인 스레드는 한번에 하나의 작업만 할 수 있습니다. (예: 자바스크립트 실행, DOM 계산, 스타일 계산, 인풋 이벤트 처리 등) 반면 마이크로 태스크(예: Promise.then)는 메인 스레드의 제어권을 반환하지 않습니다.

왜 setTimeout을 사용하지 않나요?

왜냐하면 모던 브라우저는 5번 이상의 중첩된 setTimeout 호출을 차단하려 하고 최소 지연을 4ms로 설정하므로 정확하지 않기 때문입니다.

알고리즘

리액트는 계속 진화하고 있고, 제가 설명하고 있는 이 알고리즘은 최신이 아닐 수 있습니다. 하지만 핵심 원리를 이해하기엔 충분합니다.

다음은 작업 단위들 간의 연결을 보여주는 다이어그램입니다.

리액트에서 각각의 작업 단위는 Fiber 노드라고 불립니다. 이 노드들은 연결 리스트 구조를 사용하여 서로 연결되어 있습니다.

  1. child: 부모 노드로부터 첫 번째 자식 요소로 향하는 포인터
  2. return/parent: 모든 자식 요소들은 부모 요소로 향하는 포인터를 가짐
  3. sibling: 첫 번째 자식 요소에서 다음 형제 요소로의 포인터

데이터 구조가 준비되었으니, 구체적인 구현을 살펴보겠습니다.

단순히 render 로직을 확장하여 호출 순서를 workLoop -> performUnitOfWork -> reconcileChildren -> commitRoot 순으로 재구성합니다.

  1. workLoop : requestIdleCallback을 계속적으로 호출하면서 유휴 시간을 확보합니다. 현재 여유가 있고 실행할 단위 작업이 있다면 각 단위 작업을 실행합니다.
  2. performUnitOfWork : 수행되는 구체적인 단위 작업입니다. 이는 연결 리스트 아이디어의 구현체입니다. 구체적으로, 한 번에 하나의 fiber 노드만 처리하고 다음에 처리할 노드를 반환합니다.
  3. reconcileChildren: 현재 fiber 노드를 조정(Reconcile)합니다. 이는 실제로 가상 DOM을 비교하고, 수행할 변경 사항을 기록합니다. 각 fiber 노드를 직접 수정하고 저장한 것을 볼 수 있습니다. 이는 단지 자바스크립트 객체에 대한 수정일 뿐이며 실제 DOM에는 아직 영향을 주지 않습니다.
  4. commitRoot: 현재 업데이트가 필요하고(wipRoot에 따라) 처리할 다음 단위 작업이 없다면(!nextUnitOfWork에 따라), 가상의 변경 사항을 실제 DOM에 반영해야 한다는 뜻입니다. commitRoot는 fiber 노드의 변경 사항에 따라 실제 DOM을 수정합니다.

이를 통해 우리는 중단 가능한 DOM 업데이트를 위해 fiber 아키텍처를 제대로 사용할 수 있게 되었지만, 아직 트리거가 부족합니다.

업데이트 트리거

리액트에서 일반적인 트리거는 가장 기본적인 업데이트 메커니즘인 useState입니다. 이를 구현하여 우리의 Fiber 엔진을 가동해 보겠습니다.

구체적인 구현은 다음과 같습니다.

// fiber 노드와 훅을 연결합니다. 
function useState<S>(initState: S): [S, (value: S) => void] {
const fiberNode: FiberNode<S> = wipFiber;
const hook: {
state: S;
queue: S[];
} = fiberNode?.alternate?.hooks
? fiberNode.alternate.hooks[hookIndex]
: {
state: initState,
queue: [],
};
while (hook.queue.length) {
let newState = hook.queue.shift();
if (isPlainObject(hook.state) && isPlainObject(newState)) {
newState = { ...hook.state, ...newState };
}
if (isDef(newState)) {
hook.state = newState;
}
}
if (typeof fiberNode.hooks === 'undefined') {
fiberNode.hooks = [];
}
fiberNode.hooks.push(hook);
hookIndex += 1;
const setState = (value: S) => {
hook.queue.push(value);
if (currentRoot) {
wipRoot = {
type: currentRoot.type,
dom: currentRoot.dom,
props: currentRoot.props,
alternate: currentRoot,
};
nextUnitOfWork = wipRoot;
deletions = [];
currentRoot = null;
}
};
return [hook.state, setState];
}

이 방식은 훅의 상태를 fiber 노드에 영리하게 저장하고, 큐를 통해 상태를 수정합니다. 이를 통해 리액트 훅 호출 순서가 변경되면 안되는 이유도 알 수 있습니다.

결론

우리는 비동기와 중단 가능한 업데이트를 지원하며 의존성이 없이 최소한의 리액트 모델을 구현했습니다. 주석과 타입을 제외하면 약 400줄 미만의 코드가 될 것 입니다. 이 코드가 도움이 되었기를 바랍니다.

--

--