본문 바로가기

블로그 / 기술

Durable Objects로 10인 매치 룸 서버를 만든 기록

Salmonholic의 10인 온라인 매치를 Cloudflare Durable Objects 한 개당 한 판으로 돌렸다. 틱 방식 선택, 비용 계산, 19분 소크 테스트, 방 상한까지 실제 측정값으로 정리했다.

Salmonholic ·· 6분 읽기

온라인 매치 대기 중 '다음 경기에 참가해요' 안내가 뜬 Salmonholic 게임 화면

Salmonholic은 고양이 10마리가 연어 떼를 쓸어 담으며 좁아지는 구역 안에서 마지막 한 마리를 가리는 웹 게임이다. 혼자 만드는 게임에 권위 서버를 붙이기로 하면서 가장 먼저 한 고민은 “서버를 어디에 둘 것인가”였다. 서버 관리에 시간을 쓰고 싶지 않았다. 그래서 첫 후보가 Cloudflare Durable Objects(이하 DO)였다. 이 글은 DO로 매치 룸 서버를 만든 과정과, 그 과정에서 잰 숫자들의 기록이다.

한 판 = DO 하나라는 설계

DO는 이름으로 찾을 수 있는 작은 상태 객체다. 한 객체는 한 스레드에서 돌고, 같은 이름으로 들어온 요청은 전부 같은 객체로 모인다. 매치 룸과 모양이 거의 같다. 2026-09-21에 쓴 기술 스파이크 문서의 설계는 이랬다.

  • 매치메이커 DO: 지역마다 하나. 열린 방 하나를 채우다가 10명이 모이거나 로비 시간이 끝나면 방을 시작한다.
  • 룸 DO: 매치마다 하나. idFromName(matchId)로 만든다. 로비 → 카운트다운 3초 → 플레이 → 종료 순서로 흐르고, 빈자리는 서버 봇이 채운다.
  • 틱: 15Hz. 플레이어 위치, HP, 레벨, 카드, 자기장, PvP 판정은 서버가 정한다.

화면을 가득 채우는 연어 떼는 서버가 시뮬레이션하지 않았다. 서버는 판 시작 때 시드만 나눠 주고, 각 클라이언트가 자기 주변 몬스터를 같은 시드로 결정론적으로 돌린 뒤 서버는 그 결과만 받는다. 수백 마리를 15Hz로 10명에게 복제하는 건 대역폭 낭비였기 때문이다. 대신 서버 쪽에서 말이 안 되는 결과는 걸러 낸다. 나중에 쓴 결정 문서(ADR-0004)에는 이 원칙을 “신뢰하되 상한을 둔다”로 적었다.

한 가지 더 정한 것은 Godot의 고수준 멀티플레이어 API를 쓰지 않는 것이었다. 그 API는 Godot 전용 패킷 형식을 전제로 해서 TypeScript 서버와 맞지 않는다. 엔티티가 10개뿐이라 바이너리 파서와 보간 노드를 직접 짜는 쪽이 오히려 작았다.

setInterval이냐 알람 체인이냐

DO에서 주기적으로 코드를 돌리는 방법은 두 가지였다. 흔한 setInterval, 그리고 DO 전용 알람을 매번 다시 거는 방식이다. 로컬 workerd에서 클라이언트 10개를 붙여 둘 다 재 봤다.

setInterval과 알람 체인의 틱 간격 비교 막대그래프 목표 간격은 66.67ms. setInterval이 모든 지표에서 조금씩 더 안정적이었다.

방식 평균 표준편차 p99 최대
setInterval (60초) 67.69ms 0.91ms 70.45ms 74.58ms
알람 체인 (30초) 68.38ms 1.40ms 73.50ms 81.58ms

수치 차이는 크지 않았다. 결정을 가른 건 비용이다. 알람을 한 번 걸 때마다 저장소 쓰기가 한 번 일어난다. 15Hz로 8분짜리 판을 돌리면 판당 7,200행을 쓰게 되고, 1,000판이면 7.2달러가 더 든다. setInterval은 저장소를 건드리지 않는다. 대신 setInterval이 돌고 있는 동안에는 DO가 잠들지(hibernation) 못한다. 그래서 로비 대기는 알람으로, 경기 중 틱은 setInterval로 나눴다. 사람을 기다리는 동안은 자고, 경기가 시작되면 깨어 있는 구조다.

판당 비용을 먼저 계산했다

만들기 전에 요금표로 1,000판 비용을 계산해 두었다. 평균 사람 5명과 봇 5명, 8분 경기를 가정했다.

항목 판당 1,000판 요금
요청 (수신 메시지는 20개가 요청 1건) 약 1,935건 0.29달러
실행 시간 (480초 × 128MB) 60 GB-s 0.75달러
저장소 쓰기 20행 0.02달러
합계 약 1.06달러

