DOM이 아닌 캔버스, WebGL, 터미널, PDF 같은 타깃에 React 컴포넌트를 그대로 쓰고 싶을 때 커스텀 렌더러를 만듭니다. 핵심은 react-reconciler로 Fiber가 생성하는 변경 내역을 우리의 타깃에 적용하는 HostConfig를 구현하는 것입니다. 실무에서 바로 쓸 수 있는 최소 Canvas 렌더러 예제를 통해 개념부터 코드까지 빠르게 정리합니다.
1. 커스텀 렌더러가 필요한 순간
다음과 같은 경우에 적합합니다. DOM 대신 2D 캔버스나 WebGL에 UI를 그려야 할 때, 게임 UI, 데이터 시각화 캔버스, CLI/TTY, PDF/이미지 출력, 네이티브 브릿지에 React 상태/동기화를 재사용하고자 할 때입니다.
2. 핵심 개념 훑기
Reconciler는 React 엘리먼트 트리의 변경을 계산합니다. HostConfig는 변경을 실제 타깃에 적용하는 어댑터입니다. Host Instance는 호스트 요소(rect, circle 등) 하나를 나타내는 가벼운 객체입니다. Container는 루트 컨테이너(여기서는 캔버스와 2D 컨텍스트)를 말합니다. 커밋 단계에서 append, insert, remove, update 같은 동작이 호출되며, 우리는 이 훅들에서 타깃을 갱신합니다.
3. 프로젝트 셋업
React 18 기준으로 설명합니다. react-reconciler는 사설 API 성격이라 버전 호환을 꼭 맞추는 것이 좋습니다.
// 설치
npm i react react-dom react-reconciler
4. 최소 구현: Canvas 렌더러 HostConfig
rect, circle 두 가지 요소만 지원하는 초간단 캔버스 렌더러입니다. 커밋 후 전체를 리렌더링하는 전략으로 시작하고, 성능이 필요하면 diff 기반 부분 갱신으로 확장하면 됩니다.
// canvas-renderer.js
import Reconciler from 'react-reconciler';
function createNode(type, props) {
return { type, props: { ...props }, children: [], parent: null };
}
function drawTree(ctx, node) {
if (!node) return;
const { type, props, children } = node;
if (type === 'rect') {
const { x = 0, y = 0, width = 0, height = 0, fill = '#000' } = props;
ctx.fillStyle = fill;
ctx.fillRect(x, y, width, height);
} else if (type === 'circle') {
const { cx = 0, cy = 0, r = 0, fill = '#000' } = props;
ctx.fillStyle = fill;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fill();
}
children.forEach(child => drawTree(ctx, child));
}
const hostConfig = {
now: Date.now,
getRootHostContext(rootContainer) {
return null;
},
getChildHostContext(parentHostContext, type, rootContainer) {
return null;
},
prepareForCommit(container) {
const { ctx, width, height } = container;
ctx.clearRect(0, 0, width, height);
return null;
},
resetAfterCommit(container) {
const { ctx, root } = container;
drawTree(ctx, root);
},
shouldSetTextContent(type, props) {
return false;
},
createInstance(type, props, rootContainer, hostContext, internalHandle) {
return createNode(type, props);
},
createTextInstance(text, rootContainer, hostContext, internalHandle) {
return createNode('text', { value: String(text) });
},
appendInitialChild(parent, child) {
child.parent = parent;
parent.children.push(child);
},
appendChild(parent, child) {
child.parent = parent;
parent.children.push(child);
},
insertBefore(parent, child, beforeChild) {
child.parent = parent;
const idx = parent.children.indexOf(beforeChild);
if (idx === -1) parent.children.push(child);
else parent.children.splice(idx, 0, child);
},
finalizeInitialChildren(instance, type, props, rootContainer, hostContext) {
return false;
},
prepareUpdate(instance, type, oldProps, newProps, rootContainer, hostContext) {
const payload = {};
let changed = false;
for (const key in newProps) {
if (key === 'children') continue;
if (newProps[key] !== oldProps[key]) {
payload[key] = newProps[key];
changed = true;
}
}
for (const key in oldProps) {
if (key === 'children') continue;
if (!(key in newProps)) {
payload[key] = undefined;
changed = true;
}
}
return changed ? payload : null;
},
commitUpdate(instance, updatePayload, type, oldProps, newProps, internalHandle) {
instance.props = { ...instance.props, ...updatePayload };
},
commitTextUpdate(textInstance, oldText, newText) {
textInstance.props.value = String(newText);
},
appendChildToContainer(container, child) {
container.root = child;
},
insertInContainerBefore(container, child, beforeChild) {
container.root = child;
},
removeChild(parent, child) {
const idx = parent.children.indexOf(child);
if (idx >= 0) parent.children.splice(idx, 1);
},
removeChildFromContainer(container, child) {
if (container.root === child) container.root = null;
const { ctx, width, height } = container;
ctx.clearRect(0, 0, width, height);
},
getPublicInstance(instance) {
return instance;
},
scheduleTimeout: setTimeout,
cancelTimeout: clearTimeout,
noTimeout: -1,
supportsMutation: true,
supportsHydration: false
};
const reconciler = Reconciler(hostConfig);
export function createRoot(canvas) {
const ctx = canvas.getContext('2d');
const container = { canvas, ctx, width: canvas.width, height: canvas.height, root: null };
const fiberRoot = reconciler.createContainer(container, 0, false, null);
return {
render(element) {
reconciler.updateContainer(element, fiberRoot, null, null);
},
unmount() {
reconciler.updateContainer(null, fiberRoot, null, null);
}
};
}
5. 렌더러 사용 예시
간단한 애니메이션을 그립니다. JSX 대신 createElement를 사용해 문자열 타입(rect, circle)을 반환하는 형태로 최소화합니다.
// index.html에 <canvas id='c' width='300' height='120'></canvas> 추가
// index.jsx
import React from 'react';
import { createRoot } from './canvas-renderer';
function App() {
const [x, setX] = React.useState(20);
React.useEffect(() => {
const t = setInterval(() => setX(v => (v + 5) % 260), 250);
return () => clearInterval(t);
}, []);
return React.createElement(
React.Fragment,
null,
React.createElement('rect', { x, y: 20, width: 100, height: 50, fill: 'tomato' }),
React.createElement('circle', { cx: 150, cy: 60, r: 30, fill: 'royalblue' })
);
}
const canvas = document.getElementById('c');
const root = createRoot(canvas);
root.render(React.createElement(App));
6. 버전/호환 팁과 흔한 오류
react-reconciler는 내부 API에 가깝습니다. React와의 메이저 버전 호환을 맞추고 고정 버전으로 운영하는 것이 안전합니다. HostConfig 시그니처는 버전에 따라 달라질 수 있습니다. 타입스크립트를 쓰면 누락 메서드를 컴파일 단계에서 잡을 수 있습니다. supportsMutation, scheduleTimeout, cancelTimeout, noTimeout 등을 빠뜨리면 런타임 오류가 납니다. shouldSetTextContent를 true로 잘못 돌려 텍스트 처리가 꼬이는 경우가 많습니다. 텍스트가 필요 없다면 false로 두고 Text 요소를 따로 정의해 사용하는 편이 단순합니다.
7. 이벤트, 성능, 테스트 확장 아이디어
이벤트는 캔버스 루트에 pointer/mouse 이벤트를 등록하고, 커밋 시 각 노드의 hit region을 갱신해 수동 디스패치하면 됩니다. 예: onClick을 props로 받아 컨테이너가 좌표를 기준으로 대상 노드를 찾아 호출합니다. 성능은 두 단계로 확장합니다. 1) prepareUpdate에서 실제 변경된 prop만 추려 commitUpdate에서 부분 갱신을 적용합니다. 2) resetAfterCommit에서 전체 리렌더링 대신 변경 분기만 다시 그립니다. 테스트는 ctx를 Mock으로 만들어 draw 호출 순서를 스냅샷하거나, drawTree를 분리해 단위 테스트하면 안정적입니다. 디버깅은 HostConfig 각 메서드에 콘솔 로그를 잠시 추가하고 변경 흐름을 추적하는 것이 가장 빠릅니다.
8. 마무리
커스텀 렌더러의 본질은 React의 상태 관리와 선언적 모델을 재사용하면서 타깃 플랫폼에 맞게 커밋 훅을 구현하는 것입니다. 위의 최소 Canvas 예제를 시작점으로, 도메인에 맞는 요소와 이벤트, 성능 최적화 레이어를 추가해 자신만의 렌더러를 완성해보시기 바랍니다.
'React' 카테고리의 다른 글
| React에서 Drag-and-Drop API로 파일 순차 업로드 구현하기 (1) | 2026.06.04 |
|---|---|
| React 앱에서 브라우저 성능 API 활용하여 로딩 속도 최적화하기 (1) | 2026.06.02 |
| React 앱에서 파일 다운로드 및 저장 처리하기 (0) | 2026.06.01 |
| React와 Supabase로 풀스택 앱 구축하기 (0) | 2026.06.01 |
| React로 사용자 맞춤형 대시보드 구현하기 (0) | 2026.06.01 |