오픈소스 LINE SDK for Unity를 향한 도전: 과제와 선택지

LINE SDK 개발팀의 Wei Wang입니다. 저희는 작년 LINE DEVELOPER DAY 2018에서 새로운 LINE SDK for iOS와 LINE SDK for Android를 오픈소스로 배포했습니다. 이 SDK는 LINE 로그인과 몇 가지 API를 앱에 통합하는 기능을 제공하는데요. 이를 통해 각 앱에 따라 매력적인 사용자 경험을 만들 수 있습니다. 저희는 배포를 마친 후 게임 개발자에 대한 지원도 필요하다는 점을 깨달았습니다. 게임은 App Store와 Google Play 전체 앱 중 50% 이상을 차지하며 압도적인 수익을 창출하고 있습니다. 그래서 저희는 또 하나의 중요한 플랫폼인 Unity용 LINE SDK를 개발하기로 결정했습니다. LINE SDK for Unity는 게임 개발자가 혁신적인 차기 게임 타이틀을 개발하면서 LINE SDK를 좀 더 쉽게 사용할 수 있도록 설계했습니다.

이번 글에서는 기존 SDK for Unity를 랩핑(wrapping)하기 위해 저희가 시도했던 방법을 소개하고, iOS와 Android에 LINE SDK를 통합하는 방법을 이야기하려 합니다. LINE SDK for iOS와 LINE SDK for Android처럼 LINE SDK for Unity도 오픈소스입니다. 코드는 GitHub에서 확인할 수 있고, Unity 게임에서 LINE SDK를 사용하는 방법은 셋업 가이드를 참조해 주시기 바랍니다.

개요

저희는 Unity SDK를 제공하면서 기존 iOS와 Android용 네이티브(native) SDK를 랩핑하여 사용자가 쓰기 편한 C# 인터페이스를 제공하기로 결정했습니다. SDK 전체를 다시 구현하지 않고 기존 기능을 랩핑하면 다음과 같은 이점이 있습니다.

  1. 유지관리 비용이 절감됩니다. 기존 네이티브 SDK 코드를 재사용하면 유지관리 비용을 줄일 수 있습니다. LINE SDK는 오픈소스로 공개되어 보급률이 높고 평판도 아주 좋습니다. 네이티브 SDK를 사용하면 프로젝트의 품질이 확보될 뿐만 아니라, 네이티브 SDK의 새로운 기능이나 수정사항이 Unity SDK에 동기화됩니다. 
  2. 네이티브 기능을 활용할 수 있습니다. LINE SDK의 로그인 기능을 사용하려면 LINE 앱이 설치되지 않은 경우에 사용하는 웹 뷰(WebView) 로그인이나 다른 앱과의 데이터 전송 처리 등 시스템 플랫폼의 다양한 기능이 필요합니다. 네이티브 SDK를 사용하면 이러한 문제를 네이티브에서 적절하게 대처할 수 있습니다.
  3. 익숙한 조작 방법을 제공합니다. 모델과 API 정의를 C# 레벨로 제공하기 때문에 Unity 사용자는 iOS 플랫폼과 Android 플랫폼 간 차이에 상관 없이 C# 규칙대로 LINE SDK를 사용할 수 있습니다.

LINE SDK for Unity의 기본 구조는 다음과 같습니다.

구현 방법

LINE SDK for Unity 구현 방법에 대해 설명하겠습니다(전체 소스코드는 GitHub에 공개되어 있습니다).

Unity용 네이티브 플러그인

Unity에서는 광범위한 네이티브 플러그인이 지원됩니다. 네이티브 LINE SDK와 통신하도록 하려면 Unity C# 부분과 네이티브 부분(iOS에선 Swift, Android에선 Java)을 연결하는 다리를 만들어야 합니다.

비동기(asynchronous) 작업 처리

iOS는 DllImport를 사용해서 공개된 인터페이스를 Objective-C++에서 Unity C#으로 임포트합니다. Android는 AndroidJavaObject를 사용해서 LINE SDK 네이티브에서 메서드를 호출합니다.

// iOS interface
[DllImport("__Internal")]
private static extern int foo();
public static int Foo() {
    return foo();
}
 
