「졸업까지 100턴: 아카데미 라이프」 는 브라우저에서 도는 게임입니다. 그런데 화면 안에는 앱이 둘 있습니다. 규칙과 저장을 쥔 Next.js 웹 앱, 그리고 그림을 그리는 Cocos Creator 게임이죠. 이번 리뷰에서는 우리가 "웹 퍼스트 하이브리드" 라고 부르는 이 구조가 어떻게 나뉘어 있고, 두 앱이 무엇을 주고받는지 살펴보겠습니다.
규칙과 화면이 얽혀 있던 프로토타입
첫 프로토타입은 규칙, 저장, HUD 가 그리는 코드와 얽혀 있었습니다. 연출을 고치면 규칙이 흔들리고, 개발 모드 플래그가 저장 로직 사이에 끼어 있었죠. 그래서 판을 다시 짤 때 선을 그었습니다. 판정은 웹이, 그리기는 Cocos 가.
프로토타입
- 규칙 · 저장 · HUD 가 표현 코드와 얽힘
- 연출을 바꾸면 규칙이 흔들림
- 개발 모드 플래그와 저장 로직이 섞임
웹 퍼스트
- 규칙, 상태, 저장, 입력은 모두 웹이 맡음
- Cocos 는 웹이 보낸 스냅샷을 받아 그림
- 규칙 코드는 브라우저 없이 Node 에서도 그대로 돎
흔히 하이브리드라고 하면 웹을 앱 껍데기(WebView)로 감싸는 방식을 떠올립니다. 아카데미 라이프의 하이브리드는 브라우저 안에서 두 앱이 합쳐지는 것입니다. Next.js 화면 안에 Cocos 빌드가 같은 출처의 iframe 으로 들어가 있습니다. 광고 · 결제 · 로그인 같은 플랫폼 SDK 는 아직 붙어 있지 않습니다.
다섯 겹의 층
ClientWeb/app. React 와 zustand 로 HUD, 메뉴, 입력을 맡습니다src/run, src/core, src/lobby. 브라우저를 모르는 순수 코드이고, 같은 시드면 같은 결과가 나옵니다RunGateway 와 SaveStore. 상태가 들어오고 나가는 길을 이 둘로 모았습니다AcademyProtocol 버전 25. 웹과 Cocos 가 postMessage 로 주고받는 말의 모양AcademyGameRoot 가 View 들을 조립하고, 받은 스냅샷을 그립니다| 항목 | 규모 |
|---|---|
| 웹 (ClientWeb app + src) | TypeScript 89개 파일, 24,398줄 |
| Cocos 표현 (assets/Scripts) | 24개 파일, 4,018줄 |
| 데이터 표 | 엑셀 47개 → 웹 JSON 47개 |
| 주고받는 말 | 웹 → Cocos 1종, Cocos → 웹 4종 |
숫자가 말해 주듯 무게중심은 웹에 있습니다. Cocos 쪽은 웹의 6분의 1 정도이고, 거의 모두 그리는 코드입니다.
층 사이의 선은 검사 스크립트가 지킵니다. 규칙층 파일을 훑어 window., localStorage. 같은 브라우저 API 를 쓰면 빌드 검증을 멈춥니다. 언젠가 규칙을 서버로 옮길 때를 대비한 울타리입니다.
// 규칙층은 브라우저를 모른다. 저장 백엔드와 사람의 화면 설정만 예외다.
const ruleRoots = ['src/run', 'src/core', 'src/lobby'].map(dir => path.join(webRoot, dir));
const allowed = new Set(['SaveStore.ts', 'AutoPreference.ts']);
for (const root of ruleRoots) {
if (!fs.existsSync(root)) continue;
for (const file of walkFiles(root)) {
if (allowed.has(path.basename(file))) continue;
const source = fs.readFileSync(file, 'utf8');
if (/\b(window|document|localStorage|sessionStorage)\./.test(source)) {
throw new Error(
`규칙층은 브라우저 API 를 쓰지 않아야 합니다(서버 이식 대비): ${path.relative(webRoot, file)}`);
}
}
}이어하기부터 첫 장면까지
- 세이브 읽기브라우저 저장소 → 버전 올리기 → 난수 복원
- iframe 열기
/ClientGame/index.html - gameReadyCocos 가 프로토콜 버전을 알림
- 첫 스냅샷버전이 맞으면 웹이
setRoamPresentation - 원격 자산공용 자산 호스트에서 캐릭터 · 몬스터 프레임
- stageLoaded첫 장면이 서면 웹이 로딩 안내를 내림
Cocos 의 부팅 코드는 이 흐름을 그대로 보여 줍니다. 웹이 첫 스냅샷을 줄 때까지 기다리고, 원격 자산 길을 세우고, 첫 존을 그린 다음 "섰다" 고 알립니다.
protected async start(): Promise<void> {
if (EDITOR) await this.editorDriver?.prepare();
const initial = await this.waitForInitialPresentation();
if (!initial) return;
/*
* 원격 자산 길은 첫 로드보다 먼저 세워야 한다.
*
* 게임에서 가장 큰 로드는 첫 존이고, 그것은 update 가 아니라 여기서
* 일어난다. update 에서만 세우면 첫 존의 캐릭터·NPC·몬스터 프레임이
* 전부 번들로 가고, 그 뒤로는 새 로드가 없어 원격은 한 번도 불리지
* 않는다 - 실제로 그랬다. 호스트 창에 요청이 0이었다.
*/
…
if (!this.playerActorView || !await this.playerActorView.load(initial.playerCharacterId, initial.playerVisual)) return;
this.ready = await this.showZone(initial);
// 첫 장면이 실제로 섰다. Web 은 이 신호까지 "내리는 중" 을 보여 준다.
if (this.ready) this.platformBridge?.reportStageLoaded();"호스트 창에 요청이 0이었다" 는 실제로 겪은 일입니다. 원격 자산 길을 매 프레임 갱신(update)에서만 세웠더니, 가장 큰 로드인 첫 존이 그보다 먼저 끝나 전부 번들에서 읽혔습니다. 부팅 순서를 바꿔 고쳤고, 왜 그 순서인지를 주석으로 남겼습니다.
postMessage 로 맺은 계약
| 방향 | 메시지 | 뜻 |
|---|---|---|
| 웹 → Cocos | setRoamPresentation | 지금 그릴 장면 전체. 매 프레임 순번(sequence)을 올려 보냄 |
| Cocos → 웹 | gameReady | 준비됨. 프로토콜 버전과 렌더러 빌드 번호를 함께 |
| Cocos → 웹 | stageMetrics | 그려 보니 땅이 어디인지 같은 무대 치수 |
| Cocos → 웹 | stageLoaded | 첫 장면이 실제로 섰음 |
| Cocos → 웹 | motionTiming | 공격 동작의 실제 길이 |
Cocos 쪽 다리는 세 가지를 지킵니다. 혼자 실행되면 거부하고, 같은 출처의 부모 창이 아닌 곳에서 온 말은 버리고, 이미 그린 것보다 오래된 스냅샷은 버립니다.
public async connect(): Promise<boolean> {
this.embedded = typeof window !== 'undefined' && window.parent !== window;
if (!this.embedded) {
console.error('[WebFirstPlatformBridge] Cocos 단독 실행은 지원하지 않습니다. ClientWeb에서 실행하세요.');
return false;
}
if (!this.initialized) {
window.addEventListener('message', this.receive);
this.initialized = true;
this.send('gameReady', {
protocolVersion: ACADEMY_PROTOCOL_VERSION,
rendererRevision: ACADEMY_RENDERER_REVISION,
});
…
private readonly receive = (event: MessageEvent): void => {
if (event.source !== window.parent || event.origin !== window.location.origin) return;
const message = event.data as BridgeMessage | undefined;
if (!message || message.source !== 'academy-web' || message.type !== 'setRoamPresentation') return;
const next = message.payload as Partial<RoamPresentationSnapshot>;
if (!this.isValidPresentation(next)) return;
if (this.currentPresentation && next.sequence < this.currentPresentation.sequence) return;
this.currentPresentation = next as RoamPresentationSnapshot;웹은 gameReady 를 받으면 버전부터 맞춰 봅니다. 웹은 새로 배포됐는데 Cocos 빌드는 낡은 채 남는 사고를 여기서 잡습니다.
const offReady = bridge.on<{ protocolVersion: number; rendererRevision?: string }>('gameReady', payload => {
if (payload.protocolVersion !== ACADEMY_PROTOCOL_VERSION) {
console.error(`[RoamStage] 프로토콜 불일치: web ${ACADEMY_PROTOCOL_VERSION} / game ${payload.protocolVersion}`);
return;
}
if (payload.rendererRevision !== ACADEMY_RENDERER_REVISION) {
console.error(`[RoamStage +${Date.now() - logStartedAtRef.current}ms] Cocos 렌더러 빌드 불일치:`
+ ` web=${ACADEMY_RENDERER_REVISION} game=${payload.rendererRevision ?? 'missing'}`);
}
readyRef.current = true;
setConnected(true);
onStageReadyRef.current?.();
bridge.send('setRoamPresentation', snapshotOf(roamRef.current));
iframe 에는 pointer-events: none 이 걸려 있습니다. 클릭이 Cocos 로 들어가면 포커스를 빼앗겨 웹의 키보드 입력이 죽기 때문입니다. 그래서 Cocos 코드는 입력 이벤트를 아예 받지 않습니다.
상태가 드나드는 문, RunGateway

SaveStore
읽기 · 쓰기 · 지우기 세 가지를 아는 저장소. 구현은 메모리와 브라우저 저장소 둘입니다.
SaveSlot
세이브 안의 schemaVersion 을 보고 한 단계씩 올립니다. 회차 세이브는 마이그레이터 24개로 v25 까지.
RunGateway
회차와 계정이 드나드는 문. 지금 구현은 브라우저용이고, 서버용 구현도 같은 인터페이스로 들어옵니다.
export interface SaveStore {
load(key: string): string | null;
save(key: string, value: string): void;
remove(key: string): void;
}
/** 시뮬·테스트용. 프로세스 안에서만 산다. */
export function createMemorySaveStore(): SaveStore {
const entries = new Map<string, string>();
return {
load: key => entries.get(key) ?? null,
save: (key, value) => { entries.set(key, value); },
remove: key => { entries.delete(key); },
};
}
/** 브라우저 기본 백엔드. SSR/비브라우저 환경에서는 조용히 메모리로 동작한다. */
export function createLocalStorageSaveStore(prefix: string): SaveStore {
if (typeof window === 'undefined' || !window.localStorage) {
return createMemorySaveStore();
}
const storage = window.localStorage;
return {
load: key => storage.getItem(prefix + key),
save: (key, value) => { storage.setItem(prefix + key, value); },
remove: key => { storage.removeItem(prefix + key); },
};
}브라우저가 아닌 곳(서버 렌더링, Node 시뮬)에서는 조용히 메모리로 바뀝니다. 같은 규칙 코드가 테스트에서도 그대로 도는 이유입니다. 버전 사슬은 끊기면 조용히 넘어가지 않고 실패합니다.
let version = typeof raw.schemaVersion === 'number' ? raw.schemaVersion : 0;
while (version < currentVersion) {
const migrate = migrations[version];
if (!migrate) {
throw new Error(`[SaveSlot] ${key}: v${version} → v${version + 1} 마이그레이터가 없습니다.`);
}
raw = migrate(raw);
version += 1;
raw.schemaVersion = version;
}그리고 이 모두를 RunGateway 뒤에 숨겼습니다. 화면과 스토어는 슬롯을 직접 부르지 않고 이 문을 씁니다. 검사 스크립트가 슬롯을 직접 부르는 코드도 찾아냅니다.
export interface RunGateway {
/** 이 입학 캐릭터로 이어 할 회차가 있는가. */
hasRun(playerCharacterId: string): boolean;
/** 이 입학 캐릭터의 회차를 읽는다. 없으면 null. */
loadRun(playerCharacterId: string): RunSaveData | null;
/** 회차를 남긴다. 서버가 규칙을 쥐게 되면 여기서 결과를 보내고 받는다. */
saveRun(save: RunSaveData): void;
/** 이 입학 캐릭터의 회차를 접는다(졸업·퇴학·새 회차). */
clearRun(playerCharacterId: string): void;
/** 계정(로비)을 읽는다. 없으면 null - 새 계정을 만드는 것은 부르는 쪽 몫이다. */
loadAccount(): AccountSaveData | null;
saveAccount(account: AccountSaveData): void;
}서버로 옮길 수 있다는 말이 빈말이 아닌 이유는 난수에 있습니다. 용도별 난수 스트림의 상태를 세이브에 함께 넣으니, 같은 세이브에서는 언제나 같은 결과가 나옵니다.
export function createRng(seed: number, restoredStates?: Record<string, number>): Rng {
const streams = new Map<string, MulberryStream>();
const normalizedSeed = seed >>> 0;
return {
seed: normalizedSeed,
stream(key: string): RngStream {
let stream = streams.get(key);
if (!stream) {
const restored = restoredStates?.[key];
stream = new MulberryStream(restored ?? (normalizedSeed ^ hashKey(key)));
streams.set(key, stream);
}
return stream;
},
serialize(): Record<string, number> {
const states: Record<string, number> = {};
for (const [key, stream] of streams) states[key] = stream.state();
return states;
},
};
}자산은 밖에서, 배포는 통째로
TD 시리즈 게임들이 함께 쓰는 캐릭터 · 몬스터 그림은 게임 빌드에 넣지 않고 공용 자산 호스트에서 받습니다. 무엇을 밖에서 받을지는 경로 앞머리로 정합니다.
/** 이 경로가 원격에서 올 것인가. 뿌리가 없으면 언제나 아니다. */
private static _isRemote(path: string): boolean {
return !!ElAssetBundles._remoteRoot
&& ElAssetBundles._remotePrefixes.some(prefix => path.startsWith(prefix));
}| 번들 | 원격 | |
|---|---|---|
| 어디에 | Cocos 빌드 안 | 공용 자산 호스트 |
| 파일 수 | 31개 | 14,944개 |
| 고르는 법 | TDSeriesAssets/ 로 시작하지 않는 경로 | TDSeriesAssets/ 로 시작하는 경로 |
- Cocos 빌드web-mobile
- 스테이징웹의
public/ClientGame으로 - 정적 exportNext.js 가 폴더로 내보냄
- 통째로 교체임시 폴더에 다 쓴 뒤 옮김
- 검사index.html 두 개와 기본 경로 확인
배포는 임시 폴더에 전부 쓴 다음 통째로 바꿔치기합니다. 반쯤 복사된 사이트가 방문자에게 보이는 일이 없습니다.
돌아보며
잘된 점
- 권위 분리를 말이 아니라 검사 스크립트로 지킵니다. 웹 쪽 구조 검사는 지금도 통과합니다
- 결정적 난수 덕분에 시뮬과 게임이 같은 규칙 코드를 씁니다
- 저장 방식을 바꿀 때 고칠 코드가 RunGateway 구현에 모여 있어 서버 이식의 길이 보입니다
- 원격 자산 전환을 경로 규칙으로 끝냈습니다
아쉬운 점
- 같은 계약을 웹(557줄)과 Cocos(279줄)가 손으로 따로 들고 있습니다. 검사는 버전 번호와 일부 필드를 비교하고, 나머지는 확인하지 않습니다
- Cocos 쪽 규칙 검사는 지금은 없는 파일을 읽다 멈춰, 사실상 돌지 않고 있습니다
- 순수해야 할 전투 시뮬에 Cocos 가 잰 동작 길이가 전역 값으로 들어옵니다. 헤드리스 시뮬과 실제 게임이 갈릴 수 있습니다
- 화면 파일 RunApp.tsx 가 5,099줄입니다
- 문서와 주석 몇 곳이 지금 코드와 어긋나 있습니다
경계를 지키는 검사도 코드입니다. 함께 돌보지 않으면 검사가 가장 먼저 낡는다는 것을 Cocos 쪽 검사가 보여 줍니다.
다음에 손볼 곳
계약 원본 통일
프로토콜 타입을 원본 파일에서 생성해 양쪽에 나눠 줍니다.
검사 되살리기
Cocos 규칙 검사를 지금 구조에 맞추고 배포 전 검증에 넣습니다.
동작 길이는 데이터로
공격 동작 길이를 표에 두어 시뮬과 게임이 같은 값을 읽게 합니다.
정리
- 웹 퍼스트는 "웹이 유일한 권위" 라는 뜻이었습니다. 앱 껍데기가 아니라, 판정과 상태는 웹이, 그리기는 Cocos 가 맡는 분업입니다.
- 교체할 대상은 처음부터 인터페이스로. SaveStore, RunGateway, 원격 자산 경로가 그 인터페이스이고, 덕분에 메모리 · 브라우저 · 언젠가의 서버가 같은 코드 위에 섭니다.
- 경계는 검사로 지키고, 검사도 돌봐야 합니다. 웹 쪽 검사는 살아 있고 Cocos 쪽 검사는 멈췄습니다. 그 차이가 다음 숙제입니다.
이전 리뷰에서는 ElServer 의 손으로 지은 ORM 을 살펴봤습니다. 규칙이 서버로 옮겨 가는 날, RunGateway 의 반대편 이야기를 이어 쓰겠습니다.