홈 › 읽을거리 › 가이드

정적 사이트에 워커 하나: GamePool 구조의 선택과 한계

가이드 · 2026-10-08 · GamePool 편집팀

GamePool을 처음 만들 때 정한 원칙은 빌드 도구와 프레임워크를 쓰지 않는다는 것이었습니다. 웹 게임 사이트는 서버가 복잡할수록 고장 날 곳이 늘어난다고 보았기 때문에, 대부분을 정적 파일로 두고 꼭 필요한 부분만 서버로 처리하기로 했습니다. 이 글은 그 선택이 실제 폴더와 설정 파일에서 어떤 모양이 되었고, 어떤 불편을 불렀으며, 운영하면서 어떤 문제를 만났는지를 적은 것입니다. 일반적인 웹 게임 원리 설명은 최소로 줄이고, 소스와 이력으로 확인되는 사실만 담았습니다.

게임 한 편은 파일 하나, 그 크기는 천차만별

사이트의 모든 파일은 public 폴더 아래에 있고, 게임은 public/games/<id>/index.html 한 파일에 화면·스타일·코드를 모두 담습니다. 현재 게임은 32개입니다. 가장 작은 기억력 게임(memory)은 5,419바이트이고, 가장 큰 레오니스는 379,078바이트입니다. 70배 가까운 차이가 나는 이유는 레오니스가 8개 챕터 160스테이지의 데이터와 연출 코드를 한 파일에 모두 담고 있기 때문입니다. 단일 파일이라는 제약은 큰 게임에서 불편으로 돌아옵니다. 파일 하나가 4,100줄이 넘어서 수정 범위를 찾기 어렵고, 변경 이력을 볼 때도 커밋이 한 파일에 몰립니다. 그래도 파일 하나만 열면 그 게임의 모든 것이 보인다는 장점이 더 커서 이 방식을 유지하고 있습니다.

공통 코드는 games/_shared에 모았습니다. audio.js(64,609바이트)는 소리 엔진, rank.js(13,487바이트)는 랭킹 위젯, game.js(2,491바이트)는 저장소 래퍼와 복제 방지입니다. 3D 게임 다섯 개(블록 월드, 챔피언 방어전, 사냥꾼 고깃집, 군단 점령전, 솔저 러쉬)는 games/_lib/three.min.js를 함께 씁니다. 이 파일은 three.js r186에서 쓰는 클래스만 골라 esbuild로 묶은 581,443바이트짜리 모듈입니다. 필요한 클래스만 넣었기 때문에 가끔 필요한 것이 빠져 있습니다. 챔피언 방어전에서는 번들에 Box3가 없어서 오브젝트 바운딩 박스를 직접 계산하는 함수(bboxOf)를 만들었고, AdditiveBlending 상수도 없어서 값 2를 숫자로 적어 넣었습니다(소스에 이유를 주석으로 남겨 두었습니다). 번들을 다시 만드는 대신 이렇게 우회했고, 그만큼 소스에 주석으로 이유를 남겨야 했습니다.

