Node.js FFI - (1) 시작하기
Node.js v26.1.0에 FFI(Foreign Function Interface) 모듈이 들어왔다. 아직 Stability 1 - Experimental 단계지만, 별도의 애드온 빌드 없이 동적 라이브러리와 네이티브 심볼을 곧바로 쓸 수 있다는 점이 흥미로워서 정리해봤다.
다른 런타임은 어떻게 하고 있나
FFI 자체가 새로운 개념은 아니다. 다른 런타임들은 이미 각자의 방식으로 지원하고 있다.
Koffi 같은 서드파티 모듈을 설치해야 했던 것과 달리, 이제는 Node.js 자체에서 네이티브로 제공한다는 점이 다르다.
시작하기
실험 기능이라 --experimental-ffi 플래그가 필요하다.
node --experimental-ffi app.js여기에 Permission Model을 함께 쓴다면 --allow-ffi까지 줘야 한다. 안 그러면 FFI API가 막힌다.
node --experimental-ffi --permission --allow-ffi app.jsffi.suffix로 확장자 맞추기
동적 라이브러리의 확장자는 OS마다 다르다. ffi.suffix를 쓰면 실행 중인 OS에 맞는 확장자를 알아서 돌려준다.
| OS | 확장자 |
|---|---|
| macOS | dylib |
| Linux | so |
| Windows | dll |
const { suffix } = require('node:ffi');
const path = `libsqlite3.${suffix}`;예제로 쓸 라이브러리 준비하기
실제로 돌려보려면 부를 대상이 있어야 한다. 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가 돌려주는 이름과 맞아떨어지도록 출력 파일 이름을 정해서 빌드한다.
# 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.dlldlopen으로 라이브러리 열기
ffi.dlopen으로 라이브러리를 열고, 호출할 심볼의 인자와 반환 타입을 함께 선언한다.
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()가 자동으로 호출된다.
{
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를 쓴다.
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로 돌려준다.
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 하면 안 된다.
수명 관리는 refCallback과 unrefCallback으로 한다. unrefCallback을 하면 약한 참조가 되는데, 이 상태에서 콜백 함수가 GC되면 네이티브에서 불러도 아무 일도 안 일어난다. 반환 타입이 void가 아니면 0으로 채워진 값이 네이티브로 돌아간다. 예외도 안 나기 때문에 디버깅하기 까다로운 종류의 버그다. 이미 GC된 콜백에 refCallback을 부르면 ERR_INVALID_ARG_VALUE가 난다.
메모리 직접 다루기
포인터를 직접 읽고 쓰는 함수들도 함께 제공된다. 읽는 쪽은 이렇다.
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 쪽도 마찬가지다. toBuffer와 toArrayBuffer의 마지막 인자를 false로 주면 복사 없이 네이티브 메모리를 그대로 들여다보는 뷰가 된다. 대신 그 메모리가 해제되면 그대로 사고로 이어진다.
반대로 JS 쪽 데이터를 네이티브 메모리로 밀어 넣는 함수들도 있다.
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가 정한다.
정리하면 제로카피가 꼭 필요한 게 아니면 복사해서 쓰고, 콜백과 포인터의 수명은 네이티브 쪽에서 명확하게 관리하라는 이야기다. 타입 선언 하나만 틀려도 그대로 사고로 이어지기 때문에 주의해서 써야 한다.
어디에 쓸 수 있을까
- OS 네이티브 API 직접 호출
- Win32 창 제어, 전역 단축키 등
- 하드웨어와 장비 SDK 연동
- Rust/Go 코어 연동
- 코어 로직을 Rust로 짜서
cdylib으로 빌드하고, Node에서 FFI로 호출
- 코어 로직을 Rust로 짜서
다음 글에서는 실제로 Node.js에서 FFI가 어떻게 구현되어 있는지 확인해보겠다.