Flutter 앱 내 유니티(Unity) 연동: 빌드 충돌 해결과 양방향 통신 패턴

최근 프로젝트에서 e-커머스 앱의 핵심 기능으로 '3D 가상 피팅룸'을 구현해야 했습니다. 초기에는 Fluttermodel_viewer_plusflame 같은 네이티브 3D 라이브러리를 검토했으나, 쉐이더(Shader) 커스터마이징과 복잡한 애니메이션 제어에서 한계가 명확했습니다. 결국 고성능 3D 처리가 가능한 유니티(Unity)를 플러터 앱 안에 임베딩하기로 결정했습니다. 하지만 이 과정은 순탄치 않았습니다. 분명 공식 문서대로 연동했음에도 불구하고, 안드로이드 빌드 시 java.lang.UnsatisfiedLinkError가 발생하거나, iOS에서 메모리 누수로 앱이 강제 종료되는 현상을 겪었습니다. 이 글에서는 Flutter와 Unity를 통합하는 과정에서 겪게 되는 치명적인 빌드 문제와, 이를 해결하고 두 엔진 간 데이터를 효율적으로 주고받는 프로덕션 레벨의 아키텍처를 공유합니다.

UaaL(Unity as a Library) 구조와 병목 원인 분석

우리가 흔히 사용하는 flutter_unity_widget 패키지는 본질적으로 UaaL(Unity as a Library) 기능을 래핑한 것입니다. 이는 유니티 런타임을 하나의 라이브러리 형태로 만들어, 네이티브 Android(Activity)나 iOS(ViewController) 위에서 구동시키고, 이를 다시 플러터의 PlatformView(AndroidView/UiKitView)로 렌더링하는 복잡한 계층 구조를 가집니다.

테스트 환경은 다음과 같았습니다:

  • Flutter SDK: 3.19.0 (Dart 3.3)
  • Unity Version: 2022.3.20f1 (LTS)
  • Target Device: Galaxy S23 (Android 14), iPhone 13 Pro (iOS 17)
  • Requirement: 60fps 유지, 앱 실행 후 3초 내 3D 뷰 로딩
Critical Build Error:
안드로이드 빌드 과정에서 가장 흔히 마주치는 오류는 NDK 버전 불일치입니다.
No implementation found for void com.unity3d.player.UnityPlayer.nativeRestartActivity()
이는 플러터 프로젝트의 NDK 설정과 유니티가 Export 될 때 참조한 IL2CPP 라이브러리 간의 ABI 호환성 문제입니다.

단순히 화면을 띄우는 것보다 더 심각한 문제는 컨텍스트 스위칭 비용입니다. 플러터의 UI 스레드와 유니티의 렌더링 스레드는 서로 다른 메모리 공간을 사용합니다. 따라서 두 엔진 사이에서 데이터를 주고받을 때 직렬화(Serialization)와 역직렬화(Deserialization) 과정이 빈번하게 발생하면, 프레임 드랍이 필연적으로 발생합니다. 특히 매 프레임마다 캐릭터의 좌표를 동기화해야 하는 경우, 단순한 String 메시지 패싱 방식으로는 성능 저하를 피할 수 없습니다.

실패 사례: 단순 Export 방식의 접근

처음에는 유니티에서 안드로이드 프로젝트로 Export한 뒤, 생성된 unityLibrary 모듈을 플러터의 android 폴더에 수동으로 병합(Merge)하려 했습니다. 이전 포스팅에서 다룬 네이티브 모듈 연동 방식과 유사할 것이라 판단했기 때문입니다. 하지만 이 방식은 유지보수 측면에서 재앙이었습니다.

  1. 유니티 프로젝트를 수정할 때마다 매번 Export 후 파일을 덮어써야 했고, 이 과정에서 AndroidManifest.xml 설정이 초기화되는 문제가 발생했습니다.
  2. Gradle 의존성 충돌로 인해, 플러터가 사용하는 라이브러리와 유니티가 사용하는 라이브러리 버전이 꼬이면서 빌드 시간이 10분 이상으로 늘어났습니다.

해결책: FUW(Flutter Unity Widget) 최적화 설정

가장 안정적인 해결책은 flutter_unity_widget을 사용하되, 자동화된 빌드 스크립트를 통해 유니티 Export 과정을 추상화하는 것입니다. 다음은 프로덕션 환경에서 검증된 설정 코드입니다.

