Skip to content

Building a Custom React Renderer

Published: at 오전 09:30

해당 게시글은 Building A Custom Renderer For React 번역을 바탕으로 한 글입니다.

react v18.2.0, react-reconciler v0.29.2 버전에서 작성되었으며, 버전에 따라 원본 글과 다른 부분이 있을 수 있습니다.

React는 선언적인 프론트 개발 및 컴포넌트 기반 접근 방식으로 큰 인기를 얻었다. 핵심 기능으로는 컴포넌트를 UI 요소로 변환하는 Renderer가 있다. React는 주로 웹 용 렌더러인 ReactDOM을 사용하지만, 아키텍처의 유연성을 활용하여 특정 플랫폼이나 필요에 따른 사용자 정의 렌더러(Custom Renderer)를 만들 수 있다.

어플리케이션이 더욱 다양해지고 기존 웹 플랫폼을 넘어 확장됨에 따라 전문적인 렌더링 솔루션에 대한 수요가 증가하고 있다. 사용자 정의 렌더러의 필요성은 ReactDOM을 사용한 기본 렌더링 접근 방식이 최적이 아닐 때, 모바일 어플리케이션 또는 가상 현실, 서버 측 렌더링 등 새로운 환경을 목표로 할 때 발생한다.

개발자가 React의 기본 렌더링만을 사용하여 원하는 사용자 경험을 얻기에는 상당한 성능 병목 현상이나 어려움이 존재할 수 있다. 예를 들어, 수많은 실시간 업데이트나 정교한 차트를 통해 주식 시장 데이터를 동적으로 시각화하는 금융 분석 대시보드를 생각해보자. 이러한 경우, React의 기본 렌더링 접근 방식은 데이터의 연속 흐름이나 복잡한 차트 계산을 효율적으로 관리하지 못하기 때문에 사용자 정의 렌더러가 필요하게 된다.

사용자 정의 렌더러는 여러 가지 장점을 제공한다. 첫째, 특정 병목 현상을 해결하여 향상된 성능을 제공하고 기본 렌더러 접근 방식에 비해 렌더링 속도, 메모리 사용량 및 프레임 속도를 크게 향상시킨다. 예를 들어, 사용자 정의 렌더러는 재무 분석 대시보드 예시에서 데이터 처리 및 차트 렌더링을 최적화하여 더욱 원활하고 응답성이 뛰어난 대시보드 경험을 제공할 수 있다.

다음 섹션에서는 사용자 정의 렌더러를 구축하는 과정을 알아보고, 특정 프로젝트의 요구 사항을 충족하기 위해 React의 렌더링 기능을 확장하는 단계를 설명한다.

‘react-reconciler’ 패키지 개요 (Overview fo the react-reconciler package)

React 커스텀이라는 넓은 범위 내에서, react-reconciler 패키지는 개발자가 정확하고 효율적으로 사용자 정의 렌더러(Custom Renderer)를 구현할 수 있도록 지원하는 강력한 도구이다. 이 패키지는 커스텀 렌더러를 구현하기 위한 백본(뼈대)이며, React의 Reconciliation(재조정) 과정을 위한 구조화된, 확장 가능한 프레임워크를 제공한다.

React의 Reconciliation(재조정) 과정은 어플리케이션의 현재 상태를 반영하는 데 필요한 최소한의 변경 사항 집합을 결정하여 UI를 효율적으로 업데이트하는 역할을 한다. 이 react-reconciler 패키지는 커스텀 렌더러 구현을 간소화하는 일련의 인터페이스 및 유틸리티를 제공하여 개발자가 특정 사례에 맞게 Reconciliation(재조정) 과정을 수정할 수 있게 함으로써 중요한 역할을 한다.

목적 및 사용 사례 (Purpose and Use Cases)

react-reconciler 패키지의 주요 목적은 개발자가 프로젝트 요구 사항에 완벽하게 맞는 커스텀 렌더러를 구성할 수 있게 하는 것이다. 이러한 다양성은 기존 렌더링 접근 방식으로는 충분하지 않거나 특별한 문제를 해결하기 위해 맞춤형 솔루션이 필요할 때 특히 유용하다.

