JavaScript와의 상호운용

JavaScript와의 상호운용

Kotlin/Wasm을 사용하면 Kotlin에서 JavaScript 코드를 사용하고, JavaScript에서 Kotlin 코드를 사용할 수 있어요.

Kotlin/JS와 마찬가지로 Kotlin/Wasm 컴파일러도 JavaScript와 상호운용이 가능해요. Kotlin/JS 상호운용에 익숙하다면, Kotlin/Wasm 상호운용이 비슷하다는 것을 알 수 있을 거예요. 다만 고려해야 할 핵심적인 차이점이 있습니다.

📝 참고 Kotlin/Wasm은 Beta 상태예요. 언제든 변경될 수 있습니다. 프로덕션 이전 시나리오에서 사용하세요. YouTrack에서 피드백을 남겨주시면 감사하겠습니다.

출처: Interoperability with JavaScript

본문

Kotlin에서 JavaScript 코드 사용하기

외부 선언, JavaScript 코드 스니펫이 있는 함수, @JsModule 애너테이션을 사용해 Kotlin에서 JavaScript 코드를 사용하는 방법을 알아봐요.

외부 선언

외부 JavaScript 코드는 기본적으로 Kotlin에서 보이지 않아요. Kotlin에서 JavaScript 코드를 사용하려면 외부 선언으로 그 API를 묘사할 수 있습니다.

JavaScript 함수

다음 JavaScript 함수를 살펴볼게요:

function greet (name) {
    console.log("Hello, " + name + "!");
}

Kotlin에서 이것을 외부 함수로 선언할 수 있어요:

external fun greet(name: String)

외부 함수에는 본문이 없으며, 일반적인 Kotlin 함수처럼 호출할 수 있습니다:

fun main() {
    greet("Alice")
}

JavaScript 프로퍼티

다음 전역 JavaScript 변수를 살펴봐요:

let globalCounter = 0;

Kotlin에서 외부 var 또는 val 프로퍼티로 선언할 수 있어요:

external var globalCounter: Int

이 프로퍼티는 외부에서 초기화됩니다. Kotlin 코드에서는 이 프로퍼티에 = 값 초기화를 사용할 수 없어요.

JavaScript 클래스

다음 JavaScript 클래스를 살펴볼게요:

class Rectangle {
    constructor (height, width) {
        this.height = height;
        this.width = width;
    }

    area () {
        return this.height * this.width;
    }
}

Kotlin에서 외부 클래스로 사용할 수 있어요:

external class Rectangle(height: Double, width: Double) : JsAny {
    val height: Double
    val width: Double
    fun area(): Double
}

외부 클래스 안의 모든 선언은 묵시적으로 외부 선언으로 간주됩니다.

외부 인터페이스

Kotlin에서 JavaScript 객체의 모양(shape)을 묘사할 수 있어요. 다음 JavaScript 함수와 그 반환 값을 살펴볼게요:

function createUser (name, age) {
    return { name: name, age: age };
}

이 모양을 Kotlin에서 외부 인터페이스 User 타입으로 어떻게 묘사하는지 볼게요:

external interface User : JsAny {
    val name: String
    val age: Int
}

external fun createUser(name: String, age: Int): User

외부 인터페이스는 런타임 타입 정보가 없고 순전히 컴파일 시점 개념이에요. 그래서 일반 인터페이스에 비해 몇 가지 제약이 있습니다:

  • is 검사의 오른쪽에 사용할 수 없어요.
  • 클래스 리터럴 표현식(예: User::class)에 사용할 수 없습니다.
  • reified 타입 인자로 전달할 수 없어요.
  • 외부 인터페이스로의 as 캐스팅은 항상 성공합니다.

외부 객체

객체를 담은 다음 JavaScript 변수들을 살펴볼게요:

let Counter = {
    value: 0,
    step: 1,
    increment () {
        this.value += this.step;
    }
};

