PUBLISHED

Static Hermes로 JavaScript를 C 코드로 컴파일하기

작성일: 2026.01.15

Static Hermes로 JavaScript를 C 코드로 컴파일하기

이 포스트는 parcel의 메인테이너 Devon Govett가 자신의 블로그에 올린 How to compile JavaScript to C with Static Hermes 게시글을 번역한 것이다. 번역하는 과정에서 다소 의역이 있을 수 있으며, 일부 번역에는 사견이 포함되어있기도 하다.

최근 Parcel의 더 많은 부분을 Rust로 포팅하는 작업을 진행하고 있습니다. Rust 기반 도구에서 가장 큰 과제 중 하나는 플러그인을 어떻게 지원할 것인가입니다. JavaScript, CSS, SVG 영역에는 이미 SWCOXC, Lightning CSS, oxvg와 같은 Rust 기반 대체 도구들이 존재합니다. 그러나 React Compiler, Less, Sass처럼 여전히 JavaScript로 작성된 인기 도구들도 많기 때문에, Rust 기반 도구 내부에서 이러한 도구들을 실행할 수 있는 방법이 필요합니다.

한 가지 방법은 napi를 사용하여 Node.js 내부에 Rust 코어를 임베딩하는 방식입니다. 이 구조에서는 프로그램의 진입점이 JavaScript이며, JavaScript 코드가 Rust 코드를 호출합니다. Rust 코드가 JavaScript 기반 플러그인을 호출해야 할 경우 다시 JavaScript 엔진으로 제어를 넘깁니다. Lightning CSS의 JavaScript 플러그인은 이 방식으로 구현되어 있습니다. 다만 이 방식에는 오버헤드가 존재하며, Lightning CSS를 개발할 당시 JavaScript 플러그인을 사용하는 경우 그렇지 않은 경우에 비해 약 7배의 성능 저하가 측정되었습니다.

또 다른 방법은 프로세스 간 통신을 사용하는 방식입니다. 이 구조에서는 Rust가 진입점이 되며, 플러그인을 실행해야 할 때마다 Node.js를 서브 프로세스로 실행합니다. 그러나 이 방식 역시 상당한 성능 오버헤드를 수반합니다.

Static Hermes

Hermes는 React Native를 위해 Facebook이 개발한 커스텀 JavaScript 엔진입니다. 최신 버전인 Static Hermes는 기존과는 다른 접근 방식을 취합니다. 런타임에 JIT(Just-In-Time) 컴파일러를 실행하는 대신, JavaScript를 사전에 바이트코드 또는 네이티브 바이너리로 컴파일합니다. 이 방식은 런타임에서 코드 컴파일과 최적화에 필요한 초기 구동 시간을 줄여주며, 이는 특히 모바일 환경에서 큰 차이를 만듭니다.

Static Hermes는 JavaScript를 C 코드로 컴파일한 뒤, 이를 LLVM을 사용해 머신 코드로 변환하는 방식으로 동작합니다. 이 과정에서 JavaScript 가상 머신 없이 실행 가능한 완전히 독립적인 바이너리가 생성됩니다. 컴파일된 C 코드는 Hermes가 제공하는 일부 헬퍼 함수를 사용하며, 이 함수들은 Rust와 같은 언어의 표준 라이브러리처럼 바이너리 내부에 정적으로 링크됩니다. 이 접근 방식은 LLVM의 고급 최적화를 활용할 수 있어 성능을 향상시킬 뿐만 아니라, Rust처럼 C와 인터페이스할 수 있는 다른 언어로 작성된 프로그램에 JavaScript를 매우 쉽게 임베딩할 수 있게 합니다.

Compiling Less.js to C

Rust에서 호출할 수 있는 Parcel용 Less 플러그인을 만드는 것을 목표로 작업을 시작했습니다. Static Hermes를 사용한 결과, 이를 C 라이브러리로 컴파일할 수 있었고, 해당 라이브러리를 Rust에서 호출할 수 있었습니다.

첫 번째 단계는 less npm 모듈을 외부 의존성 없이 단일 JavaScript 파일로 번들링하는 것이었습니다. Hermes는 Node 모듈을 지원하지 않기 때문에 모든 코드가 자체 포함되어 있어야 합니다. 또한 fs나 path와 같은 Node의 내장 모듈에도 의존할 수 없습니다. 이러한 요구사항을 충족하기 위해 Parcel을 사용했습니다.

untitled
JS
// 환경에 독립적인 Less 빌드를 사용하고, PluginLoader를 심(shim) 처리한다.
const less = require('less/lib/less').default({}, {});
less.PluginLoader = function() {}

// Expose a global function to compile a string of Less code to CSS.
function compile(input) {
  let result;
  less.render(input, (err, res) => {
    result = res.css;
  });
  return result;
}

globalThis.compile = compile;

Parcel로 컴파일합니다.

