Kotlin/Native 동적 라이브러리 만들기 – 튜토리얼

Kotlin/Native 동적 라이브러리 만들기 – 튜토리얼

Kotlin/Native를 사용하면 기존 프로그램에서 Kotlin 코드를 쓸 수 있도록 동적 라이브러리(dynamic library)를 만들 수 있어요. 이렇게 하면 JVM, Python, Android 등 다양한 플랫폼이나 언어 전반에 걸쳐 코드를 공유할 수 있습니다.

출처: Kotlin/Native as a dynamic library – tutorial

본문

이미 있는 네이티브 애플리케이션이나 라이브러리에서 Kotlin/Native 코드를 사용할 수 있어요. 그러려면 Kotlin 코드를 .so, .dylib, .dll 형식의 동적 라이브러리로 컴파일해야 합니다.

이 튜토리얼에서는 다음을 배우게 됩니다:

  • Kotlin 코드를 동적 라이브러리로 컴파일하기
  • 생성된 C 헤더 살펴보기
  • C에서 Kotlin 동적 라이브러리 사용하기
  • 프로젝트 컴파일하고 실행하기

커맨드 라인을 직접 써서 Kotlin 라이브러리를 만들 수도 있고, .sh.bat 같은 스크립트 파일을 이용할 수도 있어요. 다만 이 방식은 수백 개의 파일과 라이브러리를 가진 큰 프로젝트에서는 확장성이 떨어집니다. 빌드 시스템을 사용하면 Kotlin/Native 컴파일러 바이너리와 전이 의존성이 있는 라이브러리를 다운로드·캐시하고, 컴파일러와 테스트를 실행하는 과정이 단순해져요. Kotlin/Native는 Kotlin Multiplatform 플러그인을 통해 Gradle 빌드 시스템을 사용할 수 있습니다.

Gradle을 써서 Kotlin/Native와 Kotlin Multiplatform의 고급 C interop 관련 사용법을 살펴보겠습니다.

Mac을 쓰면서 macOS나 다른 Apple 타깃용 애플리케이션을 만들고 실행하려면 Xcode Command Line Tools를 먼저 설치하고 실행한 뒤 라이선스 조건에 동의해야 합니다.

Kotlin 라이브러리 만들기

Kotlin/Native 컴파일러는 Kotlin 코드로부터 동적 라이브러리를 만들어낼 수 있어요. 동적 라이브러리에는 보통 .h 헤더 파일이 딸려 나오는데, 이 파일을 통해 C에서 컴파일된 코드를 호출하게 됩니다.

Kotlin 라이브러리를 만들고 C 프로그램에서 사용해 볼게요.

새로운 Kotlin/Native 프로젝트를 만들고 IntelliJ IDEA에서 여는 방법에 대한 자세한 첫 단계는 Get started with Kotlin/Native 튜토리얼을 참고하세요.

  1. src/nativeMain/kotlin 디렉터리로 이동해서 다음 내용으로 lib.kt 파일을 만듭니다:
package example

object Object { 
    val field = "A"
}

class Clazz {
    fun memberFunction(p: Int): ULong = 42UL
}

fun forIntegers(b: Byte, s: Short, i: UInt, l: Long) { }
fun forFloats(f: Float, d: Double) { }

fun strings(str: String) : String? {
    return "That is '$str' from C"
}

val globalString = "A global String"
  1. build.gradle(.kts) Gradle 빌드 파일을 다음 내용으로 업데이트합니다:
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget

plugins {
    kotlin("multiplatform") version "2.4.20"
}

repositories {
    mavenCentral()
}

kotlin {
    macosArm64()    // macOS on Apple Silicon
    // linuxArm64() // Linux on ARM64 platforms
    // linuxX64()   // Linux on x86_64 platforms
    // mingwX64()   // on Windows

    targets.withType<KotlinNativeTarget>().configureEach {
        binaries {
            sharedLib {
                baseName = "native"       // macOS
                // baseName = "native"    // Linux
                // baseName = "libnative" // Windows
            }
        }
    }
}

tasks.wrapper {
    gradleVersion = "9.7.0"
    distributionType = Wrapper.DistributionType.ALL
}
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget

plugins {
    id 'org.jetbrains.kotlin.multiplatform' version '2.4.20'
}

repositories {
    mavenCentral()
}

kotlin {
    macosArm64()    // Apple Silicon macOS
    // linuxArm64() // Linux on ARM64 platforms
    // linuxX64()   // Linux on x86_64 platforms
    // mingwX64()   // Windows

    targets.withType(KotlinNativeTarget).configureEach {
        binaries {
            sharedLib {
                baseName = "native"       // macOS
                // baseName = "native"    // Linux
                // baseName = "libnative" // Windows
            }
        }
    }
}