react-reconciler과 함께 구축된 커스텀 렌더러의 사용 사례는 다양하다. 네이티브 모바일 어플리케이션과 같은 특정 플랫폼의 성능을 최적화하는 것부터 게임 엔진이나 증강 현실 프레임워크와 같은 특별한 환경에 React를 통합하는 것까지 다양하다. 이 패키지를 활용함으로써 개발자는 표준 웹 렌더링의 한계를 넘어 React의 기능을 수많은 혁신적인 어플리케이션으로 확장할 수 있는 유연성을 얻는다.

파이버 아키텍처와의 관계 (Relationship with the Fiber Architecture)

react-reconciler 패키지는 Reconciliation(재조정) 과정의 효율성과 응답성을 향상시키기 위해 React의 Fiber Architecture와 밀접하게 연결되어 있다. Fiber와 협력함으로써, react-reconciler은 업데이트의 우선순위 지정 및 일정을 관리하는 강력한 인프라를 활용하고, 더욱 효율적이고 성능이 뛰어난 렌더링 시스템을 구축한다.

커스텀 렌더러를 구축하려는 개발자는 이 react-reconciler와 Fiber Architecture의 관계를 이해하는 것이 중요하다. 시너지 효과로는 커스텀 렌더러가 React의 핵심 아키텍처와 원활하게 통합되어 Fiber의 Incremental Rendering(증분 렌더링)의 이점을 활용하고, 부드럽고 반응성이 좋은 사용자 경험을 제공할 수 있도록 한다.

파이버 데이터 구조 탐색 (Exploring the Fiber Data Structure)

React의 Fiber는 Virtual DOM의 컴포넌트를 나타내는 작은 작업 단위이다. 이는 Reconciliation(재조정) 과정에서 중심 역할을 하여 React가 컴포넌트를 효율적으로 업데이트하고 렌더링할 수 있게 돕는다.

파이버 노드 해부학 (Fiber Node Anatomy)

Fiber 노드는 컴포넌트에 대한 정보를 저장하는 JavaScript 객체이다. 여기에는 아래와 같은 다양한 필드가 포함된다.

작업 중인 파이버 트리 및 확정(커밋)된 파이버 트리 (Work-in-Progress and Committed Fiber Trees)

React는 Reconciliation(재조정) 과정 동안 두 개의 Fiber 트리를 유지한다. 작업이 진행 중인 트리(현재 변경 사항이 적용되고 있음)와 커밋된 트리(성공적으로 렌더링된 상태)이다. “Work-in-Progress Fiber Tree”는 작업 중인 React 컴포넌트의 현재 상태를 반영하는 동적 메모리 내 표현이다. 어플리케이션에서 state update나 prop 변경 등의 변경 사항이 발생하면 React는 새 버전의 컴포넌트 트리를 생성한다. 새롭게 생성된 이 트리는 진행 중인 변경 사항을 캡처하므로 작업 진행 트리라 한다.

반대로, “Committed Fiber Tree”는 사용자 인터페이스에서 성공적으로 렌더링된 마지막 상태를 나타낸다. Reconciliation(재조정) 과정이 완료되면 React는 업데이트된 “Work-in-Progress Fiber Tree”를 가져와서 새로운 “Committed Fiber Tree”로 지정한다. 이 “Committed Fiber Tree”는 가장 최근에 성공적으로 렌더링된 상태를 나타내며 사용자에게(화면에) 표시될 준비가 되었다.

아래 이미지에서는 커밋된 트리와 작업 진행 중인 트리 모두 표시된다. 파란색 외곽선이 있는 직사각형은 업데이트 된 노드를 나타낸다.

Committed Tree & Work-in-progress Tree

Reconciliation(재조정) 알고리즘 (Reconciliation Algorithm)

Reconciliation(재조정) 알고리즘은 UI를 효율적으로 업데이트하기 위한 React의 핵심 기능이다. Fiber 트리를 활용하여 업데이트해야 할 컴포넌트와 그 순서를 결정한다. 알고리즘은 응답성과 처리량의 균형을 유지하여 원활한 사용자 경험을 보장한다.

파이버 노드 수명주기 (Fiber Node Lifecycle)

Fiber 노드의 수명주기는 렌더링 중 다양한 단계를 거치는 동적 프로세스이다. Custom React Renderer를 구축하려면 이러한 단계를 이해하는 것이 중요하다.

아래 이미지는 Fiber 노드의 수명주기를 보여주는 순서도이다.

Fiber Node LifeCycle

사용자 정의(커스텀) React 렌더러를 구축하는 단계 (Steps to Build a Custom React Renderer)

