방장이 방을 만들고 링크를 보내면, 받은 사람은 설치도 가입도 없이 닉네임만 적고 들어옵니다. 대기방에서 채팅하고 준비를 누르면 게임이 시작되고, 끝나면 같은 멤버로 [한 판 더]. 첫 게임은 순서대로 숫자를 1~3개씩 부르다 31을 부르는 사람이 지는 31 게임입니다. 지금은 트롯본방 사이트의 탭 하나로 붙어 있지만, 도메인을 사면 코드 변경 없이 떼어낼 수 있게 만들어 두었습니다.

01왜 만들었나 — 그리고 어떻게 만들었나

보드게임 아이디어는 많은데, 하나하나를 제대로 만들어 출시하기 전에는 사람들이 정말 좋아할지 알 수 없습니다. 그래서 "게임 하나"가 아니라 "게임을 빠르게 올려 보는 판"을 먼저 만들기로 했습니다. 방·채팅·준비·턴 타이머·재접속·기록 같은 공통 부분은 플랫폼이 한 번만 만들고, 새 게임은 규칙과 화면만 가져오면 되게 하는 것이 목표였습니다.

작업은 AI 코딩 에이전트와 단계를 나눠 진행했습니다. Phase 1은 설계만 — 폴더 구조, 게임 인터페이스, DB 스키마, 실시간 채널, 서버 검증 방식 비교를 문서로 먼저 쓰고 승인한 뒤에야 코드를 쓰게 했습니다. Phase 2는 DB와 서버, Phase 3은 화면 연결. 각 단계가 끝나면 멈추고 보고하도록 했고, 새 의존성 추가·스키마 변경·원격 배포·push처럼 되돌리기 어려운 일은 반드시 먼저 묻게 했습니다. 무료 플랜의 한계(Realtime 동시 연결 200개, DB 500MB, 활동이 없으면 일시정지)도 처음부터 설계 조건으로 넣었습니다.

02구조 — 규칙은 한 벌, 쓰기는 한 문으로

전체를 관통하는 원칙은 두 가지입니다. 게임 규칙 코드는 한 곳에만 있고, 게임 상태는 오직 서버의 한 경로로만 바뀝니다. 브라우저는 "무엇을 하고 싶다"는 행동만 보냅니다.

브라우저 (React · 대기방 / 게임 화면)
   │ 방 조작: create_room · join_room · set_ready …   → Postgres RPC
   │ 게임 행동: { op: "action", expectedVersion, action } ─┐
   ▼                                                      ▼
Edge Function "game" (Deno)                      JWT로 누가 보냈는지 확인
   │ game_load → core/ planRequest() → game_commit_*   — 브라우저와 같은 규칙 코드
   ▼
Postgres  — 모든 테이블 RLS · 클라이언트 쓰기 권한 없음 · rooms.version 조건부 저장
   │ realtime.send("changed", { version })
   ▼
Realtime 비공개 채널 room:{id}   — 방 멤버만 · 채팅 · Presence · "바뀌었다" 신호
   │
   └──▶ 브라우저가 get_room_state()로 "내 시점"만 다시 읽음

pg_cron 15초 ──pg_net──▶ Edge Function   — 아무도 없을 때 턴 넘기기 · 방 정리

전체 구조 — 상태를 바꾸는 길은 하나, 규칙 코드도 하나

03게임은 규칙만 끼워 넣는다

모든 게임은 React도 Supabase도 모르는 순수 TypeScript 모듈입니다. 플랫폼은 게임 종류와 상관없이 이 인터페이스만 부릅니다.

setup(players, options, { seed })   → 초기 상태
validateAction(state, playerId, action) → ok | { code, 한국어 메시지 }
applyAction(state, playerId, action)    → 새 상태   (입력은 절대 바꾸지 않는다)
getPlayerView(state, playerId | null)  → 그 사람이 볼 수 있는 것만
isGameOver(state)                     → 결과 | null
getActivePlayers(state)               → 지금 행동할 사람
getTimeoutAction(state, playerId)     → 시간 초과 때 대신 할 최소 행동

처음 받은 요구 예시와 몇 군데를 바꿨습니다. applyAction은 playerId를 따로 받습니다 — 누가 한 행동인지는 클라이언트가 아니라 서버가 JWT로 정해야 위조할 수 없기 때문입니다. 셔플 같은 무작위는 Math.random() 대신 서버가 정한 seed로 받게 했고, 플랫폼이 게임 종류와 상관없이 턴 타이머를 돌릴 수 있도록 getActivePlayers와 getTimeoutAction을 추가했습니다.

화면(Board)은 서버가 준 view만 보고 그립니다. 31 게임은 view 안에 "지금 누를 수 있는 버튼"(allowedCounts)까지 담아서 보내기 때문에, 화면은 31을 넘는 버튼을 끄기 위해 규칙을 다시 계산할 필요가 없습니다. 새 게임을 올리는 일은 규칙 파일, 테스트, 화면 파일, 그리고 등록 세 줄(서버 레지스트리, 화면 레지스트리, DB 행)입니다. 이 과정은 다른 작업 세션이 문서만 읽고 따라 할 수 있도록 가이드로 따로 정리해 두었습니다.