wrapper {
    gradleVersion = "9.7.0"
    distributionType = "ALL"
}

binaries {} 블록은 프로젝트가 동적 또는 공유 라이브러리를 생성하도록 설정합니다. libnative는 라이브러리 이름이자 생성되는 헤더 파일 이름의 접두사로 쓰여요. 헤더 파일 안의 모든 선언에도 이 접두사가 붙습니다.

  1. 라이브러리를 빌드하려면 IDE에서 linkDebugShared<YourTargetName> Gradle 태스크를 실행하거나 터미널에서 콘솔 명령을 실행합니다. 이 예시에서는 다음과 같아요:
./gradlew linkDebugSharedMacosArm64

빌드가 끝나면 build/bin/<yourTargetName>/debugShared 디렉터리에 다음 파일들이 생성됩니다:

  • macOS: libnative_api.hlibnative.dylib
  • Linux: libnative_api.hlibnative.so
  • Windows: libnative_api.h, libnative.def, libnative.dll

linkNative Gradle 태스크를 쓰면 라이브러리의 debugrelease 두 변형을 모두 생성할 수도 있습니다.

Kotlin/Native 컴파일러는 모든 플랫폼에서 .h 파일을 만드는 데 동일한 규칙을 사용해요. 이제 Kotlin 라이브러리의 C API를 살펴보겠습니다.

생성된 헤더 파일

Kotlin/Native 선언이 C 함수로 어떻게 매핑되는지 살펴볼게요.

build/bin/<yourTargetName>/debugShared 디렉터리에서 libnative_api.h 헤더 파일을 열어 보세요. 맨 첫 부분에는 표준 C/C++ 헤더와 푸터가 들어 있습니다:

#ifndef KONAN_LIBNATIVE_H
#define KONAN_LIBNATIVE_H
#ifdef __cplusplus
extern "C" {
#endif

/// The rest of the generated code

#ifdef __cplusplus
}  /* extern "C" */
#endif
#endif  /* KONAN_LIBNATIVE_H */

그다음 libnative_api.h에는 공통 타입 정의가 들어 있는 블록이 포함됩니다:

#ifdef __cplusplus
typedef bool            libnative_KBoolean;
#else
typedef _Bool           libnative_KBoolean;
#endif
typedef unsigned short     libnative_KChar;
typedef signed char        libnative_KByte;
typedef short              libnative_KShort;
typedef int                libnative_KInt;
typedef long long          libnative_KLong;
typedef unsigned char      libnative_KUByte;
typedef unsigned short     libnative_KUShort;
typedef unsigned int       libnative_KUInt;
typedef unsigned long long libnative_KULong;
typedef float              libnative_KFloat;
typedef double             libnative_KDouble;
typedef float __attribute__ ((__vector_size__ (16))) libnative_KVector128;
typedef void*              libnative_KNativePtr;

Kotlin은 생성된 libnative_api.h 파일 안의 모든 선언에 libnative_ 접두사를 사용해요. 타입 매핑의 전체 목록은 다음과 같습니다:

Kotlin 정의 C 타입
libnative_KBoolean bool 또는 _Bool
libnative_KChar unsigned short
libnative_KByte signed char
libnative_KShort short
libnative_KInt int
libnative_KLong long long
libnative_KUByte unsigned char
libnative_KUShort unsigned short
libnative_KUInt unsigned int
libnative_KULong unsigned long long
libnative_KFloat float
libnative_KDouble double
libnative_KVector128 float attribute ((vector_size (16))
libnative_KNativePtr void*

libnative_api.h 파일의 정의 섹션은 Kotlin 기본 타입이 C 기본 타입으로 어떻게 매핑되는지 보여줍니다. Kotlin/Native 컴파일러가 모든 라이브러리에 대해 이 항목들을 자동으로 생성해요. 반대 방향 매핑은 Mapping primitive data types from C 튜토리얼에 설명되어 있습니다.

자동 생성된 타입 정의 다음에는 라이브러리에서 쓰이는 개별 타입 정의가 나옵니다:

struct libnative_KType;
typedef struct libnative_KType libnative_KType;

/// Automatically generated type definitions

typedef struct {
  libnative_KNativePtr pinned;
} libnative_kref_example_Object;
typedef struct {
  libnative_KNativePtr pinned;
} libnative_kref_example_Clazz;

C에서 typedef struct { ... } TYPE_NAME 문법은 구조체를 선언합니다.

이 패턴에 대한 자세한 설명은 이 StackOverflow 스레드를 참고하세요.

이 정의들을 보면 Kotlin 타입이 같은 패턴으로 매핑된다는 걸 알 수 있어요: Objectlibnative_kref_example_Object로, Clazzlibnative_kref_example_Clazz로 매핑됩니다. 모든 구조체는 포인터를 담은 pinned 필드만 갖고 있어요. libnative_KNativePtr 필드 타입은 파일 앞부분에서 void*로 정의됩니다.

C는 네임스페이스를 지원하지 않기 때문에 Kotlin/Native 컴파일러는 기존 네이티브 프로젝트의 다른 심볼과 충돌하지 않도록 긴 이름을 생성합니다.

서비스 런타임 함수

libnative_ExportedSymbols 구조체는 Kotlin/Native와 여러분의 라이브러리가 제공하는 모든 함수를 정의합니다. 패키지를 흉내 내기 위해 중첩 익명 구조체를 많이 사용해요. libnative_ 접두사는 라이브러리 이름에서 비롯됩니다.

libnative_ExportedSymbols에는 헤더 파일에 몇 가지 헬퍼 함수가 포함됩니다:

typedef struct {
  /* Service functions. */
  void (*DisposeStablePointer)(libnative_KNativePtr ptr);
  void (*DisposeString)(const char* string);

이 함수들은 Kotlin/Native 객체를 다룹니다. DisposeStablePointer는 Kotlin 객체에 대한 참조를 해제할 때, DisposeString은 C에서 char* 타입을 갖는 Kotlin 문자열을 해제할 때 호출돼요.

libnative_api.h 파일의 다음 부분은 런타임 함수의 구조체 선언으로 구성됩니다:

libnative_KBoolean (*IsInstance)(libnative_KNativePtr ref, const libnative_KType* type);

IsInstance 함수를 쓰면 Kotlin 객체(그 .pinned 포인터로 참조된)가 어떤 타입의 인스턴스인지 확인할 수 있어요. 생성되는 실제 연산 집합은 실제 사용법에 따라 달라집니다.

Kotlin/Native에는 자체 가비지 컬렉터가 있지만, C에서 접근하는 Kotlin 객체는 관리하지 않아요. 다만 Kotlin/Native는 Swift/Objective-C와의 상호운용을 제공하며, 가비지 컬렉터는 Swift/Objective-C ARC와 통합되어 있습니다.

라이브러리 함수

라이브러리에서 쓰이는 개별 구조체 선언을 살펴볼게요. libnative_kref_example 필드는 libnative_kref. 접두사를 붙여 Kotlin 코드의 패키지 구조를 흉내 냅니다:

typedef struct {
  /* User functions. */
  struct {
    struct {
      struct {
        struct {
          libnative_KType* (*_type)(void);
          libnative_kref_example_Object (*_instance)();
          const char* (*get_field)(libnative_kref_example_Object thiz);
        } Object;
        struct {
          libnative_KType* (*_type)(void);
          libnative_kref_example_Clazz (*Clazz)();
          libnative_KULong (*memberFunction)(libnative_kref_example_Clazz thiz, libnative_KInt p);
        } Clazz;
        const char* (*get_globalString)();
        void (*forFloats)(libnative_KFloat f, libnative_KDouble d);
        void (*forIntegers)(libnative_KByte b, libnative_KShort s, libnative_KUInt i, libnative_KLong l);
        const char* (*strings)(const char* str);
      } example;
    } root;
  } kotlin;
} libnative_ExportedSymbols;

코드는 익명 구조체 선언을 사용합니다. 여기서 struct { ... } foo는 이름이 없는 익명 구조체 타입의 필드를 바깥 구조체에 선언합니다.

C는 객체도 지원하지 않으므로 함수 포인터로 객체 의미론을 흉내 내요. 함수 포인터는 RETURN_TYPE (* FIELD_NAME)(PARAMETERS)로 선언합니다.

libnative_kref_example_Clazz 필드는 Kotlin의 Clazz를 나타냅니다. libnative_KULongmemberFunction 필드로 접근할 수 있어요. 유일한 차이는 memberFunction이 첫 번째 매개변수로 thiz 참조를 받는다는 점입니다. C는 객체를 지원하지 않기 때문에 thiz 포인터가 명시적으로 전달됩니다.

Clazz 필드(일명 libnative_kref_example_Clazz_Clazz)에는 생성자가 있어요. 이는 Clazz 인스턴스를 만드는 생성자 함수 역할을 합니다.

Kotlin의 object Objectlibnative_kref_example_Object로 접근할 수 있게 됩니다. _instance 함수는 객체의 유일한 인스턴스를 가져옵니다.

프로퍼티는 함수로 변환됩니다. get_set_ 접두사가 각각 게터와 세터 함수를 명명해요. 예를 들어 Kotlin의 읽기 전용 프로퍼티 globalString은 C에서 get_globalString 함수로 바뀝니다.

전역 함수 forFloats, forIntegers, stringslibnative_kref_example 익명 구조체 안의 함수 포인터로 바뀝니다.

진입점

이제 API가 어떻게 만들어지는지 알았으니, libnative_ExportedSymbols 구조체의 초기화가 시작점이 됩니다. libnative_api.h의 마지막 부분을 살펴볼게요:

extern libnative_ExportedSymbols* libnative_symbols(void);

libnative_symbols 함수를 쓰면 네이티브 코드에서 Kotlin/Native 라이브러리로 가는 관문을 열 수 있어요. 이것이 라이브러리에 접근하는 진입점입니다. 함수 이름의 접두사로는 라이브러리 이름이 사용됩니다.

반환된 libnative_ExportedSymbols* 포인터를 스레드별로 호스팅해야 할 수도 있습니다.

C에서 생성된 헤더 사용하기

C에서 생성된 헤더를 사용하는 것은 간단합니다. 라이브러리 디렉터리에 다음 코드로 main.c 파일을 만듭니다:

#include "libnative_api.h"
#include "stdio.h"

int main(int argc, char** argv) {
  // Obtain reference for calling Kotlin/Native functions
  libnative_ExportedSymbols* lib = libnative_symbols();

  lib->kotlin.root.example.forIntegers(1, 2, 3, 4);
  lib->kotlin.root.example.forFloats(1.0f, 2.0);

  // Use C and Kotlin/Native strings
  const char* str = "Hello from Native!";
  const char* response = lib->kotlin.root.example.strings(str);
  printf("in: %s\nout:%s\n", str, response);
  lib->DisposeString(response);

  // Create Kotlin object instance
  libnative_kref_example_Clazz newInstance = lib->kotlin.root.example.Clazz.Clazz();
  long x = lib->kotlin.root.example.Clazz.memberFunction(newInstance, 42);
  lib->DisposeStablePointer(newInstance.pinned);

  printf("DemoClazz returned %ld\n", x);

  return 0;
}

프로젝트 컴파일하고 실행하기

macOS에서

C 코드를 컴파일하고 동적 라이브러리와 링크하려면 라이브러리 디렉터리로 이동해서 다음 명령을 실행합니다:

clang main.c libnative.dylib

컴파일러는 a.out이라는 실행 파일을 만듭니다. 이걸 실행하면 C 라이브러리에서 Kotlin 코드가 실행됩니다.

Linux에서

C 코드를 컴파일하고 동적 라이브러리와 링크하려면 라이브러리 디렉터리로 이동해서 다음 명령을 실행합니다:

gcc main.c libnative.so

컴파일러는 a.out이라는 실행 파일을 만듭니다. 실행하면 C 라이브러리에서 Kotlin 코드가 동작해요. Linux에서는 애플리케이션이 현재 폴더에서 libnative.so 라이브러리를 로드하도록 LD_LIBRARY_PATH.을 포함해야 합니다.

Windows에서

먼저 x64_64 타깃을 지원하는 Microsoft Visual C++ 컴파일러를 설치해야 해요.

가장 쉬운 방법은 Windows 머신에 Microsoft Visual Studio를 설치하는 것입니다. 설치하는 동안 C++ 작업에 필요한 구성 요소를 선택하세요. 예를 들어 Desktop development with C++ 같은 워크로드 말이에요.

Windows에서 동적 라이브러리를 포함하려면 정적 라이브러리 래퍼를 생성하거나 LoadLibrary 같은 Win32API 함수를 수동으로 쓰면 됩니다.

첫 번째 옵션을 써서 libnative.dll용 정적 래퍼 라이브러리를 생성해 볼게요:

  1. 툴체인의 lib.exe를 호출해서 코드에서 DLL 사용을 자동화하는 정적 라이브러리 래퍼 libnative.lib를 생성합니다:
lib /def:libnative.def /out:libnative.lib
  1. main.c를 실행 파일로 컴파일합니다. 생성된 libnative.lib를 빌드 명령에 포함하고 시작합니다:
cl.exe main.c libnative.lib

이 명령은 실행할 수 있는 main.exe 파일을 만들어 냅니다.

더 알아보기