Kotlin에서 외부 객체로 사용할 수 있어요:

external object Counter : JsAny {
    fun increment()
    val value: Int
    var step: Int
}

외부 타입 계층 구조

일반 클래스와 인터페이스처럼, 외부 선언이 다른 외부 클래스를 확장하고 외부 인터페이스를 구현하도록 선언할 수 있어요. 하지만 같은 타입 계층 구조에서 외부 선언과 비외부 선언을 섞을 수는 없습니다.

@nativeInvoke로 호출 가능한 JavaScript 객체

외부 선언(클래스나 인터페이스)의 Kotlin 멤버 함수에 @nativeInvoke 애너테이션을 사용하면, 그 함수를 JavaScript 함수처럼 호출할 수 있게 만들어요.

이 애너테이션을 쓰면 Kotlin에서 그 함수를 호출할 때마다 JavaScript 객체에 대한 직접 호출로 번역됩니다:

import kotlin.js.nativeInvoke

@OptIn(ExperimentalWasmJsInterop::class)
external class JsAction {
    @nativeInvoke
    operator fun invoke(data: String)
}

fun main() {
    val action = JsAction() 
    action("Run task")
}

📝 참고 @nativeInvoke 애너테이션은 안정적인 상호운용 설계가 나올 때까지의 임시 해결책이에요. 현재 @nativeInvoke를 사용하면 컴파일러가 경고를 보고합니다.

JavaScript 코드가 있는 Kotlin 함수

= js("code") 본문이 있는 함수를 정의해 Kotlin/Wasm 코드에 JavaScript 스니펫을 추가할 수 있어요:

fun getCurrentURL(): String =
    js("window.location.href")

일련의 JavaScript 문장을 실행하고 싶다면, 문자열 안의 코드를 중괄호 {}로 감싸세요:

fun setLocalSettings(value: String): Unit = js(
    """{
        localStorage.setItem('settings', value);
}"""
)

객체를 반환하고 싶다면 중괄호 {}를 괄호 ()로 감싸세요:

fun createJsUser(name: String, age: Int): JsAny =
    js("({ name: name, age: age })")

Kotlin/Wasm은 js() 함수 호출을 특별한 방식으로 처리하며, 그 구현에는 몇 가지 제약이 있어요:

  • js() 함수 호출에는 문자열 리터럴 인자를 제공해야 합니다.
  • js() 함수 호출은 함수 본문의 유일한 표현식이어야 해요.
  • js() 함수는 패키지 수준 함수에서만 호출할 수 있습니다.
  • 함수의 반환 타입을 명시적으로 제공해야 해요.
  • 타입이 external fun과 비슷하게 제한됩니다.

Kotlin 컴파일러는 코드 문자열을 생성된 JavaScript 파일의 함수에 넣고 WebAssembly 형식으로 import해요. Kotlin 컴파일러는 이런 JavaScript 스니펫을 검증하지 않습니다. JavaScript 구문 오류가 있으면 JavaScript 코드를 실행할 때 보고됩니다.

📝 참고 @JsFun 애너테이션도 비슷한 기능을 제공하며, 곧 더 이상 사용되지 않을(deprecated) 예정입니다.

JavaScript 모듈

기본적으로 외부 선언은 JavaScript 전역 범위(global scope)에 해당해요. Kotlin 파일에 @JsModule 애너테이션을 붙이면, 그 안의 모든 외부 선언이 지정된 모듈에서 import됩니다.

다음 JavaScript 코드 샘플을 살펴볼게요:

// users.mjs
export let maxUsers = 10;

export class User {
    constructor (username) {
        this.username = username;
    }
}

@JsModule 애너테이션으로 이 JavaScript 코드를 Kotlin에서 사용해 보세요:

// Kotlin
@file:JsModule("./users.mjs")

external val maxUsers: Int

external class User : JsAny {
    constructor(username: String)

    val username: String
}

배열 상호운용