04서버 검증 — 두 벌로 짤 것인가, 한 벌을 옮길 것인가

"클라이언트는 행동만 보내고, 검증과 상태 변경은 서버가 한다"는 건 처음부터 정해져 있었습니다. 남은 질문은 서버 규칙을 어디에 두느냐였습니다.

  • A. Postgres 함수(PL/pgSQL)에 규칙을 복제 — 트랜잭션 하나로 끝나고 빠르지만, 규칙이 TS와 SQL 두 벌이 됩니다. 어긋나는 순간 "화면에선 되는데 서버가 거부하는" 버그가 생기고, 게임마다 SQL로 다시 짜야 하며, 카드 셔플과 손패 같은 건 SQL로 짜기가 매우 괴롭습니다.
  • B. Edge Function이 같은 TS 모듈을 import — 규칙이 한 벌이고, 스팀이나 모바일로 그대로 가져갈 수 있습니다. 대신 원자성은 다른 방법으로 지켜야 합니다.

B를 골랐습니다. 이 플랫폼의 존재 이유가 "규칙만 끼워 넣기"와 "옮길 수 있는 로직"이기 때문입니다. 원자성은 커밋을 한 문으로 좁혀서 해결했습니다. Edge Function도 테이블에 직접 쓰지 않고, service_role만 부를 수 있는 커밋 함수를 호출합니다. 커밋 함수는 방 행을 잠그고 version이 기대값과 같을 때만 저장한 뒤 버전을 올립니다. 다르면 PT409를 던지고, PostgREST가 이걸 그대로 HTTP 409로 바꿔 줍니다.

-- 같은 버전으로 두 번 커밋 → 한 번만 반영된다
select * from rooms where id = $1 for update;
if room.version <> expected then raise exception using errcode = 'PT409'; end if;
update rooms set version = version + 1 …   -- 상태·시점·로그 저장 후 신호 발송

여기에 한 겹을 더 뺐습니다. "방장만 시작, 방장 외 전원 준비, 차례 검사, 마감 확인, 한 판 더는 직전 패자가 먼저" 같은 플랫폼 규칙도 Edge Function 안에 흩어두지 않고 core/platform의 순수 함수로 만들었습니다. Edge Function은 인증 → 읽기 → 계산 → 커밋만 하는 얇은 껍데기가 됐고, 규칙은 네트워크 없이 단위 테스트로 검증됩니다.

05비공개 정보 — 채널에는 신호만 싣는다

31 게임에는 숨길 정보가 없지만, 다음에 올 카드 게임에는 손패가 있습니다. 그래서 상태를 세 층으로 나눴습니다. 전체 상태는 아무도 읽을 수 없는 서버 전용 테이블에, 관전자 시점은 방 멤버가 읽는 테이블에, 플레이어별 시점은 RLS로 본인 행만 읽히는 테이블에 둡니다.

실시간 채널에도 게임 내용을 싣지 않습니다. 커밋이 끝나면 "바뀌었다"는 신호와 버전 번호만 방 채널로 보내고, 각자 get_room_state()로 자기 시점만 다시 읽습니다. 신호 하나에 조회가 한 번 더 붙지만, 대가로 채널 설계가 게임 종류와 무관해졌습니다. 채널 자체도 비공개로 만들고 realtime.messages에 RLS를 걸어, 방 멤버만 채팅·Presence·신호를 주고받을 수 있게 했습니다. 신호를 놓칠 때를 대비해 15초마다, 그리고 창으로 돌아올 때 한 번 더 읽습니다.

06창을 닫아도 게임은 계속된다

턴 제한시간은 30초입니다. 마감 시각은 커밋할 때 DB 시계로 정하고, 클라이언트는 받은 서버 시각으로 자기 시계 오차를 보정해서 막대를 그립니다. 마감이 지나면 접속해 있는 모든 사람이 0~1.5초씩 무작위로 늦춰서 서버에 "시간 초과"를 요청합니다. 여러 명이 동시에 보내도 버전 검사 덕분에 첫 요청만 반영되고 나머지는 409로 조용히 버려집니다. 차례인 사람이 창을 닫아도 남은 사람들이 게임을 밀고 가는 구조입니다.

문제는 모두가 창을 닫았을 때입니다. 이때를 위해 pg_cron이 15초마다 마감이 5초 넘게 지난 판을 찾아 pg_net으로 Edge Function을 부릅니다. service_role 키를 크론에 넣는 대신, 이 용도만의 비밀값을 Vault와 Edge Function 양쪽에 두고 헤더로 맞춰봅니다. 연속 시간 초과가 "인원 × 2"번을 넘으면 아무도 없다고 보고 판을 정리합니다.

