이 글은 누구를 위한 것인가
- 기존 네이티브 앱에 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 임베딩이 더 나은 선택이다.