커스텀 렌더러는 모든 React 어플리케이션에 적용된다. 이 글에서는 특히 기본 React 단일 페이지 어플리케이션(이하 SPA)을 사용하여 테스트하는 데 중점을 둔다.

커스텀 렌더러 생성을 보여주기 위해 React 앱에서 사용하는 기본 렌더러를 의도적으로 제거하고 이를 커스텀 렌더러로 대체해보자. 이 커스텀 렌더러는 실제 웹 페이지에 React 페이지의 내용을 표시하는 역할을 한다.

기본 렌더러를 제거하면 처음에는 앱이 중단되어 오류가 발생한다. 그러나 기본적인 기능들을 커스텀 렌더러에 통합하면 기본 페이지가 다시 나타나서 웹 페이지에 다시 보이게 된다. 이 단계별 프로세스는 렌더러가 웹 페이지에 앱을 표시하기 위에 뒤에서 어떻게 작동하는지 이해하는 데 도움이 된다. 또한 커스텀 렌더러를 구성하기 위한 가이드 역할도 한다.

(source code는 원문 글과 다르게 vite을 사용하여 작성되었습니다.)

개발 환경 설정

터미널에서 React 어플리케이션을 생성하자. app_name 부분에는 원하는 어플리케이션 이름을 넣으면 된다.

pnpm create vite <app_name> --template react

어플리케이션이 생성되었으면 프로젝트 디렉토리로 이동한 후 다음 명령어를 실행하여 필요한 패키지를 설치한다.

cd <app_name>

pnpm install

어플리케이션을 실행하자!

pnpm dev

커스텀 렌더러를 위해, 필요한 의존성인 react-reconciler를 설치한다. 만약 TypeScript를 사용하는 경우 @types/react-reconciler도 함께 설치한다.

pnpm install react-reconciler

커스텀 렌더러 통합 (Integrating the Custom Renderer)

  1. src 폴더 아래에 ReactDOMCustom.tsx 파일을 생성하고, 이 파일에서 react-reconciler를 import 한다.
import ReactReconciler from "react-reconciler";
  1. main.tsx 파일을 열고, ReactDOMCustom 파일을 import 한 후, ReactDOM을 import 하는 코드를 제거한다.
import ReactDOMCustom from "./ReactDOMCustom.tsx";
  1. 기존 ReactDOM.render() 함수를 ReactDOMCustom.render()로 변경한다.
// 기존 렌더러 코드
ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

// 커스텀 렌더러 코드
ReactDOMCustom.render(<App />, document.getElementById("root")!);

이렇게 변경하게 되면 앱이 중단되고 브라우저 콘솔에 오류가 표시된다.

ReactDOM Error

우리의 React 앱이 브라우저에서 예상대로 작동하도록 커스텀 렌더러의 기본 로직을 구현해보자.

커스텀 Host Config 구현 (Creating a Custom Host Config)

host config 파일은 React가 사용되는 호스트 환경의 동작과 기능을 정의하는 JavaScript 모듈을 나타낸다. 호스트 환경은 React 어플리케이션이 실행되는 플랫폼 또는 런타임이며 웹 브라우저, 모바일 앱 환경 또는 기타 런타임일수도 있다.

host config는 일반적으로 커스텀 렌더러가 반드시 구현해야 하는 메서드 집합을 포함한다. 이 메서드들은 인스턴스 생성 및 업데이트, 하위 항목(children) 추가, 텍스트 컨텐츠 처리 등과 같은 렌더링 과정의 다양한 측면에 해당한다. 이러한 메서드에 대한 커스텀 구현을 제공함으로써 대상 환경 내에서 React 요소가 생성, 업데이트 및 조작되는 방법을 제어할 수 있다.

ReactDOMCustom 파일에서, reconciler 객체를 생성함으로써 react-reconciler를 인스턴스화하고, 아래 메서드들을 추가한다.

