탭 다섯 개에 WebSocket 열 개, 이게 맞을까 — SharedWorker

Coinat을 만들면서 가장 오래 붙잡고 있던 고민 중 하나를 정리해 둔다. 결론부터 말하면 SharedWorker를 쓰기로 했고, 그 결정에 이르기까지 생각이 어떻게 흘러갔는지 적어본다.

왜 이 고민을 시작했나

Coinat은 같은 코인의 Upbit 가격과 Binance 가격을 나란히 놓고, 그 차이로 김치 프리미엄(김프)을 실시간으로 보여주는 서비스다. 김프를 계산하려면 두 거래소 가격이 같은 시점에 있어야 하니, Upbit과 Binance WebSocket을 동시에 구독한다.
처음엔 단순하게 만들었다. 페이지가 뜨면 두 소켓을 연결한다. 탭이 하나일 때는 잘 동작했다. 그런데 시세 비교 서비스 특성상 사용자는 한 화면만 보지 않는다. 차트 탭 따로, 목록 탭 따로, 관심 코인 탭 따로… 탭을 켤 때마다 Upbit·Binance 연결이 두 개씩 더 생겼다. 탭 다섯 개면 연결이 열 개다. 같은 데이터를 다섯 번 받는 셈이었다.
“탭마다 똑같은 소켓을 또 여는 게 맞나?” 이 의문이 출발점이었다. 거래소 입장에서도 한 사용자가 연결을 열 개씩 잡는 건 달갑지 않을 테고, 클라이언트 입장에서도 같은 데이터를 중복으로 받느라 네트워크와 배터리를 낭비하게 된다.
notion image

SharedWorker라는 선택

원하는 그림은 분명했다. 연결을 한 군데가 쥐고, 모든 탭이 그곳에서 데이터를 나눠 받는 구조. 탭은 늘어나도 거래소 연결은 그대로 하나여야 했다.
이걸 가능하게 해주는 게 SharedWorker였다. 같은 origin에서 열린 모든 탭이 하나의 워커 인스턴스를 공유한다는 점이 정확히 내가 원하던 바였다. 워커가 거래소 소켓을 딱 한 번만 열어두면, 탭이 몇 개가 되든 그 하나를 함께 쓰면 된다.
그래서 워커가 처음 로드될 때 소켓을 각각 한 번만 만들도록 했다. 이 코드가 사실상 이 글의 핵심이다.
나머지는 단순했다. 탭이 워커에 접속하면 그 탭의 포트를 배열에 담아두고, 데이터를 요청하면 연결된 모든 탭에 한꺼번에 뿌린다. 한 탭이 요청한 데이터가 모두에게 전달되니, 각 탭이 따로 요청할 필요도 없었다.

막상 해보니 걸렸던 것들

