react-konva-utils <Html />에서 styled-components 테마 사용하기 (React 렌더러와 its-fine)

Figma에서도 DOM 요소인 코멘트가 캔버스 노드인 사각형의 위치에 맞춰 움직인다

Figma의 코멘트 요소처럼 캔버스 노드 바로 위에 DOM 요소를 렌더링해야 하는 요구사항이 생기다 보니 react-konva-utils의 `<Html />`을 이용해 DOM 요소를 렌더링할 수 있도록 했다. 처음에는 간단한 UI 요소에만 `<Html />`을 사용했는데 점점 노드 설정 팝업 등 스타일링이 들어간 요소가 필요하게 되어 styled-components를 사용하게 됐는데 그 과정에서 styled-components의 theme을 제대로 가져오지 못하는 문제에 직면하게 됐다.  이 글은 react-konva-utils의 `<Html />`와 styled-components를 사용하면서 theme 설정을 제대로 가져오지 못하는 문제를 해결해나가는, 다소 허무한 여정을 정리한 것이다. 

 

1. styled-components의 테마를 사용할 수 없는 문제

import { Html } from 'react-konva-utils'
import styled from 'styled-components'


const Node = () => (
  <Html>
    <Container />
  </Html>
)


const Container = styled.div`
  background: ${({ theme }) => theme.colors.background.primary}
`

react-konva-utils의 `<Html />` 내부에 styled-components의 테마를 사용하는 컴포넌트를 렌더링하려고 했으나 `theme.colors`가 `undefined`라는 오류가 발생했다. 문제를 확인해보니 컴포넌트의 `props.theme`이 빈 객체 {}로 전달되고 있었는데 `<ThemeProvider />`를 수정한 적이 없었기 때문에 styled-components쪽 문제는 아니라고 생각했다. 결국 react-konva-utils에 문제의 원인이 있을 거라 생각하고 `<Html />`이 어떻게 캔버스 위에 DOM 요소를 렌더링하고 있는지 찾아보았다. `<Html />`은 `<div />` 요소를 생성해서 react-konva의 Stage 컨테이너 요소에 이 `<div />`를 자식 요소로 삽입하고, 이 `<div />`를 새로운 ReactDOM root 요소로 `<Html />`에 전달된 children을 렌더링하는 방식으로 구현되어 있었다.

const [div] = useState(() => document.createElement('div'))
const root = useMemo(() => ReactDOM.createRoot(div), [div])

useLayoutEffect(() => {
  const group = groupRef.current;
  if (!group) {
    return;
  }
  const parent = group.getStage()?.container();
  if (!parent) {
    return;
  }
  parent.appendChild(div);
  
  // ...
}, [shouldTransform]);

useLayoutEffect(() => {
  root.render(children)
})

`root.render()`를 실행하는 부분이 왠지 마음에 걸려서 문제의 원인을 찾다보니 Is it possible to share contexts between renderers? github 이슈를 발견했다. 이 이슈는 서로 다른 렌더러 사이에서 컨텍스트를 공유할 수 있는 방법이 있는지에 대한 것이었는데 `ReactDOM.createRoot(div)`로 새로운 root를 생성하면 root 요소가 메인 렌더러(`div#root`에 삽입된 ReactDOM)의 DOM 트리 내부에 있더라도 두번째 렌더러는 메인 렌더러에 있는 컨텍스트를 공유할 수 없다고 한다.

 

이 문제에 대한 해결책은 아직까진 메인 렌더러의 컨텍스트를 직접 두번째 렌더러에 추가해주는 것밖에 없다고 한다. 

import AppContextProviders from 'src/App'

useLayoutEffect(() => {
  root.render(
    <AppContextProviders>
      {children}
    </AppContextProviders>
  )
})

위와 같이 `<Html />` children에서 필요한 컨텍스트들을 import 하는 방법도 가능하겠지만 이 컴포넌트를 여러 프로젝트에서 사용할 수 없게 되는 문제가 생긴다. 따라서 이 방법은 사용하지 않기로 했다.

 

 

 

2. its-fine을 이용한 해결

its-fine은 React Fiber Reconciler 내부에 직접 접근해 일반적으로는 불가능한 작업을 할 수 있도록 해주는 라이브러리이다. react-konva-utils에서는 its-fine의 `useContextBridge`를 이용해 메인 렌더러에 있는 컨텍스트를 전달하고 있다고 한다. # 메인 렌더러에 있는 컨텍스트에 접근할 수 있다면 `<Html />` children에서 styled-components의 theme도 접근할 수 있을텐데 왜 문제가 생기는지 의문이 들었다. 그래서 별도로 its-fine 최신 버전(1.2.5)을 설치하고 `<App />`에 `<FiberProvider />`를 추가했으나 여전히 문제가 해결되지 않았다. 그래서 react-konva-utils `<Html />` 코드를 그대로 가져와 컴포넌트를 만들어주고  `useContextBridge`를 적용해줬으나 문제는 여전했다... 

