DEV NOTE · STUDIO

빌드 없이 배포하는 웹 게임 사이트: Cloudflare Workers 한 저장소 구성기

rogagame.com에는 지금 게임 일곱 개가 올라가 있지만 빌드 단계는 하나도 없다. 저장소 루트를 그대로 정적 에셋으로 올리고, 서버가 꼭 필요한 기능만 같은 저장소의 Worker 하나가 맡는다. 이 글은 그 구성을 고른 이유와, 빌드가 없어서 생긴 빈자리를 캐시 규칙과 CI 검사로 메운 방법을 정리한 것이다.

저장소 루트가 곧 배포물이다

배포 설정의 핵심은 wrangler.jsonc의 assets.directory가 "."이라는 점이다. 저장소에 있는 HTML·JS·CSS가 그대로 서비스되고, dist/ 같은 산출물 폴더가 없다. html_handling은 auto-trailing-slash로 두어 about.html이 /about으로 열리고, 없는 주소는 404-page 설정으로 루트의 404 페이지를 돌려준다.

번들러를 쓰지 않은 이유는 단순하다. Vite 같은 도구를 넣으면 빌드 단계, CI, 배포 제외 목록을 전부 바꿔야 한다. 우리 게임은 대부분 바닐라 Canvas이거나 라이브러리 하나(Three.js, PixiJS)를 쓰는 규모라서, 파일을 나누는 일은 브라우저의 네이티브 ES 모듈로 충분했다. ECHO는 main.mjs가 render3d.mjs를 import하는 식으로 모듈을 쓰고, 나머지 게임은 classic script를 순서대로 불러온다. 외부 라이브러리는 CDN 대신 각 게임의 vendor/ 폴더에 복사해 둔다. 서비스 워커의 오프라인 캐시와 캐시 헤더 규칙이 모두 "자기 폴더" 기준이라 라이브러리도 그 안에 있어야 일관되게 관리된다.

번들러는 TypeScript가 필요해지거나 모듈이 수십 개로 늘어나는 게임이 나올 때 다시 검토하기로 했다. 그 전까지는 코드 한 줄을 고치고 바로 브라우저에서 확인할 수 있다는 점이 더 크다. 로컬 미리보기도 python -m http.server 하나면 된다.

정적 에셋이 먼저, Worker는 남은 경로만

같은 배포에 Worker 스크립트(worker/index.mjs)가 붙어 있지만, 요청은 먼저 정적 에셋에서 찾는다. 에셋에 없는 경로만 Worker로 넘어오고, Worker는 /api/로 시작하지 않는 요청을 즉시 404로 돌려보낸다. 게임 파일 요청에는 Worker 실행 비용이 붙지 않는 구조다.

현재 Worker가 하는 일은 JJ 한 게임을 위한 두 가지다. 첫째는 온라인 협동 방이다. 방 하나가 Durable Object 인스턴스 하나이고, 4글자 방 코드로 인스턴스를 찾는다. 서버는 게임 상태를 전혀 모른 채 두 사람의 입력 비트를 WebSocket으로 중계만 하고, 시뮬레이션은 양쪽 브라우저가 같은 결정적 코드로 돌린다. 방은 최대 2시간 뒤 알람으로 정리되고, 메시지는 4096바이트를 넘으면 받지 않는다.

둘째는 DAILY 리더보드로, D1 데이터베이스에 기록을 저장한다. 여기서 중요한 결정은 클라이언트가 보낸 시간과 점수를 믿지 않는다는 것이다. worker/verify.mjs가 게임 폴더의 시뮬레이션 파일(jj/js/world.js, gen.js, replay.js 등)을 그대로 import해서 Worker에 함께 번들하고, 같은 날짜 시드로 코스를 만든 뒤 제출된 입력 로그를 재생해 서버가 직접 계산한 시간만 등록한다. 게임 코드와 서버 검증 코드가 같은 저장소에 있기 때문에 가능한 방식이다. 반대로 말하면 물리 수치나 점수식을 바꾸면 서버 재검증도 같이 바뀌므로, 게임만 배포하고 Worker를 빠뜨리는 일이 생기지 않도록 배포 명령을 하나로 묶어 두었다.

JJ 플레이 화면. 주황색 EMBER 코어와 파란색 AZURE 코어가 빛나는 줄로 연결되어 발판 사이를 건너고 있다
JJ는 이 사이트에서 유일하게 서버 기능을 쓰는 게임이다. 온라인 방은 Durable Object가 입력만 중계하고, DAILY 기록은 Worker가 입력 로그를 재생해 다시 검증한다.

로컬 개발에서 한 번 막힌 부분도 있다. 에셋을 포함해 wrangler dev를 띄우면 저장소 안에 테스트 로그 파일이 생길 때마다 개발 서버가 재시작되고, 그때마다 Durable Object가 초기화되어 방이 사라졌다. 그래서 에셋 없이 Worker와 Durable Object만 띄우는 별도 설정 worker/dev.jsonc를 두고, 게임은 파이썬 정적 서버에서 열면서 API 주소만 쿼리 파라미터로 넘기는 방식으로 나눴다.

.assetsignore: 저장소에는 있지만 서비스하지 않는 것