// Android interface
public static int Foo() {
    var androidObject = new AndroidJavaObject("com.linecorp.linesdk.SomeClass");
    return androidObject.Call<int>("foo");
}

값 반환 여부에 상관없이 모두 동기(synchronous) 방식으로 쉽게 API를 호출할 수 있는데요. LINE SDK에서는 로그인이나 그 밖의 네트워크 관련 조작을 위해 몇 가지 비동기 API도 제공하고 있습니다. 예를 들어, iOS에는 로그인 API에 다음과 같은 서명(signature)이 있습니다.

// Login method signature in LINE SDK Swift.
public func login(
        permissions: Set<LoginPermission> = [.profile],
        options: LoginManagerOptions = [],
        completionHandler completion: @escaping (Result<LoginResult, LineSDKError>) -> Void) -> LoginProcess?

로그인 결과를 처리하려면 일종의 비동기 콜백(callback)이 필요하며, 네이티브 플랫폼과 Unity 게임 간에 데이터를 전달해야 합니다.

복잡한 방법

iOS의 경우 C#과 C++ 간의 콜백은 보통 Marshal 클래스를 이용해서 이루어집니다. 이 클래스는 관리되지 않는(unmanaged) 메모리를 할당하고, 관리되지 않는 메모리 블록을 복사하고, 관리되는 형식을 관리되지 않는 형식으로 변환하는 메서드 집합을 제공합니다. 예를 들어, C# delegate를 관리되지 않는 함수 포인터로 변환하면 C# 메서드를 네이티브로 전달할 수 있습니다. 또한 함수 포인터의 파라미터도 관리되지 않는 형식의 포인터로 표현할 수 있습니다.

// Defines a Marshal function pointer to receive callback method from the native side.
delegate void Callback(IntPtr foo);
[DllImport(DllLib)]
private static extern void method([MarshalAs(UnmanagedType.FunctionPtr)] Callback callback);
public static void Method() {
    method(CBMethod);
}
 
[MonoPInvokeCallback(typeof(Callback))]
private static void CBMethod(IntPtr foo) {
    // Convert Int pointer foo to a managed string
    string fooString = Marshal.PtrToStringAuto(foo);
    Debug.Log("Parameter is " + fooString);
}

네이티브 C++(보통 Objective-C++로 작성)에서는 extern method를 다음과 같이 정의합니다.

// Defines and invokes callback in the native side.
typedef void (*CallbackT)(const char *foo);
 
extern "C" void method(CallbackT callback);
void method(CallbackT callback) {
    // Do something asynchronously. Then call `callback`.
    //...
    callback("foo sent");
}

Unity에서 Method()를 호출하면, 네이티브에서 비동기 작업이 종료된 후 파라미터 ‘foo sent‘와 함께 CBMethod()가 호출됩니다.

Android에서는 비동기 작업의 결과를 얻기 위해 리스너(listener) 인터페이스가 있는 옵저버 패턴을 사용하곤 합니다. 예를 들어, LINE SDK for Android에는 LoginListener가 있습니다.

// Listener interface for observing login result in the LINE SDK for Android.
public interface LoginListener {
    void onLoginSuccess(@NonNull LineLoginResult result);
    void onLoginFailure(@Nullable LineLoginResult result);
}

Unity에서 Java 인터페이스를 표현하려면 AndroidJavaProxy를 사용하는 게 가장 좋습니다. AndroidJavaProxy를 상속한 클래스를 구현한 후 리스너 파라미터를 사용해서 적절한 Android API에 전달합니다.

// Login proxy in Unity C#, which behaves as a native listener.
class LoginCallback : AndroidJavaProxy
{
    public LoginCallback() : base("com.linecorp.linesdk.$LoginListener") {}
    void onLoginSuccess(AndroidJavaObject result) {
        Debug.Log("Login successfully.");
    }
 
    void onLoginFailure(AndroidJavaObject result) {
        Debug.Log("Login failed.");
    }
}
 
androidObject.Call<int>("login", new LoginCallback());

