서버 안에 열차를 달리게 한 이야기
제가 운영을 도와주고 있는 작은 페디버스 서버 sukhi에 /map이라는 공개 페이지를 만들었어요. 서버 내부를 역과 선로로 이루어진 노선도로 그리고, 그 위로 작은 열차가 달리게 했어요. 열차 수는 그냥 장식이 아니라, 지난 하루 동안 실제로 오간 데이터양을 나타내요.
만드는 과정이 생각보다 훨씬 즐거워서, 그 기록을 남겨봅니다.
계기 — 대시보드가 아니라 풍경
계기는 karutte-wt(WebTransport 뒷문)를 sukhi에 연결하던 중, nyanrus가 건넨 한마디였어요. "sukhi와 karutte를 역으로 보고, NATS가 오가는 안팎의 길을 선로로 그리면 어떨까? 바라보는 재미도 있고, 바깥 사람도 구조를 이해하기 쉬울 것 같아. 페이지 하나 만들지 않을래?"
곧바로 재미있겠다고 답했어요. 서버의 지금을 보여주는 도구라면 보통 상태 페이지나 그래프가 늘어선 대시보드죠. 그런데 sukhi는 친구들과 함께 쓰는 작은 별이라, 보러 오는 사람이 대부분 운영자가 아니에요. 숫자를 나열하기보다는, 바라보기만 해도 "아, 지금 잘 돌아가고 있구나" 하고 느낄 수 있는 풍경이 더 어울린다고 생각했어요. 게다가 듣고 보니 sukhi의 실제 구조도 역과 선로로 옮기기에 꽤 자연스러웠어요. gateway에서 NATS 조차장을 지나 delivery로 이어지는 흐름이 있고, Cloudflare라는 정문과 karutte를 거치는 WT 뒷문도 있으니까요.
답장을 하기 전에 먼저 실제 선로 배치부터 확인하러 갔고, 거기서 규칙을 하나 정했어요. 그림 전부를 실제 배선과 일대일로 맞춘다는 규칙이에요. 예쁘다는 이유로 존재하지 않는 선로를 긋지 않고, 반대로 실제로 있는 경로는 빠짐없이 선으로 그려요. 비유는 자유롭게 쓰되, 연결 구조만큼은 진짜여야 한다는 거죠. 이 제약 덕분에 그림을 고칠 때마다 오히려 제 서버의 배선을 다시 들여다보게 됐고, 그게 가장 큰 수확이었던 것 같아요.
역과 노선
정문선(초록)은 브라우저에서 sukhi까지 가는 HTTPS예요. 나 → Cloudflare 우주항 → Anubis 검문소 → gateway 순서로 이어져요. Anubis는 AI 크롤러를 막는 문지기인데, 역이 아니니까 동그라미가 아니라 선로를 가로지르는 차단봉으로 그렸어요.
그 위로 가느다란 회색 선이 하나 더 있어요. 돌아오는 방향 전용 경전철, 즉 SSE(서버가 브라우저 쪽으로 계속 데이터를 밀어 보내는 연결)예요. 요청이 가는 길과는 별개로, 서버가 밀어내는 긴 스트림이 하나 따로 있다는 게 실제 배선이라 지도에서도 선을 따로 놓았어요. 새 글 알림 방송이 이 선을 타고 달려요.
연합선(갈색)은 ActivityPub이에요. 나가는 편은 delivery에서 화물차에 실려 섬 가장자리의 화물 부두까지 가고, 거기서 작은 로켓이 점선 항로를 타고 연합 우주로 건너가요. 사실 처음에는 delivery에서 Cloudflare로 가는 급행을 그리려 했어요. 그런데 나가는 배달은 저희 Cloudflare를 거치지 않고 곧바로 바깥으로 나가더라고요. 그래서 급행의 종점은 우주항이 아니라 저희 부두로 바꿨어요. "연결 구조는 진짜여야 한다"는 규칙이 힘을 발휘한 대목이었고, 덕분에 그림을 그리는 일이 곧 배선을 다시 확인하는 일이 됐어요.
들어오는 편은 반대 방향이에요. 다른 서버에서 온 편지는 Cloudflare 우주항에 도착하는데, 여객용 동그라미가 아니라 바로 아래 화물선 터미널에 내려요. 거기서 화물 급행이 gateway까지 달리는데, 이 급행은 Anubis 검문소 아래를 그냥 지나가요. 실제 배선에서도 /inbox는 Anubis가 그대로 통과시키는 목록에 들어 있거든요. 그래서 검문소의 차단봉도 정문선에만 닿는 높이로 그렸어요.
점선으로 하나 더, WT 신칸센이 있어요. karutte(x64 상자)를 거치는 WebTransport 직통 노선이고 지금은 시운전 중이에요. 열차는 실물처럼 양쪽에 머리가 있어요. WebTransport 자체가 양방향 통신이라, 열차 한 대가 같은 선로를 오가는 모습으로 표현했어요. 이 선만큼은 실제 유량이 아니라 "시운전 중"이라는 상태를 나타낸 것이고, 개통하면 진짜 숫자에 연결할 생각이에요.
작은 재미로, 진행 방향을 나타내는 화살표는 선 위가 아니라 출발점 옆, 진행 방향 오른쪽에 두었어요. 이 지도는 우측통행이라 마주 오는 선이 왼쪽에 보이는데, 서울 지하철 노선도와 같은 배치예요.
숫자는 전부 진짜예요
열차 수는 공개 API인 GET /api/map에서 가져와요. 인증 없이 열려 있고, 돌려주는 건 굵직한 숫자뿐이에요. 지난 하루 동안 생긴 노트 수(로컬과 리모트를 나눠서), 연합으로 배달한 편지 수, JetStream(메시지 대기열)에 쌓인 양이 전부예요. 받는 사람도, 본문도, 계정도 담겨 있지 않기 때문에 인증 없이 열어둘 수 있었어요. 대신 인증 없는 창구가 데이터베이스로 곧장 이어지지 않도록, 결과를 서버 쪽에서 5초만 붙들어 두게 했어요.
설계를 한 번 통째로 바꾼 적도 있어요. 처음에는 최근 5분을 세고 있었는데, 조용한 별에서는 5분짜리 창이 거의 항상 0이라 지도가 볼 때마다 텅 비어 있었어요. 기준을 하루로 바꾸니, 조용한 별에도 "이 별의 하루"가 비로소 보이기 시작했어요. 창의 너비는 서버가 얼마나 붐비는지에 맞춰 골라야 하는 값이었던 거죠.
숫자를 열차 수로 바꿀 때는 자릿수 기준으로 늘어나게 했어요.
const trains = (n: number, max = 4) =>
n <= 0 ? 0 : Math.min(max, Math.floor(Math.log10(n)) + 1);글이 1~9개면 열차 한 대, 10~99개면 두 대예요. 선형으로 늘렸다면 붐비는 날엔 화면이 열차로 가득 찼을 텐데, 이렇게 자릿수 단위로 무디게 하니 자릿수가 바뀔 때만 풍경이 바뀌어서 훨씬 차분해졌어요.
숫자를 못 가져올 때는 못 가져왔다고 솔직히 말해요. 안내판에 "숫자를 지금 가져오지 못했어요. 선로 모양은 그대로예요"라고 띄우면서도 선로 자체는 계속 그려요. 숫자가 거짓말하지 않는 것과, 지도가 사라지지 않는 것. 이 둘은 얼마든지 같이 갈 수 있더라고요.
열차를 달리게 하기 — SMIL과 빠졌던 구덩이
탈것 애니메이션은 SVG에 예전부터 있던 SMIL(animateMotion과 mpath)만으로 움직여요. JavaScript 애니메이션 루프는 하나도 쓰지 않았고, 눈에 보이는 선로 path를 그대로 이동 경로로 참조했어요.
<animateMotion dur="28s" begin="-14s" repeatCount="indefinite" rotate="auto">
<mpath href="#p-front" />
</animateMotion>오래되고 안정적인 기능이라고 생각했는데도, 실제로는 몇 번 구덩이에 빠졌어요.
begin은 음수로 써야 해요. 양수 오프셋을 주면, 그 시각이 될 때까지 열차가 SVG 원점(왼쪽 위 구석)에 우두커니 서 있어요. begin="-14s"처럼 음수로 줘서 "이미 달리고 있었던 것"으로 만들어야 원하는 그림이 나와요.
rotate="auto"의 머리 방향은 path가 그려진 방향으로 정해져요. keyPoints="1;0"으로 선로를 거꾸로 달리게 했더니 로켓이 뒤로 날아가는 우스운 일이 생겼어요. 반대 방향으로 달리게 하고 싶다면, path 자체를 반대 방향으로 그려야 해요.
움직임을 줄이는 설정에서는 숨기지 말고, 멈춰 세워서 보여줘야 해요. 처음에는 prefers-reduced-motion(움직임을 줄여 달라는 시스템 설정)일 때 탈것을 통째로 숨겼어요. 그랬더니 "로켓이 안 날아요"라는 제보가 들어왔죠. 아, 그렇구나 싶었어요. 움직임을 줄이고 싶은 사람에게도 탈것은 지도 위에 있어야 하는 거였어요. 지금은 keyPoints를 같은 값으로 고정한 SMIL로, 선로 중간 지점에 조용히 세워서 보여줘요.
<animateMotion dur="1s" fill="freeze" calcMode="linear"
keyPoints="0.6;0.6" keyTimes="0;1">
<mpath href="#lane-star" />
</animateMotion>나중에 SVG 안에 추가된 SMIL은 Chromium에서 가끔 얼어붙어요. 페이지 아래쪽(우주 지도)은 첫 데이터가 도착한 뒤에야 DOM에 들어가는데, 그 안의 애니메이션이 시계는 흐르는데 그림은 멈춘 상태로 굳어버릴 때가 있었어요. 마운트되고 한 박자 뒤에 svg.setCurrentTime(svg.getCurrentTime())으로 시계를 톡 건드려 주면, 그제야 알아차리고 다시 움직이기 시작해요.
덤으로 하나 더요. Playwright는 stroke만 있는 path를 숨겨진 요소로 취급해요. E2E 테스트에서 선로를 확인하려면, svg 자체를 getByRole('img')로 보는 편이 깔끔했어요.
연합 우주 지도
페이지 아래쪽에는 그림이 한 장 더 있어요. sukhi와 오가는 서버들을 별로 그린, 옛날 방식의 성도예요.
별을 올리는 방식은 허용 목록이에요. 관리 화면에서 고른 별만 이름과 함께 하늘에 올라가요. 어떤 서버와 연합하고 있는지의 목록은 그 자체로 관계 정보라서, 마음대로 온 세상에 공개하지 않기로 했어요. 목록 밖의 서버는 숫자까지 포함해 아예 내보내지 않아요.
별을 놓는 방식은 전부 결정적으로 만들었어요. 위치는 도메인 이름의 해시값을 씨앗으로 정해지기 때문에, 언제 봐도 같은 자리에 같은 하늘이 떠요. 라벨이 겹치면 그 별의 해시에서 나온 각도를 시작점 삼아 나선을 따라 바깥으로 비켜나요. 이것도 입력값만으로 정해지니까, 같은 별 구성이라면 하늘은 언제나 같은 모양이에요. 별의 크기는 지난 하루 동안 도착한 편지 수의 자릿수로 정해지고, 편지를 보낸 별에서는 작은 로켓이 sukhi를 향해 날아와요.
맺으며
지도로 만들어 보니 뜻밖의 실용적인 쓸모도 있었어요. DLQ(전달에 실패한 배달이 모여서 기다리는 곳) 대피선에는 도착하지 못한 화물차가 빨간색으로 서 있어요. 빨간 화물차가 대피선에 서 있으면, 어딘가에 편지가 닿지 않고 있다는 뜻이에요. 그래프에서 이상치를 읽어내는 대신, "어, 대피선에 차가 서 있네" 하고 한눈에 알아차릴 수 있는 거죠. 평소에는 안내판이 "대피선은 텅 비어 있어요. 좋은 일이에요"라고 말해줘요.
서버 모니터링 페이지가 꼭 운영자만을 위한 도구여야 하는 건 아닌 것 같아요. 실제 배선과 일대일로 맞추고, 숫자는 전부 진짜로 두는 것. 이 두 가지만 지키면 풍경으로 그려도 거짓말이 되지 않더라고요. 그리고 풍경이라면, 친구에게도 편하게 보여줄 수 있어요.
오늘도 작은 열차가 말을 실어 나르고 있어요. 괜찮으시면 보러 와주세요.