황당한 rerendering 트러블슈팅 - softeerbootcamp-7th/WEB-Team2-Episode GitHub Wiki

개요

(주의: 트러블 슈팅의 내용은 정확한 내용이 아닐 수 있습니다.) (해당 글에서 쇼케이스 === 스토리북 같은 개념으로 보시면 됩니다)

마인드맵을 렌더링하는 ShowCase를 살펴보는 중이었는데, 1번 ShowCase와 2번 ShowCase의 rerendering이 차이가 있었습니다. 1번 쇼케이스는 자손을 추가하면 root까지 bubble up이 되며 리렌더링 되었고, 2번 쇼케이스는 부모까지만 리렌더링되었습니다.

여기서 리렌더링은 개발자 도구에서 rerendering highlight 표시, react profiler에서 rerendering 표시 모두 활성화 된 경우를 말합니다.



결론 미리보기

개발자 도구에서 rerendering이 된다고 표시되어도 실제로는 아닐 수 있다.



전제 상황

(ShowCase 컴포넌트 자체는 llm을 사용해서 생성했기 때문에 '왜 이렇게 짰지?' 라는 생각이 들 수 있습니다. 하지만 해당 트러블슈팅 문서에서 집중할 부분은 리렌더링이므로 이 부분에만 집중해주세요.)

처음에는 노드 데이터만을 관리하는 NodeContainer클래스를 다 만든 후, 제대로 동작하는 지를 확인하기 위해 ShowCase를 돌려보고 있었습니다. NodeContainer는 "tree" 형태를 관리하는 것이지 x, y, width, height를 관리하지 않기 때문에(이것을 관리하는 것은 LayoutManager의 책임입니다) 아래처럼 위치 상관없이 NodeContainer를 검증할 수 있는 쇼케이스로 테스트해봤습니다.

2026-02-03.2.02.06.mov

위 쇼케이스에서는 관련있는 노드들만 리렌더링이 되어 트리 전체가 리렌더링 되는 현상은 없습니다.

데이터를 관리하는 NodeContainer가 위 쇼케이스로 검증이 되었기 때문에, 이제 각 노드의 x, y, width, height를 관리하는 LayoutManager를 구현했습니다. 그리고 다음으로 NodeContainer와 LayoutManager를 결합하여 마인드맵 형태로 쇼케이스를 제작했습니다.

쇼케이스의 모습은 아래 이미지와 같습니다.

image

그런데 다른 llm을 사용해 2개의 서로 다른 쇼케이스를 만들어보았는데, 리렌더링 부분에서 차이가 있었습니다.

1번 쇼케이스는 <MindMapNode /><div>로 감싸져있습니다. 2번 쇼케이스는 <MindMapNode /><div>로 감싸져있지 않습니다.

두 쇼케이스는 위에 명시한 <div/> 차이 제외하고는 모든 부분이 동일하도록 수정했습니다.

// 1번 ShowCase
const NodeRenderer = React.memo(...) => {
    debugger;
    const node = useNode(nodeId);
    const { container } = useMindmapContainer();

    if (!node) return null;

    const childIds = useMemo(() => {
        return container.getChildIds(nodeId);
    }, [node, container, nodeId]);

    return (
        <>
            <div>     // ⬅️ 해당 div에 집중하세요.
                <MindMapNode nodeId={nodeId} {...} />
            </div>
            {childIds.map((childId: any) => (
                <NodeRenderer nodeId={childId} {...} />
            ))}
        </>
    );
});

// 2번 ShowCase
const NodeRenderer = React.memo(...) => {
    debugger;
    const node = useNode(nodeId);
    const { container } = useMindmapContainer();

    if (!node) return null;

    const childIds = useMemo(() => {
        return container.getChildIds(nodeId);
    }, [node, container, nodeId]);

    return (
        <>
            <MindMapNode nodeId={nodeId} {...} /> // ⬅️ 2번 ShowCase는 div가 없습니다.
            {childIds.map((childId: any) => (
                <NodeRenderer nodeId={childId} {...} />
            ))}
        </>
    );
});

특이사항은 <MindMapNode>는 memo처리가 되어 있습니다.

1번 쇼케이스는 새로운 노드를 추가할 경우 root까지 리렌더링이 bubbling됩니다.