Marshal과 AndroidJavaProxy 클래스는 유용합니다. 네이티브 파트와 Unity 파트를 연결하는 다리 역할로도 활용할 수 있습니다. 다만, LINE SDK의 규모와 여러 플랫폼을 지원해야 한다는 점을 고려하면 이 두 가지 방법은 사실 이상적인 방법은 아닙니다. 다음과 같은 몇 가지 단점이 있기 때문입니다.

  1. 관리되지 않는 메모리와 형식이 지정되지 않은 객체를 사용합니다. Marshal 클래스는 관리되지 않는 포인터에서 작동하며, AndroidJavaObject 또는 AndroidJavaProxy 클래스는 형식이 지정되지 않은 객체에서 작동합니다. 둘 다 C#의 규칙을 따르지 않습니다.
  2. SDK 확장이 어렵습니다. 기능을 추가하려면 네이티브와 Unity 모두에 형식 정의를 추가해야 해서, SDK를 변경하면 작업량이 두 배가 됩니다.
  3. iOS와 Android가 각각 방식이 달라서 멘탈 모델이 복잡해집니다.
쉬운 방법

저희는 Marshal과 AndroidJavaProxy 클래스를 활용하는 대신 훨씬 더 간단한 모델을 사용해서 비동기 콜백을 처리합니다. 그 모델은 바로 네이티브 쪽의 간단한 메서드인 UnitySendMessage 메서드입니다. 이 메서드는 토큰을 식별자로 사용하고, 데이터는 JSON 직렬화 파라미터를 사용해서 전달합니다. 

Unity 엔진 런타임은 iOS와 Android 양쪽에 UnitySendMessage 메서드를 공개해서 메시지를 임의의 게임 객체(GameObject)로 전송합니다. 게임 객체에 메시지와 동일한 이름의 메서드가 있다면, 그 메서드를 호출하여 메시지에 응답할 수 있습니다. 이런 방식으로 네이티브 쪽에서 Unity의 메서드를 호출할 수 있습니다.

// Send a message from native to Unity.
UnitySendMessage("GameObjectName", "MethodName", "Message to send");

위 메서드에는 다음과 같은 3개의 파라미터가 있습니다.

  • 대상 GameObject의 이름
  • 해당 객체를 호출하기 위한 스크립트 메서드
  • 호출된 메서드에 전달할 메시지 문자열

Unity에서 이 메시지에 응답하려면, GameObjectName이란 이름의 게임 객체에 스크립트를 추가해야 하는데요. 이 스크립트는 단일 string 파라미터를 가지는 MethodName이란 이름의 메서드를 포함해야 합니다.

// This script should be added as a component on "GameObjectName".
class MyClass: MonoBehaviour {
    void MethodName(string parameter) {
        //...
        Debug.Log(parameter);
        // "Message to send"
    }
}

식별자를 사용해서 네이티브 API를 호출하고, 동일한 식별자를 UnitySendMessage 메서드를 통해 반환하면 비동기 작업을 호출한 주체를 식별할 수 있습니다. iOS의 경우 기본 개념은 아래와 같습니다.

// Passing the `identifier` to track asynchronous operation caller.
 
// Unity - interface for the SDK users.
public static void Method() {
    // Generates an identifier internally.
    var identifier = Guid.NewGuid().ToString();
    Foo(identifier);
}
 
// Unity - wrapper
[DllImport("__Internal")]
private static extern void foo(string identifier);
public static int Foo(string identifier) {
    foo(identifier);
}
 
// iOS
extern "C" void foo(const char *identifier);
void foo(const char *identifier) {
    // Do something asynchronously. Then call `UnitySendMessage` with `identifier`.
    //...
    UnitySendMessage("NativeListener", "CallbackMethod", identifier);
}
 
// In Unity, there is a game object with the name of `NativeListener`.
void CallbackMethod(string identifier) {
    // We know who initializes the operation by checking `identifier`.
    // Do something with the callback from native side.
}

전달받은 identifier를 확인하면 특정 콜백을 어떻게 처리할지 결정할 수 있습니다. 이렇게 해서 Unity와 네이티브가 비동기 방식으로 통신할 수 있게 됩니다.

JSON과 Serializable로 데이터 전달

