Fluttrer Web에서 Javascript 유연하게 사용하기 (feat. JS interop의 A to Z)

병스커·2024년 11월 10일
0

Flutter

목록 보기
3/3
post-thumbnail

📌 이 글은 Flutter에서 JavaScript 코드를 다루는 2가지 방법을 다룹니다.

  • JS Interop
    • callMethod(dart:js)
    • promiseToFuture(dart:js_util)
    • @JS() 어노테이션(package:js)
  • JS Interop의 최신 방법
    • dart:js_interop
    • extension type
    • toDart

들어가며

Flutter Web에서 개발자가 작성한 Dart 코드는 JavaScript로 컴파일되어 브라우저에서 동작하며, CanvasKit을 통해 UI가 렌더링됩니다. 즉, Flutter Web의 코드는 결국 JavaScript 환경에서 동작하는 것이죠.

이러한 이유로 Flutter Web을 위해 Dart의 npm인 pub.dev JavaScript의 유용한 라이브러리를 래핑하여 제공하는 패키지들도 있습니다. 다만, 개인이 운영하는 경우가 많아 유지보수가 미약한 상황이 종종 발생하는데, 저 역시 flutter_web3 패키지를 사용할 때 이 문제를 겪었습니다.

NFT를 민팅하는 기능을 구현하기 위해 선택하여 사용했습니다. 이 패키지는 ethers.js를 래핑하여 Javascript에서 사용하던 인터페이스를 동일하게 사용 가능했죠.

민팅 기능에 앞서 스마트 컨트랙트를 배포해야 했습니다. 하지만 해당 패키지의 문서에서는 이와 관련된 내용이 미비했어요.

동료 분께서 해당 리포지토리에 이슈로 질문을 올렸지만, 답변이 오기까지는 기약이 없었습니다. 23년의 이슈도 여전히 있는 것을 보니 말 다했죠... flutter_web3ethers.js와 같이 대규모 커뮤니티가 아닌, 아직 개인이 소규모로 운영하는 오픈소스였기 때문에 어쩔 수 없는 한계라 여겼어요.

그런데, 동료분께서 Flutter 프레임워크에서도 Javascript 코드를 직접 작성하여 호출하는 방법이 있다는 것을 알려주셨어요!


JS Interop

JS 호출 방법 발견(feat. callMethod)

Dart에는 이미 Javascript를 호출할 수 있는 방법이 내장 패키지로 존재했습니다. 바로 dart:js에 말이죠!

This library provides access to JavaScript objects from Dart, allowing Dart code to get and set properties, and call methods of JavaScript objects and invoke JavaScript functions. The library takes care of converting between Dart and JavaScript objects where possible, or providing proxies if conversion isn't possible.

이 라이브러리는 Dart에서 JavaScript 객체에 대한 액세스를 제공하여 Dart 코드가 속성을 가져오고 설정하고, JavaScript 객체의 메서드를 호출하고, JavaScript 함수를 호출할 수 있도록 합니다. 이 라이브러리는 가능한 경우 Dart와 JavaScript 객체 간 변환을 처리하거나 변환이 불가능한 경우 프록시를 제공합니다.

dart:js library | Dart API

dart:jsJavaScript 코드를 Dart 코드에서 호출하고 제어할 수 있는 클래스와 메서드를 제공하는 라이브러리입니다.

문서에 다뤄진 용법에 따르면, 단순히 Javascript의 함수를 호출하는 방법은 정말 간단했습니다!

import 'dart:js';

main() => context.callMethod('alert', ['Hello from Dart!']);

이에 따라 저는 다음의 3단계 순서로 Dart에서 Javscript를 호출해보겠습니다!

1. Javascript 생성 및 함수 작성

Flutter 프로젝트를 생성했을 때 기본적으로 web 폴더가 존재하는 것을 알 수 있습니다. 보시다시피 JavaScript로 작성된 웹 프로젝트의 구조와 유사하죠!

Flutter Web 프로젝트의 web/index.html 파일은 Flutter 앱의 진입점 역할을 하며, Dart 코드는 여기서 컴파일된 JavaScript로 삽입되어 브라우저에서 실행됩니다. 때문에 추가할 Javascript 코드도 web 폴더 내부에 생성해야 합니다.

다음과 같이 간단한 함수 예제를 작성해보겠습니다.

// web/function.js

function showAlert(message) {
  alert(message);
}

function addNumbers(a, b) {
  return a + b;
}

이제 이 함수가 웹에서 사용될 수 있도록 해야겠죠!

2. index.html에 추가

웹 개발에서처럼 index.html에 <script> 태그를 사용해 직접 JavaScript 파일을 연결합니다.

<!DOCTYPE html>
<html>
  <head>
    <base href="$FLUTTER_BASE_HREF" />
    <meta charset="UTF-8" />
    <meta content="IE=Edge" http-equiv="X-UA-Compatible" />
    <meta name="description" content="A new Flutter project." />
    <meta name="apple-mobile-web-app-capable" content="yes" />
    <meta name="apple-mobile-web-app-status-bar-style" content="black" />
    <meta name="apple-mobile-web-app-title" content="flutter_js_sample" />
    <link rel="apple-touch-icon" href="icons/Icon-192.png" />
    <link rel="icon" type="image/png" href="favicon.png" />

    <title>flutter_js_sample</title>
    <link rel="manifest" href="manifest.json" />
  </head>
  <body>
    <script src="flutter_bootstrap.js" async></script>
    <script src="functions.js"></script> <!-- 작성한 JavaScript 삽입 -->
  </body>