# 실제로 확인한 두 장면 (브라우저 자동화)
차례인 사람 창 닫음 → 31.5초 뒤 남은 사람 화면: "철수: 1 (시간 초과)", 차례 넘어옴
두 사람 모두 창 닫음 → pg_cron이 턴 4번 진행 → 판 abandoned, game_end 이벤트 기록

07권한 — "막혀야 할 것"을 테스트로 증명한다

모든 테이블에 RLS를 켜는 것만으로는 부족했습니다. Supabase는 새 테이블에 기본으로 넓은 권한을 주기 때문에, 클라이언트 역할에서 INSERT·UPDATE·DELETE 권한 자체를 회수하고 필요한 SELECT만 다시 열었습니다. 그래서 개발자 도구로 rooms를 PATCH하면 "0행 수정"이 아니라 분명한 403 permission denied (42501)가 돌아옵니다.

SQL 테스트는 사용자를 흉내 내며 열세 가지 직접 쓰기 시도가 모두 막히는지, 외부인이 방을 못 보는지, 각자 자기 시점만 보는지 확인합니다. 그리고 테스트가 헛돌지 않는지도 확인했습니다 — 일부러 rooms에 수정 권한을 열어 테스트가 실패하는 것을 본 뒤에 다시 닫았습니다.

08측정 — 게임을 판단할 숫자

이 플랫폼은 테스트 시장이라, 게임마다 반응을 숫자로 남겨야 합니다. 게임 시작, 게임 종료(판 길이·턴 수·시간 초과 수·중단 여부), 같은 방에서 한 판 더, 초대 링크로 입장, 방장 재방문 — 다섯 가지 이벤트를 모두 서버에서 기록합니다. 클라이언트가 보낸 이벤트는 조작할 수 있기 때문입니다. 친구 "따라가기"로 들어온 입장은 초대 링크 입장과 따로 기록해서 지표가 섞이지 않게 했습니다. 한 판 더 비율이 이 게임을 계속 키울지 판단하는 첫 번째 숫자가 될 겁니다.

09계정과 친구 — 게스트로 시작해서 잃지 않게

처음 방문하면 익명 로그인으로 게스트 계정이 생깁니다. 기록을 남기고 싶을 때만 구글을 연결합니다(linkIdentity). 새 계정을 만드는 게 아니라 같은 계정에 구글이 붙는 방식이라, 게스트 때의 닉네임과 기록, 들어가 있던 방이 그대로 이어집니다. 다른 기기에서는 구글로 로그인해서 같은 계정으로 들어옵니다. 이미 다른 계정에 연결된 구글로 연결하려 하면 그 계정으로 로그인할지 묻습니다. 카카오톡 같은 인앱 브라우저에서는 구글이 로그인을 막기 때문에, 이를 감지해서 외부 브라우저로 여는 안내를 띄웁니다.

  • 닉네임 — 언제든 바꿀 수 있고, 들어가 있는 방에 바로 반영됩니다. 진행 중인 판에는 다음 판부터.
  • 친구 — 대기방이나 채팅에서 닉네임을 누르면 [프로필][친구추가][1:1채팅]. 친구 목록에서는 [따라가기][초대하기][1:1채팅].
  • 1:1 채팅 — 별도 창으로 열리고 30일 보관. "친구에게서만 받기" 설정. 그리고 게임 중에는 보내는 쪽도 받는 쪽도 금지 — 귓속말로 판을 흐리는 걸 서버에서 원천 차단합니다.
  • 게스트 — 친구 추가와 1:1 채팅은 하지도 받지도 못하고, 프로필에는 닉네임과 "게스트 계정을 사용중입니다."만 보입니다.
  • 방장 권한 — 대기 중 강퇴(링크로 재입장은 가능), 방에서 게임 변경, 게임 중 방장이 나가면 확인 후 판을 즉시 끝내고 전원 대기방으로.

10문제 해결 기록

