블로그 만들기 05 - 별자리 그래프 만들기

2025-08-28

#블로그#Nextjs#Vercel#graphics

🧠 요약

  • 3D 그래프 구성: react-force-graph로 그래프 그리기
  • 데이터 파싱: 블로그 게시글 데이터를 그래프 데이터 형식에 맞게 파싱하기

📌 본문

🌌 react-force-graph로 나만의 별자리 블로그 만들기

블로그 게시글들을 하나의 우주처럼, 별과 행성으로 표현해본다면 어떨까?

한동안 obsidian을 많이 사용하면서, 가장 사용해보고 싶었지만 잘 사용할 수 없었던 것이 그래프 뷰였다. 아무래도 글마다 백링크를 통해 연관성을 주어야하는데, 내가 잘 활용을 하지 못하고 있는것 같았다.

Pasted image 20250828183410.png 보기만 해도 굉장하다.. 스크린샷 2025-08-28 18.35.36.png 나도 뭐.. 한것같긴한데..

어쨌든 이걸 응용해서 블로그에 그래프로 게시글마다의 연관성을 보여주고자 한다.

react-force-graph 이 라이브러리를 사용하겠다.


🎯 목표

  • 블로그 게시글을 3D 그래프로 표현

  • slug 경로를 기반으로 트리 형태를 시각화

  • tag로 연결된 관계 표현

  • 각 노드는 별처럼, 배경은 우주처럼 구성

  • 클릭 시 실제 게시글/태그/카테고리로 이동 가능


🛠️ 사용한 기술

  • react-force-graph-3d: 3D force-directed graph 시각화 라이브러리

  • three.js: 배경 이미지 및 커스텀 3D 객체 렌더링

  • Next.js (App Router) + TypeScript

  • CanvasTexture를 활용한 3D 텍스트 라벨

  • Image 컴포넌트로 우주 배경 추가


📝 데이터 파싱

  • 블로그 게시글 구조
export type Post = {
  id: string;
  slug: string;
  title: string;
  date: Date;
  tags: string[];
  description: string;
  content: string;
};
  • slug: "프로젝트/블로그만들기/블로그 만들기 01"처럼 경로 구조
  • tags: "Next.js", "Vercel" 등의 키워드

여기서 현재 그래프에서 사용하고 있는 요소는 id, slug, title, tags이다.

  • 그래프 데이터 변환
import { Post } from "app/_lib/post";

interface GraphNode {
  id: string;
  group: string;
}

interface GraphLink {
  source: string;
  target: string;
}

export interface GraphData {
  nodes: GraphNode[];
  links: GraphLink[];
}

export const getGraphData = (posts: Post[]): GraphData => {
  const nodesMap = new Map<string, GraphNode>();
  const linkSet = new Set<string>(); // 중복 방지를 위한 Set
  const links: GraphLink[] = [];

  const addNode = (id: string, group: GraphNode["group"]) => {
    const upperId = id.toUpperCase(); // 대소문자 통일
    if (!nodesMap.has(upperId)) {
      nodesMap.set(upperId, { id: upperId, group });
    }
    return upperId;
  };

  for (const post of posts) {
    const titleId = addNode(post.title, "posts");

    // Slug를 트리로 표현
    const parts = post.slug.split("/");
    for (let i = 0; i < parts.length - 1; i++) {
      const parent = addNode(parts[i] ?? "", "parent_groups");
      const childId = parts[i + 1] ?? "";
      const child = addNode(
        childId,
        i === parts.length - 2 ? "posts" : "groups",
      );

      const key = `${parent}->${child}`;
      if (!linkSet.has(key)) {
        links.push({ source: parent, target: child });
        linkSet.add(key);
      }
    }

    // 태그 → 게시글 연결
    for (const tag of post.tags) {
      const tagId = addNode(tag, "tags");

      const key = `${titleId}->${tagId}`;
      if (!linkSet.has(key)) {
        links.push({ source: titleId, target: tagId });
        linkSet.add(key);
      }
    }
  }

  return {
    nodes: Array.from(nodesMap.values()),
    links,
  };
};

각 게시글마다 slug를 통해 트리 노드를 표현할 수 있다. 내 블로그에서는 계층을 2계층만 사용하고 있기 때문에 "parent_groups", "groups" 두개의 그룹 노드로 분리해주었다.