</html>

이제 이 새로운 javascript를 호출하기 위한 준비 과정이 끝났습니다!


3. Dart에서 Javascript 호출하기

JavaScript를 호출할 준비로, Dart 코드에 dart:js 패키지를 import합니다. 아까 문서에서 보았던 가장 기본적인 용법, callMethod메서드를 사용할 차례입니다!

예제의 특성 상, Flutter의 엔트리포인트, main.dart에서 곧장 실행할 수 있도록 작성해보겠습니다.

import 'dart:js' as js;
import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  // Dart에서 JS 함수 호출
  void callJsFunctions() {
    // showAlert 함수 호출
    js.context.callMethod('showAlert', ['Hello from Dart!']);

    // addNumbers 함수 호출 및 결과 받기
    final result = js.context.callMethod('addNumbers', [5, 7]);
    print('Result from addNumbers: $result'); // 콘솔에 12 출력
  }

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter JS Sample',
      home: Scaffold(
        appBar: AppBar(
          title: const Text('Flutter JS Sample'),
        ),
        body: Center(
          child: ElevatedButton(
            onPressed: callJsFunctions,
            child: const Text('Call JS Functions'),
          ),
        ),
      ),
    );
  }
}

정말 간단합니다. 제가 한 거라곤 js.context.callMethodJavscript의 함수 이름을 인수로 넣어 호출한 것 뿐이에요!

예제에서 보시다시피, callMethod의 인수는 2개입니다. dart:js 패키지의 원본 코드를 살펴보니, callMethod 메서드가 받는 2개의 인수가 각각 어떤 역할을 하는지 확인할 수 있었습니다."

// js.dart

/// Calls [method] on the JavaScript object with the arguments [args] and
/// returns the result.
///
/// The type of [method] must be either [String] or [num].
external dynamic callMethod(Object method, [List? args]);
  • method: 호출할 JavaScript 함수의 이름
  • args: JavaScript 함수에 전달할 인수(optional)

또한 callMethod 메서드는 다음의 구성으로 이루어져 있었습니다.

js.context.callMethod

  • js: dart:js 불러오기
  • context: window 객체
  • callMethod: 함수 호출 메서드

네. 중간에 있는 context는 무엇에 대한 context인지 궁금하였는데, dart:js 패키지 코드의 설명에 따르면 Javascript의 global 객체인 window 를 의미한다는 것을 알 수 있었습니다.

즉, js.context를 통해 JavaScript의 글로벌 스코프에 접근하는 셈입니다. 이렇게 하면 앞서 정의한 showAlert, addNumbers 같은 함수를 window에 직접 추가한 것처럼 호출할 수 있게 되는 것이죠!

따라서 callMethod가 호출되는 과정은 이런 순서라 생각할 수 있습니다.

  1. js 패키지에 접근
  2. context를 통해 JavaScript의 글로벌 객체, 즉 window에 접근
  3. callMethod 메서드로 JavaScript 함수 호출
  4. 호출 결과 반환

그렇다면 비동기 함수 호출은 어떻게 해야할까요?

저의 상황에서도 그랬고, 클라이언트 측에서는 비동기 함수 호출이 필요한 상황이 빈번하죠.

  • 네트워크를 통해 데이터를 가져올 때
  • 데이터 베이스에 쓰기를 수행할 때
  • 파일로부터 데이터를 읽을 때

Javascript에서는 async/await를 이용하여 이를 수행할 수 있습니다.


async function fetchData() {
  // 간단하게 2초 후 데이터를 반환하는 Promise 사용
  console.log("JS Console!");
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve("Data fetched from JavaScript!");
    }, 2000);
  });
}

const result = await fetchData();

이 방법은 Dart에서도 물론 비동기 프로그래밍은 가능했어요. Future 클래스는 JavaScript의 Promise와 유사하게 비동기 작업을 다루며, async/await 문법도 동일하게 사용할 수 있습니다!

import 'dart:async';

void main() async {
  print('비동기 함수 호출 전');

  // 비동기 함수 호출
  final result = await fetchData();
  
  print('비동기 함수 호출 후');
  print('결과: $result');
}

// 2초 후 데이터를 반환하는 비동기 함수
Future<String> fetchData() async {
  return await Future.delayed(Duration(seconds: 2), () {
    return '데이터가 준비되었습니다!';
  });
}

그런데 이러한 비동기 함수를 호출할 때 좀 전과 같이, callMethod로 호출해주면 될까요?

정답은, 반은 맞고 반은 틀립니다.

한 가지가 더 필요했어요. 바로 js_util 패키지에 있는 promiseToFuture 메서드입니다! 이 패키지 또한 Dart 내장 패키지입니다.

설명에서와 같이, Javascript의 Promise를 Dart의 Future로 변환하는 메서드입니다.이 메서드를 통해 Dart에서 JavaScript 비동기 함수 호출의 결과를 받아올 수 있습니다.

즉, Promise 객체를 반환하는 Javascrit의 비동기 함수를 Future로 변환하여 호출할 수 있다는 것이죠.

promiseToFuture(js.context.callMethod('jsAsyncFunction');

이제 Dart에서 호출해 볼 차례입니다. 순서는 일반 함수 호출과 동일합니다.


1. JavaScript에 비동기 함수 정의

위 예제와 동일합니다.

// web/functions.js

async function fetchData() {
  // 간단하게 2초 후 데이터를 반환하는 Promise 사용
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve("Data fetched from JavaScript!");
    }, 2000);
  });
}

2. index.html에 추가(이미 import한 파일이라면 생략)