눈에 띄는 고장보다, 테스트나 재독에서 걸러낸 조용한 버그가 더 많았습니다.

  • FOUND가 몰래 바뀐다 — PL/pgSQL에서 행을 조회한 뒤 PERFORM으로 다른 함수를 부르면 FOUND가 덮어써집니다. 재입장 판단이 뒤집힐 뻔한 걸 코드를 다시 읽다 찾아 별도 변수로 고정했습니다.
  • 게스트 계정이 두 개 — 여러 화면이 동시에(그리고 React StrictMode에서 두 번) 게스트 로그인을 시도하면 익명 계정이 두 개 생길 수 있었습니다. 진행 중인 로그인 요청 하나를 공유하게 바꿨습니다.
  • 초대 링크를 열 때마다 빨간 403 — 아직 멤버가 아닌 사람이 방 상태를 먼저 조회해서 생긴 오류였습니다. 동작은 맞았지만 콘솔이 매번 빨갛게 물들어서, 미리보기로 멤버 여부부터 확인하도록 순서를 바꿨습니다.
  • 링크로 들어온 게스트는 이름이 없다 — 방에 들어올 때 적은 닉네임이 방 멤버십에만 저장되고 프로필에는 비어 있었습니다. 3인 브라우저 테스트에서 게스트 프로필이 "이름 없음"으로 뜨는 걸 보고, 방 입장 트리거가 프로필 닉네임도 채우도록 했습니다.
  • 한 번만 쓰는 배포 단계 — Edge Function 배포 도구가 supabase/functions 바깥 파일을 묶지 못할 수 있어, 배포 직전에 core/를 복사하는 단계를 두었습니다. 그런데 문서가 이걸 충분히 강조하지 않아 실제로 supabase functions deploy를 직접 실행했다가 실패했습니다. 경고 박스, 번호 매긴 절차, 증상별 해결 표로 README를 다시 썼습니다. 폴더 밖에서 db push를 실행해 "Cannot find project ref"가 난 것도 같은 종류의 문서 누락이었습니다.
  • 테스트가 세상에 자기 방만 있다고 믿었다 — SQL 테스트 몇 개가 limit 1로 "그 방"을 찾고 있었는데, 브라우저 테스트가 남긴 다른 방 때문에 엉뚱한 방을 집었습니다. 코드가 아니라 테스트의 가정이 틀린 것이라, 모든 단언을 테스트 자신의 방 id로 고정했습니다.
  • 샌드박스의 벽 — 클라우드 작업 환경에서 로컬 Supabase를 띄우는 데도 여러 번 막혔습니다. 이미지 저장소가 막혀 Docker Hub로 돌리고, 요청 과다(429)는 기다렸다 하나씩 받고, Edge Function 컨테이너가 프록시 인증서를 믿지 않아 패키지를 못 받자 같은 함수 파일을 Deno로 직접 띄우고 작은 프록시로 원래 주소에 연결했습니다. 같은 방법을 두 번 시도해도 안 되면 방법을 바꾼다는 규칙이 여기서 쓸모 있었습니다.
  • "없는 게임입니다" — 원카드를 올린 뒤 방 만들기가 "없는 게임"으로 막혔습니다. 코드는 배포됐는데 게임 등록 마이그레이션이 원격 DB에 아직 적용되지 않은 상태였습니다. 여기에 대기방의 게임 선택이 서버 거절 뒤에도 오류 문구를 지우지 않아 멀쩡한 31 게임까지 "없는 게임"처럼 보였습니다. 로비와 대기방이 DB에 실제로 등록된 게임을 먼저 읽어서, 아직 없는 게임은 "준비 중"으로 막고, 게임 변경 오류는 선택창 아래에만 잠깐 보이게 고쳤습니다.
  • 지연을 줄이다 만든 장애 — 서버 함수를 DB와 같은 지역에서 돌리도록 고정하자, 게임 시작부터 모든 행동이 "서버에 연결할 수 없습니다"로 실패했습니다. 지역 고정 옵션이 요청에 x-region 헤더를 붙이는데, 서버 함수가 허용하는 헤더 목록(CORS)에 그게 없어서 브라우저가 사전 요청 단계에서 막은 것이었습니다. 함수를 로컬에 띄우고 실제 브라우저로 같은 요청을 보내 고치기 전 실패와 고친 뒤 성공을 둘 다 확인한 다음, 브라우저 쪽 지역 고정은 빼고 서버에는 헤더를 허용해 두었습니다.
  • 말풍선이 화면과 함께 올라간다 — 게임 중 채팅 말풍선이 가끔 스크롤을 따라 사라졌습니다. 조건을 재현하지는 못했지만 가능한 원인 두 가지(부모 요소가 position: fixed의 기준을 바꾸는 경우, 모바일 키보드·주소창 때문에 보이는 화면이 움직이는 경우)를 모두 막았습니다. 말풍선 층을 문서 맨 바깥에 붙이고, visualViewport 위치를 더해 항상 보이는 화면 맨 위에 붙입니다.

11검증 — 무엇을 어떻게 확인했나

단위 테스트 57     — 31 게임 8 (정상 진행 · 잘못된 턴 · 4개 이상 · 31 도달 패배 · 순수성 …)
                   — 플랫폼 규칙 8 (방장만 시작 · 레디 · 버전 · 마감 · 자리비움 멈춤 · 투표 중 · 연출 잠금 · 한 판 더)
                   — 원카드 41 (LOG_06)
SQL 테스트 42      — RLS·RPC 10 + 소셜 9 + 원카드 5 + 참여중인 방·제한 4 + 일시정지·연장 9
                   — 말풍선 설정 · 응답용 스냅샷 · 닉네임 색 · 유지기한·친구 메모 (9개 파일)
브라우저 시나리오  — 2인 한 판 전체 7단계 + 3인 소셜 12단계, 실제 Auth·Realtime 로컬 스택
경합 테스트        — 같은 버전 커밋 두 번 → 한 번만 반영, 두 번째 PT409

