블로그 Graph view 트러블슈팅

Posted: Updated:
#NextJS#ReactForceGraph

개인 블로그 개발 중 react-force-graph를 사용해 노드 간의 관계를 시각화하는 기능을 구현하던 중, 예상치 못한 벽에 부딪혔습니다. 다크 모드와 라이트 모드를 전환할 때마다 그래프를 다시 그리면서 예기치 않은 문제가 발생하여 그래프가 초기 형태에서 변형되었습니다.

1. 초기 문제 상황

라이트 모드와 다크 모드 사이의 테마를 전환할 때, ForceGraph2D 컴포넌트에서 다음과 같은 치명적인 버그들이 발생했습니다.

  1. 시뮬레이션 재시작: 물리 시뮬레이션이 처음부터 다시 시작되면서 노드들이 사방으로 흩어졌다가 다시 뭉치는 현상이 발생했습니다.
  2. 노드 크기 초기화: 노드들이 할당된 val 값을 유지하지 못하고 가장 작은 크기(size 1)로 줄어들어 버렸습니다.
  3. 레이아웃 시프트: 그래프 레이아웃이 밀리거나 줌 레벨이 예기치 않게 리셋되었습니다.


2. 디버깅 및 해결 과정

Phase 1: 시뮬레이션 재시작 원인 진단

테마가 바뀔 때마다 그래프가 '폭발'하듯 퍼졌다가 다시 수렴했습니다. 이는 컴포넌트가 언마운트 후 재마운트되거나, graphData가 완전히 새로운 데이터로 인식되고 있다는 의미였습니다.

ForceGraph2D(및 기반 라이브러리인 react-force-graph)는 전달받는 props를 감시합니다. 특정 props가 변경되면 내부적으로 d3-force 시뮬레이션을 다시 초기화하기 때문입니다.

  1. 객체 참조 무결성(Object Identity): graphData={{ nodes, links }}와 같은 방식으로 데이터를 전달하고 있었습니다. 자바스크립트에서 {}는 렌더링마다 새로운 객체 참조를 생성합니다. 데이터 내용은 같아도 '봉투'가 바뀌니 라이브러리는 이를 새로운 데이터로 인식한 것입니다.
  2. 테마 의존성: 노드와 링크의 색상을 계산하기 위해 useMemoresolvedTheme에 의존하고 있었습니다. 테마가 바뀌면 데이터 자체가 재생성되는 구조였습니다.
  3. 콜백 참조 문제: nodeColorlinkColor 함수들이 [resolvedTheme]을 의존성 배열로 가진 useCallback으로 감싸져 있었습니다. 테마 변경이 곧 함수의 참조 변경으로 이어져 재시뮬레이션을 유발했습니다.

시도 1: 강제 재마운트 (key)

key={resolvedTheme}을 추가해 깔끔하게 다시 그리는 방식을 시도해 보았습니다.

<ForceGraph2D key={resolvedTheme} ... />

상태를 깨끗하게 비워주긴 하지만, 재마운트 자체가 시뮬레이션을 처음부터 다시 시작하게 만들었습니다. 또한 급격한 언마운트/마운트 과정에서 내부 상태가 소실되어 노드 크기 문제도 해결되지 않았습니다.

Phase 2: 참조 안정화 (그래프 고정하기)

테마 외에는 '아무것도 바뀌지 않았다'고 라이브러리를 속여서 시뮬레이션 재시작을 방지하는 것이었습니다.

  1. 데이터 안정화: useMemo에서 resolvedTheme 의존성을 제거하고, 오직 데이터(posts)가 바뀔 때만 graphData를 생성하도록 수정했습니다.
    // Before: [posts, resolvedTheme] -> Re-runs on theme change
    // After: [posts] -> Only runs when data changes
    const { graphData } = useMemo(() => {
     // ... generate nodes and links ...
     return { graphData: { nodes, links } };
    }, [posts]);
  2. 콜백 안정화: nodeColor, linkColor의 의존성 배열을 비워 참조값이 절대 변하지 않게 했습니다.
    // Never recreated
    const getNodeColor = useCallback((node) => { ... }, []);
  3. 정적 배경색: backgroundColor="rgba(0,0,0,0)"로 설정하여 배경색 prop 변경으로 인한 리렌더링 요인을 차단했습니다.