UnitySendMessage 메서드를 사용하면 오직 하나의 문자열 값만 주고 받을 수 있습니다. 위의 예에서는 identifier 값을 반환했습니다. 하지만 LINE SDK에서는 일부 값을 네이티브 형식으로 Unity로 전달하는 것도 필요합니다. 예를 들어, 토큰 값 문자열, 만료기간, 토큰 범위(scope) 등을 포함한 액세스 토큰이 있습니다.

따라서 네이티브와 Unity에서 모두 해석할 수 있는 포맷으로 데이터를 직렬화하는 방법이 필요합니다. 이렇게 서로 다른 언어 간에 데이터를 교환해야 할 때는 자연스럽게 JSON을 선택하게 됩니다. JSON은 이해하기 쉽고 네이티브와 Unity 양쪽에서 완벽하게 지원됩니다. LINE SDK for Unity에서는 콜백 페이로드(payload)를 다음과 같은 형식으로 정의합니다.

// Shared structure for token between native side and Unity.
{
  "identifier": "abcdefg...", // The received GUID from Unity.
  "value": {                  // A nested object represents native object.
    "token": "...",
    ...
  }
}

그냥 identifier를 반환하는 것이 아니라, 우리에게 중요한 실제 데이터를 표현하는 value와 조합하여 JSON 문자열로 직렬화한 뒤 전송합니다.

