모바일 게임 개발: Unity와 네이티브 앱 통합 전략

모바일 개발

Unity모바일 게임게임 최적화iOSAndroid

이 글은 누구를 위한 것인가

  • 기존 네이티브 앱에 Unity 게임 화면을 임베딩하려는 팀
  • Unity 모바일 게임의 프레임 드롭과 메모리 이슈를 해결하려는 개발자
  • Unity Addressables로 에셋 번들 관리를 최적화하려는 팀

들어가며

Unity는 iOS/Android 크로스플랫폼 게임 개발의 표준이다. "Unity as a Library"로 기존 네이티브 앱에 게임 화면을 삽입할 수 있고, 반대로 Unity에서 네이티브 결제/광고 SDK를 호출할 수 있다.

이 글은 bluefoxdev.kr의 Unity 모바일 통합 가이드 를 참고하여 작성했습니다.


1. Unity 모바일 최적화 핵심

[렌더링 최적화]

드로우콜 최적화:
  배치(Batching): 같은 재질 오브젝트 합치기
  GPU Instancing: 동일 메시 대량 렌더링
  SRP Batcher: URP/HDRP에서 CPU 오버헤드 감소
  목표: 드로우콜 < 100 (모바일)

텍스처 최적화:
  ASTC: iOS/Android 공통 압축 포맷
  Mipmap: 카메라 거리별 자동 LOD
  Texture Atlas: 여러 텍스처 하나로 합치기
  크기: 2의 제곱수 (64, 128, 256, 512, 1024)

메모리 최적화:
  오브젝트 풀링: Instantiate/Destroy 최소화
  에셋 번들: 씬 별 에셋 분리 로딩
  Addressables: 에셋 참조 관리 + 언로드
  메모리 한계: iOS 1.5GB, Android 기기별 차이

프로파일링 도구:
  Unity Profiler: CPU/GPU/메모리 분석
  Frame Debugger: 드로우콜 시각화
  Memory Profiler: 힙 스냅샷 분석
  Xcode GPU Frame Capture: 네이티브 GPU 분석

[물리 최적화]
  Fixed Timestep: 0.02s (50Hz) 표준
  Layer Collision Matrix: 불필요한 충돌 제외
  Rigidbody Sleep Threshold: 정지 객체 최적화
  Mesh Collider → 단순 Collider 교체

2. Unity as a Library와 네이티브 통신

// iOS - Unity as a Library 통합
// Unity 프레임워크를 iOS 앱에 임베딩

// NativeCallProxy.mm (Unity → iOS 통신)
#import <Foundation/Foundation.h>
#import "UnityFramework/UnityFramework.h"

// Unity에서 호출하는 C# 함수가 이 함수들을 호출
extern "C" {
    // Unity → iOS: 게임 점수 전달
    void SendScoreToNative(int score) {
        [[NSNotificationCenter defaultCenter]
            postNotificationName:@"UnityScoreUpdate"
            object:nil
            userInfo:@{@"score": @(score)}];
    }
    
    // Unity → iOS: 결제 요청
    void RequestPurchaseFromNative(const char* productId) {
        NSString* nsProductId = [NSString stringWithUTF8String:productId];
        [[NSNotificationCenter defaultCenter]
            postNotificationName:@"UnityPurchaseRequest"
            object:nil
            userInfo:@{@"productId": nsProductId}];
    }
}
// SwiftUI에서 Unity 뷰 표시
import SwiftUI
import UnityFramework

class UnityManager: NSObject, ObservableObject {
    static let shared = UnityManager()
    
    var unityFramework: UnityFramework?
    @Published var isUnityReady = false
    
    func initUnity(launchOptions: [UIApplication.LaunchOptionsKey: Any]?) {
        let bundlePath = Bundle.main.bundlePath + "/Frameworks/UnityFramework.framework"
        let bundle = Bundle(path: bundlePath)
        
        unityFramework = bundle?.principalClass?.getInstance()
        unityFramework?.setDataBundleId("com.unity3d.framework")
        unityFramework?.register(self)
        unityFramework?.runEmbedded(
            withArgc: CommandLine.argc,
            argv: CommandLine.unsafeArgv,
            appLaunchOpts: launchOptions
        )
        
        isUnityReady = true
    }
    
    // iOS → Unity: 메시지 전송
    func sendToUnity(objectName: String, methodName: String, message: String) {
        unityFramework?.sendMessageToGO(
            withName: objectName,
            functionName: methodName,
            message: message
        )
    }
    