월 5달러 유료 플랜의 포함량 안에서는 약 6,000판까지 추가 요금이 없다는 계산이 나왔다. 대역폭도 봤다. 스냅샷 한 프레임이 117바이트로 잡혀 클라이언트당 초당 1.8KB 정도였다. 이 숫자를 보고 DO로 가도 된다고 판단했다.

19분 동안 방 하나를 살려 두기

스파이크 문서에서 가장 불안했던 항목은 CPU 한도였다. DO에는 요청당 CPU 시간 제한이 있는데, 몇 분씩 도는 setInterval이 이 한도에 어떻게 누적되는지 문서에 명확히 나와 있지 않았다. 그래서 실제로 오래 돌려 봤다.

첫 소크 테스트는 결과 파일이 없었다. 원인을 찾아보니 죽은 건 서버가 아니라 벤치 클라이언트였다. 자기장이 몰아치며 한 스냅샷에 사망 이벤트 7건이 실렸는데, 서버가 12바이트짜리 사망 이벤트의 길이 칸에 11을 적고 있었다. 길이를 믿고 다음 이벤트로 넘어가던 디코더는 정렬이 어긋나 버퍼 밖을 읽다가 멈췄다. 서버는 그 사이에도 19분 동안 17,100틱을 멀쩡히 돌리고 있었다. 길이 칸을 12로 고치고 다시 돌린 결과는 이렇다.

  • 1,135초(약 19분) 완주, 재시작과 강제 종료 0회
  • 실효 15.15Hz, 알 수 없는 이벤트 0건
  • 로컬에서 잰 틱당 CPU 평균 0.16ms, 최대 1.0ms

틱당 0.16ms라면 30분을 돌려도 누적 CPU는 몇 초 수준이다. 이후 방 두 개를 연달아 붙여 33분을 채운 테스트도 끊김 없이 끝났다. 신규 방의 첫 접속이 1.3~1.5초 걸리는 콜드 스타트는 매치메이커가 방을 미리 만들어 두는 방식으로 사용자에게서 숨겼다.

DO를 폴백으로 내린 이유와 공유 코드

기술적으로는 잘 돌았지만 문제는 한국 회선이었다. 내 회선에서는 Cloudflare 엣지가 한국이 아니라 미국 서부로 잡혔고, 룸을 어디에 두든 왕복 시간이 수백 ms였다. 결국 서울 VM의 Node 서버를 1차로 두고 DO는 폴백으로 남겼다. 이 부분은 별도의 이야기라 여기서는 결론만 적는다.

여기서 설계 덕을 봤다. Node 서버는 DO 서버의 sim.ts, codec.ts, rules.json을 복사하지 않고 그대로 import한다. 게임 규칙과 와이어 형식이 두 런타임에서 같은 파일이다. 재접속 시나리오 27개를 로컬 DO, 로컬 Node, 실제 두 서버에서 모두 통과시켰다. 2026-09-23 커밋 3728d70은 이 두 서버를 한 번에 정리해 넣은 것이다.

폴백이 실제로 쓰이는 순간도 만들었다. 서울 서버가 가득 차 503을 돌려주면 처음에는 곧장 오프라인 봇 경기로 떨어졌다. 수용량 보고서에서 “서울 24방을 넘으면 DO가 받아 준다”는 가정이 코드와 맞지 않는다는 걸 확인했고, 0.11.2(커밋 64d5f4a)에서 한쪽이 가득 차면 다른 쪽에 다시 요청하도록 고쳤다. 로컬 측정에서는 50방, 봇 400명까지 서버 프로세스 CPU 평균 4.5%, 메모리 약 98MB였지만, 이 값을 작은 VM에 그대로 옮기지 않고 운영 상한은 보수적으로 24방으로 두었다.

해 보고 남은 것

DO는 “한 판 = 객체 하나”라는 모델이 매치 룸과 잘 맞았고, 운영할 게 거의 없다는 장점이 컸다. 반대로 지역 라우팅은 내가 통제할 수 없었고, 메모리 상태가 사라지면 진행 중인 판도 사라진다. 5초마다 상태를 저장해 복구하는 기능은 아직 만들지 않았다. 지금까지 그런 일이 한 번도 관측되지 않아서다.

이후 서버에는 판마다 사람 플레이어의 순위, 생존 시간, 사망 원인을 모으는 통계가 붙었다(커밋 96f0fc7). 두 서버가 같은 코드를 쓰니 이런 기능도 한 번만 쓰면 됐다. 클라이언트 쪽에서 스냅샷을 어떻게 받아 부드럽게 그리는지는 10인 배틀로얄 네트워크 동기화 글에 따로 적었다. 빈자리를 채우는 봇 이야기는 봇과 매치메이킹 글에 있다. 게임은 Salmonholic 소개 페이지에서 바로 해 볼 수 있다.

#Cloudflare#Durable Objects#멀티플레이#게임 서버#웹게임

함께 읽으면 좋은 글

전체 글 →