라이브러리, 프레임워크•2026.04.09
prosemirror-view의 렌더링 엔진을 React로 완전히 다시 구현하는 것이었습니다. 이 방식이 "나머지 부분은 알아서 그리세요" 식의 무책임한 접근처럼 보일 수도 있겠지만, 원래의 문제 정의는 아주 잘 해결해 줍니다. React가 상태 관리와 렌더링을 모두 담당하게 되면, 더 이상 상태 불일치(State tearing) 문제에 직면할 일이 없기 때문입니다.
// 변경 전export function NodeView({// 변경 후export const NodeView = memo(function NodeView({ outerDeco, pos, node, innerDeco, ...props}: NodeViewProps) {pos prop에서 문제에 부딪힐 것입니다. pos prop은 이 NodeView 노드의 시작 부분에 해당하는 ProseMirror 위치입니다. ProseMirror는 위치를 정수로 모델링하며, 텍스트 문자, 리프(Leaf) 노드, 그리고 리프가 아닌 노드의 경계는 모두 위치를 1씩 증가시킵니다.React.memo를 사용하더라도 props가 변경되었기 때문에 재렌더링을 유발한다는 것을 의미합니다. 이에 대한 뾰족한 해결책은 없습니다. 키를 누를 때마다 수천 개의 NodeView 컴포넌트가 재렌더링되는 것을 막으려면 pos를 prop으로 전달할 수 없습니다.pos를 없애봅시다!prosemirror-view에서 노드 뷰 생성자(Constructor)는 getPos 함수를 전달받습니다.function getPos(): number;props.pos가 탄생한 것이죠.getPos를 전달하면 어떨까요?ChildNodeViews 컴포넌트에 있었고, 대략 다음과 같았습니다 (복잡한 부분은 일부 생략했습니다).export function ChildNodeViews({ pos, node, innerDecorations,}: { pos: number; node: Node | undefined; innerDecorations: DecorationSource;}) { // 이 노드의 첫 번째 자식의 시작 위치입니다. const innerPos = pos + 1; if (!node) return null; const children: ReactNode[] = []; // 노드의 자식들을 순회하며 각 자식의 데코레이션과 // 부모에 대한 상대적 위치를 추적하는 편의 메서드입니다. iterateChildren( node, innerDecorations, (child, outerDeco, innerDeco, offset) => { children.push( <NodeView node={child} outerDeco={outerDeco} innerDecorations={innerDeco} pos={innerPos + offset} />, ); }, ); return <>{children}</>;}pos를 getPos 함수로 교체해 보겠습니다.export function ChildNodeViews({ getPos, node, innerDecorations,}: { getPos: () => number; node: Node | undefined; innerDecorations: DecorationSource;}) { // 이 노드의 첫 번째 자식의 시작 위치를 가져오는 함수입니다. const getInnerPos = () => getPos() + 1; if (!node) return null; const children: ReactNode[] = []; iterateChildren( node, innerDecorations, (child, outerDeco, innerDeco, offset) => { children.push( <NodeView node={child} outerDeco={outerDeco} innerDecorations={innerDeco} getPos={() => getInnerPos() + offset} />, ); }, ); return <>{children}</>;}getPos 함수를 생성하기 때문입니다. useMemo로 메모이제이션할 수도 있겠지만, 그러면 노드 앞에서 타이핑할 때마다 값이 업데이트되는 원래의 문제로 돌아가게 됩니다.export function ChildNodeViews({ getPos, node, innerDecorations,}: { getPos: MutableRefObject<() => number>; node: Node | undefined; innerDecorations: DecorationSource;}) { if (!node) return null; // 이 노드의 첫 번째 자식의 시작 위치를 가져오는 Ref입니다. const getInnerPos = useRef(() => getPos.current() + 1); const children: ReactNode[] = []; iterateChildren( node, innerDecorations, (child, outerDeco, innerDeco, offset) => { children.push( <ChildElement node={child} outerDeco={outerDeco} innerDecorations={innerDeco} getInnerPos={getInnerPos} offset={offset} />, ); }, ); return <>{children}</>;}function ChildElement({ node, outerDeco, innerDecorations, offset, getInnerPos,}: { node: Node; outerDeco: Decoration[]; innerDecorations: DecorationSource; offset: number; getInnerPos: () => number;}) { const getPos = useCallback(() => { return getInnerPos() + offset; }, [getInnerPos, offset]); return ( <NodeView node={node} outerDeco={outerDeco} innerDeco={innerDecorations} getPos={getPos} /> );}useCallback을 호출할 수 없기 때문입니다.offset이 변경될 때마다 여전히 새로운 getPos 함수를 생성하고 있습니다. 이전보다는 낫습니다. 이제 첫 번째 문단에서 타이핑을 해도 첫 번째 문단 이후의 모든 문단에 있는 모든 텍스트 노드를 재렌더링할 필요는 없으니까요. 하지만 여전히 첫 번째 문단의 모든 텍스트 노드와 첫 번째 문단 이후의 모든 문단 노드는 재렌더링해야 합니다. 15,000번의 렌더링은 아니겠지만, 여전히 6,000번 정도는 될 것입니다!getPos 함수는 offset에 의존하며, 이는 단어 그대로의 의미에서 의존성(Dependency)입니다. Offset은 Ref가 아니므로 값이 변경되면 새로운 값을 캡처(Close over)하기 위해 새로운 getPos 함수를 만들어야 합니다. 이 문제를 우회하려면... 약간의 편법을 써야 할지도 모릅니다.function BadRefUpdateDemo({ value }: { value: number }) { const valueRef = useRef(value); // prop과 동기화하기 위해 렌더링 중에 Ref 값을 업데이트합니다. valueRef.current = value; const onClick = useCallback(() => { saveValue(valueRef.current); }, []); return <button onClick={onClick}>Save ({value})</button>;}value(prop)가 3에서 4로 변경됩니다.value로 <BadRefUpdateDemo />를 재렌더링하며 valueRef.current를 4로 업데이트합니다.getPos Ref가 언제 업데이트되고 어떻게 사용되는지에 대해 많은 제어권을 가지고 있기 때문에 약간의 여유가 있습니다. 렌더링 중에 getPos에 접근하는 것은 이미 좋지 않은 생각입니다. React ProseMirror는 위치가 변경될 때 노드 뷰 컴포넌트를 재렌더링하지 않도록 설계되었기 때문입니다(그것이 우리의 목표입니다!). useEffect나 useLayoutEffect 콜백에서 Ref를 읽는 것(렌더링 중에 쓰인 것이라도)은 안전합니다. 렌더링이 포기되면 이 콜백들은 아예 실행되지 않기 때문입니다.getPos에 접근하는 경우입니다. 하지만 이 경우에도 우리는 안전할 가능성이 높습니다. 트랜잭션을 디스패치하거나 특정 위치의 DOM을 조사하려면(노드의 위치 접근이 실제로 필요한 두 가지 사례), EditorView에 대한 접근을 제공하는 useEditorEventCallback을 사용해야 합니다. EditorView의 내부 상태 역시 렌더링 중에 동기화되므로, 콜백에서 EditorView로부터 읽어온 현재 위치 관련 상태는 getPos 값과 동기화된 상태일 것입니다./** * 트랜잭션 전반에 걸쳐 노드 키를 안정적으로 유지합니다. * * 이를 위해 각 노드 위치를 트랜잭션을 통해 정방향 매핑하여 * 현재 위치를 식별하고, 해당 새 위치에 키를 할당합니다. * 노드가 삭제된 경우 키를 삭제합니다. */apply(tr, value, _, newState) { if (!tr.docChanged || composing) { return value; } const next = { posToKey: new Map<number, string>(), keyToPos: new Map<string, number>(), }; const posToKeyEntries = Array.from(value.posToKey.entries()).sort( ([a], [b]) => a - b ); for (const [pos, key] of posToKeyEntries) { const { pos: newPos, deleted } = tr.mapping.mapResult(pos) if (deleted) continue; next.posToKey.set(newPos, key); next.keyToPos.set(key, newPos); } newState.doc.descendants((_, pos) => { if (next.posToKey.has(pos)) return true; const key = createNodeKey(); next.posToKey.set(pos, key); next.keyToPos.set(key, pos); return true; }); return next;},getPos를 구현하는 것과 같은 다른 작업에도 꽤 유용하다는 것이 밝혀졌습니다.export function ChildNodeViews({ getPos, node, innerDecorations,}: { getPos: MutableRefObject<() => number>; node: Node | undefined; innerDecorations: DecorationSource;}) { const reactKeys = useReactKeys(); const getInnerPos = useCallback(() => getPos() + 1, [getPos]); const childMap = useRef(new Map<string, Child>()).current; if (!node) return null; const keysSeen = new Map<string, number>(); iterateChildren( node, innerDecorations, (child, outerDeco, innerDeco, offset) => { const key = reactKeys.posToKey.get(getInnerPos() + offset); const childData = { node: child, offset, key, innerDeco, outerDeco, }; // 지난 렌더링에서의 이 자식 정보(키 기준)입니다. const prevChild = childMap.get(key); // 이 자식이 (위치 외에) 변경되지 않았다면, // 이전 자식 객체의 위치를 업데이트하고 재사용합니다. // 이는 ChildElement의 불필요한 재렌더링을 방지하면서 // getPos 구현을 최신 상태로 유지합니다. if (prevChild && areChildrenEqual(prevChild, childData)) { prevChild.offset = offset; } else { childMap.set(key, childData); } keysSeen.set(key, keysSeen.size); }, ); for (const key of childMap.keys()) { if (!keysSeen.has(key)) { childMap.delete(key); } } const children = Array.from(childMap.values()) .sort((a, b) => keysSeen.get(a.key)! - keysSeen.get(b.key)!) .map((child) => ( <ChildElement key={child.key} child={child} getInnerPos={getInnerPos} /> )); return <>{children}</>;}/** * prosemirror-view가 커스텀 노드 뷰의 update 메서드 호출 여부를 * 결정할 때 사용하는 것과 동일한 로직입니다. */function areChildrenEqual(a: Child, b: Child) { return ( a.key === b.key && a.node.eq(b.node) && sameOuterDeco(a.outerDeco, b.outerDeco) && (a.innerDeco as InternalDecorationSource).eq(b.innerDeco) );}function ChildElement({ child, getInnerPos,}: { child: Child; getInnerPos: () => number;}) { // 우리는 사실상 `child`를 Ref처럼 사용하고 있습니다. // 노드와 데코레이션이 변경되지 않는 한 안정적인 참조를 유지하지만, // offset은 최신 상태로 유지됩니다. const getPos = useCallback(() => { return getInnerPos() + child.offset; }, [getInnerPos, child]); return ( <NodeView node={child.node} outerDeco={child.outerDeco} innerDeco={child.innerDeco} getPos={getPos} /> );}
아직 댓글이 없습니다.
첫 번째 댓글을 작성해보세요!

바다를 끓일때입니다
Inkyu Oh • AI & ML-ops

플랫폼 전반의 Next.js: 어댑터, OpenNext, 그리고 우리의 약속
Inkyu Oh • 라이브러리, 프레임워크