또한, tag들의 중복을 제거한 뒤, 게시글 제목과 연결해주면 된다.

🌌 그래프 그리기

그래프를 표시하기 전, 그래프를 가져올 때는 dynamic을 사용해야한다.

import dynamic from "next/dynamic";
import BackgroundImage from "public/background1.jpg";

const ForceGraph3D = dynamic(() => import("react-force-graph-3d"), {
  ssr: false,
});

ForceGraph3D 컴포넌트를 서버사이드렌더링 하지 않고, 클라이언트 사이드에서만 렌더링하게 하기 위함인데, 그 이유는 다음과 같다.

  1. react-force-graph-3d는 브라우저 전용 API를 사용한다.

window, document, WebGLRenderingContext 등 여러 API를 사용하므로 SSR 사용이 불가하다.

  1. 페이지 로딩 최적화를 할 수 있다.

dynamic 함수는 코드 스플리팅 기능도 제공한다. 즉, 초기 페이지 로딩 시 react-force-graph-3d 와 같이 무거운 컴포넌트를 미리 가져오지 않고, 해당 컴포넌트가 실제로 필요한 시점에서만 로드함으로써 퍼포먼스를 최적화 할 수 있다.

그리고 기본적으로 backgroundColor를 조절할 수 있는데, 단색의 색상보다, 이미지를 추가하고싶어서 다음과 같이 설정했다.

      <Image
        src={BackgroundImage}
        alt="Background"
        fill
        style={{ objectFit: "cover" }}
        priority
      />
      <ForceGraph3D
        ref={fgRef}
        graphData={graphData}
        backgroundColor="#ffffff00"
        nodeLabel="id"
        nodeAutoColorBy="group"
        nodeRelSize={4}
        cooldownTicks={100} // 시뮬레이션 지속 시간
        linkCurvature={0.25}
        linkDirectionalArrowLength={3.5}
        linkDirectionalArrowRelPos={1}
        showNavInfo={true}
        onNodeClick={(node) => handleNodeClick(node)}
        nodeThreeObject={(node) => {
          const group = new THREE.Group();
          const colorMap = {
            parent_groups: "#FF69B4", // 핫핑크 (부모 그룹 느낌)
            groups: "#00BFFF", // 딥스카이 블루 (은하 느낌)
            posts: "#FFD700", // 밝은 금색 (별빛 느낌)
            tags: "#ADFF2F", // 형광 연두 (네온빛 태그)
          };

          // 타입별 구체 크기 설정
          const sizeMap = {
            parent_groups: 15,
            groups: 10,
            posts: 8,
            tags: 6,
          };

          const radius = sizeMap[node.group as keyof typeof sizeMap] || 5;

          const sphere = new THREE.Mesh(
            new THREE.SphereGeometry(radius),
            new THREE.MeshStandardMaterial({
              color: colorMap[node.group as keyof typeof colorMap] || "#cccccc",
              emissive:
                colorMap[node.group as keyof typeof colorMap] || "#cccccc",
              emissiveIntensity: 0.7,
              metalness: 0.6,
              roughness: 0.2,
            }),
          );
          group.add(sphere);

          if (node.group === "groups" || node.group === "parent_groups") {
            const label = new THREE.Sprite(
              new THREE.SpriteMaterial({
                map: createTextTexture(node.id?.toString() || ""),
                depthWrite: false,
              }),
            );
            label.scale.set(60, 40, 3); // 텍스트 사이즈
            label.position.set(0, radius + 5, 0); // 텍스트 위치 위로
            group.add(label);
          }

          return group;
        }}
      />

이런식으로 그래프의 원 배경색상을 "#FFFFFF00"으로 투명하게 만든 뒤, 미리 사용하고 싶은 배경 이미지를 추가하면 된다.

스크린샷 2025-08-28 19.04.13.png 구성하면 이런식으로 나오게 된다.

우주를 배경으로 별처럼 꾸며보고자 했으나.. 미적 감각 이슈로 천천히 개선해보기로 하였다.

🔄 회고 및 인사이트

  • react-force-graph 적재적소에 잘 사용한다면 기가막힐 것 같다.
  • 우주를 채운다는 기분으로 블로그를 더 열심히 할 수 있을것 같다.
  • 추후에 기능을 더 추가해서 멋지게 바꿔보고 싶다.