김솔비 블로그
기술 블로그

solfany.com 메인, Canvas 신경망으로 싹 엎었다

6분 읽기

들어가며

나는 사이트를 여러 개 운영하고 있다. 이 개발 블로그, 카카오톡 테마를 파는 스토어, 기획 문서 양식을 모은 문서고, AI 프롬프트를 모은 프롬피드까지. 사이트가 늘수록 "그래서 김솔비는 뭘 만드는 사람인데?"에 한 번에 답해 줄 곳이 필요했다.

solfany.com이 그 역할을 하는 허브다. 원래는 회사 소개 페이지처럼 섹션이 길게 이어지는 구조였다. 이번에 첫 화면 하나가 곧 사이트 전부가 되도록 다시 만들었다.

생각을 잇는 개발자, 김솔비

흩어진 생각과 작업을 이곳에서 하나로 이어요.

제목 아래에는 구 모양 신경망이 떠 있다. 가운데 핵이 나이고, 둘레의 빛나는 점 하나하나가 내가 운영하는 사이트다.

섹션을 지우고 한 화면으로

먼저 구조를 줄였다.

  • About, Services, CTA, 조직도, 통계 숫자 같은 섹션을 전부 지웠다. 쓰지 않는 스타일도 함께 걷어냈다.
  • 헤더도 없앴다. 오른쪽 위에 테마 버튼만 남겼다.
  • 첫 섹션을 100svh 플렉스 박스로 만들었다. 제목과 소개를 뺀 남은 높이를 전부 신경망이 차지하고, 맨 아래에 사이트 목록 한 줄이 붙는다.

사이트 목록은 src/data/services.ts에 모았다. 새 사이트를 열면 여기에 객체 하나만 추가하면 된다. 그러면 신경망의 점, 아래 목록, 상태 확인에 모두 나타난다.

