soulee.dev

Node.js FFI - (1) 시작하기

· 10 min read

Node.js v26.1.0FFI(Foreign Function Interface) 모듈이 들어왔다. 아직 Stability 1 - Experimental 단계지만, 별도의 애드온 빌드 없이 동적 라이브러리와 네이티브 심볼을 곧바로 쓸 수 있다는 점이 흥미로워서 정리해봤다.

다른 런타임은 어떻게 하고 있나

FFI 자체가 새로운 개념은 아니다. 다른 런타임들은 이미 각자의 방식으로 지원하고 있다.

  • Deno - Stable
  • Bun - Experimental
  • Koffi - Node.js용 서드파티 라이브러리

Koffi 같은 서드파티 모듈을 설치해야 했던 것과 달리, 이제는 Node.js 자체에서 네이티브로 제공한다는 점이 다르다.

시작하기

실험 기능이라 --experimental-ffi 플래그가 필요하다.

Bash
node --experimental-ffi app.js

여기에 Permission Model을 함께 쓴다면 --allow-ffi까지 줘야 한다. 안 그러면 FFI API가 막힌다.

Bash
node --experimental-ffi --permission --allow-ffi app.js

ffi.suffix로 확장자 맞추기

동적 라이브러리의 확장자는 OS마다 다르다. ffi.suffix를 쓰면 실행 중인 OS에 맞는 확장자를 알아서 돌려준다.

OS확장자
macOSdylib
Linuxso
Windowsdll
JavaScript
const { suffix } = require('node:ffi');
 
const path = `libsqlite3.${suffix}`;

예제로 쓸 라이브러리 준비하기

실제로 돌려보려면 부를 대상이 있어야 한다. C로 짧게 만들어보자.

c
// mylib.c
int add_i32(int a, int b) {
  return a + b;
}
 
// 뒤에서 콜백 예제에 쓸 함수
int apply(int (*fn)(int), int value) {
  return fn(value);
}

ffi.suffix가 돌려주는 이름과 맞아떨어지도록 출력 파일 이름을 정해서 빌드한다.

Bash
# macOS
cc -shared -fPIC -o mylib.dylib mylib.c
 
# Linux
cc -shared -fPIC -o mylib.so mylib.c
 
# Windows (개발자 명령 프롬프트)
cl /LD mylib.c /Fe:mylib.dll

dlopen으로 라이브러리 열기

ffi.dlopen으로 라이브러리를 열고, 호출할 심볼의 인자와 반환 타입을 함께 선언한다.

JavaScript
import { dlopen, suffix } from 'node:ffi';
 
const { lib, functions } = dlopen(`./mylib.${suffix}`, {
  add_i32: { arguments: ['i32', 'i32'], return: 'i32' },
  string_length: { arguments: ['pointer'], return: 'u64' },
});
 
console.log(functions.add_i32(20, 22));   // 42

핸들은 using으로 받을 수도 있다. 이렇게 하면 블록을 벗어날 때 handle.lib.close()가 자동으로 호출된다.

JavaScript
{
  using handle = dlopen(`./mylib.${suffix}`, {
    add_i32: { arguments: ['i32', 'i32'], return: 'i32' },
  });
  console.log(handle.functions.add_i32(20, 22));
}   // 여기서 자동으로 close

지원하는 타입

위에서 쓴 i32, u64, pointer 말고도 쓸 수 있는 타입 이름은 다음과 같다.

분류타입 이름
없음void
8비트char, i8, int8 / u8, uint8, bool
16비트i16, int16 / u16, uint16
32비트i32, int32 / u32, uint32
64비트i64, int64 / u64, uint64
실수f32, float, float32 / f64, double, float64
포인터pointer, ptr
포인터로 넘어가는 것string, str, buffer, arraybuffer, function

JS 값으로 오갈 때 규칙이 몇 가지 있다.

  • 8/16/32비트 정수와 실수는 JS number로 주고받는다.
  • **64비트 정수는 bigint**다. number로 넘기면 안 된다.
  • 포인터로 넘길 때는 null, string, Buffer, TypedArray, ArrayBuffer, bigint를 다 받아주지만, 돌려받는 값은 항상 bigint 주소다.
  • string 타입으로 넘긴 값은 호출하는 동안만 NUL 종료 UTF-8로 유지된다. 네이티브 쪽에서 그 포인터를 보관했다가 나중에 쓰면 안 된다.

문자열 대신 ffi.types.INT_32 같은 상수를 써도 된다.

DynamicLibrary로 지연 로딩하기

dlopen은 선언한 심볼을 한 번에 다 찾는다. 필요한 시점에 하나씩 가져오고 싶다면 DynamicLibrary를 쓴다.

JavaScript
import { DynamicLibrary, suffix } from 'node:ffi';
 
const lib = new DynamicLibrary(`./mylib.${suffix}`);
const add = lib.getFunction('add_i32', { arguments: ['i32', 'i32'], return: 'i32' });
 
console.log(add(20, 22));
console.log(add.pointer);   // 네이티브 함수 주소 (bigint)

함수가 아닌 전역 변수 같은 심볼은 lib.getSymbol('name')으로 주소만 받아올 수 있다.

콜백 넘기기

반대로 JS 함수를 네이티브 쪽에 함수 포인터로 넘길 수도 있다. registerCallback이 JS 함수를 감싼 네이티브 콜백 포인터를 bigint로 돌려준다.