const Bridge = useContextBridge()

useLayoutEffect(() => {
  root.render(<Bridge>{children}</Bridge>)
})

더 고민을 해보다가 its-fine이 React 내부 구현에 크게 의존하고 있어 버전이 중요하지 않을까 하는 생각이 들었다. 현재 프로젝트에서는 react 18.2.0, react-konva 18.2.10을 사용하고 있는데 its-fine의 릴리즈 노트를 살펴보니 1.1.2 버전에서 react 19와 관련된 수정 사항이 있었다. 게다가 react-konva 18.2.10의 package.json을 확인해보니 its-fine 1.1.1을 의존성으로 두고 있어서 its-fine을 1.1.1로 다운그레이드를 하고 다시 확인해보니 이제 `props.theme`이 정상적으로 전달되고 있었다! 혹시나 해서 프로젝트로 가져온  react-konva-utils `<Html />` 코드 대신  react-konva-utils `<Html />`를 import해서 사용했더니 다시 문제가 발생했다. 

 

커밋 내역에서 react-konva-utils 1.0.5 버전의 코드를 찾아서 `<Html />` 코드를 확인해봤는데  its-fine을 import하고 있지 않았다! its-fine의 `useContextBridge`로 컨텍스트를 전달해주고 있지 않기 때문에 문제가 생기는 건 당연했다. react-konva-utils 최근 커밋 내역을 보니 `<Html />`에 빠져있던 contextBridge가 추가되었고 이 커밋이 1.0.6에 반영되었다는 걸 알게 됐다. 프로젝트에서 사용중인 react-konva-utils 버전이 1.0.5였는데 1.0.6으로 업데이트하니 허무하게도 문제가 해결되었다. 문제 상황부터 해결까지 보면 결국 라이브러리 버전을 올리는 것으로 끝나는 삽질이었지만 그래도 react-konva-utils가 어떤 방식으로 캔버스 노드 위에 일반 DOM 요소를 렌더링하는지, 서로 다른 렌더러는 컨텍스트를 공유할 수 없다는 사실을 알게된 건 작은 수확이었다.

 

 

 

3. 관련 의문점들

1. 왜 react-konva-utils에서 createPortal이 아니라 ReactDOM.render()을 사용했을까?

`createPortal`은 ReactNode를 반환하는데 react-konva에는 `Konva.Node` 인스턴스만 렌더링할 수 있다. 따라서 아래와 같이 `<Group />`의 children으로 `createPortal`을 렌더링할 수 없다.

// ❌ 아래처럼 렌더링할 수 없다!
<Group>
  {createPortal(children, document.body)}
</Group>

`<Html />`의 props로 전달되는 children은 일반적인 DOM 노드가 아니라 React Fiber 아키텍쳐에서만 작동하는 JavaScript 객체이기 때문에 `parent.appendChild(children)`처럼 children을 바로 DOM에 삽입하고 끝낼 수 없다. 게다가 만약 이게 가능하다고 하더라도 children을 DOM 요소에 바로 삽입했기 때문에 `useState`, `useRef`, `useEffect` 등 React의 훅에 대해 알지 못해 사용할 수도 없다. 

useLayoutEffect(() => {
  const group = groupRef.current
  if (!group) {
    return
  }
  const parent = group.getStage()?.container()
  if (!parent) {
    return
  }
  // 또다른 root 요소인 div를 Stage의 container 요소의 children으로 삽입
  parent.appendChild(div)

  group.on('absoluteTransformChange', handleTransform)
  return () => {
    group.off('absoluteTransformChange', handleTransform)
    div.parentNode?.removeChild(div)
  }
}, [shouldTransform])

 

이 부분에 대해 더 찾아보다가 react-konva 코드도 살펴보니 react-konva 또한 konva의 도형 인스턴스를 렌더링하기 위해 별개의 렌더러를 사용하고 있었다. # 

export function createInstance(type, props, internalInstanceHandle) {
  let NodeClass = Konva[type];
  if (!NodeClass) {
    NodeClass = Konva.Group;
  }
  // ...
  const instance = new NodeClass(propsWithoutEvents);

  applyNodeProps(instance, propsWithOnlyEvents);

  return instance;
}

 

