로그인게임하기
코드 리뷰

[아카데미 라이프 리뷰] 판정은 웹이, 그리기는 Cocos 가 맡는 게임

2026.10.11

코드 리뷰 · 아카데미 라이프

「졸업까지 100턴: 아카데미 라이프」 는 브라우저에서 도는 게임입니다. 그런데 화면 안에는 앱이 둘 있습니다. 규칙과 저장을 쥔 Next.js 웹 앱, 그리고 그림을 그리는 Cocos Creator 게임이죠. 이번 리뷰에서는 우리가 "웹 퍼스트 하이브리드" 라고 부르는 이 구조가 어떻게 나뉘어 있고, 두 앱이 무엇을 주고받는지 살펴보겠습니다.

PROBLEM

규칙과 화면이 얽혀 있던 프로토타입

첫 프로토타입은 규칙, 저장, HUD 가 그리는 코드와 얽혀 있었습니다. 연출을 고치면 규칙이 흔들리고, 개발 모드 플래그가 저장 로직 사이에 끼어 있었죠. 그래서 판을 다시 짤 때 선을 그었습니다. 판정은 웹이, 그리기는 Cocos 가.

프로토타입

  • 규칙 · 저장 · HUD 가 표현 코드와 얽힘
  • 연출을 바꾸면 규칙이 흔들림
  • 개발 모드 플래그와 저장 로직이 섞임

웹 퍼스트

  • 규칙, 상태, 저장, 입력은 모두 웹이 맡음
  • Cocos 는 웹이 보낸 스냅샷을 받아 그림
  • 규칙 코드는 브라우저 없이 Node 에서도 그대로 돎
앱 껍데기를 씌우는 하이브리드가 아닙니다

흔히 하이브리드라고 하면 웹을 앱 껍데기(WebView)로 감싸는 방식을 떠올립니다. 아카데미 라이프의 하이브리드는 브라우저 안에서 두 앱이 합쳐지는 것입니다. Next.js 화면 안에 Cocos 빌드가 같은 출처의 iframe 으로 들어가 있습니다. 광고 · 결제 · 로그인 같은 플랫폼 SDK 는 아직 붙어 있지 않습니다.

STRUCTURE

다섯 겹의 층

화면ClientWeb/app. React 와 zustand 로 HUD, 메뉴, 입력을 맡습니다
규칙src/run, src/core, src/lobby. 브라우저를 모르는 순수 코드이고, 같은 시드면 같은 결과가 나옵니다
문RunGateway 와 SaveStore. 상태가 들어오고 나가는 길을 이 둘로 모았습니다
계약AcademyProtocol 버전 25. 웹과 Cocos 가 postMessage 로 주고받는 말의 모양
그림Cocos 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 를 쓰면 빌드 검증을 멈춥니다. 언젠가 규칙을 서버로 옮길 때를 대비한 울타리입니다.