루트를 통째로 올리면 올리지 말아야 할 파일도 같이 올라갈 위험이 있다. .assetsignore가 그 경계를 정한다. .git, .github, node_modules, wrangler.jsonc, 환경 변수 파일(.dev.vars*, .env*), 그리고 worker/ 폴더와 모든 *.md 문서를 제외한다.

*.md 제외는 생각보다 의미가 크다. 각 게임의 GAME_DESIGN.md, CHANGELOG.md는 저장소 안에서 기준 문서로 쓰이지만 웹에 그대로 노출될 필요는 없다. worker/를 빼는 이유는 서버 코드가 정적 파일로 다운로드되지 않게 하기 위해서다. Worker 코드는 main 설정으로 따로 번들되어 올라가므로 에셋 목록에 있을 이유가 없다. 비밀값은 애초에 저장소에 넣지 않는다는 규칙을 두고, 제외 목록은 실수를 막는 두 번째 울타리로 생각한다.

_headers: 오래 캐시할 것과 매번 확인할 것

빌드가 없으니 파일 이름에 해시를 붙여 주는 도구도 없다. 대신 HTML에서 스크립트를 부를 때 ?v=1.2.0 같은 버전 쿼리를 붙이고, 그 전제 아래 게임 폴더의 js/, css/, vendor/는 1년 동안 변하지 않는 파일로 캐시한다. 반대로 진입점인 index.html, sw.js, manifest.webmanifest는 no-cache로 매번 서버에 확인하게 한다. 진입점만 새로 받으면 그 안의 버전 쿼리가 바뀌어 있으니 새 스크립트가 자연스럽게 내려온다.

/:game/js/*
  Cache-Control: public, max-age=31536000, immutable
/:game/index.html
  Cache-Control: no-cache

처음에는 이 규칙을 /*/js/*처럼 와일드카드로 썼는데 게임 폴더 규칙이 적용되지 않았다. 이 형식의 헤더 파일에서 경로 중간 한 단계는 :game 같은 플레이스홀더로 써야 매칭된다. 변경 이력에 "per-game cache rules match"라는 커밋으로 남아 있는 수정이고, 이후 새 게임을 추가할 때 헤더 파일을 건드리지 않아도 되는 이유이기도 하다.

버전 세 곳 일치와 CI가 지키는 것

버전 쿼리 방식에는 약점이 하나 있다. 사람이 숫자를 바꾸는 걸 잊으면 브라우저가 옛 스크립트를 1년 동안 들고 있을 수 있다. 게다가 각 게임의 버전은 세 군데에 적힌다. js/version.js의 상수, 서비스 워커 sw.js의 VERSION, index.html의 모든 ?v= 태그다. 서비스 워커 버전이 바뀌어야 새 캐시가 만들어지고, HTML 태그가 바뀌어야 새 파일을 받는다. 셋 중 하나만 어긋나도 "배포했는데 안 바뀌었다"는 상황이 된다.

그래서 GitHub Actions 워크플로(.github/workflows/deploy.yml)가 푸시마다 이 일치를 검사한다. 아래는 실제 스크립트의 일부다.

V1=$(grep -oP "_VERSION='\K[^']+" "$d/js/version.js")
V2=$(grep -oP "const VERSION='\K[^']+" "$d/sw.js")
test "$V1" = "$V2"
grep -q "?v=$V1\"" "$d/index.html" || { echo "..."; exit 1; }

같은 워크플로가 모든 게임 폴더의 js/*.js, js/*.mjs, sw.js에 node --check를 돌려 문법 오류를 잡고, JJ는 헤드리스 회귀 테스트까지 돌린다. 수제 스테이지 24개의 저장된 입력 로그를 재생해 여전히 클리어되는지 보고, 생성기로 시드 60개를 만들어 전부 풀리는지 확인한다. 물리 수치를 바꿨을 때 기존 레벨이 깨지는 일을 배포 전에 알 수 있다. 번들러가 해 주던 검증 일부를 이렇게 몇 줄의 셸 스크립트로 대신한 셈이다.

배포 작업 자체는 저장소 변수가 켜져 있을 때만 Actions에서 실행되도록 막아 두었다. API 토큰과 계정 식별자는 저장소가 아니라 GitHub Secrets에 들어간다. 평소에는 커밋, 푸시, 로컬에서 npx wrangler deploy 한 번, 마지막으로 curl -I로 게임 주소가 200을 돌려주는지 확인하는 순서를 한 세트로 처리한다.

정리

빌드 없는 정적 배포의 장점은 단순함이다. 저장소에서 보이는 파일이 곧 사용자가 받는 파일이고, 서버가 필요한 기능은 같은 저장소의 Worker가 게임 코드를 그대로 가져다 쓴다. 대가로 생긴 빈자리, 즉 캐시 무효화와 문법 검사, 버전 관리는 _headers, .assetsignore, CI의 몇 줄로 메웠다. 남은 과제는 버전 세 곳을 사람이 고치는 수고를 줄이는 일이다. 지금은 CI가 실패로 알려 줄 뿐 대신 고쳐 주지는 않는다. 게임이 커져 번들러를 도입하게 되면 그때 이 부분부터 자동화할 생각이다.