let reconciler = ReactReconciler({
  // host config options
  supportsMutation: true,
  createInstance(type, props, rootContainer, hostContext, internalHandle) {
    // Logic for creating new instance
  },
  createTextInstance(text, rootContainer, hostContext, internalHandle) {
    // Logic for creating a text instance
  },
  appendChildToContainer(container, child) {
    // Logic for appending a child to the container
  },
  appendChild(parentInstance, child) {
    // Logic for appending a child to a parent
  },
  appendInitialChild(parentInstance, child) {
    // Logic for appending initial child
  },
  prepareUpdate(
    instance,
    type,
    oldProps,
    newProps,
    rootContainer,
    hostContext
  ) {
    // Logic for preparing an update
  },
  commitUpdate(
    instance,
    updatePayload,
    type,
    prevProps,
    nextProps,
    internalHandle
  ) {
    // Logic for committing an update
  },
  finalizeInitialChildren() {
    // Logic for finalizing initial children
  },
  getChildHostContext() {
    // Logic for getting child host context
  },
  getPublicInstance() {
    // Logic for getting public instance
  },
  getRootHostContext() {
    // Logic for getting root host context
  },
  prepareForCommit() {
    // Logic before committing changes
  },
  resetAfterCommit() {
    // Logic after committing changes
  },
  shouldSetTextContent() {
    return false;
  },
  // 아래 코드는 원본 글과 다르게 추가된 코드입니다.
  clearContainer() {
    // Logic for clearing the container
    console.log("Clearing container:", container);
    while (container.firstChild) {
      container.removeChild(container.firstChild);
    }
  },
});

createInstance()

이 메서드는 제공된 type과 props를 기반으로 HTML 요소를 생성하고 구성한다. document.createElement 메서드를 사용하여 지정된 타입의 새 HTML 요소를 생성한다. 그런 다음 props 객체에서 className 및 src와 같은 특정 속성을 확인하여, 존재하는 경우 생성된 요소에 적용한다.

이를 달성하기 위해 HTML 속성(alt, className, href, rel, src, target)을 나타내는 문자열 배열이 생성된다. 이 메서드는 이 배열을 반복하며 props 객체에 해당 속성이 있는 경우 요소에 해당 속성을 설정한다. 마지막으로 함수는 생성된 HTML 요소를 반환한다.

// Define a function to create a new instance of an element
createInstance(
  type, // The type of element to create (e.g., "div", "span")
  props, // The properties (attributes) to apply to the element
  rootContainer, // The root container instance to which the element belongs
  hostContext, // The host context of the element
  internalHandle // The internal instance handle of the element
) {
  // Create a new HTML element based on the provided type
  let element = document.createElement(type);

  // Apply the className and src properties from the props object if they exist
  if (props.className) element.className = props.className;
  if (props.src) element.src = props.src;

  // Iterate through an array of specific attributes to check if they exist in the props object
  ["alt", "className", "href", "rel", "src", "target"].forEach((attr) => {
    // If the attribute exists in the props object, set it on the element
    if (props[attr]) element[attr] = props[attr];
  });

  // Log information about the created text instance
  console.log("Created instance:", type, props);

  // Return the created element
  return element;
},

createTextInstance()

이 메서드의 목적은 사용자 인터페이스에서 텍스트 노드를 생성하는 것이다. 제공된 컨텐츠를 포함한 텍스트 노드를 반환함으로써 이를 수행한다. 구현 과정에는 document.createTextNode 메서드의 호출과 text를 인수로 전달하는 과정이 포함된다.

// Define a function to create a new text instance
createTextInstance(
  text, // The text content of the instance
  rootContainer, // The root container instance to which the text belongs
  hostContext, // The host context of the text instance
  internalHandle // The internal instance handle of the text instance
) {
  console.log("Created text instance:", text);

  // Create a new text node with the provided text content
  return document.createTextNode(text);
},

appendChildToContainer(), appendChild(), and appendInitialChild()

이 메서드들은 사용자 인터페이스 내의 parent 컨테이너에 child 요소를 추가하는 데 유용하다. 이들 간의 구별은 UI 렌더링 과정의 특정 사례 또는 수명 주기 이벤트를 기반으로 한다. 각 메서드는 브라우저에 내장된 appendChild API를 활용하고 child 요소를 인수로서 전달하여 이를 달성한다.

// Function to append a child to a container
appendChildToContainer(container, child) {
  // Log information about appending child to container
  console.log("Appending child to container:", child);
  // Append the child to the container
  container.appendChild(child);
},
// Function to append a child to a parent element
appendChild(parentInstance, child) {
  // Log information about appending child to parent
  console.log("Appending child to parent:", child);
  // Append the child to the parent element
  parentInstance.appendChild(child);
},
// Function to append an initial child to a parent element
appendInitialChild(parentInstance, child) {
  // Log information about appending initial child to parent
  console.log("Appending initial child to parent:", child);
  // Append the initial child to the parent element
  parentInstance.appendChild(child);
},