image

2번 쇼케이스는 새로운 노드를 추가할 경우 직속 부모까지만 리렌더링 됩니다.

image

둘의 결과 차이가 왜 발생하는 것인지 알고싶었습니다. 마인드맵의 노드가 많을 수 있으니 리렌더링 관점에서 분석해보는 것은 시기가 지금이냐 나중이냐만 다르지 어차피 반드시 해야할 일이라고 판단했습니다.



문제 해결 과정 (삽질까지 모두 포함)

해당 문제의 원인을 금방 파악할 수 있을 것이라 생각했기 때문에 늘 쓰던 디버깅 방식부터 사용했습니다.

순차적으로 디버깅 방법과 해당 디버깅이 실패한 이유를 나열하겠습니다.

<MindMapNode/>에 로그 심어서 리렌더링 확인하기

-> 1, 2번 쇼케이스 모두 root 노드에 해당하는 <MindMapNode/>의 로그는 출력되지 않았습니다. 둘 다 추가한 자식과 부모에 대한 로그만 출력되었습니다. (여기서 memo는 잘 실행되고 있음을 알았어야 했습니다)


➋ 두 쇼케이스에서 변수통제(변수가 될만한 부분을 모두 없애기)

-> 그래도 똑같이 1번만 root까지 리렌더링이 되었습니다.


➌ 개발자 도구에서 component highlights 보기

-> 그래도 똑같이 1번만 root까지 리렌더링이 되었습니다. (root까지 highlight가 된다는 뜻)


➍ 개발자 도구에서 profiler 보기

-> 그래도 똑같이 1번만 root까지 리렌더링이 되었습니다. (root까지 활성화가 된다는 뜻) 자세한 내용을 보니, 부모가 리렌더링 되었기 떄문에 MindMapNode가 리렌더링 되었다 라고만 뜨는데, rootNode위로 부모가 활성화된 부분은 없었고 정확히 어떤 부모(조상)가 원인인지에 대한 내용이 없었습니다.

image image (가장 depth가 깊은 4개의 노드를 추가하는 테스트에서 root까지 총 6개의 노드가 모두 리렌더링 됨)

➎ debugger 심기

-> 큰 차이점은 찾지 못했습니다.


➏ 2번째 변수통제

-> <div>로 감싸느냐 마느냐가 정확한 요인임을 파악했습니다.


<div>를 children prop을 받아 렌더링하는 <Wrapper/>로 대체해보기.

-> 리렌더링 문제가 해결되었습니다.

const Wrapper = memo(({ children }: { children: ReactNode }) => {
    return <div>{children}</div>;
});

<MindMapNode/>를 fragment로 감싸거나 감싸지 않거나

-> fragment로 감싸면 rerendering 됩니다.



찾은 문제 포인트가 왜 이런 결과를 초래했는지

<>로 감싸느냐 마느냐가 리렌더링을 유발했을까요? 왜 안 감싸면 리렌더링이 해결되었을까요? 저는 이 부분이 가장 답답했습니다.

jsx코드는 컴파일되면 아래와 같이 변환됩니다.

const element = _jsx(Fragment, {
  children: _jsx(MindMapNode, {...})
});

부모가 리렌더링 되면 _jsx(Fragment)가 재실행되기 때문에 MindMapNode를 감싸는 Fragment는 리렌더링 됩니다. MindMapNode는 memo되었으므로 bailout되면 실행되지 않습니다.



결론

그래서 결국.. highlight된건 MindMapNode를 감싸는 Fragment이지 MindMapNode가 아니었습니다. 굉장히 혼란스럽네요... 리렌더링 되는거라고 표시되는게 사실 리렌더링이 안되고 있던거라니..

근데 왜 라벨이 MindMapNode라고 뜨는건지는 아직 모르겠습니다.

그래도 profiler 처음 써보고, react내부 코드 다시 읽어보면서 재밌었습니다.

추가적인 내용

react profiler는 일반적인 html tag는 지원하지 않는다고 합니다. 아마 이게 렌더링이 얼마나 걸리는지를 html tag는 측정할 필요가 없기 때문 같습니다. html tag는 내부에 훅도 뭐도 없이 그냥 렌더링할 뿐이니까요.

⚠️ **GitHub.com Fallback** ⚠️