일반 함수 호출의 예제에서 이미 연결하였으므로, 생략합니다.

<!DOCTYPE html>
<html>
  <head>
    <base href="$FLUTTER_BASE_HREF" />
    <meta charset="UTF-8" />
    <meta content="IE=Edge" http-equiv="X-UA-Compatible" />
    <meta name="description" content="A new Flutter project." />
    <meta name="apple-mobile-web-app-capable" content="yes" />
    <meta name="apple-mobile-web-app-status-bar-style" content="black" />
    <meta name="apple-mobile-web-app-title" content="flutter_js_sample" />
    <link rel="apple-touch-icon" href="icons/Icon-192.png" />
    <link rel="icon" type="image/png" href="favicon.png" />

    <title>flutter_js_sample</title>
    <link rel="manifest" href="manifest.json" />
  </head>
  <body>
    <script src="flutter_bootstrap.js" async></script>
    <script src="functions.js"></script> <!-- 본 예제에서는 이미 존재 -->

  </body>
</html>

3. Dart에서 Javascript 비동기 함수 호출

이번에는 dart:js_util 패키지가 추가되었습니다.

import 'dart:js' as js;
import 'dart:js_util';
import 'package:flutter/material.dart';

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  // JavaScript 함수 호출을 위한 비동기 함수
  Future<void> callJsAsyncFunction() async {
    try {
      // JavaScript의 fetchData() 함수를 호출하고, Promise 결과를 Future로 변환
      final result = await promiseToFuture(js.context.callMethod('fetchData'));
      print('JavaScript 함수 결과: $result');
    } catch (e) {
      print(e);
    }
  }

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter JS Sample',
      home: Scaffold(
        appBar: AppBar(
          title: const Text('Flutter JS Sample'),
        ),
        body: Center(
    	    ElevatedButton(
	    	    onPressed: callJsAsyncFunction,
        		child: const Text('Call JS Async Function'),
        	),
        ),
      ),
    );
  }
}

그런데 이런 에러가 나타났습니다…!

NoSuchMethodError: tried to call a non-function, such as null: 'jsPromise.then'

직역해보았어요.

NoSuchMethodError: tried to call a non-function, such as null: 'jsPromise.then'
NoSuchMethodError: null과 같은 비함수 호출을 시도했습니다: 'jsPromise.then'

함수가 아닌 것을 호출하려 했다니? 막연합니다. 구체적으로 NoSuchMethodError가 무엇을 의미하는지 공식 문서로 들어가보았어요.


NoSuchMethodError란?

Invoked when a nonexistent method or property is accessed.

존재하지 않는 메서드나 속성에 액세스할 때 호출됩니다.


A dynamic member invocation can attempt to call a member which doesn't exist on the receiving object.

동적 멤버 호출은 수신 개체에 존재하지 않는 멤버를 호출하려고 시도할 수 있습니다.

📌 출처: noSuchMethod method | Dart API

이 내용을 토대로 저의 케이스를 대입해보면, Dart가 jsPromise 객체에서 then 메서드를 호출하려고 시도했으나, jsPromise가 null 또는 Promise 타입이 아닌 경우이기 때문에 발생했다는 것이죠.

callMethod가 JavaScript의 비동기 함수를 호출하지 못한 것일까요?

먼저, JavaScript에서 함수가 정상적으로 호출되는지 확인해보기 위해 콘솔 로그를 추가해보았어요.

async function fetchData() {
  console.log("JS Console!"); // 디버깅 로그 추가
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve("Data fetched from JavaScript!");
    }, 2000);
  });
}

JavaScript의 함수는 잘 호출되었습니다!


그런데 왜 Dart는 이를 감지하지 못하는 것일까요?

()
external Promise<num> get threePromise; // Resolves to 3

void main() async {
  final Future<num> threeFuture = promiseToFuture(threePromise);

  final three = await threeFuture; // == 3
}

가만...! promiseToFuture에 대한 문서의 내용을 다시 보니 간과한 것이 있었습니다. @JS()라는 어노테이션이 있었어요!
문서를 대강 읽은 폐해입니다... 그리고 사실

promiseToFuture에 커서만 올려도 툴팁에 동일한 내용있었죠.

똑같이 적용해보았어요.

import 'package:flutter/material.dart';
import 'dart:js_util'; 
import 'package:js/js.dart'; // JS() 어노테이션을 사용하기 위해 추가

