LiveTrack streams a group's live location over WebSockets, self-hosted on a single VPS. The reflex is to reach for a managed realtime service and let it handle reconnects and backpressure. I wrote the socket layer by hand instead, because in a live tracker the failure states are the product. I wanted the seam where it breaks to be code I own.
LiveTrack는 그룹의 실시간 위치를 웹소켓으로 주고받아요. VPS 한 대에 셀프호스팅했고요. 보통은 관리형 realtime 서비스를 써서 재연결·백프레셔를 맡기죠. 저는 대신 소켓 계층을 직접 짰어요. 실시간 추적기에서는 실패 상태가 곧 제품이라, 어디서 어떻게 깨지는지까지 제가 책임지는 코드로 만들고 싶었거든요.
A managed realtime service is the right default for most apps. It solves reconnection, backpressure, presence, and horizontal scale: the parts that are tedious and easy to get subtly wrong. Reaching for one is usually the responsible call, and I want to be clear up front that this note is not an argument against it.
관리형 realtime 서비스는 대부분의 앱에 맞는 디폴트예요. 재연결, 백프레셔, 프레즌스, 수평 확장처럼 지루하고 미묘하게 틀리기 쉬운 부분을 대신 풀어줘요. 보통은 그걸 쓰는 게 책임 있는 선택이고, 이 노트가 그걸 반대하는 글이 아니라는 걸 먼저 분명히 해둘게요.
The reason I didn't, here, is narrow: in a group location tracker almost every interesting state is a
failure state. A phone drops to no signal mid-update. A socket half-sends a position and the connection
dies. A client reconnects while a write from its previous session is still in flight. A managed SDK hands
you an onError callback and a reconnect you can't see inside. I wanted to see inside.
제가 여기서 안 쓴 건 딱 한 가지 이유 때문이에요. 그룹 위치 추적기에서는 신경 써야 하는 상태가
거의 다 실패 상태거든요. 업데이트 도중 폰이 신호를 잃어요. 소켓이 위치를 반쯤 보내다 연결이 죽어요.
이전 세션에서 보낸 쓰기가 아직 처리되는 중인데 클라이언트가 재연결해요. 관리형 SDK가 주는 건
onError 콜백과, 안을 들여다볼 수 없는 재연결뿐이에요. 저는 그 안을 보고 싶었어요.
So I wrote a small classifier instead. Every failure gets two labels: which layer it came from (the network, my server, or the user's phone) and how severe it is (transient, permanent, or partial). A managed reconnect collapses all of that into one event. I wanted the labels.
그래서 대신 작은 분류기를 만들었어요. 실패마다 라벨을 두 개 붙여요. 어디서 왔는지(레이어: 네트워크, 제 서버, 아니면 사용자 폰), 그리고 얼마나 나쁜지(심각도: transient, permanent, partial). 관리형 재연결은 이걸 전부 하나의 이벤트로 뭉뚱그려요. 저는 그 라벨이 필요했어요.
The severity decides the move. A transient failure retries once and moves on: a socket that drops, the database briefly busy, GPS that flickers for a second. A permanent one surfaces and stops: a denied location permission, a rejected token, a malformed request; retrying just loops the same error. A partial one keeps the session alive and catches up later: high latency, an analytics write that failed, the app sent to the background. The user keeps moving, and the data reconciles when it can.
심각도가 다음 행동을 정해요. transient는 한 번 재시도하고 넘어가요. 소켓이 끊기거나, DB가 잠깐 바쁘거나, GPS가 한순간 깜빡이는 경우요. permanent는 드러내고 멈춰요. 위치 권한 거부, 거부된 토큰, 잘못된 요청 같은 거요. 재시도해봤자 같은 에러만 반복돼요. partial은 세션을 살려두고 나중에 따라잡아요. 지연이 크거나, 분석 쓰기가 실패하거나, 앱이 백그라운드로 간 경우요. 사용자는 계속 움직이고, 데이터는 가능해지면 맞춰져요.
A managed SDK gives you one reconnect that treats all three the same. But "retry," "stop," and "catch up later" are three different answers, and on a live tracker that difference is the product. That's why I wanted the classification in my own code, where I could see it and change it.
관리형 SDK는 이 셋을 똑같이 다루는 하나의 재연결을 줘요. 그런데 '재시도', '멈춤', '나중에 따라잡기'는 서로 다른 답이고, 라이브 추적기에서는 그 차이가 곧 제품이에요. 그래서 저는 이 분류를 제 코드 안에, 보이고 바꿀 수 있는 곳에 두고 싶었어요.
Two honest edges. First, this is a build-vs-buy choice, not a war story. I didn't ship a managed version, watch it fail, and rip it out. I chose the hand-written layer up front because owning the failure was the point, and I'd make the opposite call the moment failure handling stops being the thing that matters. If I needed presence and horizontal scale I wasn't going to hand-roll, a managed service wins, full stop.
미리 짚고 넘어갈 두 가지가 있어요. 첫째, 이건 build-vs-buy 선택이지 무용담이 아니에요. 관리형 버전을 출시했다가 깨지는 걸 보고 걷어낸 게 아니에요. 실패를 직접 감당하는 게 목적이라 처음부터 직접 짠 계층을 골랐고, 실패 처리가 더는 중요하지 않은 순간이 오면 반대로 결정할 거예요. 직접 구현할 생각도 없는 프레즌스·수평 확장이 필요하다면, 그럴 땐 관리형이 맞아요, 두말없이.
Second, the scope is small, and I'd rather name the ceiling than imply I've cleared it. LiveTrack self-hosts on a single Hetzner VPS, Postgres and all, inside one Docker Compose stack; that's a real scaling ceiling, not a humblebrag. And I didn't self-host everything on principle: the one dependency I deliberately rented is auth (Firebase). Owning the socket layer was worth the failure modes it taught me; owning an identity provider would have bought me nothing but risk. The claims here are about design, not load. The socket layer (rooms, a heartbeat, backpressure) and the failure classifier are code I'm happy to walk through live.
둘째, 범위는 작아요. 넘어선 척하지 않고 어디까지가 한계인지 분명히 말할게요. LiveTrack는 Hetzner VPS 한 대에 셀프호스팅해요. Postgres까지 전부 하나의 Docker Compose 스택 안에 있어요. 이건 은근한 자랑이 아니라 진짜 확장 한계예요. 그리고 무조건 다 셀프호스팅한 건 아니에요. 의도적으로 빌려 쓴 의존성은 인증(Firebase) 하나예요. 소켓 계층은 직접 짜면서 배운 실패 모드만으로도 그럴 값어치가 있었지만, 인증 제공자까지 직접 만들었다면 리스크만 늘었을 거예요. 여기서 하는 주장은 부하 감당이 아니라 설계에 대한 거예요. 소켓 계층(방, heartbeat, backpressure)과 실패 분류기는 라이브로 기꺼이 같이 뜯어볼 수 있는 코드예요.
LiveTrack is a self-hosted real-time group location platform (React Native, Node.js + Fastify, ws, PostgreSQL/PostGIS, on a Hetzner VPS). The architecture diagram and stack are on the project card, and the repo is public: github.com/Poiurity/LiveTrack.
LiveTrack는 셀프호스팅 실시간 그룹 위치 플랫폼이에요(React Native, Node.js + Fastify, ws, PostgreSQL/PostGIS, Hetzner VPS). 아키텍처 다이어그램과 스택은 프로젝트 카드에 있고, 저장소는 공개예요: github.com/Poiurity/LiveTrack.