Supabase Realtime으로 위치를 나누는 공간은 왜 20명에서 막히나

동딩
2026.11.02·4분 읽기·
  • Supabase Realtime약 9,840통
  • Durable Object 중계98통
같은 100명이 초당 세는 위치 메시지

위치만 중계로 옮기면 사람 수만큼 센다

Cloudflare Durable Object 중계는 들어오는 메시지만 요금에 셉니다. 나가는 메시지는 무료이고, 들어오는 메시지도 20통을 요청 1건으로 칩니다. Durable Object는 이름 하나에 객체 하나만 뜨는 Cloudflare의 작은 서버입니다. 탭마다 WebSocket을 이 객체 하나에 붙이고, 받은 위치를 다른 연결에 그대로 나눠 줍니다.

ts
import { DurableObject } from "cloudflare:workers";
export class Relay extends DurableObject {
async fetch() {
const [client, server] = Object.values(new WebSocketPair());
this.ctx.acceptWebSocket(server); // 메시지가 없으면 잠든다
return new Response(null, { status: 101, webSocket: client });
}
async webSocketMessage(ws: WebSocket, msg: string | ArrayBuffer) {
for (const s of this.ctx.getWebSockets()) if (s !== ws) s.send(msg);
}
}

acceptWebSocket으로 받으면 메시지가 없는 동안 객체가 잠들고 연결은 붙은 채 남습니다. 잠든 시간에는 시간 요금이 붙지 않습니다. Cloudflare 무료 플랜은 한도를 넘으면 과금 대신 오류로 막으므로, 중계가 오류를 돌려주거나 여섯 번 잇달아 붙지 못하면 탭이 Supabase 채널로 돌아가게 둡니다.

Worker 진입점과 wrangler 설정까지 옮겨 쓰려면

무료 플랜은 SQLite 저장소 클래스만 만들 수 있어 new_sqlite_classes로 등록합니다. 저장소는 쓰지 않습니다. 다른 사이트의 페이지가 이 중계를 쓰지 못하게 Origin도 봅니다.

ts
type Env = { RELAY: DurableObjectNamespace<Relay> };
const ORIGIN = /^https:\/\/world\.dongding\.dev$/;
export default {
async fetch(req: Request, env: Env) {
if (req.headers.get("Upgrade") !== "websocket") return new Response("websocket only", { status: 426 });
if (!ORIGIN.test(req.headers.get("Origin") ?? "")) return new Response("origin", { status: 403 });
return env.RELAY.get(env.RELAY.idFromName("world")).fetch(req);
},
};
jsonc
{
"name": "world-relay",
"main": "worker.ts",
"compatibility_date": "2026-10-01",
"durable_objects": { "bindings": [{ "name": "RELAY", "class_name": "Relay" }] },
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["Relay"] }]
}

Supabase는 받은 사람 수만큼 센다

한 칸에 20명이 초당 한 번씩 위치를 보내면 Supabase는 초당 400통을 셉니다. Realtime은 broadcast 한 통을 보낸 한 통과 받은 사람 수만큼 셉니다. 무료 플랜의 초당 메시지 한도는 100입니다.

제가 만든 3D 섬 world에서 광장에 모여 공연을 보면 이렇게 됩니다.

Supabase는 받은 사본까지 세고, 중계는 들어온 한 통만 센다

Supabase Realtime보낸 1통에 받은 사람 수를 더해 센다같은 칸19명위치 1통지수채널사본 19통20통위치 한 통이 세어지는 수20명이 초당 한 번씩이면 400통무료 한도는 초당 100통
Durable Object 중계들어온 메시지만 센다같은 칸19명위치 1통지수중계사본 19통, 무료1통위치 한 통이 세어지는 수20명이 초당 한 번씩이면 20통나가는 사본은 세지 않는다
요금에 세는 메시지세지 않는 메시지
같은 칸 20명 중 지수가 위치 한 통을 보낼 때

섬을 구역으로 나눠 내 둘레 구역만 구독해도 사람이 한 칸에 몰리면 소용이 없습니다. 그 칸 안에서는 여전히 모두가 모두에게 보냅니다. 보내는 빈도를 2초에 한 번까지 낮춰도 20명이면 초당 200통입니다.

Supabase를 Pro로 올리면 한도가 초당 500, Team이면 2,500으로 늡니다. 세는 수는 사람 수의 제곱으로 늘어서 아래에서 잰 100명이면 Team 한도도 넘습니다. broadcast가 presence·postgres_changes와 어디서 갈리는지1는 따로 정리해 두었습니다.

1·이 블로그의 글·시스템·2026.10.12
Supabase Realtime은 구독됐는데 왜 이벤트가 오지 않나
SUBSCRIBED인데 이벤트가 안 오면 publication부터 봅니다. postgres_changes는 이벤트마다 정책을 봐서 강퇴가 바로 먹고, broadcast·presence는 들어올 때만 봐서 JWT 만료까지 받습니다.