원카드 이후의 DB 테스트는 Docker를 쓸 수 없는 작업 환경에서 돌려야 했습니다. 그래서 일반 Postgres를 띄우고 Supabase가 제공하는 것들(auth.uid(), 역할, pg_cron·pg_net·Vault 자리)을 흉내 내는 작은 스텁을 먼저 깐 뒤, 실제 마이그레이션 파일을 처음부터 순서대로 적용하고 모든 SQL 테스트를 다시 돌리는 방식으로 매번 확인했습니다. 한 트랜잭션 안에서는 now()가 멈춰 있기 때문에, "2일이 지난 방" 같은 상황은 기준 시각 컬럼을 직접 옮겨서 만듭니다.

브라우저 테스트는 서로 다른 브라우저 컨텍스트로 돌려서 각각 다른 게스트가 되게 했습니다. 방 생성 → 링크 입장 → 채팅 → 준비 → 게임 → 31 도달 → 한 판 더 → 개발자 도구로 rooms 수정 시도(403) → 차례인 사람 창 닫기(자동 진행)까지가 첫 번째 시나리오였습니다. 구글 로그인 자체는 샌드박스에서 돌릴 수 없어서, 같은 id를 유지한 채 게스트를 정식 계정으로 바꾸는 방식으로 "닉네임·방·기록이 이어지는지"를 대신 확인했습니다.

12두 번째 게임이 넓혀 놓은 것

원카드(LOG_06)를 올리면서 "이 게임에만 필요한 것"과 "다음 게임에도 필요한 것"을 계속 나눴습니다. 뒤쪽은 게임 파일이 아니라 플랫폼에 들어갔고, 새 게임 추가 가이드의 "플랫폼 한계" 표도 그때마다 고쳤습니다.

  • 방 옵션(특별룰) 체크박스 — 게임이 화면 등록부에 { key, label, description }만 적으면 대기방에 체크박스가 생깁니다. 방장만, 대기 중에만 바꿀 수 있고(set_room_options), 규칙이 바뀌면 모두의 준비가 풀립니다. 값은 클라이언트가 보낸 JSON이라 게임 모듈이 setup에서 모양을 다시 검사합니다.
  • 게임 중 채팅 — 게임 화면이 [게임판 → 채팅 → 참가자] 순서가 됐습니다. 입력줄은 "채팅 [입력칸] [전송]" 한 줄로 기록 위에 두었고, 전송을 눌러도 입력칸 포커스를 빼앗지 않아 모바일 키보드가 내려가지 않습니다.
  • 채팅 말풍선 — 게임하면서 대화를 바로 보도록, 내 메시지는 오른쪽·다른 사람은 왼쪽에 화면을 따라다니는 말풍선이 쌓입니다. 사라지는 시간, 투명도, 최대 누적 수, 최대 폭, 글자 크기를 각자 정하고 계정에 저장합니다.
  • 닉네임 색 — 흰 바탕에서 잘 안 보이는 노랑·흰색 계열을 뺀 20색 중에서 고르고 계정에 저장합니다. 채팅 기록, 참가자 목록, 말풍선이 모두 이 색을 씁니다. 바꾸면 들어가 있는 방에 바로 알려 다른 사람 화면에도 즉시 반영됩니다.
  • 친구 메모 — 친구 목록에서 [메모]로 20자짜리 나만의 메모를 달면 닉네임 (메모)로 보입니다. 상대는 볼 수 없고, 친구를 삭제하면 함께 지워집니다.

13멈춰도 사라지지 않는 판

처음엔 연속 시간 초과가 "인원 × 2"번을 넘으면 판을 버렸습니다. 카드 게임은 한 판이 길어서, 모두 잠깐 자리를 비웠다는 이유로 판이 날아가는 건 아까웠습니다. 그래서 판을 버리는 대신 멈춥니다(paused).

  • 일시정지 — 이번 판 참가자 누구나 [게임 일시정지]를 누르면 30초 투표가 열리고, 그동안 차례 마감을 비워 시간이 흐르지 않습니다. 참가자 전원이 동의하면 멈추고, 한 명이라도 거절하거나 30초가 지나면 남은 시간 그대로 이어집니다. 처음엔 방장만 요청할 수 있었는데, 참가자 모두에게 열었습니다(start_match_vote에서 방장 확인 한 줄만 뺐습니다). 대신 요청한 사람은 투표 창에서 [취소]로 거둘 수 있고(cancel_my_vote), 일시정지 투표였으면 투표 동안 멈춰 둔 차례 시간을 그대로 돌려줍니다.
  • 나가기 확인 — 방장이 아닌 참가자가 게임 화면이나 대기방에서 [나가기]를 누르면 "정말 나가시겠습니까? 현재 참여중인 방에서 나가고 홈으로 이동합니다."를 한 번 묻습니다. 버튼 한 번에 판과 방에서 바로 빠지던 것을 막았습니다.
  • 이어하기 — 멈춘 판은 참가자 누구나 [이어하기 요청]을 할 수 있고, 역시 전원이 동의해야 이어집니다. 모두 자리를 비워 멈춘 판(연속 시간 초과, 30분 무활동)도 같은 방식으로 이어갑니다.
  • 조작 잠금 — 멈춤·투표·게임 끝에는 누구도 카드를 고를 수 없습니다. 예전엔 모두 자리를 비워 판이 끝난 뒤에도 차례였던 사람이 카드를 선택할 수는 있었는데(실행만 안 될 뿐), 화면 자체를 잠갔습니다.
  • 연출 잠금과 차례 연장 — 게임 모듈이 두 가지를 선택적으로 말할 수 있게 인터페이스를 넓혔습니다. getTurnTiming(state).lockMs는 방금 만든 상태에서 다음 차례를 얼마나 멈출지(카드 공개 팝업 같은 연출)이고, 서버는 그 시각 전의 행동을 거부합니다. turnExtendMaxMs를 두면 차례 중 조작마다 마감을 다시 채우는 가벼운 DB 함수(extend_turn)를 쓸 수 있습니다. 이 함수는 버전을 올리지 않고 새 마감 시각만 신호로 보냅니다.

