무엇을 만들었나
옵시디언(Obsidian)에는 노트끼리의 연결을 점과 선으로 보여주는 그래프 뷰가 있다. 이 블로그에도 비슷한 걸 붙이고 싶었다. 목표는 이렇게 잡았다.
- 로컬 그래프: 전체 문서가 아니라 지금 보고 있는 문서를 중심으로, 거리 1~3칸 안에 있는 관련 문서만 보여준다.
- 자유로운 조작: 노드를 드래그하고, 줌·팬(확대·이동)하고, 클릭하면 그 문서로 이동한다.
- 프로젝트 톤에 맞는 디자인: 흔한 "파란 점 그래프"가 아니라, 이 블로그의 시각 언어(잉크 패널 + 모노 라벨)에 녹인 "관측도" 느낌.
아래는 그걸 만든 과정을 단계별로, 개념 설명을 곁들여 정리한 글이다. 각 단계는 배경지식 → 어떤 상황이었나 → 핵심 작업 → 교훈 순서로 자기완결적으로 읽히게 썼다.
1. 그래프에 그릴 데이터 만들기 (BFS)
배경지식 (개념)
- 그래프(graph): 점(노드, node)과 그 점들을 잇는 선(엣지, edge / 링크, link)으로 이뤄진 자료구조. 여기서 노드는 문서 하나, 링크는 두 문서 사이의 관계다.
- 관계(relation): 이 블로그의 문서는 본문에
[[다른-문서]]같은 위키링크나 frontmatter의related필드로 서로를 가리킨다. 이 연결들을 미리 색인해 둔 것이 프로젝트의RelationIndex다. - BFS(너비 우선 탐색, Breadth-First Search): 시작점에서 가까운 것부터 한 겹씩 퍼져 나가며 탐색하는 방법. "현재 문서에서 1칸, 2칸, 3칸 떨어진 문서"를 구할 때 딱 맞는다. 이 "몇 칸 떨어졌나"를 **depth(깊이)**라고 부른다.
- 직렬화(serialization): 서버에서 만든 데이터를 클라이언트(브라우저)로 넘기려면 함수·클래스가 아닌 순수한 JSON 형태여야 한다. 이렇게 넘길 수 있는 모양으로 바꾸는 걸 직렬화라고 한다.
어떤 상황이었나
문서 간 관계 데이터는 이미 RelationIndex에 다 들어 있었다. 문제는 그래프 뷰가 필요한 건 "전체 관계"가 아니라 **"현재 문서를 중심으로 한 작은 부분 그래프"**라는 점이었다. 예를 들어 A 문서를 보고 있으면, A와 직접 연결된 문서(depth 1), 그 문서들과 연결된 문서(depth 2)… 이런 식으로 최대 3칸까지만 뽑아야 한다.
핵심 작업
src/lib/content/graph.ts에 getLocalGraph(index, root, maxDepth) 함수를 새로 만들었다. 하는 일은 BFS 그 자체다.
- 시작 문서를 depth 0으로 큐에 넣는다.
- 겹(depth)을 하나씩 늘려 가며, 각 문서의 이웃을 꺼내 아직 안 본 문서면 노드로 추가한다. 이때 그 문서의 depth를 함께 기록한다.
maxDepth(기본 3)에 도달하면 멈춘다.
이웃을 꺼내는 건 RelationIndex에 새로 추가한 getNeighbors(doc)가 담당한다. "나가는 링크(내가 가리키는 문서)"와 "들어오는 링크(나를 가리키는 문서, 이른바 백링크)"를 둘 다 이웃으로 본다. 방향이 반대인 같은 링크는 한 번만 그리도록 중복을 제거했고, 비공개·초안 문서는 그래프에서 뺐다.
결과 데이터는 이렇게 순수 JSON으로 나온다.
노드마다 depth를 실어 보낸 게 핵심이다. 서버는 depth 3까지 한 번에 계산해 보내고, "1~3 깊이 슬라이더"는 브라우저에서 이 값을 기준으로 필터만 한다. 슬라이더를 움직여도 서버에 다시 요청하지 않으니 즉각 반응한다.
이 로직은 눈에 안 보이는 데다 틀리기 쉬워서, getLocalGraph에 대한 단위 테스트를 6개 붙였다. depth 제한이 맞는지, 중복 링크가 합쳐지는지, 비공개 문서가 빠지는지 등을 검증한다.
교훈
- 클라이언트에서 할 조작(슬라이더 필터)이 서버 데이터의 부분집합이라면, 서버가 최대 범위를 한 번에 주고 클라이언트는 필터만 하게 하자. 조작할 때마다 다시 요청하는 것보다 훨씬 매끄럽다.
- 화면에 안 보이는 데이터 변환 로직일수록 테스트를 남겨야 한다. 그래프가 이상하게 그려질 때 원인이 데이터인지 그림인지 바로 가를 수 있다.
2. 그래프를 화면에 그리기 (force 시뮬레이션)
배경지식 (개념)
- force-directed layout(힘 기반 배치): 노드끼리는 서로 밀어내고, 링크로 이어진 노드는 서로 당기는 "가상의 물리 법칙"을 반복 적용해 자연스러운 배치를 찾는 방식. 옵시디언 그래프가 살아 움직이는 것처럼 보이는 원리다.
- canvas: 브라우저에서 그림을 픽셀 단위로 직접 그리는 HTML 요소. 노드가 수십 개씩 매 프레임 움직이는 그래프는 canvas가 적합하다.
- SSR(서버 사이드 렌더링): Next.js는 페이지를 서버에서 미리 HTML로 만든다. 그런데 canvas나
window는 브라우저에만 있어서, 서버에서 실행하면 에러가 난다. - 동적 임포트(dynamic import): 특정 컴포넌트를 서버 렌더링에서 빼고 브라우저에서만 불러오는 기법. Next.js에서는
next/dynamic에ssr: false를 줘서 처리한다.
어떤 상황이었나
force 시뮬레이션을 직접 구현하는 건 배보다 배꼽이 커진다. 이미 잘 만들어진 라이브러리 react-force-graph-2d가 있었다. 이걸 쓰되, 위의 SSR 문제를 피해야 했다.
핵심 작업
document-graph-view.tsx라는 클라이언트 컴포넌트를 만들고, 그 안에서 next/dynamic(..., { ssr: false })로 그래프 라이브러리를 불러왔다. 이러면 서버는 이 컴포넌트를 건드리지 않고, 브라우저에서만 canvas를 그린다. 무거운 그래프 라이브러리가 상세 페이지에서만 로드되니 다른 페이지 성능에도 영향이 없다.
한 가지 함정이 있었다. 시뮬레이션이 끝난 뒤 그래프를 화면 크기에 맞춰 자동으로 맞추는 zoomToFit 기능을 쓰려면 라이브러리 인스턴스에 대한 **ref(참조)**가 필요한데, next/dynamic은 이 ref를 그대로 넘겨주지 못한다. 그래서 force-graph-canvas.tsx라는 얇은 래퍼(wrapper) 컴포넌트를 하나 더 두고, 그 안에서 ref를 직접 소유하도록 했다. onEngineStop(시뮬레이션이 멈추는 순간) 콜백에서 딱 한 번 zoomToFit을 호출한다.
교훈
- canvas나
window에 의존하는 라이브러리는 Next.js에서next/dynamic(..., { ssr: false })로 감싼다. - 그런 라이브러리의 imperative API(직접 명령해야 하는 기능, 예:
zoomToFit)가 필요하면, ref를 소유하는 별도의 얇은 클라이언트 래퍼로 감싸는 게 깔끔하다.
3. depth를 "궤도"로 만들기 (커스텀 force)
배경지식 (개념)
- d3-force:
react-force-graph가 내부적으로 쓰는 힘 계산 엔진. 링크 당김(link), 노드 밀어냄(charge) 같은 힘을 조합한다. 여기에 내가 만든 커스텀 힘을 추가할 수 있다. - alpha: 시뮬레이션의 "에너지" 값. 처음엔 크고 점점 식으며(0에 수렴) 배치가 안정된다. 커스텀 힘은 이 alpha에 비례해 세기를 조절한다.
- 고정 노드(pinned node): 힘을 무시하고 특정 좌표에 붙박이로 두는 노드. 라이브러리에서 노드에
fx,fy(fixed x/y) 값을 주면 그 자리에 고정된다.
어떤 상황이었나
기본 배치는 예쁘긴 한데 depth 정보가 안 보였다. 1칸 떨어진 문서든 3칸 떨어진 문서든 그냥 아무 데나 놓였다. "현재 문서에서 얼마나 먼가"를 눈으로 읽히게 하고 싶었다.
핵심 작업
아이디어는 단순하다. depth를 중심으로부터의 거리로 만든다. depth 1은 반지름 88px 궤도, depth 2는 176px 궤도… 이렇게.
radialByDepth라는 커스텀 힘 함수를 만들었다. 매 시뮬레이션 틱마다 각 노드에 대해 "지금 위치의 반지름"과 "그 노드 depth가 있어야 할 목표 반지름"의 차이를 구하고, 그 차이를 줄이는 방향으로 속도를 살짝 더해 준다. 현재 문서는 fx: 0, fy: 0으로 원점에 고정해서, 모든 궤도가 이 문서를 중심으로 돌게 했다.
이 궤도 힘이 잘 먹히도록 기본 힘도 손봤다. 링크 당김(link)과 노드 밀어냄(charge)을 약하게 줄여서, 내 궤도 힘이 배치를 주도하게 만들었다. 결과적으로 노드들이 depth별 동심원 근처에 자리 잡는다.
교훈
- 배치(layout) 자체가 정보를 전달할 수 있다. 여기서는 "중심에서의 거리 = 관련성의 멀고 가까움"을 궤도로 인코딩했다.
- 라이브러리의 기본 힘과 커스텀 힘이 싸우면 원하는 그림이 안 나온다. 내 힘을 살리려면 기본 힘의 세기를 낮추는 튜닝이 필요하다.
4. 흔한 그래프 말고 "관측도"로 (디자인)
배경지식 (개념)
- 디자인 토큰(design token): 색·간격·글꼴 같은 값을
--signal,--border처럼 이름 붙인 CSS 변수로 관리하는 것. 테마를 바꿔도 이 변수만 갈면 전체가 따라간다. - canvas는 CSS 변수를 못 읽는다: HTML 요소는
var(--signal)을 쓸 수 있지만, canvas에 그리는 그림은 자바스크립트로 실제 색 문자열을 직접 넘겨야 한다. - AI 슬롭(AI slop): 그라디언트·글로우·이모지를 아무 데나 켜 놓아 "AI가 대충 만든 것처럼" 보이는 디자인. 이걸 피하는 게 목표였다.
어떤 상황이었나
기본 상태는 검은 배경에 파란 점 몇 개라 심심했다. 이 블로그의 시각 언어(1px 잉크 보더, 모노 대문자 라벨, 종이 질감)에 맞춰 "천체 관측 장비 화면" 같은 느낌을 주고 싶었다.
핵심 작업
- 패널: 프로젝트에 이미 있던
.section-ink(잉크 배경) +.paper-grain(종이 노이즈) 유틸리티를 재사용했다. 새 스타일을 발명하지 않고 기존 톤에 얹었다. - 궤도 그리기:
onRenderFramePre(매 프레임 노드보다 먼저 그리는 콜백)에서 depth별 점선 원과 십자선을 canvas에 직접 그렸다. 3단계에서 만든 궤도가 눈에 보이게 된다. - 현재 문서 마커: 처음엔 조준경(reticle)처럼 십자 틱을 붙였다가, 과해서 점 + 심플 링으로 정리했다. 강조색(주황)은 이 하나에만 쓴다.
- 색: 패널이 항상 잉크색이라 그래프 팔레트는 테마와 무관하게 고정값으로 뒀다. 문서 타입(til/knowledge/blog/portfolio)마다 색을 구분한다.
- HUD·범례: 좌상단
LOCAL GRAPH, 우상단23 NODES · D2처럼 모노 대문자 계기판 라벨을 얹었다. 범례는 실제로 그래프에 존재하는 타입만 보여준다.
교훈
- 대담함은 한 곳에만 쓴다. "궤도"라는 시그니처 하나에 힘을 주고 나머지(색·라벨)는 조용하게 두면, 화려하지 않아도 의도가 또렷하다.
- 디자인 시스템이 있으면 새 컴포넌트도 기존 토큰·유틸리티부터 뒤진다. 재사용이 톤 일관성을 지킨다.
5. 배경에 별을 반짝이게 (CSS 애니메이션)
배경지식 (개념)
- 컴포지터 친화적 애니메이션: 브라우저가 레이아웃을 다시 계산하지 않고 GPU로 값싸게 처리할 수 있는 속성은
transform과opacity뿐이다. 이 둘만 애니메이트하면 저사양에서도 버벅이지 않는다. - hydration(하이드레이션): 서버가 만든 HTML에 React가 이벤트를 붙여 살아 움직이게 하는 과정. 이때 서버가 만든 화면과 브라우저가 만든 화면이 다르면 경고·오류가 난다. 그래서 랜덤 값처럼 매번 달라지는 건 서버 렌더링에 넣으면 안 된다.
어떤 상황이었나
관측도 패널의 배경이 허전했다. "별처럼 반짝이고, blur를 랜덤하게 넣어 원근감을 주자"는 요청이 들어왔다.
핵심 작업
별 42개를 만들되, 자바스크립트로 그리지 않고 CSS로 처리했다. 각 별은 작은 <span>이고, 위치·크기·blur(흐림)·애니메이션 주기·시작 지연을 별마다 랜덤으로 인라인 스타일에 박았다. 반짝임 자체는 CSS @keyframes로 opacity만 0.1↔0.8을 오간다. blur가 별마다 다르니 어떤 별은 또렷하고 어떤 별은 뿌옇게 보여 깊이감이 생긴다.
hydration 문제를 피하려고, 별 좌표는 useMemo로 마운트 시 딱 한 번 생성하고, 화면 크기 측정이 끝난 뒤(브라우저에서만) 렌더링했다. 서버는 별을 아예 그리지 않으니 서버·브라우저 화면이 어긋날 일이 없다. 그리고 prefers-reduced-motion(사용자가 "움직임 줄이기"를 켠 경우)일 땐 애니메이션을 끄고 은은한 고정 밝기로 뒀다.
교훈
- 반복되는 잔잔한 모션은 자바스크립트보다 CSS
@keyframes가 싸고 안정적이다. 단,opacity·transform만 애니메이트한다. - 랜덤 값은 서버 렌더링에 넣지 말고 브라우저에서만 생성한다. 안 그러면 hydration이 깨진다.
- 모션에는 항상
prefers-reduced-motion대비를 넣는다.
6. 전체 화면으로 크게 보기 (Fullscreen API)
배경지식 (개념)
- Fullscreen API: 특정 요소를 브라우저 전체 화면으로 키우는 표준 브라우저 기능.
element.requestFullscreen()으로 켜고document.exitFullscreen()으로 끈다. 모달(팝업)을 직접 만들 필요가 없다. - useSyncExternalStore: React 바깥에 있는 상태(여기서는 "지금 전체화면인가")를 React가 안전하게 구독하게 해 주는 훅.
어떤 상황이었나
패널이 작아서 노드가 많으면 답답했다. 전체 화면으로 키우는 기능이 필요했다.
핵심 작업
모달을 새로 만들지 않고 브라우저 네이티브 Fullscreen API를 그대로 썼다. 버튼을 누르면 그래프 컨테이너에 requestFullscreen()을 호출한다. 전체화면 상태는 fullscreenchange 이벤트를 useSyncExternalStore로 구독해 추적하고, 그에 맞춰 컨테이너 높이를 100vh로 바꾼다. 화면 크기가 바뀌면 래퍼가 zoomToFit을 다시 불러 그래프를 새 크기에 맞춘다. 별 배경, 궤도, 조작 다 그대로 따라온다.
교훈
- 브라우저가 이미 해 주는 일은 직접 만들지 않는다. 네이티브 Fullscreen API 하나로 모달 코드·오버레이·스크롤 잠금 전부를 안 짜도 됐다.
마무리
작은 위젯 하나였지만 배운 게 촘촘했다. 데이터는 서버에서 최대 범위를 계산하고 클라이언트는 필터만 하도록 나눴고, 무거운 canvas 라이브러리는 동적 임포트 + ref 래퍼로 감쌌다. 배치는 커스텀 힘으로 depth를 궤도에 인코딩했고, 디자인은 시그니처 하나에만 힘을 주는 원칙으로 흔한 그래프를 피했다. 별은 CSS opacity 애니메이션으로 싸게 반짝이게, 전체 화면은 네이티브 API로 공짜로 얻었다.
관통하는 원칙은 하나다. 이미 있는 것(관계 색인, 디자인 토큰, 브라우저 API)을 먼저 찾아 쓰고, 없는 것만 최소한으로 새로 만든다. 그게 코드도 줄이고 결과도 일관되게 만든다.