()
external dynamic fetchData(); // JavaScript의 fetchData 함수 정의

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  Future<void> callJsAsyncFunction() async {
    try {
      // JavaScript의 fetchData 함수 호출, 결과는 Promise 객체로 반환됨
      final jsPromise = fetchData();

      // promiseToFuture로 JavaScript의 Promise를 Dart의 Future로 변환
      final result = await promiseToFuture(jsPromise);

      print('JavaScript 함수 결과: $result');
    } catch (e) {
      print('Error: $e');
    }
  }

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter JS Sample',
      home: Scaffold(
        appBar: AppBar(
          title: const Text('Flutter JS Sample'),
        ),
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              ElevatedButton(
                onPressed: callJsAsyncFunction,
                child: const Text('Call JS Async Function'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

이전까지 dart:js, dart:js_util은 별도의 설치가 필요 없는 내장 패키지였지만 JS() 어노테이션을 사용하기 위해서는 package:js를 설치해주어야 했습니다.

 $ flutter pub add js

결과는... 성공!

@JS() 어노테이션으로 인터페이스를 정의하니 드디어 JavaScript 비동기 함수 호출에 성공했습니다!

이제 과정을 알아봐야겠죠!


JS() 어노테이션

@JS() 어노테이션은 Dart 코드에서 JavaScript 객체와 함수에 직접적으로 접근할 수 있도록 도와주는 역할을 합니다. 이 어노테이션을 사용하면, Dart에서 JavaScript의 함수나 속성에 정적으로 접근할 수 있죠.

위 예제에서 우리는 이렇게 간단히 인터페이스를 정의한 것만으로 JavaScript의 fetchData 함수를 사용할 수 있었습니다.

()
external dynamic fetchData();

그런데 @JS()가 정확히 무슨 기능을 하고, external 키워드는 무엇일까요?
어떻게 JavaScript 함수를 불러올 수 있는 것인지 원리를 이해할 필요가 있었어요.

JS()가 하는 기능은

This package's main library, js, provides annotations and functions that let you specify how your Dart code interoperates with JavaScript code. The Dart-to-JavaScript compilers — dartdevc and dart2js — recognize these annotations, using them to connect your Dart code with JavaScript.

이 패키지의 메인 라이브러리인 js는 Dart 코드가 JavaScript 코드와 상호 운용되는 방식을 지정할 수 있는 주석과 함수를 제공합니다. Dart-to-JavaScript 컴파일러인 dartdevcdart2js는 이러한 어노테이션을 인식하여 이를 사용하여 Dart 코드를 JavaScript와 연결합니다.

📌 출처: js | pub.dev

내부 구현 원리를 정확히 알 수는 없지만, 이 어노테이션은 Dart-to-JavaScript 컴파일러가 인식하여 동작을 수행하도록 만들어주는 것으로 보입니다. pub.dev의 Readme 설명을 참고했을 때, dartdevcdart2js가 이 @JS() 어노테이션을 해석하여 Dart 코드를 JavaScript와 연결해주는 역할을 수행하고 있음을 알 수 있었어요.

결국, 이 컴파일 과정 덕분에 @JS() 어노테이션이 붙은 인터페이스나 함수를 Dart에서 정의하고 호출할 수 있었던 것입니다.

🚧 그런데 주의할 것

A second library in this package, js_util, provides low-level utilities that you can use when it isn't possible to wrap JavaScript with a static, annotated API.

이 패키지의 두 번째 라이브러리인 js_util은 어노테이션이 달린 정적 API로 JavaScript를 래핑할 수 없을 때 사용할 수 있는 하위 수준 유틸리티를 제공합니다.

📌 출처: js | pub.dev

js_util이 언급되며 살짝 걸리는, 수상한 내용이 있습니다.

"어노테이션이 달린 정적 API로 JavaScript를 래핑할 수 없을 때"가 어떤 상황을 의미하는 것일까요?

이 부분은 하단의 'js_util에서 간과한 사실'에서 다루겠습니다.


external 키워드란

external interop members provide an idiomatic syntax for JS members.
external interop 멤버는 JS 멤버에 대한 관용적 구문을 제공합니다.

📌 출처: JavaScript interop > Usage | dart.dev

external 키워드는 JavaScript와 상호 작용할 때 JavaScript의 속성이나 메서드를 "Dart다운 문법"으로 사용할 수 있게 한다는 의미입니다. 더 자세히 이해하기 위해 external의 링크를 타고 들어가보았어요.

An external function is a function whose body is implemented separately from its declaration. Include the external keyword before a function declaration

외부 함수는 선언과 별도로 본문이 구현되는 함수입니다. 다음과 같이 함수 선언 앞에 external 키워드를 포함합니다.


An external function's implementation can come from another Dart library, or, more commonly, from another language. In interop contexts, external introduces type information for foreign functions or values, making them usable in Dart. Implementation and usage is heavily platform specific, so check out the interop docs on, for example, C or JavaScript to learn more.

외부 함수의 구현은 다른 Dart 라이브러리에서 나올 수 있으며, 더 일반적으로는 다른 언어에서 나올 수 있습니다. 상호 운용성 컨텍스트에서 external은 외부 함수나 값에 대한 유형 정보를 도입하여 Dart에서 사용할 수 있도록 합니다. 구현 및 사용법은 플랫폼에 따라 크게 다르므로 C 또는 JavaScript 등의 상호 운용성 문서를 확인하여 자세히 알아보세요.

📌 출처: Functions | dart.dev

external 은 Javascript로 작성된 함수나 속성을 Dart에서 편리하게 호출하거나 접근할 수 있도록 해주는 키워드라는 뜻이죠. 실제 구현은 외부에서 구현되는 것입니다.

즉, 위 예제에서 fetchData() 함수는 Dart에서 구현된 것이 아니라, JavaScript 쪽에서 구현된 것이라는 의미입니다. Dart에서는 이 함수의 구체적인 구현을 알 필요 없이 호출할 수 있었죠.

이렇게 @JS()external 키워드가 무엇인지 알게되니, 비로소 어떤 과정을 거쳐 JavaScript의 함수 호출이 가능한 원리가 이해되었습니다!


그런데 왜 이전에 @JS()를 안써도 JavaScript 호출이 가능했죠?

앞서 경험한 것과 같이, js.context.callMethod로도 충분히 호출이 가능했었죠. 여기서 context에 주목해야 합니다. context는 최상위 전역 객체, 즉 window를 의미했습니다. 즉, 사실상 JS()로 인터페이스를 정의한 것과 동일한 효과인 것입니다. 단순히 JavaScript의 함수를 호출하는데는 문제가 없었어요. 오히려 한번에 호출이 가능하니 간편하죠.

하지만 결정적인 차이는 타입 안전성 👷

위의 과정에서 보았듯, 똑같이 promiseToFuture를 사용해도 context.callMethod를 사용하는 방법과 @JS() 어노테이션을 사용하는 방식은 결과가 달랐습니다.

promiseToFuture의 인수로 context.callMethod('jsAsyncFunction')를 전달했던 경우, 리턴값인 Promise 타입을 제대로 인식하지 못하고 NoSuchMethodError라는 에러가 났었죠.

import 'dart:js' as js;
import 'dart:js_util';

void main() {
  callJsAsyncFunction(); // NoSuchMethodError: tried to call a non-function, such as null: 'jsPromise.then'
}

Future<void> callJsAsyncFunction() async {
  try {
    final result = await promiseToFuture(js.context.callMethod('fetchData'));
    print('JavaScript 함수 결과: $result');
  } catch (e) {
    print('Error: $e');
  }
}

반면 JS()로 인터페이스를 정의하고 promiseToFuture 메서드를 이용했을 때는 올바르게 Promise를 인식하고 비동기 함수 호출에 성공했습니다.


import 'dart:js' as js;
import 'dart:js_util';
import 'package:js/js.dart';

()
external dynamic fetchData();

void main() {
  callJsAsyncFunction(); // JavaScript 함수 결과: Data fetched from JavaScript!
}

  Future<void> callJsAsyncFunction() async {
    try {
      final jsPromise = fetchData();
      final result = await promiseToFuture(jsPromise);
      print('JavaScript 함수 결과: $result');
    } catch (e) {
      print('Error: $e');
    }
  }

이렇게 @JS() 어노테이션을 이용하여 연결한 함수는 context.callMethod와 역할이 비슷하지만, 큰 차이가 있었습니다. 바로 타입 안전성을 제공한다는 점입니다.

Promise는 JavaScript 고유의 타입이기 때문에, Dart가 이를 인식할 수 있도록 타입 매핑이 필요합니다. context.callMethod는 단순히 함수를 호출하는 데 그치지만, @JS() 어노테이션을 사용해 인터페이스를 정의하면 JavaScript의 동적 타입을 Dart가 이해할 수 있는 정적 타입으로 매칭할 수 있게 됩니다.

덕분에 @JS() 어노테이션으로 정의한 함수는 Null 에러 없이 Dart에서 올바르게 호출할 수 있었던 것입니다!

js_util에서 간과한 사실

가만보니 Dart이 공식 문서에서 js_util 패키지는 이렇게 소개되어 있었어요.

Utility methods to manipulate package:js annotated JavaScript interop objects in cases where the name to call is not known at runtime.

런타임에 호출할 이름을 알 수 없는 경우, package:js 어노테이션이 달린 JavaScript interop 개체를 조작하는 유틸리티 메서드입니다.


You should only use these methods when the same effect cannot be achieved with @JS() annotations.

@JS() 어노테이션으로 동일한 효과를 얻을 수 없는 경우에만 이 메서드를 사용해야 합니다.

📌 출처: dart:js_util library | api.dart.dev

JavaScript 객체나 함수의 이름을 런타임에 알 수 없는 경우에 dart:js_util을 사용하라는 것이었어요. 또한, @JS() 어노테이션으로 동일한 효과를 얻을 수 없는 경우에 dart:js_util의 유틸리티 메서드를 사용하라고 작성되어있었네요...!

"런타임에 호출할 이름을 알 수 없다"가 무슨 의미일까요?

JavaScript에서 함수나 메서드 이름이 정적으로 정의되지 않고 동적으로 결정되는 경우를 의미합니다.

예) 외부 API나 다른 라이브러리에서 반환하는 값에 따라 함수가 달라질 때

function getFunctionBasedOnCondition(condition) {
  if (condition) {
    return function() {
      console.log("Condition is true!");
    };
  } else {
    return function() {
      console.log("Condition is false!");
    };
  }
}

const dynamicFunction = getFunctionBasedOnCondition(true);
dynamicFunction(); // 이 함수는 런타임에 결정됨

위 코드에서 getFunctionBasedOnCondition(true)가 반환하는 함수는 실행 중에 조건에 따라 동적으로 결정됩니다.

따라서 이 함수는 @JS() 어노테이션을 사용해 미리 정의할 수 없고, dart:js_util을 사용하여 동적으로 함수를 호출할 수 있는 경우에 해당되었어요.

반대로 이런 특수한 경우가 아닌, 대부분의 정적 호출은 모두 @JS() 어노테이션을 사용하길 권장한다는 의미입니다.

이제 Dart에서 JavaScript를 호출하는 방법을 모두 마스터 했습니다!


아니 그런데... deprecated 될 방법이었다고..?

이제 완벽히 이해했다고 생각했는데 또... 줄곧 이렇게 Dart의 공식 문서를 보고 또 보면서도 외면하고 넘어갔던 것이 눈에 들어왔습니다... 바로 js_interop입니다. 지금까지 열심히 읽었던 모든 문서의 최상단에서 언급하고 있었습니다.

They are not deprecated yet, but will likely be in the future. Therefore, prefer using dart:js_interop going forwards and migrate usages of old interop libraries when possible.

아직 Deprecarted 되진 않았지만, 곧 될 수도 있습니다. 따라서 앞으로는 dart:js_interop을 사용하고, 가능하면 이전 interop 라이브러리의 사용을 마이그레이션하는 것이 좋습니다.

📌 출처: js | pub.dev

모든 문서에서 공통적으로 얘기하는 내용이었어요. 당장 Deprecated하진 않을 것이지만, 마이그레이션할 것을 권장한다는 내용이었죠...!


현재의 JS Interop 방식, js_interop

사실 Dart 공식 문서의 JavaScript interop에서는 이렇게 친절히 테이블로 정리되어 있었습니다...! 지금까지 다루었던 패키지인 dart:js, dart:js_util, package:js에서 제공되던 기능을 모두 dart:js_interopdart:js_interop_unsafe에 통합했다는 것이죠.

이 또한 이름처럼 Dart의 내장 패키지이기 때문에 별도 설치가 필요 없었죠.

이전 JavaScript Interop 패키지들과의 가장 큰 차이점은, JavaScript를 위한 방대한 Type을 지니고 있다는 점이었어요.

This JavaScript interop library works by introducing an abstraction over JavaScript values, a Dart type hierarchy ("JS types") which mirrors known JavaScript types, and a framework for introducing new Dart types that bind Dart type declarations to JavaScript values and external member declarations to JavaScript APIs.

이 JavaScript interop 라이브러리는 JavaScript 값 위에 추상화를 도입하여 작동합니다. Dart에서 JavaScript 타입을 반영하는 타입 계층("JS 타입")을 만들고, Dart 타입 선언을 JavaScript 값에 바인딩하고 external 멤버 선언을 JavaScript API와 연결할 수 있도록 새로운 Dart 타입을 도입하는 구조를 제공합니다.

📌 출처: dart:js_interop library | api.dart.dev

Dart 코드에서 JavaScript 타입을 다루기 쉽게 만든만큼 강력해지고, 사용이 훨씬 간단해졌어요. 지난 과정처럼 구태여 패키지를 3개나 import할 일도 없으니 말이에요!

일반 함수 호출부터 적용해보겠습니다.


1. JavaScript 함수 호출

이전의 과정과 똑같이 3단계로 이루어집니다.

  1. JavaScript 함수 정의
  2. index.html 연결
  3. Dart에서 호출

이전 단계는 모두 동일하므로, 차이가 발생한 3번만 다루겠습니다.
이번에는 한 단계씩 끊어 과정을 작성해보겠습니다.

1. dart:js_interop 패키지 import

import 'dart:js_interop';

가장 먼저, dart:js_interop 패키지를 import 합니다.

2. @JS() 어노테이션으로 인터페이스 정의

()
external void showAlert(String message);

이번에는 functions.js에 정의해두었던 showAlert 함수만 사용해보겠습니다. 해당 함수에서는 String 타입의 message을 매개변수로 지정해두었기 때문에, 똑같이 인수로 전달하도록 작성합니다.

차이점으로는 이전 과정에서 package:js 패키지로부터 @JS를 사용할 수 있었는데, 이번에는 dart:js_interop 패키지에 포함되어있기 때문에 바로 사용 가능하다는 점이 있습니다!

3. Dart 함수 정의

void callJsFunction(String message) async {
	showAlert(message);
}

정말 간단해졌죠? 똑같이 message를 매개변수로 작성하고, 블록에서 위 @JS() 어노테이션에 정의한 함수를 호출합니다.

4. 함수 호출

...
ElevatedButton(
	onPressed: () => callJsFunction('js_interop 성공!'),
    child: const Text('Call JS Function'),
),
...

이번에는 전달한 인수가 있기 때문에, OnPressed에 콜백 함수로 callJsFunction을 작성해야합니다.

결과는!

전체 코드

import 'package:flutter/material.dart';
import 'dart:js_interop'; // 1. js_interop 패키지 추가

// 2. @JS() 어노테이션으로 인터페이스 정의
()
external void showAlert(String message);

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  // 3. Dart 함수 정의
  void callJsFunction(String message) async {
    showAlert(message);
  }

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter JS Sample',
      home: Scaffold(
        appBar: AppBar(
          title: const Text('Flutter JS Sample'),
        ),
        body: Center(
          child: 
          	ElevatedButton(
            	onPressed: () => callJsFunction('js_interop 성공!'), // JavaScript - Dart 함수 호출
                child: const Text('Call JS Function'),
          	),
        ),
      ),
    );
  }
}

