전체 아키텍처
💡
게임 index.html에 SDK를 포함하면 끝입니다. SDK가 gameId(URL 파라미터)와 JWT 토큰(localStorage, 같은 도메인 공유)을 자동으로 읽어 API를 직접 호출합니다. 비로그인 유저는 localStorage로 자동 폴백됩니다.
🎮
게임 iframe
게임 index.html + sdk/movigame-sdk.js
await MoviSave.set('gold', 100);
gameId URL ?gameId=42 자동 파싱
token localStorage('portal_token') 자동 읽기 — 같은 도메인 공유
비로그인 localStorage('movi_{gameId}_{key}') 폴백
REST API · Bearer JWT
⚙️
Admin API  :3001
admin-api/src/routes/saves.js
PUT /saves/:gameId/:key → 단일 키 저장 (UPSERT)
PUT /saves/:gameId → 여러 키 한번에 저장
GET /saves/:gameId/:key → 단일 키 로드
GET /saves/:gameId → 전체 키-값 로드
DEL /saves/:gameId/:key → 단일 키 삭제
DEL /saves/:gameId → 전체 삭제
PostgreSQL
🗄️
game_saves 테이블
Migration 007
PK user_id PK game_id PK key VARCHAR(100) data TEXT 무제한 · 서버 1MB/키 제한

SDK 파일 구조
게임 내부용 (유일한 SDK 파일)
📦 sdk/movigame-sdk.js
게임 index.html<script>로 포함.
window.MoviSave 객체 노출.
gameId·token을 자동으로 읽어 API 직접 호출.
포털 페이지 추가 작업 불필요.
API 라우트
⚙️ routes/saves.js
포털 JWT (principal: 'portal') 전용.
키당 1MB 제한 · setMany 최대 50개.
userId는 JWT에서 파싱 — 게임은 모름.
DB 마이그레이션
🗄️ migrate-007-game-saves.js
game_saves 테이블 생성 + 인덱스.
실행: node admin-api/src/migrate-007-game-saves.js

DB 스키마 — game_saves (Migration 007)
컬럼타입기본값설명
user_id INTEGER PKFK portal_users.id (ON DELETE CASCADE)
game_id INTEGER PKFK games.id (ON DELETE CASCADE)
key VARCHAR(100) PK 저장 키 이름 (예: 'gold', 'level', 'save1')
data TEXT JSON 직렬화된 값 (DB 무제한, 서버에서 키당 1MB 제한)
updated_at TIMESTAMPTZ now() 마지막 저장 시각 — UPSERT 시 자동 갱신
ℹ️
복합 PK (user_id, game_id, key) — 같은 키 저장 시 자동 덮어쓰기(UPSERT).
MySQL 이전 시 TEXT는 64KB 제한 → MEDIUMTEXT(16MB)로 변경 필요.
마이그레이션 실행: node admin-api/src/migrate-007-game-saves.js

API 엔드포인트 — /saves
🔐
모든 엔드포인트는 포털 JWT 필수 (Authorization: Bearer <token>, principal: 'portal'). 어드민 토큰은 403 반환. userId는 JWT에서 파싱하므로 게임에서 유저를 알 필요 없음.
메서드경로설명요청 body응답
GET /saves/:gameId/:key 단일 키 로드 — 없으면 data: null { data: any|null }
GET /saves/:gameId 전체 키-값 로드 { data: { key: value, … } }
PUT /saves/:gameId/:key 단일 키 저장 (UPSERT) — 1MB 초과 시 413 { data: any } { ok: true, updated_at }
PUT /saves/:gameId 여러 키 한번에 저장 — 최대 50개 { data: { k: v, … } } { ok: true, count }
DELETE /saves/:gameId/:key 단일 키 삭제 { ok: true }
DELETE /saves/:gameId 해당 게임 전체 세이브 삭제 { ok: true, deleted: N }

SDK 사용법
<!-- 포털에서 게임 iframe 로드 시 gameId 포함 --> <iframe src="/res/MoviSoft/com.movisoft.game/game/index.html?gameId=42"></iframe> <!-- 게임 index.html --> <script src="/movigames/sdk/movigame-sdk.js"></script> <script> // ── 단일 키 저장 / 로드 await MoviSave.set('gold', 100); await MoviSave.set('items', ['sword', 'shield']); const gold = await MoviSave.get('gold'); // → 100 (없으면 null) const items = await MoviSave.get('items'); // → ['sword', 'shield'] // ── 여러 키 한번에 저장 / 전체 로드 await MoviSave.setMany({ gold: 100, level: 3, items: ['sword'] }); const all = await MoviSave.getAll(); // → { gold: 100, level: 3, items: ['sword'] } // ── 삭제 await MoviSave.remove('gold'); // 단일 키 삭제 await MoviSave.removeAll(); // 이 게임의 세이브 전체 삭제 // ── 로그인 여부 (동기) if (MoviSave.isLoggedIn()) { /* 서버 저장 */ } else { /* localStorage 폴백 */ } </script>
서버 저장 쓰로틀 (30초)
⏱️
set / setMany 호출은 localStorage에 즉시 기록(백업)되지만, 실제 서버 전송은 마지막 전송으로부터 30초 경과 후 한 번만 이루어집니다. 30초 이내의 중간 호출들은 내부 버퍼에 쌓이다가 타이머 만료 시 setMany로 묶여 한 번에 전송됩니다.
// 1초마다 호출해도 서버에는 30초마다 1번만 전송 setInterval(async () => { await MoviSave.set('score', score); // → 30초 동안은 localStorage에만, 이후 서버 플러시 }, 1000); // 게임 종료 직전 — 버퍼를 즉시 서버에 강제 플러시 window.addEventListener('beforeunload', () => { MoviSave.flush(); // 쓰로틀 무시하고 즉시 전송 });
ℹ️
get / getAll은 버퍼에 미전송 값이 있으면 그 값을 우선 반환합니다 — 메모리 일관성 보장.
remove / removeAll은 쓰로틀 없이 즉시 서버에 반영됩니다.
전송 실패 시 payload가 버퍼로 복귀되어 다음 플러시 때 재시도됩니다.

비로그인 유저 처리 (localStorage 폴백)
⚠️
비로그인 유저의 세이브는 브라우저 localStorage에만 저장됩니다.
키 형식: movi_{gameId}_{key}
브라우저 데이터 삭제 시 소멸 — 로그인 후 서버 세이브와 자동 병합되지 않습니다.
필요하다면 로그인 직후 MoviSave.getAll()로 로컬 데이터를 읽어 MoviSave.setMany()로 서버에 업로드하는 마이그레이션 로직을 별도로 추가해야 합니다.