예전에 react-konva의 도형 컴포넌트를 import 했을 때 `Rect`를 아래처럼 콘솔로 찍어본 적이 있는데 그 값이 `"Rect"` string이어서 조금 혼란스러웠던 적이 있다. `<Rect />`는 React Fiber Node였지만 그 `type`도  `"Rect"였다. 

import { Rect } from "react-konva"

const App = () => {
  console.log(Rect) // "Rect"
  console.log(<Rect />) 
  
  return (/* ... */)
}

우리가 보통 React를 사용할 때 함수형 컴포넌트의 `type`은 해당 함수이고, HTML 요소라면 HTML 태그인데 react-konva에서는 HTML 태그가 아님에도 `type`이 string이다?! 그때는 왜 그런 건지 이해를 하지 못했는데 react-konva가 별도의 렌더러를 이용해 Konva 노드를 렌더링하기 때문이라는 걸 알게 되니 빈 퍼즐 조각이 짜맞춰지는 느낌이었다.  

 

 

다시 원래 주제로 돌아와서, 만약 `Konva.Stage` 내부에 `<div />`와 같은 일반 DOM 요소를 렌더링하려고 하면 `console.error`를 출력하고 `Konva.Group`을 사용한다. `Konva.Stage` 내부에 직접 DOM 요소를 렌더링할 수도 없고, `<Group />`으로 감싸서 렌더링하는 방법도 결국은 똑같이 불가능하기 때문에 DOM 요소를 렌더링하기 위해 또다른 렌더러를 생성하는 방법을 사용한 것 같다. 내가 React 아키텍쳐에 대해 깊은 이해가 있는 것은 아니기 때문에 이것이 새로운 렌더러에서 DOM 요소를 그리는 이유인지는 알 수 없다. 나중에 React 아키텍쳐를 더 깊게 공부해서 이 부분에 대해 확신을 가질 수 있으면 좋겠다.

 

 

2. dependency array없이 root.render를 사용해도 문제가 없을까?

`<Html />` 코드를 보면 아래와 같이 `useLayoutEffect`에서 `root.render()`를 실행하고 있는데 dependency array를 설정하지 않고 있다.

useLayoutEffect(() => {
  root.render(<Bridge>{children}</Bridge>)
})

`useLayoutEffect`에서 deps를 설정하지 않았기 때문에 `root.render()`는 `<Html />`이 재렌더링될 때마다 항상 호출된다. 보통 React에서 `useEffect`, `useLayoutEffect`를 deps 없이 사용하면 문제가 되는 경우가 많은데 이 부분은 괜찮은 건지 의문이 들었다. 이 부분을 찾아보니 createRoot API 페이지에 설명되어 있었다. `root.render()`는 여러번 호출하더라도 이전, 이후의 트리 구조가 동일하다면 React Node의 State는 보존되기 때문에 문제가 없다고 한다.

 

 

3. its-fine의 useContextBridge가 어떻게 Context Provider를 가져올 수 있을까?

its-fine 사용 예제를 보면 its-fine 관련 훅을 `<FiberProvider />` 내부에서 사용해야 하는 일반적인 규칙 외에 `<FiberProvider />`를 어디에 위치시켜야 하는지에 대한 제약이 없는 것으로 보인다. 실제로 its-fine을 사용하는 react-konva에서는 `<Stage />`에  `<FiberProvider />`를 두고 있음을 볼 수 있다. 

가만히 생각해보면  App의 컨텍스트들이 `<FiberProvider />`의 children으로서 존재해야 그것들에 접근할 수 있을 것 같은데 react-konva에서 문제 없이 useContextBridge로 App의 모든 컨텍스트 Fiber Node를 가져올 수 있다. 그래서 어떤 식으로 컨텍스트들을 가져오는지 의문이 들어 its-fine 코드를 살펴보게 됐다.

export function useContextBridge(): ContextBridge {
  const contextMap = useContextMap()

  // Flatten context and their memoized values into a `ContextBridge` provider
  // ...
}

`useContextBridge`는 내부적으로 `useContextMap`을 호출하는데 `useContextMap`는 현재 Fiber Node에서부터 시작해 `node.return` 속성을 타고 현재 `node`가 Context일 때 Map 객체의 키로 삽입하는 방식으로 구현되어 있다. 이 이터레이션은 `node.return`이 null(root Fiber Node)일 때까지 이어진다. React Fiber 아키텍쳐에서 Fiber Node의 `return` 속성은 부모 Fiber Node를 가리키는데 탐색 방향이 부모 → 자식이 아니라 자식 → 부모이기 때문에 `<App />`에 있는 컨텍스트를 가져올 수 있는 것이다.

export function useContextMap(): ContextMap {
  const fiber = useFiber()
  const [contextMap] = React.useState(() => new Map<React.Context<any>, any>())

  // Collect live context
  contextMap.clear()
  let node = fiber
  
  while (node) {
    if (node.type && typeof node.type === 'object') {
      const enableRenderableContext = node.type._context === undefined && node.type.Provider === node.type
      const context = enableRenderableContext ? node.type : node.type._context
      
      if (context && context !== FiberContext && !contextMap.has(context)) {
        contextMap.set(context, React.useContext(wrapContext(context)))
      }
    }
    // fiber node의 return은 부모 노드를 가리킨다
    node = node.return!
  }
  return contextMap
}

컨텍스트 탐색을 `fiber`에서부터 시작하는데  `useFiber`가 반환하는 `fiber`는 어떤 노드를 가리키는 걸까? root 노드? 현재 노드?

export function useFiber(): Fiber<null> | undefined {
  const root = React.useContext(FiberContext)
  if (root === null) {  
    throw new Error('its-fine: useFiber must be called within a <FiberProvider />!')
  }

  const id = React.useId()
  const fiber = React.useMemo(() => {
    for (const maybeFiber of [root, root?.alternate]) {
      if (!maybeFiber) continue
      const fiber = traverseFiber<null>(maybeFiber, false, (node) => {
        let state = node.memoizedState
        
        while (state) {
          if (state.memoizedState === id) return true
          state = state.next
        }
      })
      if (fiber) return fiber
    }
  }, [root, id])

  return fiber
}

`useFiber`의 내부 구현을 살펴보면 root에서부터 `memoizedState`가 `useId` 훅이 반환하는 값과 같은 경우 true를 반환하도록 하고 있다. 한편, 탐색의 시작점인 `root`는 렌더러의 root 요소가 아니라 `<FiberProvider />`의 Fiber 노드를 가리킨다. React Fiber Tree의 구체적인 구조는 알지 못해 `traverseFiber`에 3번째 인수로 전달된 함수의 내부 변수들에 대해 자세히 설명하기는 어렵지만 `id`는 `useFiber`를 사용하는 컴포넌트에서 State 값이기 때문에 `state.memoizedState === id`는 `traverseFiber`에서 현재 탐색 중인 노드가 `useFiber`를 호출하는 노드인지 검사하는 부분이다. 따라서, `useFiber`는 이 훅을 호출한 노드의 Fiber Node를 반환하는 훅이다.

이야기를 처음부터 정리해보면 `useContextBridge`은 이 훅을 호출한 Fiber Node에서부터 시작해 모든 부모 Fiber Node들을 탐색해 그 중에서 컨텍스트 Fiber Node들을 모아 반환하는 작업을 해준다. 

 

 

4. 왜 메인 렌더러 외에 별개의 렌더러를 사용할까?

이 부분에 대해 더 알아보니 react-konva-utils뿐만 아니라 별개의 렌더러로 내부 요소를 렌더링하는 라이브러리가 react-pdf, react-pixi 등 여럿 있음을 알게 되었다. 

 

Reconciler
VDOM을 다루고, 변경 전과 후의 트리에서 차이점을 찾아내 변경 사항을 Renderer에 보내주는 역할

Renderer
Reconciler가 보내준 변경 사항을 실제 UI에 반영

 

* react-tiny-dom

https://github.com/jiayihu/react-tiny-dom?tab=readme-ov-file

 

* react-konva를 이용한 것과 Konva.js만을 사용한 방법을 비교해보면?

https://x.com/0xca0a/status/1282999626782650368

https://stackoverflow.com/questions/63861502/what-is-the-purpose-of-a-custom-react-renderers-within-a-dom-host

 

https://www.google.com/search?q=react+why+use+custom+reconciler+site:stackoverflow.com&client=ms-android-samsung-ss&sca_esv=d4df4a45296c77b3&sca_upv=1&source=android-browser&prmd=visn&sxsrf=ADLYWIKPTUqgdZLYgxwg08pE3EXMVa2YuQ:1725248855794&sa=X&ved=2ahUKEwiQmsvprKOIAxVZkq8BHYGZL98QrQJ6BAgeEAo&biw=412&bih=857&dpr=3.5

 

 

 

 

 

 

[참고]

Building an Async React Renderer with Diffing in Web Worker