ghlee.dev
Deno를 활용한 나만의 JS 런타임 만들기
2024. 11. 15.이 글에서는 Deno의 내부에서 deno_core
에 해당하는 컴포넌트만 이용하여, 자체적으로 API를 정의하면서 커스텀 JavaScript 런타임을 만들어보려고 한다. 이를 활용하면 Rust로만 가능한 작업을 JavaScript API로 만들어서 사용하는 등의 실무적인 활용이 가능할 것으로 생각한다. 또한 JavaScript에 존재하는 빌트인 API가 어떤 방식으로 만들어지는지 대략적으로 알게 될 거라 생각한다.
Rust와 JavaScript에 대한 기본 지식을 필요로 한다.
Deno의 내부에 대한 설명은 이전에 작성한 글이 있으니 참고하기를 바란다.
우선 간단한 커스텀 런타임의 기반을 만들어보고, 이후 직접 API를 구성해 보겠다.
의존성 설치
우선 새 Rust 프로젝트를 하나 만들어야 한다. cargo
를 이용하여 프로젝트를 생성하자.
이제 필요한 의존성을 설치한다.
설치하고 나면 Cargo.toml
은 아래처럼 보일 것이다.
deno_core
에 대해 간단히 설명하면, V8 엔진을 감싸서 엔진에서 제공하는 공개 API를 추상화한 라이브러리로 코드 내부에서는 JsRuntime
이라는 구조체를 제공하는데, 우리는 이를 활용할 생각이다.
tokio
는 비동기 Rust 런타임으로 이벤트 루프 역할을 담당한다. Node.js에서 사용하는 libuv 라이브러리에 대응한다. deno_core
와 tokio
를 함께 사용하면 JavaScript의 비동기 처리 객체인 Promise
와 Rust의 Future
를 매핑(Mapping)할 수 있다.
커스텀 런타임 실행하기
설치한 의존성을 이용하여 간단한 런타임을 작성해보자.
차근차근 코드를 설명해 보겠다. 우선 CustomJsRuntime
구조체를 선언하고 구현부에 비동기 run
함수를 만들었다. run
함수에서는 JsRuntime
의 인스턴스를 만들어서 js_runtime
변수에 할당하는데, 이때 파일 시스템 기반의 모듈 로더인 FsModuleLoader
를 인스턴스의 옵션에 넣어준다.
run
함수의 인자로 받은 file_path
를 이용하여 메인 모듈을 가져와 main_module
변수에 할당하고, js_runtime
을 이용하여 가져온 모듈을 load_main_es_module
함수를 이용하여 로드하고, mod_evaluate
함수를 이용하여 평가한다. 그 다음으로 이벤트 루프를 실행한 후 이전에 평가한 결과를 반환한다.
main
함수에서는 단일 스레드의 tokio
비동기 런타임을 생성하고, 런타임 내부에서 CustomJsRuntime
을 이용하여 src/app.js
파일을 실행한다.
app.js
는 우선 다음과 같이 작성해 보았다.
이는 JavaScript 코드이다. Rust로 작성한 코드와 헷갈릴 수 있으니 주의해야 한다.
Deno.core.print
는 deno_core
에서 사전에 정의한 빌트인(BuiltIn) API로 JsRuntime
인스턴스를 생성할 때 이미 정의되어 있다. 이는 deno_core
의 내부 코드에서 확인해 볼 수 있는데, 아래 코드는 그 일부만 가져와보았다.
deno_core
내부의 01_core.js
파일에서는 앞서 사용했던 print
함수를 ObjectAssign
을 이용하여 globalThis.Deno.core
에 바인딩 한다. print
함수는 op_print
함수를 호출하는데, op_print
함수는 Rust로 작성되어 있으며, 이는 아래와 같다.
그렇다면 JavaScript의 print
함수는 Rust의 op_print
함수를 어떻게 알고 호출하는 것일까? 이는 JsRuntime
의 생성자 함수 내부에 답이 있다.
JsRuntime
의 생성자 함수 내부에는 execute_builtin_sources
라는 함수를 호출하는데, 이때 앞서 정의한 op_print
함수가 빌트인 API로 주입되게 된다. 이는 최종적으로 V8 엔진의 외부 참조 코드로 사용된다.
원래 이야기로 돌아와서 앞서 작성한 코드를 cargo run
명령어로 실행하면 아래와 같이 Hello runjs!
를 볼 수 있다.
API 만들기
이제까지 런타임을 생성하여 사전에 정의되어 있던 빌트인 API를 실행해 보았다. 이제는 웹에서 사용 빈도가 높은 API 중 fetch
를 직접 구현해 볼 생각이다. 우선 JavaScript의 fetch
함수가 호출할 Rust의 op_fetch
함수부터 만들어보자.
우선 Rust에서 HTTP 요청에 필요한 의존성인 reqwest
를 설치한다.
reqwest
는 Rust에서 사용하는 네트워크 통신 라이브러리이다. 이를 이용하여 main.rs
에 op_fetch
를 정의한다.
op_fetch
는 인자로 받은 url
을 HTTP GET 요청에 사용하고, 이에 대한 응답을 텍스트로 반환하는 간단한 함수이다. 다만 op_fetch
의 상단에 op2
라고 하는 매크로를 사용해 주어야 한다. 이 매크로에는 V8 엔진이 op_fetch
함수를 이해할 수 있도록 변환하는 기능이 포함되어 있다.
extension
도 다른 형식의 매크로인데, 인자에 별칭과 정의한 op_fetch
함수를 넣어주면, 별칭을 기반으로 V8 엔진에 필요한 구조체를 만들어준다. 여기서는 custom_runtime
이라는 이름의 구조체가 생성될 것이다.
마지막으로 아래 코드처럼 JsRuntime
옵션에 extensions
를 추가하면 정의한 op_fetch
세팅이 완료된다.
이제 JavaScript에서 op_fetch
에 접근할 수 있다. bootstrap.js
파일을 생성하고 fetch
함수를 정의해 보자.
코드는 간단하다. globalThis
객체에 커스텀 런타임에 관한 네임스페이스를 정의하고, fetch
함수 내부에서 op_fetch
를 호출하면 된다. 이때 ops
는 Deno.core
에서 가져올 수 있다. 이는 V8 엔진을 감싼 deno_core
의 JsRuntime
구조체에서 정의된 네임스페이스이다.
다시 main.rs
로 돌아와서 bootstrap.js
을 사용하기 위한 코드 한 줄을 추가하면 커스텀 API 세팅이 완료된다.
이제 기존의 app.js
를 fetch
함수를 사용하도록 수정해 보자.
이 시점에서
console.log
는 빌트인 API로 정의되어 있지 않아서console
는deno_cli
컴포넌트에서 세팅된다.
custom_runtime
네임스페이스를 통해 fetch
를 사용하는 간단한 코드이다. URL은 무료로 공개되어 있는 API의 URL을 사용했다. 이제 cargo run
을 통해 실행하면 아래와 같은 결과가 출력될 것이다.
이렇게 자체적인 fetch
API를 구축에 성공했다. 🎉
마치며
Deno의 아키텍처의 핵심 컴포넌트인 deno_core
를 사용하여 자체적인 JavaScript 런타임을 만들어보고, 빌트인 API가 어떤 방식으로 만들어지는지도 엿볼 수 있었다.
이를 이용하면, JavaScript API로 Rust의 기능을 사용할 수 있는 장점이 있다. 만약 구성원이 Rust를 잘 모르는 JavaScript 엔지니어라면 이 방법을 통해 실용적으로 활용할 여지가 있다고 생각한다. 내가 있던 블록체인 도메인을 예를 들면, JavaScript API 사용으로 Rust 기반의 스마트 컨트랙트를 사용해 볼 수 있게 하는 등의 방법이 있을 것이다.
(23.11.29 추가) 전체 코드는 Github에 업로드해두었으니 참고하길 바란다.