    // 예: 게임 레벨 시작
    func startLevel(_ level: Int) {
        sendToUnity(objectName: "GameManager", methodName: "StartLevel", message: "\(level)")
    }
}

struct UnityView: UIViewRepresentable {
    @ObservedObject var unityManager = UnityManager.shared
    
    func makeUIView(context: Context) -> UIView {
        let view = UIView()
        
        if let unityView = unityManager.unityFramework?.appController().rootView {
            view.addSubview(unityView)
            unityView.frame = view.bounds
            unityView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        }
        
        return view
    }
    
    func updateUIView(_ uiView: UIView, context: Context) {}
}

// 게임 화면을 포함한 SwiftUI 뷰
struct GameContainerView: View {
    @StateObject private var unityManager = UnityManager.shared
    @State private var playerScore = 0
    
    var body: some View {
        ZStack {
            // Unity 게임 뷰
            UnityView()
                .ignoresSafeArea()
            
            // 네이티브 오버레이 UI
            VStack {
                HStack {
                    Text("점수: \(playerScore)")
                        .font(.headline.bold())
                        .padding(8)
                        .background(.ultraThinMaterial)
                        .cornerRadius(8)
                    Spacer()
                    Button("일시정지") {
                        unityManager.sendToUnity(objectName: "GameManager", methodName: "Pause", message: "")
                    }
                    .buttonStyle(.bordered)
                }
                .padding()
                Spacer()
            }
        }
        .onReceive(NotificationCenter.default.publisher(for: Notification.Name("UnityScoreUpdate"))) { notification in
            playerScore = notification.userInfo?["score"] as? Int ?? 0
        }
    }
}
// Unity C# - 네이티브 통신
using System.Runtime.InteropServices;
using UnityEngine;

public class NativeBridge : MonoBehaviour
{
    // iOS 네이티브 함수 선언
#if UNITY_IOS
    [DllImport("__Internal")]
    private static extern void SendScoreToNative(int score);
    
    [DllImport("__Internal")]
    private static extern void RequestPurchaseFromNative(string productId);
#endif

    // 점수 변경 시 네이티브에 전달
    public static void ReportScore(int score)
    {
#if UNITY_IOS
        SendScoreToNative(score);
#elif UNITY_ANDROID
        using var player = new AndroidJavaClass("com.unity.player.UnityPlayer");
        using var activity = player.GetStatic<AndroidJavaObject>("currentActivity");
        activity.Call("onScoreUpdate", score);
#endif
    }
    
    // iOS에서 호출받는 메서드 (UnitySendMessage 대상)
    public void StartLevel(string levelStr)
    {
        if (int.TryParse(levelStr, out int level))
        {
            GameManager.Instance.LoadLevel(level);
        }
    }
}

// Addressables 에셋 로딩
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;

public class AssetLoader : MonoBehaviour
{
    [SerializeField] private AssetReference enemyPrefabRef;
    
    async void LoadEnemy(Vector3 position)
    {
        // 에셋 비동기 로드
        var handle = Addressables.InstantiateAsync(enemyPrefabRef, position, Quaternion.identity);
        await handle.Task;
        
        if (handle.Status == AsyncOperationStatus.Succeeded)
        {
            var enemy = handle.Result;
            // 에셋 참조 저장 (언로드에 필요)
        }
    }
    
    void ReleaseEnemy(GameObject enemy)
    {
        Addressables.ReleaseInstance(enemy);
    }
}

// 오브젝트 풀링
public class BulletPool : MonoBehaviour
{
    [SerializeField] private GameObject bulletPrefab;
    private Queue<GameObject> pool = new Queue<GameObject>();
    
    public GameObject GetBullet()
    {
        if (pool.Count > 0)
        {
            var bullet = pool.Dequeue();
            bullet.SetActive(true);
            return bullet;
        }
        return Instantiate(bulletPrefab);
    }
    
    public void ReturnBullet(GameObject bullet)
    {
        bullet.SetActive(false);
        pool.Enqueue(bullet);
    }
}

마무리

Unity 모바일 최적화의 80%는 드로우콜과 메모리다. 드로우콜은 배치와 GPU Instancing으로 줄이고, 메모리는 Addressables로 필요할 때만 로드하고 즉시 해제한다. Unity as a Library는 강력하지만 빌드 복잡도가 올라가므로, 게임 비중이 높다면 Unity 단독 앱 + 네이티브 SDK 임베딩이 더 나은 선택이다.