뭘 만들었냐면요
옵시디언(Obsidian)에는 노트끼리의 연결을 점과 선으로 보여주는 그래프 뷰가 있어요. 이 블로그에도 비슷한 걸 붙이고 싶었어요.
목표는 이렇게 잡았어요.
- 로컬 그래프: 전체 문서 말고 지금 보고 있는 문서를 중심으로, 1~3칸 안에 있는 관련 문서만 보여주기
- 자유로운 조작: 노드를 드래그하고, 확대·이동하고, 클릭하면 그 문서로 이동하기
- 이 블로그 톤에 맞는 디자인: 흔한 "파란 점 그래프" 말고, 잉크 패널 + 모노 라벨에 녹인 "관측도" 느낌으로
아래는 만든 과정을 단계별로, 개념 설명 곁들여서 정리한 거예요. 각 단계는 배경지식 → 어떤 상황이었나 → 핵심 작업 → 교훈 순서라, 필요한 데만 읽으셔도 돼요.
1. 그래프에 그릴 데이터 만들기 (BFS)
배경지식 (개념)
- 그래프(graph): 점(노드)과 그 점들을 잇는 선(엣지 또는 링크)으로 이뤄진 자료구조예요. 여기선 노드가 문서 하나, 링크가 두 문서 사이의 관계예요.
- 관계(relation): 이 블로그 문서들은 본문의
[[다른-문서]]위키링크나 frontmatter의related필드로 서로를 가리켜요. 이 연결들을 미리 색인해둔 게RelationIndex예요. - BFS(너비 우선 탐색): 시작점에서 가까운 것부터 한 겹씩 퍼져 나가며 찾는 방법이에요. "현재 문서에서 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도 같이 기록해요
maxDepth(기본 3)에 도달하면 멈춰요
이웃 꺼내오는 건 RelationIndex에 새로 넣은 getNeighbors(doc)가 해요. "내가 가리키는 문서"랑 "나를 가리키는 문서(백링크)"를 둘 다 이웃으로 봐요. 방향만 반대인 같은 링크는 한 번만 그리게 중복을 없앴고, 비공개·초안 문서는 그래프에서 뺐어요.
결과는 이렇게 순수 JSON으로 나와요.
{
nodes: [{ id, title, type, href, depth }],
links: [{ source, target, kind }],
}
노드마다 depth를 실어 보낸 게 포인트예요. 서버가 depth 3까지 한 번에 계산해서 주고, "1~3 깊이 슬라이더"는 브라우저에서 이 값으로 필터만 해요. 슬라이더를 움직여도 서버에 다시 안 물어보니까 즉각 반응하죠.
이 로직은 눈에 안 보이는데 틀리기도 쉬워서, 단위 테스트를 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라는 얇은 래퍼를 하나 더 뒀어요. 그 안에서 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값을 주면 그 자리에 고정돼요.
어떤 상황이었냐면요
기본 배치는 예쁘긴 한데 depth 정보가 안 보였어요. 1칸 떨어진 문서든 3칸 떨어진 문서든 그냥 아무 데나 놓이더라고요. "현재 문서에서 얼마나 먼가"를 눈으로 읽히게 하고 싶었어요.
핵심 작업
아이디어는 단순해요. depth를 중심에서의 거리로 만드는 거예요. depth 1은 반지름 88px 궤도, depth 2는 176px 궤도… 이런 식으로요.
radialByDepth라는 커스텀 힘을 만들었어요. 매 틱마다 각 노드의 "지금 반지름"과 "그 depth가 있어야 할 목표 반지름" 차이를 구하고, 그 차이를 줄이는 방향으로 속도를 살짝 더해줘요. 현재 문서는 fx: 0, fy: 0으로 원점에 고정해서 모든 궤도가 이 문서를 중심으로 돌게 했고요.
이 궤도 힘이 잘 먹히게 기본 힘도 손봤어요. 링크 당김이랑 노드 밀어냄을 약하게 줄여서, 제 궤도 힘이 배치를 주도하게 만들었어요. 결과적으로 노드들이 depth별 동심원 근처에 자리를 잡아요.
교훈
- 배치 자체가 정보를 전달할 수 있어요. 여기선 "중심에서의 거리 = 관련성의 멀고 가까움"을 궤도로 인코딩했어요.
- 라이브러리 기본 힘이랑 커스텀 힘이 싸우면 원하는 그림이 안 나와요. 내 힘을 살리려면 기본 힘 세기를 낮추는 튜닝이 필요해요.
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단계에서 만든 궤도가 눈에 보이게 됐죠. - 현재 문서 마커: 처음엔 조준경처럼 십자 틱을 붙였는데 좀 과하더라고요. 점 + 심플한 링으로 정리했어요. 강조색(주황)은 여기 하나에만 써요.
- 색: 패널이 항상 잉크색이라 그래프 팔레트는 테마랑 상관없이 고정값으로 뒀어요. 문서 타입(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)을 먼저 찾아 쓰고, 없는 것만 최소한으로 새로 만들기. 코드도 줄고 결과도 일관돼요.