JavaScript의 JsArray<T>를 Kotlin의 기본 배열이나 List 타입으로 복사할 수 있어요. 반대로 이런 Kotlin 타입을 JsArray<T>로 복사할 수도 있습니다.

JsArray<T>Array<T>로, 또는 그 반대로 변환하려면 사용 가능한 어댑터 함수 중 하나를 사용하세요.

제네릭 타입 간 변환 예시를 볼게요:

val list: List<JsString> =
    listOf("Kotlin", "Wasm").map { it.toJsString() }

// .toJsArray()를 사용해 List 또는 Array를 JsArray로 변환
val jsArray: JsArray<JsString> = list.toJsArray()

// .toArray()와 .toList()를 사용해 다시 Kotlin 타입으로 변환
val kotlinArray: Array<JsString> = jsArray.toArray()
val kotlinList: List<JsString> = jsArray.toList()

타입이 있는 배열(예: IntArrayInt32Array)을 Kotlin 대응 타입으로 변환하는 어댑터 함수도 비슷하게 제공돼요. 자세한 정보와 구현은 kotlinx-browser 저장소를 참고하세요.

타입 있는 배열 간 변환 예시를 볼게요:

import org.khronos.webgl.*

    // ...

    val intArray: IntArray = intArrayOf(1, 2, 3)
    
    // .toInt32Array()를 사용해 Kotlin IntArray를 JavaScript Int32Array로 변환
    val jsInt32Array: Int32Array = intArray.toInt32Array()
    
    // toIntArray()를 사용해 JavaScript Int32Array를 다시 Kotlin IntArray로 변환
    val kotlinIntArray: IntArray = jsInt32Array.toIntArray()

JavaScript에서 Kotlin 코드 사용하기

@JsExport 애너테이션을 사용해 JavaScript에서 Kotlin 코드를 사용하는 방법을 알아봐요.

@JsExport 애너테이션이 있는 함수

Kotlin/Wasm 함수를 JavaScript 코드에서 사용할 수 있게 하려면 @JsExport 애너테이션을 사용하세요:

// Kotlin/Wasm

@JsExport
fun addOne(x: Int): Int = x + 1

@JsExport 애너테이션으로 표시한 Kotlin/Wasm 함수는 생성된 .mjs 모듈의 기본 export에서 프로퍼티로 보입니다. 그러면 JavaScript에서 이 함수를 사용할 수 있어요:

// JavaScript

import exports from "./module.mjs"

exports.addOne(10)

Kotlin/Wasm 컴파일러는 Kotlin 코드에 있는 어떤 @JsExport 선언에서도 TypeScript 정의를 생성할 수 있어요. 이 정의를 IDE와 JavaScript 도구에서 사용하면 코드 자동 완성, 타입 검사 지원, 그리고 JavaScript와 TypeScript에서 Kotlin 코드를 더 쉽게 사용할 수 있습니다.

Kotlin/Wasm 컴파일러는 @JsExport 애너테이션으로 표시된 모든 최상위 함수를 수집하고, .d.ts 파일에 TypeScript 정의를 자동으로 생성해요.

TypeScript 정의를 생성하려면 build.gradle.kts 파일의 wasmJs{} 블록에 generateTypeScriptDefinitions() 함수를 추가하세요:

kotlin {
    wasmJs {
        binaries.executable()
        browser {
        }
        generateTypeScriptDefinitions()
    }
}

⚠️ 주의 Kotlin/Wasm에서 TypeScript 선언 파일 생성은 Experimental이에요. 언제든 제거되거나 변경될 수 있습니다.

타입 대응 관계

Kotlin/Wasm은 JavaScript 상호운용 선언의 시그니처에서 특정 타입만 허용해요. 이 제약은 external, = js("code"), @JsExport가 있는 선언에 모두 균일하게 적용됩니다.

Kotlin 타입이 JavaScript 타입과 어떻게 대응하는지 볼게요:

Kotlin JavaScript
Byte, Short, Int, Char, UByte, UShort, UInt Number
Float, Double Number
Long, ULong BigInt
Boolean Boolean
String String
반환 위치의 Unit undefined
함수 타입, 예: (String) -> Int Function
JsAny와 하위 타입 어떤 JavaScript 값
JsReference Kotlin 객체에 대한 불투명 참조
기타 타입 지원되지 않음

이 타입들의 nullable 버전도 사용할 수 있어요.

JsAny 타입

JavaScript 값은 Kotlin에서 JsAny 타입과 그 하위 타입으로 표현돼요.

Kotlin/Wasm 표준 라이브러리는 이 타입들 중 일부에 대한 표현을 제공합니다:

  • kotlin.js 패키지:
    • JsAny
    • JsBoolean, JsNumber, JsString
    • JsArray
    • Promise

외부 인터페이스나 클래스를 선언해 사용자 지정 JsAny 하위 타입을 만들 수도 있어요.

JsReference 타입

Kotlin 값은 JsReference 타입을 사용해 불투명 참조(opaque reference)로 JavaScript에 전달할 수 있어요.

예를 들어 이 Kotlin 클래스 User를 JavaScript에 노출하고 싶다면:

class User(var name: String)

toJsReference() 함수로 JsReference<User>를 만들고 JavaScript에 반환할 수 있어요:

@JsExport
fun createUser(name: String): JsReference<User> {
    return User(name).toJsReference()
}

이 참조는 JavaScript에서 직접 사용할 수 없고 빈 동결된 JavaScript 객체처럼 동작해요. 이 객체를 조작하려면 참조 값을 푸는 get() 메서드를 사용해 JavaScript에 더 많은 함수를 export해야 합니다:

@JsExport
fun setUserName(user: JsReference<User>, name: String) {
    user.get().name = name
}

클래스를 만들고 JavaScript에서 이름을 바꿀 수 있어요:

import UserLib from "./userlib.mjs"

let user = UserLib.createUser("Bob");
UserLib.setUserName(user, "Alice");

타입 파라미터

JavaScript 상호운용 선언은 상한이 JsAny나 그 하위 타입이라면 타입 파라미터를 가질 수 있어요. 예를 들어:

external fun <T : JsAny> processData(data: JsArray<T>): T

예외 처리

Kotlin try-catch 표현식을 사용해 Kotlin/Wasm 코드에서 JavaScript 예외를 잡을 수 있어요. 예외 처리는 다음과 같이 동작합니다:

  • JavaScript에서 던져진 예외: Kotlin 쪽에서 자세한 정보를 사용할 수 있어요. 이런 예외가 JavaScript로 다시 전파되면 더 이상 WebAssembly로 감싸지지 않습니다.
  • Kotlin에서 던져진 예외: JavaScript 쪽에서 일반적인 JS 오류로 잡을 수 있어요.

Kotlin 쪽에서 JavaScript 예외를 잡는 예시를 볼게요:

external object JSON {
    fun <T: JsAny> parse(json: String): T
}

fun main() {
    try {
        JSON.parse("an invalid JSON")
    } catch (e: JsException) {
        println("Thrown value is: ${e.thrownValue}")
        // SyntaxError: Unexpected token 'a', "an invalid JSON" is not valid JSON

        println("Message: ${e.message}")
        // Message: Unexpected token 'a', "an invalid JSON" is not valid JSON

        println("Stacktrace:")
        // Stacktrace:

        // 전체 JavaScript 스택 트레이스를 출력
        e.printStackTrace()
    }
}

이 예외 처리는 WebAssembly.JSTag 기능을 지원하는 최신 브라우저에서 자동으로 작동합니다:

  • Chrome 115+
  • Firefox 129+
  • Safari 18.4+

Kotlin/Wasm과 Kotlin/JS 상호운용의 차이점