렌더 메서드 활성화 (Enabling the Render Method)

재조정(Reconciliation) 객체의 API는 최상위 React DOM API와 약간 다르다. 렌더 메서드를 main.tsx 파일에 통합하려면 ReactDOMCustom.tsx 파일에서 render 메서드를 사용하여 객체를 정의해야 한다. 이 렌더 메서드는 두 개의 인수를 사용한다: 렌더링 할 component와 어디에 위치시킬 것인지 결정하는 container

let ReactDOMCustom = {
  render(component, div) {
    // Logic for rendering
  },
};

render 함수 내에서 createContainer 메서드를 사용하여 새로운 컨테이너를 생성한다. 이 메서드는 세 가지 인수를 사용한다: container 그것 자체 인수와 동시성 모드(concurrent mode) 및 서버 측 하이드레이션(server-side hydration)을 나타내는 false로 설정된 boolean 두 값

let container = reconciler.createContainer(div, false, false);

다음으로 렌더링 과정을 시작하기 위해 updateContainer 메서드를 호출한다. 이 메서드는 네 가지 인수를 필요로 한다: 렌더링된 component, 사전 설정된 container 그리고 하이드레이션 및 콜백 실행에 대한 옵션을 나타내는 두 개의 null 값

reconciler.updateContainer(whatToRender, container, null, null);

참고로, 전체 render 메서드는 다음과 같다.

// ReactDOMCustom object to encapsulate custom rendering logic
let ReactDOMCustom = {
  // Render method to render a React component into a specified container
  render(component, div) {
    // Create a container using the reconciler's createContainer method
    let container = reconciler.createContainer(div, false, false);

    // Update the container with the specified component to trigger the rendering process
    reconciler.updateContainer(component, container, null, null);
  },
};

export default ReactDOMCustom;

성공적으로 커스텀 렌더러가 구성되었다면, 브라우저에서 아래와 같은 결과를 확인할 수 있다.

Success Custom Renderer

실제 사례 (Real-World Examples)

커스텀 React 렌더러의 실제 사례는 React 아키텍처의 다양성과 적응성을 보여준다.

커스텀 리액트 렌더러의 사례 연구 (Case Studies of Custom React Renderers)

사용 사례 (Use Cases)

다음은 Custom React Renderer와 관련된 몇 가지 일반적 사용 사례이다.

이익 (Benefits)

Custom React Renderer는 다음과 같은 여러 이점들을 제공한다.

결론 (Conclusion)

Custom React Renderer는 개발자에게 특정 플랫폼 및 사용 사례(use cases)에 맞게 프론트엔드 개발을 맞춤화할 수 있는 다양한 솔루션을 제공한다. 개발자는 React의 핵심 렌더링 개념과 react-reconciler 패키지를 활용하여 React Three Fiber와 React Native 같은 실제 사례처럼, 효율적으로 커스텀 렌더러를 구축할 수 있다.


원본 글과 다르게 코드를 추가한 부분

reconciler에 clearContainer 메서드를 추가하지 않았을 경우

clearContainer() {
  while (container.firstChild) {
    container.removeChild(container.firstChild);
  }
},

다음과 같은 에러가 발생한다.

Custom Renderer Error1

에러 메시지를 읽어보면 removeChildFromContainer, clearContainer, detachDeletedInstance 메서드를 추가해야 한다는 것을 알 수 있다. 이러한 메서드들은 커스텀 렌더러 내에서 요소들의 생명 주기를 관리하는 데 필요하다.

그렇다면 왜 clearContainer 메서드만 추가하여도 문제가 해결될까 ❓

여기서 clearContainer 메서드는 이미 모든 자식 요소를 제거하는 기능을 수행하고 있기 때문에, 이로 인해 다른 삭제 관련 메서드가 호출되지 않아서 에러가 발생하지 않는다. 하지만! 완전한 React Reconciler Host 설정을 위해서는 명확하게 removeChildFromContainer와 detachDeletedInstance 메서드를 추가해주는 것이 좋다.

이렇게 렌더러를 갈아끼울 수 있다는 것은 정말 신기한 부분 중 하나이다 ദ്ദിᐢ._.ᐢ₎