멈춘 판을 오래 들고 있으면 DB가 버틸지도 먼저 계산했습니다. 원카드 한 판을 50번씩 시뮬레이션해 재 보니, 판 상태와 각자의 시점은 덮어쓰기라 늘지 않고 늘어나는 건 행동 기록뿐이라 6명이 최대 550수를 둬도 한 판에 약 100KB였습니다. 방장 한 명이 여는 방은 최대 3개라 무료 DB 500MB로 수천 판을 멈춰 둘 수 있습니다. 데이터보다 조심할 건 서버 호출 수였고, 그래서 차례 연장을 판 저장과 떼어 냈습니다.

14참여중인 방과 방의 수명

게임 도중에 페이지를 나가면 다시 돌아갈 방법이 초대 링크뿐이었습니다. 메뉴에 [참여중인 방]을 두어 들어가 있는 모든 방을 최근 활동 순으로 보여 주고, 누르면 바로 다시 들어가며, 그 자리에서 나갈 수도 있게 했습니다. 내가 방장인 방에는 (방장)이 붙고, 상태는 대기 중·게임 중·중단 세 가지로 보여 줍니다.

  • 제한 — 방장으로 여는 방은 3개, 동시에 참여하는 방은 5개까지입니다. 참여 제한은 방 만들기·링크 입장·친구 따라가기·다시 들어오기가 모두 지나는 room_members에 트리거 하나로 걸었습니다. 넘으면 전용 오류 코드(PT423)가 돌아오고, 화면은 안내문과 함께 [참여중인 방 확인] 버튼을 보여 줍니다.
  • 넘겨받는 방장은 허용 — 방장이 나가 다른 사람이 방장이 되는 경우엔 개수를 세지 않습니다. 그렇게 4개의 방장이 된 사람은 방장 방이 2개 이하로 줄 때까지 새 방만 못 만듭니다.
  • 유지기한 2일 — 예전엔 대기방 2시간, 끝난 방 24시간, 멈춘 판 3일로 제각각이었습니다. 이제 방을 만든 뒤, 마지막 판이 끝난 뒤, 판이 멈춘 뒤 중 가장 최근 시점부터 2일 동안 게임을 하지 않으면 방과 판이 사라집니다. 기준 시각(rooms.idle_since)은 판 상태가 바뀔 때 트리거가 갱신하고, 채팅이나 준비 같은 활동으로는 늘어나지 않으며, 게임이 진행 중인 방은 지우지 않습니다. 목록에는 방마다 "언제까지 · 몇 시간 남음"이 보입니다.

15지연 줄이기 — 지역이 아니라 왕복 수

게임에 약간의 딜레이가 느껴져서 원인을 따라갔습니다. 프로젝트는 이미 서울 지역이었습니다. 문제는 거리보다 한 행동이 차례로 거치는 왕복 수였습니다 — 브라우저 → 서버 함수 → 로그인 확인 → 판 읽기 → 저장 → "바뀜" 신호 → 각자 다시 읽기. 유료 플랜은 DB 성능과 한도를 늘릴 뿐 이 왕복은 그대로라, 코드로 줄였습니다.

  • 동시에 — 로그인 확인과 판 읽기를 차례가 아니라 동시에 합니다. 로그인 토큰 서명을 비대칭 키(ES256)로 바꿔 두어, 확인도 인증 서버에 묻지 않고 그 자리에서 끝납니다(getClaims).
  • 응답에 내 화면을 — 저장 직후 서버 함수가 행동한 사람의 새 스냅샷을 응답에 담아, 브라우저가 한 번 더 읽는 왕복을 없앴습니다. 남의 시점을 요청할 수 없도록 이 함수는 service_role만 부를 수 있습니다.
  • 사전 요청 캐시 — 브라우저가 매번 보내던 CORS 사전 요청(OPTIONS)을 캐시하게 했습니다.
  • 미리 깨우기 — 게임 화면에 들어오면 빈 요청(ping)으로 서버 함수를 깨워, 첫 행동이 콜드 스타트를 맞지 않게 했습니다.
  • 시간 초과는 차례인 사람이 먼저 — 예전엔 마감 뒤 모두가 0.3~1.8초씩 무작위로 기다렸다 보냈습니다. 이제 차례인 사람은 0.15초 뒤 바로 보내고, 나머지는 그 사람이 없을 때를 대비해 조금 뒤에 보냅니다.
  • 누르는 즉시 — 원카드 화면은 제출·드로우를 누르는 순간 카드를 움직이고, 서버가 거절하면 되돌립니다(LOG_06).