// Serialize a native object to JSON, then send it as a message parameter to Unity.
void foo(const char *identifier) {
    // Do something asynchronously.
    // Then call `UnitySendMessage` with `identifier` and the actual data.
 
    [loginManager loginWithCompletionHandler:^(LineSDKLoginResult * result, NSError *error) {
        NSDictionary *dic = @{
            @"identifier": convertToNSString(identifier),
            @"value": [result toDictionary]
        };
 
        NSData *data = [NSJSONSerialization dataWithJSONObject:dic options:kNilOptions error:nil];
        const char* payload = convertToCString([[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]);
 
        UnitySendMessage("NativeListener", "CallbackMethod", payload);
    }];
}

Unity에선 JsonUtility를 사용해서 JSON 문자열을 역직렬화한 후 특정 형식의 C#에서 관리하는(managed) 객체로 다시 변환합니다. 이런 방법으로 아래 그림과 같이 임의의 데이터를 네이티브와 Unity 게임 간에 주고 받을 수 있습니다.

iOS에서 LINE SDK 통합(Swift)

저희는 2018년에 LINE SDK for iOS 버전 5를 Objective-C와 Swift로 릴리스했고 앞으로 이 SDK를 지속적으로 발전시켜 나갈 예정입니다. Swift로 작업하는 것은 좋지만, Swift는 아직 모듈의 안정성이 확보되지 않았기 때문에(Swift 5에서도 동일) Swift SDK를 바이너리 형식으로 제공하는 것은 불가능합니다. 

네이티브 Swift SDK를 Unity 프로젝트에 통합하려면 소스코드의 의존성을 해결하고 관리할 수 있는 방법이 필요합니다. 또한 Unity는 iOS 프로젝트를 주로 Objective-C++로 내보내고, 네이티브의 인터페이스는 C로 작성되어야 하기 때문에 C와 C++, Objective-C, Swift 간의 상호운용성도 과제입니다. 이런 몇 가지 과제에 대해 아래에서 설명하겠습니다.

네이티브 SDK 설치

Unity에서는 ‘바이너리 라이브러리 내보내기(export)’를 잘 지원하고 있습니다. 바이너리 파일은 Assets/Plugins/iOS 폴더 아래에 위치합니다. 하지만, Swift SDK를 선택했기 때문에 개발자의 기기에 설치된 Swift 툴체인(toolchain)을 사용해서 소스부터 빌드해야 합니다. 네이티브 SDK와 유사하게 의존성 관리 도구로 CocoaPods나 Carthage를 선택할 수 있습니다. 모든 통합 작업은 포스트 프로세스 스크립트가 수행합니다.

PostProcessBuildAttribute 속성은 Unity의 내보내기가 종료되는 시점에 훅(hook)을 제공합니다. 훅을 이용하면 의존성을 설정하는 스크립트를 실행하고 최종 프로젝트를 구성할 수 있습니다. LINE Swift SDK는 오픈소스 프로젝트입니다. 따라서 CocoaPods나 Carthage를 통해 SDK를 설치하는 건 LINE SDK를 일반적인 iOS 프로젝트에 추가하는 것과 별 차이가 없습니다.

다음과 같이 iOS 프로젝트에 SDK를 추가할 수 있습니다.

  1. Podfile 또는 Cartfile을 내보내기한 프로젝트의 루트에 배치합니다.
  2. 터미널에서 pod install 또는 carthage update를 실행하면, 네이티브 SDK의 소스코드가 GitHub에서 다운로드되고 필요하면 컴파일됩니다.
  3. 프레임워크 연결, 빌드 설정 변경 등 추가로 필요한 설정을 합니다.

흥미로운 점은 거의 모든 설정 작업에서 Unity의 Xcode API를 사용할 수 있다는 점입니다. 모든 API는 Unity의 UnityEditor.iOS.Xcode 네임스페이스(namespace) 아래 존재하며, 이를 통해 내보내기한 Xcode 프로젝트를 Unity 에디터에서 C# 코드로 조작할 수 있습니다.

예를 들어, Xcode 프로젝트의 빌드 설정을 변경하려면 PBXProject 객체를 생성한 후 그 객체에 SetBuildProperty를 호출하면 됩니다.

// Set a build setting value in exported Xcode project.
 
var path = PBXProject.GetPBXProjectPath(projectRoot);
var project = new PBXProject();
project.ReadFromFile(path);
 
project.SetBuildProperty(target, "ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES", "YES");

Unity Xcode API를 사용하면 네이티브 SDK를 통합하기 위해 필요한 대부분의 설정 작업(링크 단계에 프레임워크 추가, 콜백 URL 스킴 설정 등)을 자동화할 수 있습니다.

상호운용성

iOS의 LINE SDK for Unity를 사용할 땐 다양한 언어로 작업해야 합니다. 네이티브 SDK의 로직은 순수한 Swift로 구현되어 있는데요. 호환되지 않는 몇 가지 언어 기능(Swift의 enum과 struct 등)이 포함되어 Objective-C에서 직접 사용할 수 없습니다.

Swift SDK를 Objective-C에서 사용하기 위해 래퍼(wrapper)를 추가로 준비했습니다. 이 래퍼도 Swift로 작성됐지만 Objective-C와 호환되는 언어 기능만 사용되었습니다. Unity로 작업할 때, 내보내기한 프로젝트가 실제론 Objective-C++ 프로젝트이기 때문에 이 래퍼와 통신해야 합니다. 즉, 다음과 같은 처리가 필요합니다.

  • 네이티브의 Swift SDK를 C++ 프로젝트에 가져오기 위한 clang 모듈을 활성화한다.
  • Swift 라이브러리를 번들에 삽입한다.
  • 런타임에 정확한 검색 경로를 설정한다.

이 처리를 앞서 말한 Unity Xcode API로 수행할 수 있습니다.

// Enable clang module to use Swift SDK in Objective-C++.
project.SetBuildProperty(target, "CLANG_ENABLE_MODULES", "YES");
 
// Setup Swift environment.
// These two are only necessary when using Carthage. CocoaPods will help us to setup them.
project.SetBuildProperty(target, "ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES", "YES");
project.SetBuildProperty(target, "LD_RUNPATH_SEARCH_PATHS", "$(inherited) @executable_path/Frameworks");

Unity의 Xcode API를 사용해서 저희는 프로젝트 구성 시간을 대폭 단축시킬 수 있었습니다. 여러분도 이 API를 사용해서 프로젝트를 한층 더 깊이 커스터마이징해 보시길 추천합니다.

시스템 이벤트 수신

사용자가 자신의 기기에 이미 LINE 앱을 설치한 경우, LINE SDK는 그 LINE 앱을 인증 바로가기로 열 수 있습니다. 사용자명과 패스워드는 필요 없습니다. 이는 Unity 게임과 LINE 앱 간에 통신이 이루어지고 있다는 것을 의미합니다. 대부분의 경우 URL 스킴(scheme)을 사용해서 사용자의 인증 정보를 게임에 전달하는데요. LINE SDK for Unity에서는 그 이벤트를 얻을 방법이 필요합니다.

iOS 앱을 개발 중이라면 방법은 간단합니다. Objective-C 또는 Swift 소스코드에 몇 줄을 추가해서 커밋하면 됩니다. 하지만, Unity에서 작업하는 경우에는 Xcode 프로젝트를 Unity에서 내보낼 때마다 프로젝트가 덮어쓰기됩니다. 다행히도 Unity에서는 앱 열기 이벤트 알림에 등록하는 방법을 제공합니다.

// Code exported from Unity. It defines a listener interface for anyone to use.
@protocol AppDelegateListener<LifeCycleListener>
 
//...
 
// notification will be posted from
// - (BOOL)application:(UIApplication*)application openURL:(NSURL*)url sourceApplication:(NSString*)sourceApplication annotation:(id)annotation
// notification user data is the NSDictionary containing all the params
- (void)onOpenURL:(NSNotification*)notification;
 
@end

AppDelegateListener에 준거한 클래스를 생성한 후 UnityRegisterAppDelegateListener를 호출해서 알림 옵저버(observer)로 추가하면, 앞으로 발생하는 해당 이벤트를 얻을 수 있습니다.

// Register the app delegate listener to receive app life cycle events.
 
@interface LineSDKAppDelegateListener()<AppDelegateListener>
//...
@end
 
@implementation LineSDKAppDelegateListener
- (instancetype)init {
    self = [super init];
    if (self) {
        // ...
        UnityRegisterAppDelegateListener(self);
    }
    return self;
}
 
- (void)onOpenURL:(NSNotification *)notification {
    // Current app is opened (be navigated from LINE app).
}
@end

LINE의 GitHub 저장소에서 LineSDKAppDelegateListener 코드 전체를 확인할 수 있습니다.

Android에서 LINE SDK 통합

Android SDK 통합은 간단합니다. 저희는 Unity에서 기능을 쉽게 트리거(trigger)할 수 있도록 네이티브 LINE SDK API를 랩핑하는 unity-wrapper android 프로젝트를 제공하고 있습니다. 플러그인 바이너리는 미리 빌드되어 Assets/Plugins/Android 폴더에 저장되어 있습니다. unity-wrapper android 프로젝트에서 수정이 필요하면 언제든지 수정 후 바이너리를 다시 빌드할 수 있습니다.

커스텀 Gradle 템플릿을 사용해서 의존성 관리

Android 플랫폼용 Unity 프로젝트에서는 Gradle 빌드 시스템을 사용합니다. unity-wrapper 프로젝트와 LINE SDK 라이브러리에 필요한 모든 의존성을 포함시키려면 Unity 프로젝트에서 커스텀 Gradle 템플릿을 구성하는 것이 중요합니다.

Unity Player Settings의 Android settings 탭에 있는 Publishing Settings에서 Custom Gradle Template가 활성화되어 있는 걸 확인합니다. mainTemplate.gradle이라는 이름의 파일이 Assets/Plugins/Android 폴더에 생성됩니다.

템플릿 파일에 아래 내용을 추가해야 합니다.

  • buildscript 섹션
buildscript {
    ext.kotlin_version = '1.3.11'
    ...
 
    dependencies {
        ...
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
 
    }
}
  • dependencies 섹션
dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
 
    implementation 'com.linecorp:linesdk:5.0.1'
 
    implementation 'com.google.code.gson:gson:2.8.5'
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
 
    ...
}

통합에 관한 자세한 사항은 ‘LINE SDK for Unity 가이드‘를 참고하시기 바랍니다.

마치며

저희는 여러분이 제작할 훌륭한 게임 차기작이나 기존 게임에 LINE을 쉽게 추가할 수 있도록 LINE SDK for Unity를 만들었습니다. 더 통합하기 쉽고 더 사용하기 쉽게 만들기 위해 노력했습니다. LINE 로그인이나 그 밖의 LINE API를 Unity 게임에 통합하는 것에 관심 있으신 분들은 LINE SDK for Unity 관련 문서를 확인해 주시기 바랍니다.

LINE SDK for iOS나 LINE SDK for Android와 동일하게 LINE SDK for Unity도 오픈소스 프로젝트입니다. 문제점을 발견하거나 제안하고 싶은 사항이 있을 땐 부담 없이 저장소에서 이슈를 열어 코멘트를 입력해 주세요.