시작하며
PHP FFI(Foreign Function Interface)는, 공유 라이브러리를 읽어들이고, C 함수를 호출하며, C의 데이터 구조에 접근하기 위한 PHP 확장입니다. PHP 확장을 직접 만들지 않아도, 기존의 C API를 PHP에서 직접 사용할 수 있습니다.
PHP 공식 문서는, FFI를 저레벨이고 위험한 기능으로 설명하고 있습니다. C와 대상 라이브러리의 API를 이해한 개발자만이 사용해야 하는 기능입니다.
FFI는 안전하게 추상화된 일반적인 PHP API가 아닙니다. 포인터, 소유권, 해방 함수를 잘못하면 크래시나 메모리 파괴로 이어집니다.
FFI는 “속도를 위한 기능”도 아닙니다. PHP 공식 문서에서도, FFI의 데이터 구조 접근은 네이티브한 PHP 배열이나 오브젝트보다 느리다고 설명하고 있습니다. FFI를 선택하는 이유는 속도가 아니라, 기존의 C 라이브러리를 PHP에서 사용하고 싶기 때문입니다.
FFI를 사용할 수 있는 환경과 제한
PHP 공식 문서에서는, FFI 확장을 활성화하는 방법을 다음과 같이 설명하고 있습니다.
- PHP를
--with-ffi를 붙여 빌드
- Windows에서는
php.ini에서 php_ffi.dll을 활성화
php.ini의 ffi.enable로 사용 가부를 제어
ffi.enable은 다음 3가지 값을 취합니다.
| 설정값 | 의미 |
|---|
true | FFI API를 활성화 |
false | FFI API를 비활성화 |
preload | CLI SAPI와 preload된 파일에서만 FFI API를 허가 |
Web 서버 환경에서는 ffi.enable=false나 ffi.enable=preload가 자주 사용됩니다. Laravel Cloud를 포함한 호스팅 환경에서도, 일반적인 Web 앱으로서 FFI를 사용할 수 있다고 한정할 수 없습니다.
그러므로, Laravel 앱에서 FFI를 사용하는 경우에도, 우선은 CLI 도구나 배치 처리에서 성립하는지를 생각해 주세요. FPM이나 Apache mod_php에서 상시 활성화한다는 전제는 피하는 편이 안전합니다.
기본적인 사용법
PHP FFI의 기본은 FFI::cdef(), FFI::new(), FFI::addr(), FFI::string()의 4가지입니다.
<?php
$ffi = FFI::cdef(<<<'CDEF'
typedef unsigned int time_t;
typedef unsigned int suseconds_t;
struct timeval {
time_t tv_sec;
suseconds_t tv_usec;
};
struct timezone {
int tz_minuteswest;
int tz_dsttime;
};
int gettimeofday(struct timeval *tv, struct timezone *tz);
CDEF, 'libc.so.6');
$tv = $ffi->new('struct timeval');
$tz = $ffi->new('struct timezone');
$ffi->gettimeofday(FFI::addr($tv), FFI::addr($tz));
echo $tv->tv_sec.PHP_EOL;
이 예에서 짚어 둘 점은 다음과 같습니다.
FFI::cdef()는 C의 선언 문자열과 공유 라이브러리명에서 FFI 오브젝트를 만든다
$ffi->new('struct timeval')은 C의 데이터 구조를 확보한다
FFI::addr($tv)는 struct timeval *와 같은 포인터 인수를 만든다
$tv->tv_sec와 같이 구조체 필드에 접근할 수 있다
문자열이나 바이너리를 받는 API에서는 FFI::string()을 사용합니다.
<?php
$jsonPtr = $ffi->new('char*');
// C 側が $jsonPtr に結果ポインタを書き込む想定
$result = $ffi->some_function(FFI::addr($jsonPtr));
if ($result !== 0) {
throw new RuntimeException('C API call failed.');
}
$json = FFI::string($jsonPtr);
FFI::new()로 확보한 메모리는, 일반적으로는 PHP의 참조 카운트로 해방됩니다. 한편, C 라이브러리가 반환한 포인터는 라이브러리 전용의 free 함수로 해방하는 경우가 있습니다. 어느 쪽 소유권인지를 API마다 확인해 주세요.
헤더 파일의 로딩
FFI::load()를 사용하면, C 헤더 파일에서 선언을 로드할 수 있습니다. 헤더 파일에는 FFI_SCOPE와 FFI_LIB를 쓸 수 있습니다.
#define FFI_SCOPE "mylib"
#define FFI_LIB "/absolute/path/to/libmylib.so"
typedef struct MyContext MyContext;
MyContext *mylib_new(void);
void mylib_delete(MyContext *context);
<?php
FFI::load(__DIR__.'/mylib.h');
$ffi = FFI::scope('mylib');
$context = $ffi->mylib_new();
단, PHP 공식 문서는 FFI::cdef()와 FFI::load()의 어느 쪽에서도 일반적인 C 전처리기 명령은 사용할 수 없다고 설명하고 있습니다. #include, #define, 조건부 컴파일을 그대로 전달할 수 없습니다.
그 때문에, 실운용에서는 다음의 어느 쪽인가를 선택하는 경우가 많습니다.
단순한 API라면 `FFI::cdef()`에 직접 쓴다
의존하는 타입이나 함수가 적다면, 필요한 선언만을 PHP 측에 삽입합니다.
복잡한 API라면 전처리된 헤더를 준비한다
원래 헤더에서 매크로나 조건부 컴파일을 제거한, FFI용 헤더를 별도의 파일로 관리합니다.
실제 유스케이스: VOICEVOX Core for PHP
VOICEVOX Core for PHP는, VOICEVOX CORE의 C 동적 라이브러리를 Pure PHP에서 사용하는 실제 예입니다. FFI의 실용 패턴이 정리되어 있어, 추상적인 설명보다 이해하기 쉬운 소재입니다.
1. FFI용으로 정형한 헤더를 갖는다
VOICEVOX Core의 원래 헤더는 그대로는 사용하지 않고, headers/voicevox_core_ffi.h에 FFI용의 선언을 잘라내고 있습니다. 여기에서는 불투명 포인터, 구조체, free 함수를 명시하고 있습니다.
typedef struct VoicevoxSynthesizer VoicevoxSynthesizer;
typedef struct VoicevoxInitializeOptions {
int32_t acceleration_mode;
uint16_t cpu_num_threads;
} VoicevoxInitializeOptions;
int32_t voicevox_synthesizer_new(
const struct VoicevoxOnnxruntime *onnxruntime,
const struct OpenJtalkRc *open_jtalk,
struct VoicevoxInitializeOptions options,
struct VoicevoxSynthesizer **out_synthesizer
);
void voicevox_synthesizer_delete(struct VoicevoxSynthesizer *synthesizer);
void voicevox_json_free(char *json);
void voicevox_wav_free(uint8_t *wav);
이 설계에서 중요한 것은, C의 상세한 내부 구조를 PHP 측에 보이지 않고, 불투명 핸들과 함수 호출에 가두고 있다는 것입니다. PHP에서 다루는 면적이 작아지고, 래퍼 클래스도 정리하기 쉬워집니다.
2. FFI::cdef()를 한 곳에 집약한다
src/VoicevoxFFI.php는, 헤더 로딩과 라이브러리 경로 해결을 한 곳에 집약하고 있습니다.
<?php
class VoicevoxFFI
{
private static ?FFI $ffi = null;
public static function getInstance(): FFI
{
return self::$ffi ??= FFI::cdef(
file_get_contents(__DIR__.'/../headers/voicevox_core_ffi.h'),
self::getLibraryPath(),
);
}
public static function getLibraryPath(): string
{
if ($path = getenv('VOICEVOX_CORE_LIB_PATH')) {
return $path;
}
return match (PHP_OS_FAMILY) {
'Darwin' => 'libvoicevox_core.dylib',
'Windows' => 'voicevox_core.dll',
default => 'libvoicevox_core.so',
};
}
}
이 형태로 하면, 여러분의 앱 측은 FFI::cdef()를 직접 호출하지 않아도 됩니다. 라이브러리 경로의 차분도, 환경 변수와 OS 판정으로 흡수할 수 있습니다.
3. out parameter를 래핑하여 오브젝트화한다
src/Synthesizer.php의 컨스트럭터는, struct VoicevoxSynthesizer*를 확보하고, voicevox_synthesizer_new()에 그 주소를 전달하고 있습니다.
<?php
$options = $this->ffi->voicevox_make_default_initialize_options();
$options->acceleration_mode = $accelerationMode->value;
$options->cpu_num_threads = $cpuNumThreads;
$ptr = $this->ffi->new('struct VoicevoxSynthesizer*');
$result = $this->ffi->voicevox_synthesizer_new(
$onnxruntime->handle(),
$openJtalk->handle(),
$options,
FFI::addr($ptr),
);
$this->handle = $ptr;
이 패턴은, C API의 Type **out_value를 PHP에서 받는 기본 형태입니다.
new('struct VoicevoxSynthesizer*')로 포인터 변수를 만든다
FFI::addr($ptr)로 Type **를 전달한다
- 취득한 핸들을 PHP 오브젝트의 프로퍼티에 보관한다
4. C가 반환한 메모리는 PHP 문자열로 복사하고 즉시 해방한다
VOICEVOX Core for PHP는, JSON 문자열이나 WAV 바이너리를 받으면, 우선 FFI::string()으로 PHP 문자열로 복사하고, 그 후에 C 측의 free 함수를 호출하고 있습니다.
<?php
$jsonPtr = $this->ffi->voicevox_synthesizer_create_metas_json($this->handle);
$json = FFI::string($jsonPtr);
$this->ffi->voicevox_json_free($jsonPtr);
return $json;
<?php
$wavSize = $this->ffi->new('uint64_t');
$wavPtr = $this->ffi->new('uint8_t*');
$result = $this->ffi->voicevox_synthesizer_tts(
$this->handle,
$text,
$styleId,
$options,
FFI::addr($wavSize),
FFI::addr($wavPtr),
);
$wav = FFI::string($wavPtr, (int) $wavSize->cdata);
$this->ffi->voicevox_wav_free($wavPtr);
return $wav;
이 “복사하여 즉시 해방”의 흐름은, FFI에서 가장 중요한 구현 패턴 중 하나입니다. C 측의 버퍼를 PHP의 라이프사이클에 섞지 않음으로써, 소유권을 단순화할 수 있습니다.
주의점과 베스트 프랙티스
| 관점 | 실천 포인트 |
|---|
| 실행 환경 | 우선 CLI 전제로 설계한다 |
| 헤더 관리 | 원래 헤더를 그대로 전달하지 않고, FFI용으로 정형한 선언을 별도 관리한다 |
| 라이브러리 경로 | 절대 경로나 환경 변수로 전환 가능하게 한다 |
| 메모리 관리 | FFI::string()으로 PHP에 복사하고, 라이브러리 전용의 해방 함수를 반드시 호출한다 |
| 래퍼 설계 | 생의 CData를 앱 전체에 확산시키지 않고, 전용 클래스에 가둔다 |
| 호환성 | PHP 버전과 대상 라이브러리의 ABI 변경을 함께 확인한다 |
Laravel 앱의 request/response 사이클에 FFI를 직접 섞으면, 환경 차분과 장애 분리가 갑자기 어려워집니다. 우선은 CLI 커맨드나 큐 이외의 단발 프로세스에서 성립시켜 주세요.
FFI가 적합한 것은, 이미 안정된 C API가 있고, PHP 확장을 직접 만들 정도는 아닌 케이스입니다. 반대로, 일반적인 Web 호스팅에서 동작시키고 싶은 기능이나, PHP만으로 완결할 수 있는 처리에는 적합하지 않습니다.
PHP FFI를 사용하면, 여러분은 기존의 C 라이브러리를 Pure PHP에서 호출할 수 있습니다. 단, FFI는 저레벨이고 위험한 구조입니다. Web 서버에서 항상 사용할 수 있다고 한정할 수도 없습니다.
VOICEVOX Core for PHP의 구현을 보면, 실전에서는 다음 4점이 중요하다는 것을 알 수 있습니다.
- FFI용의 헤더를 별도로 준비한다
FFI::cdef()와 라이브러리 경로 해결을 한 곳에 집약한다
- 불투명 포인터를 전용 클래스에서 래핑한다
- C가 반환한 메모리는 PHP에 복사한 뒤 전용 함수로 해방한다
VOICEVOX Core for PHP의 페이지도 함께 읽으면, FFI의 개념과 실제 패키지 설계를 왕복하며 이해할 수 있습니다.