1. Unity 측 설정 (C#)

유니티 프로젝트에 FlutterUnityIntegration 패키지를 설치한 후, 플러터로 메시지를 보내는 인터페이스를 구성해야 합니다. 아래 코드는 단순 문자열이 아닌 JSON 객체를 효율적으로 전송하는 매니저 클래스입니다.

// UnityProject/Assets/Scripts/FlutterMessageManager.cs
using System;
using UnityEngine;
using Newtonsoft.Json; // JSON 처리를 위한 필수 라이브러리

public class FlutterMessageManager : MonoBehaviour
{
    // 싱글톤 패턴으로 인스턴스 관리
    public static FlutterMessageManager Instance { get; private set; }

    void Awake()
    {
        if (Instance == null) {
            Instance = this;
            DontDestroyOnLoad(gameObject);
        } else {
            Destroy(gameObject);
        }
    }

    // Flutter로 데이터를 보낼 때 호출
    public void SendToFlutter(string action, object data)
    {
        var payload = new
        {
            action = action,
            timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
            data = data
        };

        string jsonMessage = JsonConvert.SerializeObject(payload);
        
        // 중요: UnityMessageManager는 플러터 패키지에서 제공하는 브릿지입니다.
        // API 2022.3 이상에서는 WebGL 호환성을 위해 체크가 필요할 수 있습니다.
        try {
            UnityMessageManager.Instance.SendMessageToFlutter(jsonMessage);
        } catch (Exception e) {
            Debug.LogError($"Failed to send message to Flutter: {e.Message}");
        }
    }
    
    // Flutter에서 호출할 메서드 (SendMessage 타겟)
    public void OnFlutterMessage(string message)
    {
        Debug.Log($"Received from Flutter: {message}");
        // 메시지 파싱 로직 구현
    }
}

위 코드에서 중요한 점은 DontDestroyOnLoad를 통해 씬(Scene)이 변경되어도 통신 매니저가 살아있도록 유지하는 것입니다. 플러터와 연결이 끊어지는 대부분의 원인은 씬 전환 시 매니저 객체가 파괴되기 때문입니다.

2. Flutter 측 구현 (Dart)

플러터에서는 UnityWidget을 배치하고, 컨트롤러를 통해 유니티 씬을 조작합니다. 여기서 핵심은 위젯이 로드되는 시점과 초기화되는 시점의 비동기 처리입니다.

// lib/widgets/unity_view_container.dart
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_unity_widget/flutter_unity_widget.dart';

class UnityViewContainer extends StatefulWidget {
  @override
  _UnityViewContainerState createState() => _UnityViewContainerState();
}

class _UnityViewContainerState extends State<UnityViewContainer> {
  UnityWidgetController? _unityWidgetController;
  bool _isUnityLoaded = false;

  @override
  void dispose() {
    // 메모리 누수 방지를 위해 컨트롤러 정리 필수
    _unityWidgetController?.dispose();
    super.dispose();
  }

  void onUnityCreated(controller) {
    _unityWidgetController = controller;
    setState(() {
      _isUnityLoaded = true;
    });
    
    // 초기화 데이터 전송 (예: 사용자 설정, 아바타 ID)
    _sendInitialConfig();
  }

  void onUnityMessage(message) {
    // Unity에서 보낸 JSON 메시지 파싱
    try {
      var decoded = jsonDecode(message.toString());
      print('Action: ${decoded['action']}');
      
      if (decoded['action'] == 'CLICK_ITEM') {
        // Flutter UI 오버레이 표시 로직
        _showItemDetails(decoded['data']);
      }
    } catch (e) {
      print('Message Parse Error: $e');
    }
  }

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        UnityWidget(
          onUnityCreated: onUnityCreated,
          onUnityMessage: onUnityMessage,
          useAndroidViewSurface: true, // Android 12+ 렌더링 호환성 해결
          borderRadius: BorderRadius.circular(16.0),
          fullscreen: false, // 임베디드 모드 설정
        ),
        if (!_isUnityLoaded)
          const Center(child: CircularProgressIndicator()),
      ],
    );
  }
  
  void _sendInitialConfig() {
    _unityWidgetController?.postMessage(
      'FlutterMessageManager', // Unity 내 GameObject 이름
      'OnFlutterMessage',      // 호출할 메서드 이름
      jsonEncode({'type': 'INIT', 'token': 'user_1234'})
    );
  }
  
  void _showItemDetails(dynamic data) {
    // ...
  }
}