JavaScript
import { DynamicLibrary, suffix } from 'node:ffi';
 
const lib = new DynamicLibrary(`./mylib.${suffix}`);
 
const callback = lib.registerCallback(
  { arguments: ['i32'], return: 'i32' },
  (value) => value * 2,
);
 
const apply = lib.getFunction('apply', {
  arguments: ['function', 'i32'],
  return: 'i32',
});
 
console.log(apply(callback, 21));   // 42
 
lib.unregisterCallback(callback);

시그니처를 생략하면 void ()로 잡힌다.

콜백에는 제약이 꽤 붙는다.

  • 만들어진 것과 같은 시스템 스레드에서만 호출해야 한다.
  • 예외를 던지면 안 된다.
  • Promise를 반환하면 안 된다.
  • 선언한 반환 타입과 맞는 값을 돌려줘야 한다.
  • 실행 중에 자기 라이브러리의 close()를 부르거나, 자기 자신을 unregister 하면 안 된다.

수명 관리는 refCallbackunrefCallback으로 한다. unrefCallback을 하면 약한 참조가 되는데, 이 상태에서 콜백 함수가 GC되면 네이티브에서 불러도 아무 일도 안 일어난다. 반환 타입이 void가 아니면 0으로 채워진 값이 네이티브로 돌아간다. 예외도 안 나기 때문에 디버깅하기 까다로운 종류의 버그다. 이미 GC된 콜백에 refCallback을 부르면 ERR_INVALID_ARG_VALUE가 난다.

메모리 직접 다루기

포인터를 직접 읽고 쓰는 함수들도 함께 제공된다. 읽는 쪽은 이렇다.

JavaScript
import { toString, toBuffer, toArrayBuffer, getInt32, setInt32 } from 'node:ffi';
 
setInt32(ptr, 0, 42);
console.log(getInt32(ptr, 0));
 
const s = toString(ptr);              // NUL terminated
const buf = toBuffer(ptr, 64, false); // copy: false (제로카피)

getInt8부터 getFloat64까지 비트 폭별로 다 있고, set 쪽도 마찬가지다. toBuffertoArrayBuffer의 마지막 인자를 false로 주면 복사 없이 네이티브 메모리를 그대로 들여다보는 뷰가 된다. 대신 그 메모리가 해제되면 그대로 사고로 이어진다.

반대로 JS 쪽 데이터를 네이티브 메모리로 밀어 넣는 함수들도 있다.

JavaScript
import { exportString, exportBuffer, exportArrayBuffer } from 'node:ffi';
 
exportString('hello', ptr, 6);        // NUL 포함이라 길이는 6
exportBuffer(Buffer.from('hi'), ptr, 2);
exportArrayBuffer(arrayBuffer, ptr, arrayBuffer.byteLength);

export 계열은 메모리를 직접 할당해주지 않는다. 이미 쓸 수 있는 네이티브 메모리를 가리키는 포인터와, 그만큼의 공간이 있다는 걸 호출하는 쪽이 보장해야 한다. exportString은 NUL 종료 문자를 붙이기 때문에 length에 그 자리까지 포함해야 하고, UTF-16이나 UCS-2면 종료 문자가 2바이트다.

주의할 점

dlopen을 하면 라이브러리가 Node.js를 구동하는 프로세스에 mmap되어, 내 프로세스의 메모리 주소 공간으로 그대로 올라온다. 자유롭게 메모리를 읽고 쓸 수 있다는 뜻이기도 하지만, 반대로 잘못 건드리면 프로세스 크래시나 메모리 오염을 일으킬 수 있다.

문서도 이 점을 분명히 해두고 있다. node:ffi포인터 유효성, 메모리 소유권, 네이티브 객체의 수명을 추적하지 않는다. 그래서 지켜야 할 것들을 이렇게 나열해뒀다.

  • 해제된 메모리를 읽거나 쓰지 말 것
  • 네이티브 메모리가 풀린 뒤에 제로카피 뷰를 쓰지 말 것
  • 심볼의 시그니처를 틀리게 선언하지 말 것
  • 네이티브가 아직 부를 수 있는 콜백을 unregister 하지 말 것
  • library.close()unregisterCallback() 뒤에 그 콜백 포인터를 호출하지 말 것
  • 포인터를 돌려받았다고 해서 소유권이 넘어온 것으로 넘겨짚지 말 것. 반환된 주소를 누가 해제해야 하는지는 그 네이티브 API가 정한다.

정리하면 제로카피가 꼭 필요한 게 아니면 복사해서 쓰고, 콜백과 포인터의 수명은 네이티브 쪽에서 명확하게 관리하라는 이야기다. 타입 선언 하나만 틀려도 그대로 사고로 이어지기 때문에 주의해서 써야 한다.

어디에 쓸 수 있을까

  1. OS 네이티브 API 직접 호출
    • Win32 창 제어, 전역 단축키 등
  2. 하드웨어와 장비 SDK 연동
  3. Rust/Go 코어 연동
    • 코어 로직을 Rust로 짜서 cdylib으로 빌드하고, Node에서 FFI로 호출

다음 글에서는 실제로 Node.js에서 FFI가 어떻게 구현되어 있는지 확인해보겠다.