16탭 하나로 시작해서, 떼어낼 수 있게

지금은 트롯본방 상단의 탭 하나로 연결됩니다. 하위 경로(/board/) 대신 같은 저장소의 별도 Cloudflare Pages 프로젝트와 서브도메인을 골랐습니다. 빌드 없이 배포되던 기존 사이트를 건드리지 않아도 되고, SPA 라우팅이 섞이지 않고, 나중에 새 도메인을 사면 그 프로젝트에 도메인만 붙이면 되기 때문입니다. 저장소 분리도 git subtree split으로 이력을 유지한 채 할 수 있게 폴더를 하나로 묶어 두었습니다. 게임 규칙 폴더(core/)는 의존성이 하나도 없어서, 스팀이든 모바일이든 JavaScript가 도는 곳이면 그대로 들고 갈 수 있습니다.

17게임이 시계를 알게 — 판 하나에 마감 하나

지금까지 규칙 모듈은 시간을 몰랐습니다. 차례 시간을 세는 것도, 시간이 지나면 자동 행동을 넣는 것도 플랫폼의 일이었습니다. 차례 없이 모두가 100초 동안 동시에 던지는 홉 틱텍토(LOG_08)는 달랐습니다. 공의 움직임, 아이템을 받는 시각, 피버타임이 모두 "지금 게임이 몇 초째인가"에 달려 있고, 일시정지한 시간은 빼고 세야 했습니다.

ActionContext.deadline   행동을 처리할 때 이 판의 마감 시각을 게임에 넘긴다
   — 게임 시계 = 판 길이 − (마감 − 지금). 일시정지·투표 때 플랫폼이 남은 시간만큼
     마감을 다시 잡으므로, 멈춘 시간은 자동으로 빠진다
StepPlan.turnDeadlineAt  마감을 유지하는 게임(keepDeadline)이면 서버가 원래 마감 시각을 그대로 넘긴다
   — game_commit_step(20261019000000)은 이 값이 있으면 다시 계산하지 않고 저장한다

규칙 모듈은 여전히 시계를 읽지 않는다 — 플랫폼이 넘겨 준 마감으로만 센다

두 번째 장치는 버그에서 나왔습니다. 마감을 유지하는 게임인데도 DB가 커밋할 때마다 "커밋 시각 + 남은 시간"으로 마감을 다시 계산하고 있었습니다. 차례 게임에선 보이지 않던 몇십 ms의 차이가, 1초에 여러 번 던지는 게임에서는 쌓여서 판이 조금씩 길어졌습니다. 이제 서버가 계산한 마감 시각이 그대로 저장되고, 플랫폼 테스트가 "던져도 마감 시각이 그대로인지"를 확인합니다.

화면 쪽도 같은 원칙입니다. 게임 판(Board)에 넘기는 시계 정보에 마감 시각(clock.deadline)을 더해, 화면도 서버와 같은 식으로 게임 시계를 계산합니다. 판 전체의 마감은 5or10의 판정 창과 같은 판정용 마감으로 선언해, 시간이 다 되면 접속한 모두가 바로 끝내기를 요청하고 자리비움으로 세지 않습니다.

18방 안에서만 쓰는 코인