서버는 /api/* 하나: 선택한 이유와 한계

서버 코드는 worker/index.js 하나뿐입니다. wrangler.jsonc에서 run_worker_first를 /api/*로만 지정해, 그 주소의 요청은 워커가 처리하고 나머지는 모두 public의 정적 파일이 응답합니다. 워커가 하는 일은 두 가지로 닉네임 랭킹과 게임 종류 투표(/message 페이지)입니다. 저장소는 클라우드플레어의 Durable Object(SQLite)이고, 설정 파일의 주석에 적어 둔 대로 별도 데이터베이스를 만들거나 연결하지 않아도 배포할 때 자동으로 만들어집니다. 랭킹은 게임·난이도마다, 투표는 하나의 저장소를 씁니다. 처음 만든 랭킹(마이그레이션 v1) 뒤에 투표(v2)를 추가할 때도 설정 몇 줄과 클래스 하나 추가로 끝났습니다.

이 선택의 대가도 분명합니다. 첫째, 서버가 아는 정보가 닉네임과 점수뿐이라 계정이나 기기 간 기록 이동이 불가능합니다. 내 기록이 다른 기기에 따라가지 않는 것은 단점이 아니라 이 구조의 직접적인 결과입니다. 둘째, 서버가 점수의 진위를 검증할 방법이 없습니다. 게임이 사용자 브라우저에서 돌기 때문에 서버는 보내온 숫자를 믿을 수밖에 없고, 그래서 게임별 점수 상한과 출처 확인, 3초 제한 같은 가벼운 방어만 둡니다. 셋째, 배포 환경의 이름이 코드에 박혀 있습니다. 워커 이름과 Durable Object 클래스 이름은 한번 정하면 바꾸기 어려워, 새 기능을 넣을 때마다 마이그레이션 번호를 하나씩 올리게 됩니다.

도메인 설정에서도 이력에서 얻은 규칙이 있습니다. workers_dev와 preview_urls를 꺼서 임시 주소(workers.dev)로는 접속되지 않게 했습니다. 같은 내용이 두 주소로 검색에 노출되는 것을 막기 위한 설정이라고 파일 주석에 적어 두었습니다. 그리고 routes에 gamepool.kr과 www.gamepool.kr을 명시해, 대시보드에서 도메인 연결이 풀리더라도 다음 배포 때 복구되게 했습니다.

저장소가 막힌 시크릿 모드에서 게임이 멈추던 문제

브라우저 저장소(localStorage)는 일부 브라우저의 시크릿 모드, 사이트 데이터 차단 설정, 일부 미리보기 화면에서 접근하는 순간 예외를 던질 수 있습니다. 게임이 저장소를 직접 읽고 쓰면 그 예외 하나로 게임이 시작조차 못 하는 일이 생깁니다. 그래서 모든 게임은 games/_shared/game.js가 만든 store.get·store.set만 쓰도록 했습니다. 두 함수는 try-catch로 감싸져 있어 읽기는 null을, 쓰기는 아무 일도 하지 않고 넘어갑니다. 게임 코드에서 localStorage를 직접 부르는 것은 프로젝트 규칙으로 금지되어 있습니다. 저장 키 이름은 gp<게임id>:이름 형식으로(예: gpalkkagi:record, gpmemory:best:4x4) 통일했고, 소리 설정은 gp:sound, 닉네임은 gp:nick입니다.

이 규칙은 말로만 두지 않고 점검으로 확인합니다. scripts/smoke.js는 모든 게임을 두 번 엽니다. 한 번은 정상 상태로, 한 번은 localStorage 접근 자체가 SecurityError를 던지도록 막은 상태로 열어서 콘솔 오류가 나는지 봅니다. 새 게임에서 저장소를 직접 호출하는 코드가 있으면 이 점검에서 오류로 드러날 수 있습니다. 한계도 있습니다. 저장소가 막힌 환경에서는 최고 기록과 닉네임, 소리 설정이 모두 저장되지 않아 접속할 때마다 처음부터 시작하는 것처럼 보입니다. 게임은 멈추지 않지만 기록은 남지 않습니다.

캔버스를 화면에 맞추는 일에서 겪은 것

캔버스 게임은 가상 좌표로 계산하고 화면에 그릴 때 확대 비율을 곱합니다. 플래피는 가로·세로 중 더 빡빡한 쪽에 맞춰 확대 비율을 정하고(Math.min(innerWidth / W, innerHeight / H)), 화면 배율은 최대 3으로 제한한 값(Math.min(devicePixelRatio, 3))으로 캔버스 실제 크기를 키웁니다. 배율은 아무리 높은 화면이라도 3까지만 씁니다. 창 크기가 바뀔 때는 resize 이벤트로 이 계산을 다시 합니다.

3D 게임에서는 비율 문제가 더 미묘하게 나타났습니다. 솔저 러쉬에서 도로를 넓히자 게이트(×·+ 문)의 글씨가 옆으로 늘어나 보였습니다. 게이트 표면이 가로 7.25 : 세로 2.5 비율의 판인데 글씨를 그리는 캔버스 텍스처가 256×176이어서, 텍스처가 판에 맞춰 가로로 늘어난 것이었습니다. 텍스처를 512×176(비율 약 2.9)으로 바꿔 판과 같은 비율로 맞추자 글씨가 정상으로 돌아왔습니다. 알까기는 반대로 드래그가 핵심이라 캔버스에 touch-action: none을 걸어 손가락을 끌 때 화면이 스크롤되거나 확대되지 않게 했습니다.

다른 사이트가 게임을 퍼 가지 못하게 한 장치

게임이 단일 파일이라 누구든 파일 하나만 복사하면 자기 사이트에서 돌릴 수 있습니다. 그래서 막는 장치를 세 겹으로 두었습니다. 첫째, _headers 파일에서 모든 응답에 frame-ancestors 'self'와 X-Frame-Options: SAMEORIGIN을 붙여 다른 사이트의 iframe 안에서는 우리 페이지가 열리지 않게 했습니다. 둘째, game.js가 현재 주소가 gamepool.kr(개발용 localhost 포함)이 아니거나 남의 사이트 iframe 안이면 게임 대신 안내 화면을 띄웁니다. 번역 서비스 주소(translate.goog)만 예외로 허용합니다. 셋째, 일반 페이지 코드(common.js)도 허가된 도메인 밖에서 열리면 gamepool.kr로 보냅니다.

반대로 정상적인 경우도 챙겼습니다. 게임 파일을 주소창에 직접 열면(iframe 밖) 화면 한쪽에 GamePool에서 플레이 버튼이 나타나 정식 게임 페이지(/play/<id>/)로 보내 줍니다. 정식 페이지는 플레이 버튼을 눌러야 게임 iframe을 불러오는 지연 로드 방식이라, 게임을 하지 않는 방문자는 게임 파일을 내려받지 않습니다. 이 장치들은 복제를 어렵게 할 뿐이고, 파일을 받아 코드를 고쳐 쓰는 사람까지 완전히 막을 수는 없습니다.

서비스 워커: 네트워크 우선, 실패하면 저장본

sw.js는 짧습니다. 같은 도메인의 GET 요청은 일단 네트워크에서 받고, 받아 온 응답을 복사해 캐시(gamepool-v1)에 저장해 두었다가 네트워크가 실패하면 저장본을 보여 줍니다. 광고처럼 다른 도메인으로 가는 요청은 건드리지 않습니다. 캐시 우선이 아니라 네트워크 우선이므로, 새 버전을 올리면 온라인 상태에서는 곧바로 새 파일이 보입니다. _headers에서도 sw.js 자체는 항상 최신인지 확인하도록 no-cache로 지정했고, 썸네일 같은 assets 아래 파일도 캐시하되 매번 변경 여부를 확인합니다. 한계는 오프라인에서는 한 번이라도 열어 본 페이지와 파일만 보인다는 점입니다. 처음 보는 게임은 연결이 없으면 열리지 않습니다.

배포와 점검 흐름에서 얻은 규칙

작업이 끝나면 PR을 올리고, 사용자가 main에 합치면 자동 배포됩니다. 게임 소개·분류 페이지와 사이트맵은 scripts/build.js가 games.js 한 곳에서 생성하므로, 생성된 파일을 직접 고치지 않는다는 규칙이 있습니다. 클라우드플레어는 .html로 끝나는 주소를 확장자 없는 주소로 리디렉트하기 때문에, 내부 링크는 처음부터 확장자 없는 최종 주소(/about, /play/<id>/)로 적습니다. 로컬에서 wrangler dev로 같은 규칙을 재현한 뒤 smoke.js를 돌리면, 모든 페이지와 게임을 브라우저로 열어 콘솔 오류, 리디렉트(3xx), 4xx·5xx 응답을 한꺼번에 점검합니다. 리디렉트는 오류는 아니지만 불필요한 이동이 있다는 신호라서 문제로 모읍니다.

점검은 코드만이 아니라 글을 쓰는 과정에서도 문제를 찾아냈습니다. 게임 소개 본문을 보강하던 중 스도쿠에서, 힌트를 쓴 직후 되돌리기를 누르면 그 칸이 고정된 채 비어서 판을 끝낼 수 없는 버그를 발견했습니다. 되돌리기 코드가 값만 되돌리고 힌트로 고정된 칸 표시(given)를 풀지 않은 것이 원인이었고, 한 줄(given[h.i] = false)로 고쳤습니다. 같은 작업에서 게임 설명 문구와 실제 소스가 어긋난 곳도 약 12건 정정했습니다. 설명을 소스 기준으로 다시 확인하는 일은 단순 교정이 아니라 점검의 연장이었습니다.

직접 확인해 보는 방법

  • 개발자 도구(F12)의 저장소 탭에서 gp로 시작하는 항목이 이 사이트가 남긴 전부입니다. 그중 gp:nick과 gp:sound는 전체 공용, 게임 이름이 붙은 것은 그 게임의 기록입니다.
  • 시크릿 창에서 게임을 열어 보세요. 브라우저에 따라 저장이 막히기도 하지만 게임은 정상으로 열립니다. 창을 닫으면 그 창의 기록은 어차피 사라집니다.
  • 창 크기를 줄였다 늘려 보면 캔버스 게임이 화면 비율에 맞춰 다시 그려지는 것을 볼 수 있습니다.
  • 게임 파일 주소(/games/<id>/)를 직접 열면 화면 한쪽에 GamePool에서 플레이 버튼이 나타납니다.

정적 사이트에 워커 하나를 붙인 구조는 가볍고 고장 날 곳이 적은 대신, 계정과 기록 이동, 서버 검증 같은 것을 포기한 선택이었습니다. 그 포기가 시크릿 모드에서 기록이 사라지는 현상, 승수를 믿고 받을 수밖에 없는 랭킹으로 드러납니다. 그래서 구조를 설명할 때도 장점만이 아니라 이 한계를 함께 적어 두었습니다.

관련 게임 플레이하기

다른 읽을거리

가이드반응 속도 게임: 두더지·러너·새의 숫자와 봇 실험두더지 잡기·점프 러너·날갯짓 새의 소스 수치(출현 간격, 점프 물리, 탭 한 번의 상승량)와 봇 실험으로 정리한 연습 포인트입니다.가이드GamePool 이 내 브라우저에 두는 것과 서버에 보내는 것저장 키, 랭킹 닉네임 규칙, 서버 검증과 IP 처리, 지우는 법, 복제 사이트를 가려내는 방법을 GamePool 소스 기준으로 정리했습니다.공략2048 모서리 전략: 봇 1,000판으로 확인한 차이이 사이트 2048의 소스 규칙(새 타일 확률, 합치기 순서, 점수)과 봇 1,000판 결과로 모서리 고정과 정렬이 왜 효과가 있는지 풀어 썼습니다.공략블록 퍼즐 습관: 점수표와 봇 실험으로 본 판 관리블록 낙하와 블록 채우기의 점수표·속도·블록 출현 확률을 소스에서 읽고, 봇으로 확인한 결과로 구멍을 만들지 않는 습관과 한 번에 지우는 판단을 정리했습니다.칼럼난이도를 고친 기록: 솔저 러쉬·레오니스·챔피언 방어전시작 병력, 체력 곡선, 몬스터 +40%, 보스 체력 배율, 연타 배수문 세 번의 설정값까지. 실제로 난이도를 고친 변경 전후 숫자와 쉽다·어렵다를 판단한 근거를 정리했습니다.가이드방치형 경영 게임: 구매 순서 봇 실험과 상장 시점스타트업 타이쿤의 가격·수익 공식을 소스에서 읽고, 구매 순서와 상장 시점을 봇으로 비교했습니다. 라면가게·고깃집의 업그레이드 본전 계산도 함께 정리했습니다.