📌 이 글은 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_web3
는 ethers.js
와 같이 대규모 커뮤니티가 아닌, 아직 개인이 소규모로 운영하는 오픈소스였기 때문에 어쩔 수 없는 한계라 여겼어요.
그런데, 동료분께서 Flutter 프레임워크에서도 Javascript 코드를 직접 작성하여 호출하는 방법이 있다는 것을 알려주셨어요!
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
는 JavaScript 코드를 Dart 코드에서 호출하고 제어할 수 있는 클래스와 메서드를 제공하는 라이브러리입니다.
문서에 다뤄진 용법에 따르면, 단순히 Javascript의 함수를 호출하는 방법은 정말 간단했습니다!
import 'dart:js';
main() => context.callMethod('alert', ['Hello from Dart!']);
이에 따라 저는 다음의 3단계 순서로 Dart에서 Javscript를 호출해보겠습니다!
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;
}
이제 이 함수가 웹에서 사용될 수 있도록 해야겠죠!
웹 개발에서처럼 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를 호출하기 위한 준비 과정이 끝났습니다!
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.callMethod
로 Javscript의 함수 이름을 인수로 넣어 호출한 것 뿐이에요!
예제에서 보시다시피, 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
가 호출되는 과정은 이런 순서라 생각할 수 있습니다.
js 패키지
에 접근context
를 통해 JavaScript의 글로벌 객체, 즉window
에 접근callMethod
메서드로 JavaScript 함수 호출- 호출 결과 반환
저의 상황에서도 그랬고, 클라이언트 측에서는 비동기 함수 호출이 필요한 상황이 빈번하죠.
- 네트워크를 통해 데이터를 가져올 때
- 데이터 베이스에 쓰기를 수행할 때
- 파일로부터 데이터를 읽을 때
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에서 호출해 볼 차례입니다. 순서는 일반 함수 호출과 동일합니다.
위 예제와 동일합니다.
// web/functions.js
async function fetchData() {
// 간단하게 2초 후 데이터를 반환하는 Promise 사용
return new Promise((resolve) => {
setTimeout(() => {
resolve("Data fetched from JavaScript!");
}, 2000);
});
}
일반 함수 호출의 예제에서 이미 연결하였으므로, 생략합니다.
<!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>
이번에는 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가 무엇을 의미하는지 공식 문서로 들어가보았어요.
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.
동적 멤버 호출은 수신 개체에 존재하지 않는 멤버를 호출하려고 시도할 수 있습니다.
이 내용을 토대로 저의 케이스를 대입해보면, 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의 함수는 잘 호출되었습니다!
()
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()
어노테이션은 Dart 코드에서 JavaScript 객체와 함수에 직접적으로 접근할 수 있도록 도와주는 역할을 합니다. 이 어노테이션을 사용하면, Dart에서 JavaScript의 함수나 속성에 정적으로 접근할 수 있죠.
위 예제에서 우리는 이렇게 간단히 인터페이스를 정의한 것만으로 JavaScript의 fetchData
함수를 사용할 수 있었습니다.
()
external dynamic fetchData();
그런데 @JS()
가 정확히 무슨 기능을 하고, external
키워드는 무엇일까요?
어떻게 JavaScript 함수를 불러올 수 있는 것인지 원리를 이해할 필요가 있었어요.
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
anddart2js
— recognize these annotations, using them to connect your Dart code with JavaScript.이 패키지의 메인 라이브러리인 js는 Dart 코드가 JavaScript 코드와 상호 운용되는 방식을 지정할 수 있는 주석과 함수를 제공합니다. Dart-to-JavaScript 컴파일러인
dartdevc
및dart2js
는 이러한 어노테이션을 인식하여 이를 사용하여 Dart 코드를 JavaScript와 연결합니다.📌 출처: js | pub.dev
내부 구현 원리를 정확히 알 수는 없지만, 이 어노테이션은 Dart-to-JavaScript 컴파일러가 인식하여 동작을 수행하도록 만들어주는 것으로 보입니다. pub.dev의 Readme 설명을 참고했을 때, dartdevc
와 dart2js
가 이 @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
interop members provide an idiomatic syntax for JS members.
external
interop 멤버는 JS 멤버에 대한 관용적 구문을 제공합니다.
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.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에서 올바르게 호출할 수 있었던 것입니다!
가만보니 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()
어노테이션으로 동일한 효과를 얻을 수 없는 경우에만 이 메서드를 사용해야 합니다.
JavaScript 객체나 함수의 이름을 런타임에 알 수 없는 경우에 dart:js_util
을 사용하라는 것이었어요. 또한, @JS() 어노테이션으로 동일한 효과를 얻을 수 없는 경우에 dart:js_util의 유틸리티 메서드를 사용하라고 작성되어있었네요...!
JavaScript에서 함수나 메서드 이름이 정적으로 정의되지 않고 동적으로 결정되는 경우를 의미합니다.
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를 호출하는 방법을 모두 마스터 했습니다!
이제 완벽히 이해했다고 생각했는데 또... 줄곧 이렇게 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 라이브러리의 사용을 마이그레이션하는 것이 좋습니다.
모든 문서에서 공통적으로 얘기하는 내용이었어요. 당장 Deprecated하진 않을 것이지만, 마이그레이션할 것을 권장한다는 내용이었죠...!
사실 Dart 공식 문서의 JavaScript interop에서는 이렇게 친절히 테이블로 정리되어 있었습니다...! 지금까지 다루었던 패키지인 dart:js
, dart:js_util
, package:js
에서 제공되던 기능을 모두 dart:js_interop
과 dart: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 andexternal
member declarations to JavaScript APIs.이 JavaScript interop 라이브러리는 JavaScript 값 위에
추상화
를 도입하여 작동합니다. Dart에서 JavaScript 타입을 반영하는 타입 계층("JS 타입
")을 만들고, Dart 타입 선언을 JavaScript 값에 바인딩하고external
멤버 선언을 JavaScript API와 연결할 수 있도록 새로운 Dart 타입을 도입하는 구조를 제공합니다.
Dart 코드에서 JavaScript 타입을 다루기 쉽게 만든만큼 강력해지고, 사용이 훨씬 간단해졌어요. 지난 과정처럼 구태여 패키지를 3개나 import할 일도 없으니 말이에요!
일반 함수 호출부터 적용해보겠습니다.
이전의 과정과 똑같이 3단계로 이루어집니다.
- JavaScript 함수 정의
- index.html 연결
- Dart에서 호출
이전 단계는 모두 동일하므로, 차이가 발생한 3번만 다루겠습니다.
이번에는 한 단계씩 끊어 과정을 작성해보겠습니다.
dart:js_interop
패키지 importimport 'dart:js_interop';
가장 먼저, dart:js_interop
패키지를 import 합니다.
@JS()
어노테이션으로 인터페이스 정의()
external void showAlert(String message);
이번에는 functions.js에 정의해두었던 showAlert
함수만 사용해보겠습니다. 해당 함수에서는 String 타입의 message
을 매개변수로 지정해두었기 때문에, 똑같이 인수로 전달하도록 작성합니다.
차이점으로는 이전 과정에서 package:js
패키지로부터 @JS
를 사용할 수 있었는데, 이번에는 dart:js_interop
패키지에 포함되어있기 때문에 바로 사용 가능하다는 점이 있습니다!
void callJsFunction(String message) async {
showAlert(message);
}
정말 간단해졌죠? 똑같이 message를 매개변수로 작성하고, 블록에서 위 @JS()
어노테이션에 정의한 함수를 호출합니다.
...
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'),
),
),
),
);
}
}
이번에는 이전 과정에서 골치를 앓았던 비동기 함수 호출 차례입니다! 마찬가지로 4단계로 진행해보겠습니다.
dart:js_interop
패키지 import(생략)import 'dart:js_interop';
@JS()
어노테이션으로 인터페이스 정의()
external JSPromise fetchData();
낯설은 타입, JSPromise
가 보입니다. 이 부분은 후술하겠습니다.
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
가 생겼습니다.
이 부분도 역시 후술하겠습니다.
...
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<T extends JSAny?>
extension
type
A JavaScript Promise or a promise-like object.
JSPromise<T extends JSAny?>
확장 타입은 JavaScript의Promise
또는Promise와 유사한 객체
를 나타낸다.
Because
JSPromise
is anextension
type,T
is only a static guarantee and theJSPromise
may not actually resolve to aT
.
또한JSArray
와 마찬가지로,T
는 다른 곳에서 추가적인 검사를 요구할 수도 있다.JSPromise
를Future<T>
로 변환할 때, 실제로 이 Future가T
로 변환되는지 확인하기 위해형 변환
이 이루어지며, 이를 통해타입 안전성
을 보장한다.
Also like with
JSArray
, T may introduce additional checking elsewhere. When converted to aFuture<T>
, there is a cast to ensure that the Future actually resolves to a T to ensure soundness.
JSPromise
는확장 타입
(extension)이기 때문에,제네릭 타입
T
는 컴파일 시점에서만 보장된다. 실제로JSPromise
가 반드시T
로 변환된다는 보장은 없다.
이 내용을 토대로 이해해보면, @JS()로 정의된 JSPromise
fetchData() 함수는 JavaScript의 비동기 함수인 fetchData
와 연결되도록 만든 것입니다.
()
external JSPromise fetchData();
JSPromise
를 사용해야 하는 이유는, Dart 코드에서 JavaScript의 비동기 함수를 Promise
객체로 받아와서 Future
로 변환할 수 있기 때문입니다.
즉, 비동기 함수를 JSPromise
타입으로 선언하는 이유로는 3가지가 있습니다.
JSPromise
는 JavaScript의 Promise
타입을 Dart에서 다룰 수 있도록 해주는 타입입니다.
JSPromise<T>
는 Dart에서 사용하는 타입 안전성을 보장합니다. Dart에서는 JSPromise
를 Future
로 변환할 때, Future<T>
로 변환하게 되는데, 이때 실제로 T 타입으로 값을 반환하는지 확인하여 타입 안전성을 유지할 수 있습니다.
JSPromise
는 Promise
가 실제로 어떤 타입으로 변환되는지 강제하진 않지만, promiseToFuture
를 사용할 때 Future<T>
로 캐스팅함으로써 JavaScript의 비동기 함수가 특정한 타입을 반환하지 않으면 오류가 발생할 수 있도록 합니다.
이제 왜 JavaScript의 비동기 함수를 호출 할 JSPromise
란 타입으로 선언했는지 알게되었어요. 그런데 또 하나 낯설었던 toDart
는 무엇이었을까요?
Future<void> callJsAsyncFunction() async {
...
try {
final result = await fetchData().toDart;
...
사실 제가 dart:js_interop 패키지에도 promiseToFuture
메서드를 동일하게 사용할 것이라 생각하며 찾아낸 부분이었어요. 역시나 Dart API 문서에서 바로 찾을 수 있었어요!
정확히는 JSPromiseToFuture
였습니다. JSPromise
를 Future
로 변환할 때 사용한다고 써있는 것을 보니 정답이 맞다고 생각했죠.
그런데 저는 민망하게도, 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
를 반환한다.
즉, toDart
는 JSPromise
의 성공 또는 실패 결과를 Dart의 Future
로 변환해주는 역할을 한다는 것입니다!!
toDart
상세 내용의 구현을 보아, getter
로 정의되어 있는 속성
이었습니다. 그래서 fetchData().toDart
처럼 접근하는 것만으로도 Future로 변환되는 것이었죠..!
final result = await fetchData().toDart;
그런데 반복적으로 등장했던 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 anextension type
, you can choose to reuse some members of the representation type, omit others, replace others, and add new functionality.
Extention 타입
은 표현 타입에 대해 사용할 수 있는 연산이나 인터페이스 집합을 엄격하게 제한합니다. 즉,extension 타입
의 인터페이스를 정의할 때 표현 타입의 멤버 중 일부를 재사용하거나, 필요 없는 부분을 생략하고, 특정 멤버를 다른 것으로 대체하거나, 새로운 기능을 추가할 수 있는 것입니다.
사실 이 부분이 과거 JS interop 방식과의 가장 핵심적인 차이였습니다.
extension type
을 사용한 dart:js_interop
방식은 타입 안전성, 코드 간결성, 성능 최적화, 확장성 면에서 과거의 dart:js
와 dart:js_util
을 사용한 방법보다 발전된 방식이란 것을 비로소 알 수 있었습니다.
즉, JavaScript와 Dart 간의 상호 운용을 더 안전하고 효율적으로 처리할 수 있게 해주며, 코드의 유지보수성도 크게 향상시킬 수 있는 것이었어요!
이 내용을 토대로, JSPromise
와 JSPromiseToFuture
의 실제 구현을 쫓아가보았습니다.
/// 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);
}
/// 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 Promise
의 extension type
이었고, JSPromiseToFuture
또한 JSPromise
의 extension 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 라이브러리를 래핑한 패키지를 사용할 때 부가적으로 필요한 기능을 직접 구현하는 목적으로 사용하는 경우에는 유용할 것으로 보입니다!
긴 글 읽어주셔서 감사합니다!!