untitled
CSS
parcel build less.js --no-optimize

이 명령은 dist/less.js를 생성하며, 이는 외부 의존성이 전혀 없는 완전히 자체 포함된 파일입니다. 이 파일은 전역 compile 함수를 노출합니다.

다음 단계는 이를 C 라이브러리로 컴파일하는 것입니다. 먼저 Static Hermes 자체를 빌드해야 합니다. 해당 과정은 Static Hermes의 공식 안내를 따릅니다.

untitled
SH
./build_release/bin/shermes -O -c -exported-unit=less dist/less.js

이 명령은 less.o 오브젝트 파일을 생성합니다. 컴파일된 C 소스 코드를 보고 싶다면 -c 대신 -emit-c 플래그를 사용할 수 있습니다.

다음으로 JavaScript 함수를 호출하기 위한 간단한 C 래퍼가 필요합니다.

untitled
JAVA
// compile.c
#include <stdlib.h>
#include <hermes/VM/static_h.h>
#include <hermes/hermes.h>

// Static Hermes가 생성한 `less` 유닛에 대한 선언입니다.
// 이 심볼은 `less.o`에서 제공됩니다.
extern "C" SHUnit sh_export_less;

extern "C" char* compile_less(char *in) {
  // Hermes 런타임을 초기화합니다.
  static SHRuntime *s_shRuntime = nullptr;
  static facebook::hermes::HermesRuntime *s_hermes = nullptr;

  if (s_shRuntime == nullptr) {
    s_shRuntime = _sh_init(0, nullptr);
    s_hermes = _sh_get_hermes_runtime(s_shRuntime);
    if (!_sh_initialize_units(s_shRuntime, 1, &sh_export_less)) {
      abort();
    }
  }

  // 전역 `compile` 함수를 가져와 호출합니다.
  std::string res = s_hermes->global()
    .getPropertyAsFunction(*s_hermes, "compile")
    .call(*s_hermes, std::string(in))
    .getString(*s_hermes)
    .utf8(*s_hermes);

  // C++ 문자열을 반환 가능한 C 문자열로 변환합니다.
  char* result = new char[res.size() + 1];
  strcpy(result, res.c_str());
  return result;
}

이 파일을 clang++로 또 하나의 오브젝트 파일로 컴파일합니다.

untitled
SH
clang++ -c -O3 -std=c++17 -IAPI -IAPI/jsi -Iinclude -Ipublic -Ibuild_release/lib/config compile.c

이 명령은 compile.o 오브젝트 파일을 생성합니다.

마지막으로 Rust에서 compile_less C 함수를 호출합니다.

untitled
CSS
// main.rs
use std::ffi::{CStr, CString};
use std::os::raw::c_char;

// 호출할 C 함수를 선언합니다.
extern "C" {
  fn compile_less(input: *const c_char) -> *const c_char;
}

fn main() {
  // C 문자열을 생성합니다.
  let input = CString::new(
    r#"// Variables
      @link-color: #428bca;
      @link-color-hover: darken(@link-color, 10%);

      a,
      .link {
        color: @link-color;
      }
      a:hover {
        color: @link-color-hover;
      }
      .widget {
        color: #fff;
        background: @link-color;
      }
    "#,
  )
  .unwrap();

  // C 함수를 호출하고 Rust String으로 변환합니다.
  let res = unsafe {
    let ptr = compile_less(input.as_ptr());
    CStr::from_ptr(ptr).to_string_lossy().into_owned()
  };

  // 결과를 출력합니다.
  println!("OUTPUT: {}", res);
}

다음으로 rustc를 사용해 모든 것을 링크하여 컴파일합니다.

untitled
SH
rustc main.rs -O \
  -C link-arg=less.o \
  -C link-arg=compile.o \
  -Lbuild_release/lib \
  -Lbuild_release/jsi \
  -Lbuild_release/tools/shermes \
  -lshermes_console_a \
  -lhermesvm_a \
  -ljsi \
  -lc++ \
  -Lbuild_release/external/boost/boost_1_86_0/libs/context/ \
  -lboost_context \
  -l framework=Foundation

이제 프로그램을 실행하면 Rust를 통해 Less를 컴파일하는 것을 확인할 수 있습니다.

untitled
CSS
./main

결론

이 예제는 단순한 첫 사례이지만, 인터프리터를 임베딩하지 않고도 네이티브 도구가 사전 컴파일된 JavaScript 기반 플러그인과 통합될 수 있는 가능성을 보여줍니다. 또 다른 잠재적인 활용 사례로는 Babel 기반의 React Compiler가 있으며, 이는 많은 사용자에게 빌드 파이프라인에서 남아 있는 거의 유일한 JavaScript 기반 도구입니다. 이에 대해서도 간단히 시도해 보았으나, 현재로서는 일부 기능이 부족한 것으로 보이는 문제들에 부딪혔습니다.