2. JavaScript 비동기 함수 호출

이번에는 이전 과정에서 골치를 앓았던 비동기 함수 호출 차례입니다! 마찬가지로 4단계로 진행해보겠습니다.

1. dart:js_interop 패키지 import(생략)

import 'dart:js_interop';

2. @JS() 어노테이션으로 인터페이스 정의

()
external JSPromise fetchData();

낯설은 타입, JSPromise가 보입니다. 이 부분은 후술하겠습니다.

3. Dart 함수 정의

Future<void> callJsAsyncFunction() async {
	print('비동기 함수 호출 전');

	try {
      // JavaScript의 fetchData() 함수 호출 결과를 Dart의 Future로 변환하고, 값을 받아온다.
      final result = await fetchData().toDart;
      print('JavaScript 비동기 함수 결과: $result');
    } catch (e) {
      print('Error: $e');
    }
    
    print('비동기 함수 호출 후');
  }

이전 interop 방식과 차이가 두드러지는 부분이 눈에 띕니다. promiseToFuture 메서드가 사라졌어요! 한 줄이 줄어들었고, 대신에 이번에도 낯설은 toDart가 생겼습니다.

이 부분도 역시 후술하겠습니다.

4. 함수 호출

...
ElevatedButton(
	onPressed: callJsAsyncFunction,
    child: const Text('Call JS Async Function'),
),
...