깔끔하게 끝날 줄 알았는데, 만들고 나서야 보이는 것들이 있었다.
첫째, 닫힌 탭이 배열에 남았다.
탭을 닫아도 워커는 그 포트를 계속 들고 있었다. 브로드캐스트할 때마다 이미 죽은 탭에도 메시지를 던지는 꼴이었다. 처음엔 단순하게 생각했다. 탭이 닫힐 때 배열에서 그 포트만 빼면 되지 않나. 실제로 탭이 정상적으로 닫힐 때는 disconnect 신호를 보내 정리하게 해뒀다.
그런데 브라우저를 강제 종료하면 그 신호가 아예 오지 않는다는 게 문제였다. 내가 빼주기를 기다리는 방식으로는 새는 구멍을 다 막을 수 없었다.
그래서 발상을 바꿔 포트를 WeakRef로 감쌌다. 탭이 사라지면 그 포트를 가리키는 강한 참조가 없어지니, 가비지 컬렉터가 회수하고 나면 deref()undefined를 돌려준다 — 그걸로 포트가 살아있는지 죽었는지 판별할 수 있게 됐다. 그런데 여기서 한 번 더 걸렸다.
WeakRef 로 감싸기만 하면 GC가 알아서 다 치워줄 줄 알았는데, 그게 아니었다. GC가 회수하는 건 MessagePort 객체뿐이고, 그걸 담고 있는 배열 슬롯은 내가 빼주기 전까지 그대로 남는다. 결국 누군가는 주기적으로 배열을 돌면서 "이 포트 아직 살아있나"를 확인해 죽은 걸 걷어내야 했다.
그래서 일정 주기(10초)로 도는 청소 루틴(reapDeadPorts)을 두고, GC가 회수해 deref()가 비어버린 포트를 배열에서 떨어내도록 했다. 청소 타이머는 연결된 탭이 있을 때만 돌게 해서, 마지막 탭이 떠나면 워커도 같이 잠들도록 했다.
다만 이 방식은 정리 시점을 내가 못 박지 못한다. 죽은 포트가 사라지는 건 GC가 실제로 그 객체를 회수한 다음이고, GC가 언제 도는지는 브라우저 마음이다. 그러니 "탭이 닫히고 정확히 몇 초 뒤 정리"는 보장되지 않고 "언젠가는 정리된다"에 가깝다.
정리 시점을 보장해야 한다면 포트마다 마지막 신호 시각을 들고 있다가 일정 시간 조용하면 걷어내는 방식으로 바꿔야 하는데, 지금 상황에선 "결국 정리된다"로 충분하다고 봤다.
둘째, SharedWorker가 모든 환경에서 되는 건 아니었다.
당연히 다 될 줄 알았는데 모브라우저나 버전에 따라 SharedWorker를 지원하지 않는 경우가 있었다. 특히 모바일 쪽은 확신하기 어려웠다. 지원 여부를 가정하고 그냥 쓰면, 미지원 환경에선 앱이 통째로 깨진다.
여기서 방향을 하나 더 잡았다. SharedWorker가 안 되면 탭별 워커(Dedicated Worker)로, 그것도 안 되면 메인 스레드에서 직접 소켓을 돌리는 식으로 단계적으로 내려가게 했다.
notion image
셋 다 똑같은 형태의 데이터를 돌려주도록 인터페이스를 맞춰서, 윗단 코드는 지금 어느 방식으로 돌고 있는지 신경 쓸 필요가 없게 했다. 여기에 “정해둔 시간 안에 첫 데이터가 안 오면 다음 단계로 넘어간다”는 타임아웃(4초, 위 점선)을 붙여, 워커 스크립트 로드 자체가 실패하는 경우까지 흘려보내도록 했다.
물론 이 fallback 단계에서는 다시 탭별 연결이 생긴다. “연결 하나”라는 원래 목표는 SharedWorker가 되는 환경에서의 이점이고, 안 되는 환경에서는 적어도 깨지지 않게 하는 게 목표였다. 둘을 구분해 받아들이고 나니 마음이 편했다.

정리하며

돌아보면 이 모든 고민은 결국 한 문장으로 수렴한다. 두 거래소 가격을, 같은 시점에, 끊김 없이 비교하기. 김프라는 숫자 하나를 정확하게 보여주려고 연결을 하나로 모으고, 죽은 탭을 정리하고, 미지원 브라우저까지 챙긴 셈이다.
얻은 건 명확하다. 탭을 몇 개 켜든 거래소 연결은 하나, 닫힌 탭은 알아서 정리, 그리고 어떤 브라우저에서도 일단 화면은 뜬다. 대신 미지원 환경에선 연결이 탭마다 생기고 첫 데이터까지 최대 몇 초가 걸릴 수 있다는 비용은 그대로 안고 간다.
실시간 데이터를 다룰 때 “탭마다 연결”이라는 기본 동작이 생각보다 빨리 비용이 된다는 걸 이번에 직접 부딪히며 배웠다. SharedWorker는 그 비용을 단번에 줄여준 도구였고, 그걸 안전하게 쓰기 위한 곁가지 고민들이 오히려 더 오래 기억에 남는다.

© 2026 dan.dev.log, All right reserved.

Built with NextJS

탭 다섯 개에 WebSocket 열 개, 이게 맞을까 — SharedWorker | Dan DevLog