시뮬레이션이 더 이상 재시작되지 않았습니다! 테마를 바꿔도 노드들이 제자리에 가만히 유지되었습니다. 그러나 색상이 업데이트되지 않는 새로운 문제가 발생하였습니다.

getNodeColor가 생성될 당시의 테마(클로저)를 기억하고 있어, 테마가 바뀌어도 초기 색상만 반환했기 때문입니다. 심지어 클로저 문제를 해결해도 ForceGraph2D는 링크 색상을 다시 렌더링하지 않았습니다.


Phase 3: 동적 컬러링 및 프레임 기반 렌더링

시뮬레이션 중단을 막기 위해 동일한 함수 참조를 전달하면서도, 그 함수가 실행될 때는 현재 테마에 맞는 색상을 반환하게 만드는 것이었습니다.

해결책 A: Mutable Refs 활용

현재 테마 색상을 useRef에 담았습니다. Ref는 값이 변해도 렌더링을 유발하지 않으며, 항상 최신 값을 유지하는 컨테이너 역할을 합니다.

const themeColorsRef = useRef(THEME_COLORS.light);

useEffect(() => {
  themeColorsRef.current = resolvedTheme === 'dark' ? THEME_COLORS.dark : THEME_COLORS.light;
}, [resolvedTheme]);

이제 안정된 콜백 함수에서 Ref의 최신 값을 읽어옵니다.

const getNodeColor = useCallback((node) => {
  // 함수 참조는 고정되지만, 내부에선 항상 최신 ref 값을 읽습니다.
  const { textMain } = themeColorsRef.current;
  if (node.type === 'project') return textMain;
  return textMain;
}, []);

해결책 B: 링크 색상 캐싱 문제

Ref를 써도 링크 색상은 여전히 바뀌지 않았습니다. ForceGraph2D는 성능 최적화를 위해 링크 색상을 캐싱합니다. 데이터(graphData)가 바뀌지 않았으니 링크 색상도 그대로일 것이라 판단하는 것이죠. 노드 vs 링크: 노드는 움직일 때마다 매 프레임 다시 그려지지만, 링크는 상대적으로 정적이라 캐싱이 더 강력하게 적용됩니다.

해결책 C: 프레임 기반 렌더링 (linkCanvasObject)

캐싱 메커니즘을 우회하기 위해 linkCanvasObject를 사용해 직접 그리는 방식을 선택했습니다. 이 함수는 매 애니메이션 프레임마다 실행되어 최신 정보를 화면에 뿌려줍니다.

<ForceGraph2D
  linkCanvasObjectMode={() => 'replace'} // 라이브러리 기본 그리기 대신 직접 그리겠다고 선언
  linkCanvasObject={(link, ctx) => {
    // 매 프레임 실행되어 Ref에서 최신 색상을 가져옵니다.
    const { linkBase } = themeColorsRef.current;
    
    if (typeof link.source === 'object' && typeof link.target === 'object') {
      const { x: sx, y: sy } = link.source;
      const { x: tx, y: ty } = link.target;
      if (sx == null || sy == null || tx == null || ty == null) return;

      ctx.beginPath();
      ctx.moveTo(sx, sy);
      ctx.lineTo(tx, ty);
      ctx.strokeStyle = `rgba(${linkBase}, 0.5)`;
      ctx.lineWidth = 1;
      ctx.stroke();
    }
  }}
/>

최종 해결 요약

  1. 안정적인 Props: graphData, 콜백 함수, 배경색 등을 고정하여 시뮬레이션 재시작을 방지했습니다.
  2. 가변 상태 활용: 테마 색상을 useRef에 담아 테마 변경 시 즉각 반영되도록 했습니다.
  3. 프레임 기반 렌더링: linkCanvasObject를 통해 링크를 직접 그림으로써 내부 캐싱을 우회하고 색상 변화를 즉시 반영했습니다.

결과

  • 부드러운 테마 전환: 색상이 지연 없이 즉각 업데이트됩니다.
  • 안정적인 레이아웃: 시뮬레이션이 튀지 않고, 노드 위치와 줌 레벨이 그대로 유지됩니다.
  • 정확한 노드 크기: 테마가 바뀌어도 설정된 노드 크기가 보존됩니다.

Comments (0)

댓글을 불러오는 중...