모든 멀티게임 방에서 고를 수 있는 코인을 게임이 아니라 플랫폼 기능으로 넣었습니다. 기본은 꺼짐이고, 방장이 대기방에서 [코인 사용]을 켜면 참가자 모두 10코인으로 시작하며 나중에 들어온 사람도 10코인을 받습니다. 코인은 그 방 안에서만 쓰이고, 방을 나가면 사라집니다(다시 들어오면 10코인). 코인이 있는 사람이 나가려 하면 코인이 사라진다고 먼저 알려 줍니다.

  • 참가비와 순위 상금 — 참가비는 모두 같고(0~1000), 상금은 현재 인원만큼 1등부터 칸이 생깁니다(6명이면 1~6등). 상금은 −1000~1000이라 꼴찌에게 벌금을 매길 수도 있고, 양수는 빨강·음수는 파랑으로 보입니다. 판이 끝나면(matches.status → finished) DB 트리거가 참가자마다 참가비를 빼고 등수 상금을 더합니다. 공동 순위면 같은 등수 상금을 받고, 중간에 버려진 판은 정산하지 않습니다.
  • 한도 — 보유 코인은 −9999~9999입니다. 빚도 질 수 있지만, 참가비를 내면 −9999 아래로 내려가게 되는 사람은 [준비]를 눌러도 준비가 되지 않고(버튼은 그대로 눌림) 우는 도트 얼굴과 함께 "더이상은 빌려드릴 수 없어요ㅠ"가 뜹니다. 9999를 넘게 되는 지급은 안내와 함께 거절됩니다. 참가비를 낼 수 없는 사람이 있어도 게임은 진행할 수 있습니다.
  • 지급은 공평하면 바로, 아니면 투표 — 방장이 모두에게 같은 양을 주는 건 바로 반영합니다. 특정 사람에게만 주거나 빼는 것, 그리고 모두를 10코인으로 되돌리는 [코인 리셋]은 참가자 전원 동의 투표(30초)를 거칩니다. 한 명이라도 거절하거나 시간이 지나면 취소되고, 요청한 방장은 도중에 거둘 수 있습니다. 봇은 코인을 갖지만 투표에는 들어가지 않습니다.
  • 바뀌면 준비가 풀린다 — 켜기·끄기, 참가비·상금 변경, 지급, 리셋 등 코인 정보가 바뀌면 모두의 준비가 풀립니다. 바뀐 조건을 보고 다시 준비하라는 뜻입니다. 코인을 바꾸는 건 대기 중이거나 판이 끝난 뒤에만 됩니다.
  • 보이는 곳 — 참가자 목록의 이름 옆에 16×16 도트 코인 아이콘과 보유 코인(금색, 음수면 파랑)이 붙고, 게임 결과 아래에 이번 판 정산(+/−와 남은 코인)이 나옵니다.

구현에서 가장 신경 쓴 건 기존 함수를 건드리지 않는 것이었습니다. 방 입장·퇴장(join_room·leave_room), 방 상태 읽기(room_state_for), 판 저장(game_commit_step)은 다른 작업(게임 봇 등)도 함께 고치는 함수라, 여기에 코인을 끼우면 마이그레이션끼리 서로의 정의를 덮어쓸 위험이 컸습니다. 그래서 입장·퇴장은 room_members 트리거가 코인을 10/없음으로 두고, 정산은 matches 트리거가 하고, 화면은 방 버전이 바뀔 때 get_room_coins로 따로 읽습니다. 다시 정의한 함수는 한도를 확인해야 하는 set_ready 하나뿐입니다. 게임 결과의 모양이 게임마다 달라서(ranks 맵, ranking 배열, 승자·패자) 결과에서 등수를 꺼내는 함수(result_rank)를 하나 두었습니다.

검증은 로컬 Postgres 16에 전체 마이그레이션을 적용해 시나리오로 했습니다 — 켜면 모두 10, 규칙 변경 시 준비 해제, 전원 지급과 9999 한도, 전원 동의일 때만 반영되는 골라 지급, 거절된 리셋, 요청자만 가능한 취소, 31 게임 결과로 참가비 5·상금 [10, 0, −3] 정산(15/15/2), −9999 한도에서 준비 거부, 나가면 사라지고 다시 오면 10. 화면은 서버 없이 보는 개발용 페이지(/dev/coins)를 375px로 찍어 확인했습니다.

19지금 이 자리

플랫폼 골격, 31 게임, 서버 검증, 비공개 채널, 턴 타이머와 자동 진행, 방 정리, 측정 이벤트, 그리고 구글 계정·친구·1:1 채팅·방장 권한까지 로컬 스택에서 끝까지 확인했습니다. 광고와 결제는 일부러 만들지 않았고, 방장이 오리지널 게임 방을 열 때 쓸 권한 확인 자리만 한 줄짜리 함수로 남겨 두었습니다. 참가자는 앞으로도 항상 무료입니다.

이번 반복(Iterate)에서 남은 원칙은 네 가지입니다. 규칙은 한 벌, 쓰기는 한 문, 채널엔 신호만, 아무도 없어도 판은 끝난다.

그 뒤로 실제 프로젝트에서 구글 로그인을 켰고, 두 번째 게임인 원카드(LOG_06)를 올렸습니다. 원카드는 원칙 하나를 고쳐 쓰게 했습니다 — 아무도 없으면 판은 끝나는 게 아니라 멈추고, 모두가 동의하면 이어집니다. 게임이 늘수록 플랫폼이 할 일도 늘었습니다. 참여중인 방과 방의 수명, 일시정지와 이어하기, 게임 중 채팅과 말풍선, 닉네임 색과 친구 메모. 그 뒤 차례 없는 실시간 게임(LOG_08)이 규칙 모듈에 판의 마감을 넘기게 했고, 참가자 누구나 일시정지를 요청할 수 있게 됐으며, 방 안에서만 쓰는 코인이 판과 판 사이를 이어 줍니다. 다음은 한 판 더 비율과, 코인을 켠 방에서 판이 얼마나 더 이어지는지를 관찰(Observe)하는 일입니다.