ts
{ key: "blog", name: "개발 블로그", href: "https://blog.solfany.com", description: "개발하며 마주친 문제와 해결 과정을 기록해요.", tags: ["문제 해결 기록", "회고", "실무 노하우"], accent: "#5b55e8", feed: "https://blog.solfany.com/rss.xml", // 있으면 최근 글을 끌어온다 }

Canvas로 그린 구 모양 신경망

처음에는 평면 그래프였다. 세포막 안에 갇힌 신경망도 만들어 봤다. 결국 우주에 떠서 천천히 도는 구로 정했다. 라이브러리 없이 Canvas 2D로 그렸다.

구 위에 점을 고르게 뿌리기

구 표면에 점을 무작위로 뿌리면 극지방에 몰린다. 그래서 피보나치 구(Fibonacci sphere)를 썼다. 황금각만큼씩 돌면서 위에서 아래로 내려오면 점이 거의 고르게 퍼진다.

ts
const golden = Math.PI * (3 - Math.sqrt(5)); // 황금각 ≈ 137.5° for (let i = 0; i < neuronCount; i++) { const y = 1 - (i / (neuronCount - 1)) * 2; // 1 → -1 const r = Math.sqrt(1 - y * y); // 그 높이에서 구의 반지름 const th = golden * i; nodes.push([Math.cos(th) * r, y, Math.sin(th) * r]); }

일곱 개 중 하나는 구 안쪽에 두고, 표면의 점들도 반지름을 조금씩 흔들어서 너무 기계적으로 보이지 않게 했다.

3D를 2D로: 회전과 원근

매 프레임마다 각 점을 Y축(좌우 회전)과 X축(기울기)으로 돌린 뒤, 거리에 따라 크기를 줄이는 원근 투영을 한다. 식은 몇 줄이면 된다.

ts
const toScreen = (x: number, y: number, z: number) => { const cy = Math.cos(yaw), sy = Math.sin(yaw); const cp = Math.cos(pitch), sp = Math.sin(pitch); const x1 = x * cy + z * sy; // Y축 회전 const z1 = -x * sy + z * cy; const y2 = y * cp - z1 * sp; // X축 회전 const z2 = y * sp + z1 * cp; const k = FOCAL / (FOCAL + z2); // 멀수록 작게 return { x: cx + x1 * R * k, y: cy0 + y2 * R * k, z: z2 }; };

z가 큰(뒤쪽) 점부터 그리고, 뒤쪽일수록 작고 흐리게 그린다. 이것만으로도 입체감이 꽤 난다. 마우스를 움직이면 구가 그쪽으로 살짝 기울어진다. 터치 화면에서는 스크롤과 겹치므로 기울이지 않는다.

신호가 번져 나간다

가운데 핵에서 주기적으로 신호가 출발한다. 신호는 핵 → 사이트 점 → 태그 점 → 구 표면 뉴런 순서로 번지고, 닿은 점은 잠깐 밝아졌다가 식는다. 사이트 점에 마우스를 올리면 그 사이트로 가는 경로만 남기고 나머지는 흐려진다.

점은 진짜 링크다

신경망 대부분은 Canvas에 그리지만, 핵과 사이트 점만은 실제 <a> 요소다. 매 프레임 투영한 좌표로 transform만 옮긴다. 그래서 다음이 자연스럽게 된다.

  • 키보드 Tab으로 사이트 점 사이를 이동할 수 있다.
  • 화면 낭독기가 링크로 읽는다.
  • 검색 엔진이 사이트 사이의 연결을 링크로 인식한다.

점에 마우스를 올리면(휴대폰은 한 번 탭하면) 사이트 소개와 최근 글, 바로가기가 담긴 카드가 뜬다.

사이트가 살아 있는지 확인하기

허브는 /api/hub 라우트로 각 사이트의 상태를 확인한다.

  • 각 사이트에 HEAD 요청을 보낸다. 5초 안에 응답이 없거나 5xx면 오프라인으로 본다. 4xx는 서버가 살아 있다는 뜻이므로 온라인으로 친다.
  • RSS가 있는 사이트는 최근 글 세 편을 가져와 카드에 보여 준다.
  • 요청마다 모든 사이트를 두드리지 않도록 CDN에서 몇 분 동안 캐시한다.

오프라인인 사이트는 점이 회색으로 가라앉고 신호도 가지 않는다. 화면 위쪽의 4 NODES ONLINE 표시도 이 결과를 따른다.

배터리를 아끼는 애니메이션

계속 돌아가는 Canvas는 노트북 팬을 돌리기 쉽다. 그래서 필요 없을 때는 멈추게 했다.

  • IntersectionObserver: 신경망이 화면 밖으로 나가면 멈춘다.
  • visibilitychange: 다른 탭으로 가면 멈춘다.
  • prefers-reduced-motion: 움직임 줄이기를 켠 사용자에게는 멈춘 한 장면만 보여 준다.

Lighthouse가 LCP를 찾지 못했다

성능을 재 보려고 Lighthouse를 돌렸더니 점수 대신 NO_LCP 오류가 나왔다. 첫 화면에서 가장 큰 요소(LCP)를 찾지 못했다는 뜻이다.

원인은 등장 애니메이션이었다. 제목과 소개가 opacity: 0에서 시작해 떠오르는데, 브라우저는 완전히 투명한 요소를 LCP 후보에서 뺀다. 다른 요소는 Canvas뿐이라 후보가 하나도 남지 않았다.

해결은 한 글자였다. 0이 아니라 눈에 보이지 않는 0.01에서 시작한다.

css
@keyframes fade-up { /* 완전히 투명(0)이면 LCP 로 세지 않으므로, 눈에 안 띄는 0.01 에서 시작 */ from { opacity: 0.01; transform: translateY(24px); } to { opacity: 1; transform: none; } }

글꼴이 늦게 와서 화면이 밀렸다

다음으로 걸린 건 CLS(레이아웃 이동) 0.19였다. 0.1을 넘으면 "나쁨"이다.

허브는 원래 next/font로 Inter와 Noto Sans KR을 불러왔다. 블로그와 글꼴을 맞추려고 Pretendard(동적 서브셋)로 바꿨는데, 그 뒤로 휴대폰 폭에서 문제가 생겼다. 대체 글꼴로 한 줄이던 소개 문장이 Pretendard가 도착하면 두 줄로 바뀌었고, 그만큼 아래 신경망이 밀려 내려갔다.

휴대폰 폭에서는 소개 문장에 처음부터 두 줄 자리를 잡아 두었다.

css
.hero-sub { text-wrap: balance; /* 두 줄일 때 마지막 줄에 한 낱말만 남지 않게 */ word-break: keep-all; /* 한국어 낱말 중간에서 끊지 않게 */ } @media (max-width: 480px) { .hero-sub { min-height: 2lh; } /* 글꼴이 늦게 와도 높이가 변하지 않게 */ }

lh 단위는 "그 요소의 줄 높이"라서, 글자 크기나 줄 간격을 바꿔도 따로 계산할 필요가 없다. CLS는 0.026으로 내려갔다.

입체 구슬에서 빛나는 점으로

처음에는 핵과 사이트 점을 빛을 받는 입체 구슬로 그렸다. 왼쪽 위에 하이라이트를 넣고 반대편에 반사광까지 그렸다. 그런데 막상 띄워 놓고 보니 작은 점에 음영을 넣으니 오히려 싸 보였다. 몇 픽셀짜리 점에서는 입체감보다 뭉개짐이 먼저 눈에 들어왔다.

그래서 전부 빛나는 점으로 바꿨다.

  • 가운데는 하얗게 밝고, 둘레로 갈수록 각 사이트 색이 은은하게 번진다.
  • 태그와 뉴런 점에도 옅은 빛 번짐을 넣었다.
  • 핵은 하얀 심에 보라빛 광륜을 둘렀다.

우주 배경과도 더 잘 어울린다. 별과 같은 언어로 그려졌기 때문이다.

보안과 점수

  • npm audit 취약점: 10개에서 0개로 줄였다. Next.js를 16.3.6으로 올렸다.
  • Lighthouse 모바일: 측정이 안 되던 상태에서 85~86점이 됐다. 접근성과 SEO는 100점이다.

마치며

이번 작업에서 가장 오래 고민한 건 코드가 아니라 한 화면에 무엇을 남길까였다. 섹션을 지우고, 헤더를 지우고, 결국 제목 한 줄, 소개 한 줄, 신경망 하나만 남았다.

기술적으로는 두 가지를 배웠다.

  1. 성능 측정 도구는 사람 눈과 다르게 본다. 사람에게는 똑같이 투명한 opacity: 0과 0.01이 Lighthouse에게는 전혀 다르다.
  2. 화려함에도 크기에 맞는 표현이 있다. 작은 점에는 입체 음영보다 빛 번짐이 어울렸다.

허브에 새 점이 늘어날 때마다 이 신경망도 조금씩 커질 것이다.