앱인토스 개발자센터 로고
Skip to content

Vite로 Unity WebGL 빌드 감싸기

이 가이드는 Unity에서 빌드한 WebGL 파일을 Vite(React 기반) 프로젝트로 감싸는 방법을 안내해요.
앱인토스에 배포하려면 @apps-in-toss/web-framework도 함께 설치해야 해요.

1. Vite 프로젝트 생성

아래 명령어 중 사용하는 패키지 매니저에 맞게 선택해 주세요.
React + TypeScript 템플릿으로 프로젝트가 생성돼요.

sh
npm create vite@latest unity-webgl-wrapper -- --template react-ts
cd unity-webgl-wrapper
npm install
sh
pnpm create vite unity-webgl-wrapper --template react-ts
cd unity-webgl-wrapper
pnpm install
sh
yarn create vite unity-webgl-wrapper --template react-ts
cd unity-webgl-wrapper
yarn install

2. 앱인토스 SDK 설치

프로젝트 루트에서 앱인토스 SDK를 설치해 주세요.
앱인토스 환경에 배포하기 위해서 이 SDK가 꼭 필요해요.

bash
npm install @apps-in-toss/web-framework

3. Unity WebGL 빌드 결과물 복사

Unity에서 WebGL 빌드를 완료한 후, 출력 폴더의 구조는 보통 다음과 같아요.

bash
Build/
├── index.html
├── Build/
└── TemplateData/

Build/ 안의 파일들을 vite 프로젝트의 public/unity 폴더로 복사해주세요.

bash
mkdir -p public/unity
cp -r [UnityBuildPath]/Build/* public/unity/

그 다음 index.html, TemplateData 파일도 public/unity 폴더로 복사해주세요.

복사 후 구조 예시는 다음과 같아요.

bash
public/
└── unity/
    ├── index.html
    ├── {YourProject}.data.br
    ├── {YourProject}.framework.js.br
    ├── {YourProject}.loader.js
    ├── {YourProject}.wasm.br
    └── TemplateData/

TIP

public 폴더에 들어 있는 파일들은 Vite dev 서버에서 정적 파일로 서빙돼요. 즉, /unity/index.html로 접근할 수 있어요.

4. Unity 게임을 보여주는 컴포넌트 만들기

Unity WebGL 빌드 파일을 직접 로드해서 <canvas>에 그리는 컴포넌트를 작성해요.
iframe은 사용할 수 없고, Unity의 createUnityInstance()를 통해 DOM에 직접 렌더링해야 해요.

iframe은 사용할 수 없어요

Unity WebGL을 iframe으로 삽입하면 앱인토스 기능(API, SDK 등)이 정상 동작하지 않아요.
또한 보안 심사에서도 반려될 수 있기 때문에, iframe 방식은 지원하지 않습니다.

React 환경에서 Unity WebGL을 동작시키려면, Unity에서 빌드된 JavaScript 런타임을 직접 불러와서 DOM에 렌더링해 주세요.

tsx
// src/UnityCanvas.tsx
import React, { useEffect, useRef, useState } from "react";

/**
 * Props
 * - basePath: public 폴더 내 unity 빌드가 위치한 경로 (ex: /unity)
 * - loaderFile: loader 스크립트 파일명 (ex: "test.loader.js")
 * - fileBasename: Unity 빌드 파일의 기본 이름 (ex: "test" -> test.data.br, test.wasm.br 등)
 */
type Props = {
  basePath?: string;
  loaderFile?: string;
  fileBasename?: string;
  onProgress?: (p: number) => void;
  onLoaded?: () => void;
  onError?: (e: Error) => void;
  style?: React.CSSProperties;
};

