Kotlin/Native 동적 라이브러리 만들기 – 튜토리얼
Kotlin/Native 동적 라이브러리 만들기 – 튜토리얼
Kotlin/Native를 사용하면 기존 프로그램에서 Kotlin 코드를 쓸 수 있도록 동적 라이브러리(dynamic library)를 만들 수 있어요. 이렇게 하면 JVM, Python, Android 등 다양한 플랫폼이나 언어 전반에 걸쳐 코드를 공유할 수 있습니다.
본문
이미 있는 네이티브 애플리케이션이나 라이브러리에서 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 튜토리얼을 참고하세요.
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"
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는 라이브러리 이름이자 생성되는 헤더 파일 이름의 접두사로 쓰여요. 헤더 파일 안의 모든 선언에도 이 접두사가 붙습니다.
- 라이브러리를 빌드하려면 IDE에서
linkDebugShared<YourTargetName>Gradle 태스크를 실행하거나 터미널에서 콘솔 명령을 실행합니다. 이 예시에서는 다음과 같아요:
./gradlew linkDebugSharedMacosArm64
빌드가 끝나면 build/bin/<yourTargetName>/debugShared 디렉터리에 다음 파일들이 생성됩니다:
- macOS:
libnative_api.h와libnative.dylib - Linux:
libnative_api.h와libnative.so - Windows:
libnative_api.h,libnative.def,libnative.dll
linkNativeGradle 태스크를 쓰면 라이브러리의debug와release두 변형을 모두 생성할 수도 있습니다.
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 타입이 같은 패턴으로 매핑된다는 걸 알 수 있어요: Object는 libnative_kref_example_Object로, Clazz는 libnative_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_KULong은 memberFunction 필드로 접근할 수 있어요. 유일한 차이는 memberFunction이 첫 번째 매개변수로 thiz 참조를 받는다는 점입니다. C는 객체를 지원하지 않기 때문에 thiz 포인터가 명시적으로 전달됩니다.
Clazz 필드(일명 libnative_kref_example_Clazz_Clazz)에는 생성자가 있어요. 이는 Clazz 인스턴스를 만드는 생성자 함수 역할을 합니다.
Kotlin의 object Object는 libnative_kref_example_Object로 접근할 수 있게 됩니다. _instance 함수는 객체의 유일한 인스턴스를 가져옵니다.
프로퍼티는 함수로 변환됩니다. get_과 set_ 접두사가 각각 게터와 세터 함수를 명명해요. 예를 들어 Kotlin의 읽기 전용 프로퍼티 globalString은 C에서 get_globalString 함수로 바뀝니다.
전역 함수 forFloats, forIntegers, strings는 libnative_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용 정적 래퍼 라이브러리를 생성해 볼게요:
- 툴체인의
lib.exe를 호출해서 코드에서 DLL 사용을 자동화하는 정적 라이브러리 래퍼libnative.lib를 생성합니다:
lib /def:libnative.def /out:libnative.lib
main.c를 실행 파일로 컴파일합니다. 생성된libnative.lib를 빌드 명령에 포함하고 시작합니다:
cl.exe main.c libnative.lib
이 명령은 실행할 수 있는 main.exe 파일을 만들어 냅니다.
더 알아보기
- Swift/Objective-C와의 상호운용에 대해 더 알아보기
- Kotlin/Native as an Apple framework 튜토리얼 확인하기