Kotlin/Wasm 상호운용은 Kotlin/JS 상호운용과 공통점이 많지만, 고려해야 할 핵심적인 차이점이 있어요.

Kotlin/Wasm Kotlin/JS
외부 enum 외부 enum 클래스를 지원하지 않음 외부 enum 클래스를 지원함
타입 확장 비외부 타입이 외부 타입을 확장하는 것을 지원하지 않음 비외부 타입을 지원함
JsName 애너테이션 외부 선언에 붙일 때만 효과가 있음 일반 비외부 선언의 이름을 바꾸는 데 사용할 수 있음
js() 함수 js("code") 함수 호출은 패키지 수준 함수의 단일 표현식 본문으로 허용됨 js("code") 함수를 어떤 컨텍스트에서든 호출할 수 있으며 dynamic 값을 반환함
모듈 시스템 ES 모듈만 지원함. @JsNonModule 애너테이션의 대응물이 없음. 기본 객체의 프로퍼티로 export를 제공함. 패키지 수준 함수만 export할 수 있음 ES 모듈과 레거시 모듈 시스템을 지원함. 이름 있는 ESM export를 제공함. 클래스와 객체를 export할 수 있음
타입 모든 상호운용 선언(external, = js("code"), @JsExport)에 더 엄격한 타입 제약을 균일하게 적용함. 선택된 몇 가지 내장 Kotlin 타입과 JsAny 하위 타입을 허용함 외부 선언에 모든 타입을 허용함. @JsExport에서 사용할 수 있는 타입을 제한함
Long 타입이 JavaScript BigInt에 대응함 JavaScript에서 사용자 지정 클래스로 보임
배열 아직 상호운용에서 직접 지원하지 않음. 대신 새 JsArray 타입을 사용할 수 있음 JavaScript 배열로 구현됨
기타 타입 Kotlin 객체를 JavaScript에 전달하려면 JsReference<>가 필요함 외부 선언에서 비외부 Kotlin 클래스 타입 사용을 허용함
예외 처리 JsExceptionThrowable 타입으로 어떤 JavaScript 예외든 잡을 수 있음 Throwable 타입으로 JavaScript Error를 잡을 수 있음. dynamic 타입으로 어떤 JavaScript 예외든 잡을 수 있음
동적 타입 dynamic 타입을 지원하지 않음. 대신 JsAny를 사용하세요 (아래 샘플 코드 참고) dynamic 타입을 지원함

📝 참고 형식이 없거나 느슨하게 형식화된 객체와의 상호운용을 위한 Kotlin/JS dynamic 타입은 Kotlin/Wasm에서 지원되지 않아요. dynamic 타입 대신 JsAny 타입을 사용할 수 있습니다:

// Kotlin/JS
fun processUser(user: dynamic, age: Int) {
    // ...
    user.profile.updateAge(age)
    // ...
}

// Kotlin/Wasm
private fun updateUserAge(user: JsAny, age: Int): Unit =
    js("{ user.profile.updateAge(age); }")

fun processUser(user: JsAny, age: Int) {
    // ...
    updateUserAge(user, age)
    // ...
}

웹 관련 브라우저 API

kotlinx-browser 라이브러리는 JavaScript 브라우저 API를 제공하는 독립 실행형 라이브러리예요. 다음을 포함합니다:

  • org.khronos.webgl 패키지:
    • Int8Array 같은 타입 있는 배열
    • WebGL 타입
  • org.w3c.dom.* 패키지:
    • DOM API 타입
  • kotlinx.browser 패키지:
    • windowdocument 같은 DOM API 전역 객체

kotlinx-browser 라이브러리의 선언을 사용하려면 프로젝트의 빌드 구성 파일에 의존성으로 추가하세요:

val wasmJsMain by getting {
    dependencies {
        implementation("org.jetbrains.kotlinx:kotlinx-browser:0.3")
    }
}

더 알아보기