export default function UnityCanvas({
  basePath = "/unity",
  loaderFile = "test.loader.js", 
  fileBasename = "test", 
  onProgress,
  onLoaded,
  onError,
  style,
}: Props) {
  const containerRef = useRef<HTMLDivElement | null>(null);
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  const unityInstanceRef = useRef<any | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    let mounted = true;
    const scriptUrl = `${basePath}/${loaderFile}`;

    // 동적 스크립트 추가
    const script = document.createElement("script");
    script.src = scriptUrl;
    script.async = true;
    script.onload = () => {
      if (!mounted) return;
      // 캔버스 준비
      const container = containerRef.current!;
      const canvas = document.createElement("canvas");
      canvasRef.current = canvas;
      canvas.id = "unity-canvas";
      canvas.style.width = "100%";
      canvas.style.height = "100%";
      canvas.style.display = "block";
      container.appendChild(canvas);

      const createOpts = {
        dataUrl: `${basePath}/${fileBasename}.data.br`,
        frameworkUrl: `${basePath}/${fileBasename}.framework.js.br`,
        codeUrl: `${basePath}/${fileBasename}.wasm.br`,
        streamingAssetsUrl: "",
        companyName: "YourCompany",
        productName: fileBasename,
        productVersion: "1.0",
      };

      if (typeof (window as any).createUnityInstance !== "function") {
        const err = new Error("createUnityInstance is not available on window. Loader script may have failed to load.");
        console.error(err);
        onError?.(err);
        setLoading(false);
        return;
      }

      (window as any).createUnityInstance(canvas, createOpts, (progress: number) => {
        if (!mounted) return;
        onProgress?.(progress);
        // optional internal state
      }).then((inst: any) => {
        if (!mounted) {
          // 컴포넌트가 언마운트 되었으면 즉시 종료
          if (inst && inst.Quit) inst.Quit();
          return;
        }
        unityInstanceRef.current = inst;
        setLoading(false);
        onLoaded?.();
      }).catch((e: any) => {
        console.error("createUnityInstance error:", e);
        onError?.(e instanceof Error ? e : new Error(String(e)));
        setLoading(false);
      });
    };

    script.onerror = (e) => {
      const err = new Error("Failed to load Unity loader script: " + scriptUrl);
      console.error(err, e);
      onError?.(err);
      setLoading(false);
    };

    document.body.appendChild(script);

    return () => {
      mounted = false;
      // Unity 인스턴스 종료
      const inst = unityInstanceRef.current;
      if (inst && typeof inst.Quit === "function") {
        inst.Quit().catch((err: any) => {
          // ignore
          console.warn("UnityQuit error", err);
        });
      }
      // 캔버스/스크립트 정리
      if (canvasRef.current && containerRef.current?.contains(canvasRef.current)) {
        containerRef.current.removeChild(canvasRef.current);
      }
      try {
        document.body.removeChild(script);
      } catch {}
    };
  }, [basePath, loaderFile, fileBasename]);

  // SendMessage helper
  const sendMessage = (gameObject: string, method: string, value?: string | number | boolean) => {
    const inst = unityInstanceRef.current;
    if (inst && typeof inst.SendMessage === "function") {
      inst.SendMessage(gameObject, method, value);
    } else {
      console.warn("Unity instance not ready or SendMessage missing");
    }
  };

  return (
    <div style={{ width: "100vw", height: "100vh", position: "relative", ...style }}>
      {loading && (
        <div style={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center", pointerEvents: "none" }}>
          <div>Loading Unity...</div>
        </div>
      )}
      <div ref={containerRef} style={{ width: "100%", height: "100%" }} />
      {/* 필요 시 외부에서 sendMessage 사용 가능하게 ref 전달 로직 추가 */}
    </div>
  );
}

src/App.tsx에서 해당 컴포넌트를 불러와 사용해요.

tsx
// src/App.tsx
import UnityCanvas from './UnityCanvas';

function App() {
  return (
    <div style={{ width: '100vw', height: '100vh' }}>
      <UnityCanvas />
    </div>
  );
}

export default App;

5. 개발 서버 실행

아래 명령어로 Vite 개발 서버를 실행해 보세요.

bash
npm run dev

브라우저에서 Vite 개발 서버 주소(http://localhost:5173 등)로 접속하면 React 앱 안에서 Unity 게임이 정상적으로 렌더링되는지 확인할 수 있어요.

6. 앱인토스 배포환경 구성

개발 서버가 정상적으로 실행되는 것을 확인했다면, 다음 명령어로 프로젝트를 앱인토스 배포환경으로 구성해주세요.

bash
npx ait init

명령어 실행 후, 아래와 같은 질문에 순서대로 응답해 주세요.

  1. web-framework 를 선택하세요.
  2. 앱 이름(appName)을 입력하세요.
    • 이 이름은 앱인토스 콘솔에서 앱을 만들 때 사용한 이름과 같아야 해요.
    • 앱 이름은 각 앱을 식별하는 고유한 키로 사용돼요.
    • appName은 intoss://{appName}/path 형태의 딥링크 경로나 테스트·배포 시 사용하는 앱 전용 주소 등에서도 사용돼요.
    • 샌드박스 앱에서 테스트할 때도 intoss://{appName}으로 접근해요.
      단, 출시하기 메뉴의 QR 코드로 테스트할 때는 intoss-private://{appName}이 사용돼요.
  3. 웹 번들러의 dev 명령어를 입력해주세요.
bash
vite
  1. 웹 번들러의 build 명령어를 입력해주세요.
bash
tsc -b && vite build
  1. 사용할 포트 번호를 입력하세요.
bash
5173

초기화가 완료되면 granite.config.ts 파일이 생성돼요. 배포하려는 서비스에 맞게 수정해주세요.

7. 정적 사이트 빌드 및 배포

bash
npm run build

빌드가 완료되면 .ait 파일이 생성돼요. 이 파일을 콘솔에 업로드하면 미니앱을 배포할 수 있어요.