100명이면 98통으로 줄어든다

같은 100명을 중계로 옮기면 세는 위치가 초당 9,840통에서 98통으로 줄어듭니다.

world 중계를 Cloudflare에 올리고 가짜 접속자를 한 칸에 몰아 20초씩 쟀습니다. 셋 중 한 명꼴로 움직이고, 중계를 쓸 때는 빈도를 300 / 사람 수(초당 최대 4번)로 맞췄습니다. Supabase라면 들어온 위치와 같은 칸에 복사된 위치를 더해 세고, Durable Object는 들어온 위치만 셉니다.

100명이면 무료 한도인 하루 10만 요청을 약 5.6시간에 씁니다. 객체 하나를 하루 종일 켜 둬도 시간 요금은 무료 한도 안입니다.

객체 하나는 한 스레드에서 돌고 초당 요청 1,000건이 권장 한도입니다. 들어오는 위치는 빈도를 맞춰 두어 사람이 늘어도 초당 300통을 넘지 않지만, 객체가 내보낼 사본은 사람 수만큼 불어납니다.

  • 100명116ms
  • 120명626ms
  • 150명1,760ms, 연결 106개 끊김
모두 움직일 때 위치가 닿는 시간(p95)

셋 중 한 명꼴로 움직이면 150명까지 멀쩡하고 200명에서 연결이 드문드문 떨어집니다.

내보내는 사본이 초당 2만에서 3만 통 사이가 객체 하나의 선입니다. 사람이 더 모이면 섬이나 게임 판마다 이름을 달리해 객체를 나눕니다.

받는 쪽 화면은 가짜 사람 100명이 내 아바타 곁에서 돌아도 맥(M1 Pro)에서 초당 60프레임을 지킵니다.

운영과 같은 빌드로, CPU를 4배 느리게 한 휴대폰 크기 화면도 같았습니다. 실제 휴대폰은 그래픽 칩이 이보다 약하니 따로 재 봐야 합니다.

측정 원자료와 조건이 필요하다면

지연 p95는 서울에서 보낸 위치 100통 가운데 95통이 중계를 거쳐 다른 탭에 닿기까지 걸린 시간입니다. 끊긴 연결은 재는 동안 진행자 서버가 닫은 연결이고, 못 붙음은 처음부터 열리지 않은 연결입니다.

접속자움직이는 사람중계에 들어온 위치(초당)같은 칸에 복사된 위치(초당)지연 p95
20727.9531.0435ms
501767.83,334.9180ms
1003398.49,741.6114ms
접속자움직이는 사람내보낸 사본(초당)지연 p95끊긴 연결
10010029,700116ms0
12012035,687626ms0
1505014,857330ms0
1501507,7741,760ms106
2006619,327328ms2, 못 붙음 9
300996,5434,887ms100, 못 붙음 14

Cloudflare 무료 플랜에 올린 중계에 서울의 맥 한 대에서 가짜 탭을 붙여 쟀습니다. 지연에는 바다 건너 중계까지 오가는 거리가 들어 있습니다. 첫 표의 세 회차를 맥 안의 wrangler dev로 재면 p95가 10~14ms였습니다. 20명 회차는 맨 처음 돌려 잠든 객체가 깨어나는 시간이 섞였고, 최대 2.2초가 나왔습니다.

한 컴퓨터에서 붙이느라 그동안만 IP 하나의 연결 상한을 풀었습니다. 둘째 표는 맥 한 대의 Node 프로세스 하나가 초당 수만 통을 받아서, 받는 쪽이 밀린 몫도 지연에 섞였을 수 있습니다. 100명이 모두 움직이는 회차를 다시 재면 p95가 256ms로, 회차마다 두 배 넘게 흔들렸습니다.

요금을 자세히 따져 보려면

시간 요금은 128MB 객체가 1초 깨어 있을 때 0.128 GB-s로 셉니다. 객체 하나를 하루 종일 켜 둬도 약 11,060 GB-s라 무료 한도 13,000 안입니다.

Workers Paid는 월 $5에 요청 100만 건과 시간 40만 GB-s가 들어 있고, 넘으면 요청 100만 건당 $0.15, 시간 100만 GB-s당 $12.50입니다. 100명이 셋 중 한 명꼴로 움직이며 한 달 내내 머물면 요청이 약 1,275만 건이라 요청 몫이 $1.76쯤 더 붙습니다. 객체 하나를 종일 켠 시간은 한 달 약 33만 GB-s로 포함량 안입니다. 무료 플랜의 하루 한도는 00:00 UTC, 한국 시각 오전 9시에 다시 찹니다.

Comments

댓글

powered by giscus ↗