이번에는 매개변수가 필요하지 않으므로, 즉시 함수를 호출합니다.

결과는!

비동기 함수가 정상적으로 호출되는지 확인해보기 위해, 앞뒤로 콘솔을 추가했었습니다. 보시다시피! 비동기 함수 호출은 가운데에 정상적으로 호출되었습니다!

전체 코드

import 'package:flutter/material.dart';
import 'dart:js_interop'; // 1. js_interop 패키지 추가

// 2. @JS() 어노테이션으로 인터페이스 정의
()
external JSPromise fetchData();

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});
	
  // 3. Dart 함수 정의
  Future<void> callJsAsyncFunction() async {
    print('비동기 함수 호출 전');

    try {
      // JavaScript의 fetchData() 함수 호출 결과를 Dart의 Future로 변환하고, 값을 받아온다.
      final result = await fetchData().toDart;
      print('JavaScript 비동기 함수 결과: $result');
    } catch (e) {
      print('Error: $e');
    }

    print('비동기 함수 호출 후');
  }

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter JS Sample',
      home: Scaffold(
        appBar: AppBar(
          title: const Text('Flutter JS Sample'),
        ),
        body: Center(
          child: ElevatedButton(
            onPressed: callJsAsyncFunction, // JavaScript - Dart 함수 호출
            child: const Text('Call JS Async Function'),
          ),
        ),
      ),
    );
  }
}