useAndroidViewSurface: true 옵션은 매우 중요합니다. 최신 안드로이드 버전에서 하이브리드 컴포지션(Hybrid Composition) 모드를 활성화하여, 플러터 위젯 트리와 유니티 뷰가 겹칠 때 발생하는 깜빡임이나 터치 씹힘 현상을 방지합니다.

성능 벤치마크 및 결과 분석

단일 Flutter 앱과 유니티가 포함된 앱의 리소스 사용량을 비교해보았습니다. 측정은 Galaxy S23 디바이스에서 프로파일링 도구를 사용하여 진행했습니다.

지표 (Metric) Flutter Native (model_viewer) Flutter + Unity (Optimized) 비고
초기 앱 용량 (APK) 24 MB 58 MB Unity 엔진 런타임 포함으로 증가
메모리 사용량 (Idle) 85 MB 210 MB Unity 3D Context 오버헤드
평균 FPS (복합 씬) 45 FPS (불안정) 60 FPS (고정) 물리 연산 및 파티클 처리 시 압도적 차이
배터리 소모량 (1시간) 8% 14% GPU 부하 증가

위 표에서 볼 수 있듯이, 유니티를 도입하면 앱 용량과 메모리 사용량이 필연적으로 증가합니다. 하지만 복잡한 3D 씬에서의 프레임 방어율은 네이티브 라이브러리와 비교할 수 없을 정도로 뛰어납니다. 단순한 3D 모델 뷰어라면 model_viewer_plus가 낫지만, 인터랙티브 요소가 포함된 게임형 콘텐츠라면 유니티 도입으로 인한 오버헤드는 충분히 감수할 가치가 있는 투자입니다.

flutter_unity_widget 패키지 확인하기

주의사항 및 엣지 케이스

실무 적용 시 반드시 고려해야 할 몇 가지 엣지 케이스가 있습니다.

  1. iOS Bitcode 지원 중단: Xcode 14 이상부터 Bitcode가 deprecated 되었습니다. 유니티 빌드 설정에서 Bitcode를 비활성화하지 않으면 아카이빙 단계에서 실패할 수 있습니다. PostProcessBuild 스크립트를 작성하여 ENABLE_BITCODENO로 자동 설정하도록 해야 CI/CD 파이프라인이 깨지지 않습니다.
  2. Android Back Button 처리: 유니티가 활성화된 상태에서 안드로이드 뒤로 가기 버튼을 누르면, 기본적으로 유니티 액티비티가 종료되면서 플러터 앱 전체가 죽는 현상이 발생할 수 있습니다. 플러터 측에서 WillPopScope(또는 PopScope)를 사용하여 뒤로 가기 이벤트를 가로채고, 유니티 뷰만 언로드(Unload)하거나 숨기는 처리를 해야 합니다.
  3. 배경 투명 처리: 유니티 배경을 투명하게 만들어 플러터 UI 위에 띄우고 싶다면, 유니티의 Main Camera 설정에서 Clear FlagsSolid Color로, Background의 알파값을 0으로 설정해야 합니다. 하지만 이는 GPU 비용을 증가시키므로 꼭 필요한 경우에만 사용해야 합니다.
Best Practice: 유니티 뷰는 앱 실행 시 바로 로드하지 말고, 필요할 때 Lazy Loading 방식으로 띄우는 것이 초기 구동 속도(Cold Start)를 확보하는 지름길입니다.

결론

Flutter와 유니티의 결합은 '앱의 생산성'과 '게임의 몰입감'이라는 두 마리 토끼를 잡을 수 있는 강력한 전략입니다. 초기 설정과 빌드 구성이 까다롭지만, flutter_unity_widget을 올바르게 설정하고 메시지 통신 프로토콜을 명확히 한다면, 네이티브 앱 개발자들도 어렵지 않게 고품질 3D 콘텐츠를 서비스에 통합할 수 있습니다. 지금 여러분의 앱이 단순한 정보 전달을 넘어 경험을 전달해야 한다면, 이 하이브리드 아키텍처를 검토해 보시기 바랍니다.

Post a Comment