architecture_check.jsTDAcademyLife/ClientWeb/tools/architecture_check.jsr4026 · 30–43줄
// 규칙층은 브라우저를 모른다. 저장 백엔드와 사람의 화면 설정만 예외다.
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)}`);
    }
  }
}
FLOW

이어하기부터 첫 장면까지

  1. 세이브 읽기브라우저 저장소 → 버전 올리기 → 난수 복원
  2. iframe 열기/ClientGame/index.html
  3. gameReadyCocos 가 프로토콜 버전을 알림
  4. 첫 스냅샷버전이 맞으면 웹이 setRoamPresentation
  5. 원격 자산공용 자산 호스트에서 캐릭터 · 몬스터 프레임
  6. stageLoaded첫 장면이 서면 웹이 로딩 안내를 내림

Cocos 의 부팅 코드는 이 흐름을 그대로 보여 줍니다. 웹이 첫 스냅샷을 줄 때까지 기다리고, 원격 자산 길을 세우고, 첫 존을 그린 다음 "섰다" 고 알립니다.

AcademyGameRoot.tsTDAcademyLife/ClientGame/assets/Scripts/AcademyGameRoot.tsr4026 · 80–91, 94–97줄92–93줄(원격 자산 주소 지정)은 줄임
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)에서만 세웠더니, 가장 큰 로드인 첫 존이 그보다 먼저 끝나 전부 번들에서 읽혔습니다. 부팅 순서를 바꿔 고쳤고, 왜 그 순서인지를 주석으로 남겼습니다.

CONTRACT

postMessage 로 맺은 계약

방향메시지뜻
웹 → CocossetRoamPresentation지금 그릴 장면 전체. 매 프레임 순번(sequence)을 올려 보냄
Cocos → 웹gameReady준비됨. 프로토콜 버전과 렌더러 빌드 번호를 함께
Cocos → 웹stageMetrics그려 보니 땅이 어디인지 같은 무대 치수
Cocos → 웹stageLoaded첫 장면이 실제로 섰음
Cocos → 웹motionTiming공격 동작의 실제 길이

Cocos 쪽 다리는 세 가지를 지킵니다. 혼자 실행되면 거부하고, 같은 출처의 부모 창이 아닌 곳에서 온 말은 버리고, 이미 그린 것보다 오래된 스냅샷은 버립니다.

LocalPlatformBridge.tsTDAcademyLife/ClientGame/assets/Scripts/Platform/LocalPlatformBridge.tsr4026 · 27–39, 57–65줄사이의 대기 · 정리 메서드는 줄임
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 빌드는 낡은 채 남는 사고를 여기서 잡습니다.

RoamStage.tsxTDAcademyLife/ClientWeb/app/run/RoamStage.tsxr4026 · 516–528줄
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));
브라우저 창 같은 액자 속 무대에서 로라와 에드윈이 연기하고, 관객의 손가락은 유리에 닿아 그 신호가 아래 감독석의 알렌에게 전해지는 그림
누르는 손은 무대가 아니라 감독석(웹)에 닿고, 무대(Cocos)는 웹이 보낸 장면을 그립니다.
입력은 웹이 받습니다

iframe 에는 pointer-events: none 이 걸려 있습니다. 클릭이 Cocos 로 들어가면 포커스를 빼앗겨 웹의 키보드 입력이 죽기 때문입니다. 그래서 Cocos 코드는 입력 이벤트를 아예 받지 않습니다.

STORAGE

상태가 드나드는 문, RunGateway

복도 끝의 아치 문으로 로라와 학생들이 세이브 일지를 들고 가고, 문 뒤에는 지금의 책장 방, 문 옆 벽에는 미래의 서버 탑이 연필 스케치로 그려진 그림
문(RunGateway)은 그대로 두고 문 뒤의 방을 바꿀 수 있게 했습니다.
저장소

SaveStore

읽기 · 쓰기 · 지우기 세 가지를 아는 저장소. 구현은 메모리와 브라우저 저장소 둘입니다.

버전

SaveSlot

세이브 안의 schemaVersion 을 보고 한 단계씩 올립니다. 회차 세이브는 마이그레이터 24개로 v25 까지.

문

RunGateway

회차와 계정이 드나드는 문. 지금 구현은 브라우저용이고, 서버용 구현도 같은 인터페이스로 들어옵니다.

SaveStore.tsTDAcademyLife/ClientWeb/src/core/save/SaveStore.tsr4026 · 9–36줄
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 시뮬)에서는 조용히 메모리로 바뀝니다. 같은 규칙 코드가 테스트에서도 그대로 도는 이유입니다. 버전 사슬은 끊기면 조용히 넘어가지 않고 실패합니다.

SaveSlot.tsTDAcademyLife/ClientWeb/src/core/save/SaveSlot.tsr4026 · 48–57줄
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 뒤에 숨겼습니다. 화면과 스토어는 슬롯을 직접 부르지 않고 이 문을 씁니다. 검사 스크립트가 슬롯을 직접 부르는 코드도 찾아냅니다.

RunGateway.tsTDAcademyLife/ClientWeb/src/run/RunGateway.tsr4026 · 25–38줄
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;
}

서버로 옮길 수 있다는 말이 빈말이 아닌 이유는 난수에 있습니다. 용도별 난수 스트림의 상태를 세이브에 함께 넣으니, 같은 세이브에서는 언제나 같은 결과가 나옵니다.

Rng.tsTDAcademyLife/ClientWeb/src/core/Rng.tsr4026 · 80–101줄
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;
    },
  };
}
SHIP

자산은 밖에서, 배포는 통째로

TD 시리즈 게임들이 함께 쓰는 캐릭터 · 몬스터 그림은 게임 빌드에 넣지 않고 공용 자산 호스트에서 받습니다. 무엇을 밖에서 받을지는 경로 앞머리로 정합니다.

ElAssetBundles.tsTDAcademyLife/ClientGame/assets/ElCocosFramework/Core/ElAssetBundles.tsr4026 · 69–73줄
/** 이 경로가 원격에서 올 것인가. 뿌리가 없으면 언제나 아니다. */
private static _isRemote(path: string): boolean {
    return !!ElAssetBundles._remoteRoot
        && ElAssetBundles._remotePrefixes.some(prefix => path.startsWith(prefix));
}
번들원격
어디에Cocos 빌드 안공용 자산 호스트
파일 수31개14,944개
고르는 법TDSeriesAssets/ 로 시작하지 않는 경로TDSeriesAssets/ 로 시작하는 경로
  1. Cocos 빌드web-mobile
  2. 스테이징웹의 public/ClientGame 으로
  3. 정적 exportNext.js 가 폴더로 내보냄
  4. 통째로 교체임시 폴더에 다 쓴 뒤 옮김
  5. 검사index.html 두 개와 기본 경로 확인

배포는 임시 폴더에 전부 쓴 다음 통째로 바꿔치기합니다. 반쯤 복사된 사이트가 방문자에게 보이는 일이 없습니다.

RETRO

돌아보며

잘된 점

  • 권위 분리를 말이 아니라 검사 스크립트로 지킵니다. 웹 쪽 구조 검사는 지금도 통과합니다
  • 결정적 난수 덕분에 시뮬과 게임이 같은 규칙 코드를 씁니다
  • 저장 방식을 바꿀 때 고칠 코드가 RunGateway 구현에 모여 있어 서버 이식의 길이 보입니다
  • 원격 자산 전환을 경로 규칙으로 끝냈습니다

아쉬운 점

  • 같은 계약을 웹(557줄)과 Cocos(279줄)가 손으로 따로 들고 있습니다. 검사는 버전 번호와 일부 필드를 비교하고, 나머지는 확인하지 않습니다
  • Cocos 쪽 규칙 검사는 지금은 없는 파일을 읽다 멈춰, 사실상 돌지 않고 있습니다
  • 순수해야 할 전투 시뮬에 Cocos 가 잰 동작 길이가 전역 값으로 들어옵니다. 헤드리스 시뮬과 실제 게임이 갈릴 수 있습니다
  • 화면 파일 RunApp.tsx 가 5,099줄입니다
  • 문서와 주석 몇 곳이 지금 코드와 어긋나 있습니다

경계를 지키는 검사도 코드입니다. 함께 돌보지 않으면 검사가 가장 먼저 낡는다는 것을 Cocos 쪽 검사가 보여 줍니다.

NEXT

다음에 손볼 곳

1순위

계약 원본 통일

프로토콜 타입을 원본 파일에서 생성해 양쪽에 나눠 줍니다.

2순위

검사 되살리기

Cocos 규칙 검사를 지금 구조에 맞추고 배포 전 검증에 넣습니다.

3순위

동작 길이는 데이터로

공격 동작 길이를 표에 두어 시뮬과 게임이 같은 값을 읽게 합니다.

정리

  1. 웹 퍼스트는 "웹이 유일한 권위" 라는 뜻이었습니다. 앱 껍데기가 아니라, 판정과 상태는 웹이, 그리기는 Cocos 가 맡는 분업입니다.
  2. 교체할 대상은 처음부터 인터페이스로. SaveStore, RunGateway, 원격 자산 경로가 그 인터페이스이고, 덕분에 메모리 · 브라우저 · 언젠가의 서버가 같은 코드 위에 섭니다.
  3. 경계는 검사로 지키고, 검사도 돌봐야 합니다. 웹 쪽 검사는 살아 있고 Cocos 쪽 검사는 멈췄습니다. 그 차이가 다음 숙제입니다.

이전 리뷰에서는 ElServer 의 손으로 지은 ORM 을 살펴봤습니다. 규칙이 서버로 옮겨 가는 날, RunGateway 의 반대편 이야기를 이어 쓰겠습니다.

← 개발 노트 목록