원하는대로 잘 성공했지만, 역시 처음보는 타입과 메서드는 찝찝합니다...! 무엇을 의미하고, 무슨 기능을 하는지 찾아보았어요!


JSPromise란

JSPromise<T extends JSAny?> extension type
A JavaScript Promise or a promise-like object.

JSPromise<T extends JSAny?> 확장 타입은 JavaScript의 Promise 또는 Promise와 유사한 객체를 나타낸다.


Because JSPromise is an extension type, T is only a static guarantee and the JSPromise may not actually resolve to a T.
또한 JSArray와 마찬가지로, T는 다른 곳에서 추가적인 검사를 요구할 수도 있다. JSPromiseFuture<T>로 변환할 때, 실제로 이 Future가 T로 변환되는지 확인하기 위해 형 변환이 이루어지며, 이를 통해 타입 안전성을 보장한다.


Also like with JSArray, T may introduce additional checking elsewhere. When converted to a Future<T>, there is a cast to ensure that the Future actually resolves to a T to ensure soundness.
JSPromise확장 타입(extension)이기 때문에, 제네릭 타입 T는 컴파일 시점에서만 보장된다. 실제로 JSPromise가 반드시 T로 변환된다는 보장은 없다.

📌 출처: JSPromise extension type | Dart API

이 내용을 토대로 이해해보면, @JS()로 정의된 JSPromise fetchData() 함수는 JavaScript의 비동기 함수인 fetchData와 연결되도록 만든 것입니다.

()
external JSPromise fetchData();

JSPromise를 사용해야 하는 이유는, Dart 코드에서 JavaScript의 비동기 함수를 Promise 객체로 받아와서 Future로 변환할 수 있기 때문입니다.

즉, 비동기 함수를 JSPromise 타입으로 선언하는 이유로는 3가지가 있습니다.

1. JavaScript 비동기 함수와의 호환성

JSPromise는 JavaScript의 Promise 타입을 Dart에서 다룰 수 있도록 해주는 타입입니다.

2. 타입 안정성 보장

JSPromise<T>는 Dart에서 사용하는 타입 안전성을 보장합니다. Dart에서는 JSPromiseFuture로 변환할 때, Future<T>로 변환하게 되는데, 이때 실제로 T 타입으로 값을 반환하는지 확인하여 타입 안전성을 유지할 수 있습니다.

3. 형 변환을 통한 예외 방지

JSPromisePromise가 실제로 어떤 타입으로 변환되는지 강제하진 않지만, promiseToFuture를 사용할 때 Future<T>로 캐스팅함으로써 JavaScript의 비동기 함수가 특정한 타입을 반환하지 않으면 오류가 발생할 수 있도록 합니다.


toDart란(feat.promiseToFuture)

이제 왜 JavaScript의 비동기 함수를 호출 할 JSPromise란 타입으로 선언했는지 알게되었어요. 그런데 또 하나 낯설었던 toDart 는 무엇이었을까요?

Future<void> callJsAsyncFunction() async {
	...
	try {
      final result = await fetchData().toDart;
	...  

사실 제가 dart:js_interop 패키지에도 promiseToFuture 메서드를 동일하게 사용할 것이라 생각하며 찾아낸 부분이었어요. 역시나 Dart API 문서에서 바로 찾을 수 있었어요!

정확히는 JSPromiseToFuture 였습니다. JSPromiseFuture로 변환할 때 사용한다고 써있는 것을 보니 정답이 맞다고 생각했죠.

그런데 저는 민망하게도, JSPromiseToFuture를 메서드처럼 사용하려 했었어요...extension 타입을 제대로 이해하지 못했기 때문에 발생한 문제였죠. 한참을 헤메다 다시 문서를 보며, toDart를 사용해야한다는 것을 뒤늦게 알았습니다.

toDart → Future<T>

A Future that either completes with the result of the resolved JSPromise or propagates the error that the JSPromise rejected with.
JSPromise가 완료되면 그 결과값으로 완료되는 Future를 반환하고, 만약 JSPromise가 에러와 함께 거부되면 그 에러를 전달하는 Future를 반환한다.

즉, toDartJSPromise의 성공 또는 실패 결과를 Dart의 Future로 변환해주는 역할을 한다는 것입니다!!

toDart 상세 내용의 구현을 보아, getter로 정의되어 있는 속성이었습니다. 그래서 fetchData().toDart처럼 접근하는 것만으로도 Future로 변환되는 것이었죠..!

final result = await fetchData().toDart;

extension이란

그런데 반복적으로 등장했던 Extension type은 무엇이었을까요? 가만 보니JSPromise도, JSPromiseTuFutre도 모두 Extension Type이었어요.

이 부분을 제대로 이해하지 못하여 toDart에서 헤멨습니다. 역시 이해하기 위해, Dart의 공식 문서에서 Extension type 부분을 찾아갔습니다.

An extension type is a compile-time abstraction that "wraps" an existing type with a different, static-only interface. They are a major component of static JS interop because they can easily modify an existing type's interface (crucial for any kind of interop) without incurring the cost of an actual wrapper.

extension 타입은 기존 타입에 대해 "다른 정적 인터페이스로 감싼" 컴파일 타임 추상화 개념입니다. 특히 정적 JS interop에서 큰 역할을 하는데, 기존 타입의 인터페이스를 간편하게 수정할 수 있으면서도 실제로 래퍼(wrapper)를 만드는 비용이 들지 않습니다.


Extension types enforce discipline on the set of operations (or interface) available to objects of an underlying type, called the representation type. When defining the interface of an extension type, you can choose to reuse some members of the representation type, omit others, replace others, and add new functionality.

Extention 타입표현 타입에 대해 사용할 수 있는 연산이나 인터페이스 집합을 엄격하게 제한합니다. 즉, extension 타입의 인터페이스를 정의할 때 표현 타입의 멤버 중 일부를 재사용하거나, 필요 없는 부분을 생략하고, 특정 멤버를 다른 것으로 대체하거나, 새로운 기능을 추가할 수 있는 것입니다.

📌 출처: Extension type | dart.dev

사실 이 부분이 과거 JS interop 방식과의 가장 핵심적인 차이였습니다.

extension type을 사용한 dart:js_interop 방식은 타입 안전성, 코드 간결성, 성능 최적화, 확장성 면에서 과거의 dart:jsdart:js_util을 사용한 방법보다 발전된 방식이란 것을 비로소 알 수 있었습니다.

즉, JavaScript와 Dart 간의 상호 운용을 더 안전하고 효율적으로 처리할 수 있게 해주며, 코드의 유지보수성도 크게 향상시킬 수 있는 것이었어요!

이 내용을 토대로, JSPromiseJSPromiseToFuture의 실제 구현을 쫓아가보았습니다.

JSPromise

/// A JavaScript `Promise` or a promise-like object.
///
/// Because [JSPromise] is an extension type, [T] is only a static guarantee and
/// the [JSPromise] may not actually resolve to a [T].
///
/// Also like with [JSArray], [T] may introduce additional checking elsewhere.
/// When converted to a [Future<T>], there is a cast to ensure that the [Future]
/// actually resolves to a [T] to ensure soundness.
('Promise')
extension type JSPromise<T extends JSAny?>._(JSPromiseRepType _jsPromise)
    implements JSObject {
  external JSPromise(JSFunction executor);
}

JSPromiseToFuture

/// Conversions from [JSPromise] to [Future].
extension JSPromiseToFuture<T extends JSAny?> on JSPromise<T> {
  /// A [Future] that either completes with the result of the resolved
  /// [JSPromise] or propagates the error that the [JSPromise] rejected with.
  external Future<T> get toDart;
}

실제 구현을 살펴보니 비로소 이 extension type이 사용된 방식을 이해할 수 있었어요. JSPromise 은 JavaScript Promiseextension type이었고, JSPromiseToFuture 또한 JSPromiseextension type였습니다.

과거의 JS interop 방식(dart:js, dart:js_util, package:js)에 비해 어떤 인과관계로 JavaScript와 Dart 간의 타입 매칭이 이뤄질 수 있었는지 좀 더 이해가 수월하다는 것 또한 알 수 있었죠!


마치며

단순히 Flutter(Dart)에서 JavaScript를 직접 작성하여, 호출할 수 있는 방법을 찾아온 시행착오의 과정이었는데 생각보다 연관된 다양한 개념을 익히게 되었습니다...! 어쩌다보니 JavaScript의 상호운용성(interop)의 과거와 현재를 모두 체험하고 어떤 이유로 변화하게 되었는지까지도 알게되었네요.

Flutter로 Web을 개발하는 경우에는 저처럼 JavaScript가 친숙한 개발자들이 많을 것이라 생각합니다. 때문에 이렇게 쉽게 JavaScript를 운용할 수 있는 방법을 알았으니, 대부분의 기능을 JavaScript로 작성하는 것이 더 편리하지 않을까싶은 의문도 생겼어요.

물론 아닙니다.

JavaScript의 비중이 더 높아질 것이라면 Flutter를 선택할 이유가 없고, 유지보수 차원에서도 문제 여지가 많아질 것입니다. 단적인 예로, 위 과정에서 보셨듯이 index.html에 script를 삽입해서 사용하는 방법 밖에 사용할 수 없습니다.

그럼에도 저와 같이 JavaScript 라이브러리를 래핑한 패키지를 사용할 때 부가적으로 필요한 기능을 직접 구현하는 목적으로 사용하는 경우에는 유용할 것으로 보입니다!


긴 글 읽어주셔서 감사합니다!!

profile
디자인 하던 FE 개발자입니다.

0개의 댓글