본문으로 이동
메뉴 여닫기
환경 설정 메뉴 여닫기
개인 메뉴 여닫기
로그인하지 않음
지금 편집한다면 당신의 IP 주소가 공개될 수 있습니다.
Foreign function interface; FFI; 외부 함수 인터페이스
한 프로그래밍 언어로 짠 프로그램이 다른 언어로 작성·컴파일된 함수를 호출하게 해 주는 장치

외부 함수 인터페이스(foreign function interface, FFI)는 한 프로그래밍 언어(호스트 언어)로 작성한 프로그램이 다른 언어(게스트 언어)로 작성되거나 컴파일된 루틴을 호출하고 그 서비스를 쓰게 해 주는 메커니즘이다. 대개 DLL이나 .so 같은 공유 라이브러리에 들어 있는 기계어 함수를 부르는 형태로 쓰인다.

FFI가 필요한 경우는 크게 세 가지다. 호스트 언어로는 할 수 없는 일(운영체제 API, 하드웨어 드라이버 호출)을 게스트 언어로는 할 수 있을 때, 호스트 언어로는 느린 일(수치 계산, 압축, 암호)을 게스트 언어로 빠르게 할 수 있을 때, 그리고 이미 게스트 언어로 잘 만들어진 라이브러리를 번역하지 않고 그대로 쓰고 싶을 때다. C 언어는 역사가 길고 쌓인 코드가 많으며 운영체제 API가 C로 정의되어 있어서, 대부분의 언어가 C 함수를 부르는 FFI를 갖추고 있다. 사실상 C의 호출 방식이 언어 사이의 공용어 역할을 한다.

이 용어는 Common Lisp 구현들이 다른 언어 호출 기능을 부르던 이름에서 나온 것으로 알려져 있고, 지금은 Haskell, Rust, PHP, 파이썬, LuaJIT 같은 언어의 공식 문서가 그대로 쓴다. 다른 이름을 쓰는 언어도 있다. Ada는 언어 바인딩(language binding), Java는 Java Native Interface(JNI), Java Native Access(JNA), 그리고 Java 22부터의 Foreign Function & Memory API(FFM API)라고 부른다. 지금은 이런 기능을 두루 가리키는 일반 용어로 쓰인다.

FFI의 핵심 일은 호스트 언어의 의미와 호출 규약을 게스트 언어의 의미와 규약에 맞추는 것이다. 두 쪽의 실행 환경과 응용 프로그램 이진 인터페이스(ABI)도 함께 고려해야 한다.

맞춰야 할 것 내용
호출 규약(calling convention) 인자를 어느 레지스터·스택 순서로 넘기는지, 반환값은 어디에 두는지, 스택은 누가 정리하는지. 예: x86의 cdecl·stdcall, x86-64의 System V ABI와 Microsoft x64 규약
심벌 찾기 공유 라이브러리를 올리고(dlopen, LoadLibrary) 함수 이름으로 주소를 찾음(dlsym, GetProcAddress). 링커가 정적으로 연결할 수도 있다
이름 장식(name mangling) C++는 오버로딩 때문에 심벌 이름에 타입 정보를 섞는다. 다른 언어에 내보낼 함수는 extern "C"로 장식을 끈다
자료형 대응 int·long 크기, 구조체 배치와 패딩, 문자열 표현(NUL 종료, 인코딩), 배열과 포인터
메모리 소유권 누가 할당하고 누가 해제하는지, 가비지 컬렉터가 객체를 옮기거나 지우지 않게 하는 방법
제어 흐름 예외·패닉이 경계를 넘을 때의 처리, 콜백(게스트가 호스트를 되부르는 것), 스레드

이를 맞추는 방법은 몇 가지가 있다.

  • 게스트 쪽 함수를 정해진 방식으로 작성하게 한다. JNI 함수나 CPython 확장 모듈은 호환성 헤더가 정한 모양으로 짜야 한다.
  • 도구로 게스트 함수를 감싸는 접착 코드(glue code)를 자동 생성한다. SWIG, Rust의 bindgen, Java의 jextract가 C 헤더를 읽어 바인딩을 만든다.
  • 래퍼 라이브러리를 둔다.
  • 언어 경계를 넘을 수 있는 기능을 제한한다. 예컨대 C에서 부르는 C++ 함수는 보통 참조 매개변수를 쓰거나 예외를 던지면 안 된다.

호출 방식은 동적 방식과 컴파일 방식으로 나뉜다. 동적 방식(파이썬 ctypes, cffi의 ABI 모드, JNA, LuaJIT FFI, Java FFM API)은 실행 중에 함수 시그니처를 선언하고 libffi 같은 라이브러리나 JIT 컴파일러가 알맞은 호출 코드를 만들어 부른다. C 컴파일러 없이 쓸 수 있지만 선언이 틀리면 컴파일러가 잡아 주지 못한다. 컴파일 방식(JNI, CPython C API, Node-API, cffi의 API 모드, Cython, pybind11, PyO3)은 접착 코드를 C·C++·Rust로 짜서 컴파일하므로 타입 검사를 받고 빠르지만 빌드가 복잡하다.

C++는 C와 특별한 처리 없이 호환된다. 다만 C++가 이름 장식을 하므로 공유 라이브러리로 내보낼 심벌은 extern "C" 블록으로 감싸야 하고, extern "C" 함수는 안에서 C++ 코드를 불러도 되지만 C 타입만 반환하고 예외를 밖으로 던지면 안 된다.

언어 주요 FFI 특징
파이썬 ctypes(표준), cffi, Cython, pybind11, PyO3 ctypes는 C 호환 자료형과 DLL 호출을 제공. PyO3는 Rust로 파이썬 확장을 만든다
Java 등 JVM 언어 JNI, JNA, FFM API(Java 22) Scala, Kotlin 같은 JVM 언어도 같은 장치를 쓴다. FFM API는 JNI 없이 순수 Java로 네이티브 함수 호출과 힙 밖 메모리 접근
Rust extern "C" 블록, bindgen, cbindgen 외부 함수 호출은 unsafe. bindgen은 C 헤더에서 Rust 선언 생성, cbindgen은 그 반대
C# 등 .NET P/Invoke(DllImport, LibraryImport) .NET 7부터 소스 생성 방식 LibraryImport
Node.js Node-API(N-API), node-addon-api ABI 안정. Node 버전이 바뀌어도 다시 컴파일할 필요 없음
Go cgo 주석에 C 코드를 적고 import "C"
Haskell foreign import / foreign export Haskell 2010 표준의 일부
LuaJIT ffi 라이브러리 C 선언을 그대로 파싱, JIT가 C 수준 코드 생성
Ada pragma Import / pragma Export 표준에 C·COBOL·Fortran 연동 포함. Interfaces.C 패키지
C++ 직접 호출, extern "C" C 코드를 그대로 부름
D extern(C), extern(C++) C 전부, C++와 Objective-C 일부
Ruby ffi gem, C 확장
PHP FFI 확장(PHP 7.4) 실행 중 C 선언으로 라이브러리 호출[1]
Dart dart:ffi Flutter에서 네이티브 라이브러리 호출
Swift, Kotlin/Native C 헤더 직접 가져오기, cinterop
ActionScript AIR 네이티브 확장(ANE)

ctypes는 C 호환 자료형을 제공하고 DLL이나 공유 라이브러리의 함수를 호출하는 표준 라이브러리 모듈로, 순수 파이썬만으로 라이브러리를 감쌀 수 있게 한다. CDLL로 올린 라이브러리의 함수를 부르는 동안에는 GIL을 풀어 다른 파이썬 스레드가 돌 수 있고, 파이썬 C API를 부를 때 쓰는 PyDLL은 GIL을 쥔 채 호출한다.[2]

from ctypes import CDLL, c_char_p, c_double
from ctypes.util import find_library

libm = CDLL(find_library("m"))          # Windows라면 CDLL("msvcrt")
libm.cos.argtypes = [c_double]          # 시그니처를 알려 주지 않으면 int로 가정한다
libm.cos.restype = c_double
print(libm.cos(0.0))                    # 1.0

libc = CDLL(find_library("c"))
libc.printf(b"Hello %s\n", b"FFI")      # 파이썬 bytes가 char*로 넘어간다

공식 문서는 ctypes가 파이썬의 안전 장치를 건너뛰고 프로세스 메모리에 직접 접근하므로, 잘못 쓰면 데이터 손상, 정보 노출, 충돌이 생길 수 있다고 경고한다.[2] cffi는 C 선언을 문자열로 적는 방식이며, 실행 중에 부르는 ABI 모드와 C 컴파일러로 확장 모듈을 만드는 API 모드가 있다.

Java: JNI와 FFM API

편집 원본 편집

JNI는 JVM에서 도는 Java 코드가 C, C++, 어셈블리로 작성된 라이브러리와 상호 작용하게 하는 표준 인터페이스다. JDK 1.0에 있던 네이티브 메서드 인터페이스가 객체 필드를 C 구조체처럼 직접 건드리고 보수적 가비지 컬렉션에 기대는 등 다른 JVM이 채택하기 어려운 문제가 있어, Netscape의 JRI 같은 선행 설계를 참고해 만들어졌다.[3] Java 쪽에 native 메서드를 선언하고, 정해진 이름 규칙(Java_패키지_클래스_메서드)의 C 함수를 구현해 컴파일해야 한다.

public class Hello {
    static { System.loadLibrary("hello"); }   // libhello.so / hello.dll
    private native int add(int a, int b);     // 구현은 C에
}
#include <jni.h>
JNIEXPORT jint JNICALL Java_Hello_add(JNIEnv *env, jobject self, jint a, jint b) {
    return a + b;
}

Java 22에서 정식 기능이 된 FFM API(JEP 454, JDK 19~21에서 미리 보기)는 C 코드를 짜지 않고 Java만으로 네이티브 함수를 부른다. java.lang.foreign 패키지의 Linker(다운콜·업콜), SymbolLookup(심벌 찾기), FunctionDescriptor(C 함수 시그니처), Arena(수명이 정해진 힙 밖 메모리), MemorySegment(경계가 검사되는 메모리 구간)가 핵심이다. jextract 도구가 C 헤더에서 이런 코드를 기계적으로 만들어 준다. 네이티브 호출처럼 위험한 연산은 --enable-native-access 옵션으로 명시적으로 허용하지 않으면 경고가 나온다.[4]

import java.lang.foreign.*;
import java.lang.invoke.MethodHandle;
import static java.lang.foreign.ValueLayout.*;

Linker linker = Linker.nativeLinker();
MethodHandle strlen = linker.downcallHandle(
        linker.defaultLookup().find("strlen").orElseThrow(),
        FunctionDescriptor.of(JAVA_LONG, ADDRESS));

try (Arena arena = Arena.ofConfined()) {             // 블록이 끝나면 메모리 해제
    MemorySegment s = arena.allocateFrom("Hello");   // NUL 종료 C 문자열
    long len = (long) strlen.invokeExact(s);          // 5
}

Rust는 extern 블록에 외부 함수를 선언하고, 컴파일러가 안전성을 보장할 수 없으므로 unsafe 블록 안에서만 부르게 한다. 보통은 이런 호출을 안전한 공개 API로 감싸 둔다. 반대로 C에서 부를 Rust 함수는 이름 장식을 끄는 no_mangle 속성과 extern "C"를 붙이고 cdylib로 빌드한다. C와 주고받는 구조체에는 #[repr(C)]를 붙여 필드 배치를 C와 같게 만든다.[5]

use std::ffi::{c_char, c_int, CString};

unsafe extern "C" {
    fn puts(s: *const c_char) -> c_int;       // libc의 puts
}

#[unsafe(no_mangle)]
pub extern "C" fn rust_add(a: i32, b: i32) -> i32 {   // C에서 부를 함수
    a + b
}

fn main() {
    let msg = CString::new("Hello from Rust").unwrap();  // NUL 종료 문자열
    unsafe { puts(msg.as_ptr()); }
}

큰 C 라이브러리는 bindgen으로 헤더에서 이런 선언을 자동 생성하고, Cargo의 빌드 스크립트(build.rs)에서 C 코드를 컴파일해 링크하는 방식이 흔하다. 패닉이 FFI 경계를 넘어 풀려 나가야 한다면 "C-unwind" ABI를 써야 하며, 그렇지 않은 경계에서 외부 예외가 넘어오면 정의되지 않은 동작이다.[5]

P/Invoke(Platform Invoke)는 관리 코드에서 비관리 라이브러리의 구조체, 콜백, 함수에 접근하는 기술이다. 네이티브 함수와 같은 시그니처의 메서드를 선언하고 라이브러리 이름을 속성으로 붙인다. .NET 7 이전에는 extern과 DllImport를 썼고, 지금은 partial 메서드에 LibraryImport를 붙이면 소스 생성기가 마샬링 코드를 만든다. 네이티브에서 관리 코드로 되부르는 콜백에는 델리게이트를 쓴다.[6]

using System.Runtime.InteropServices;

public static partial class Native
{
    [LibraryImport("user32.dll", StringMarshalling = StringMarshalling.Utf16)]
    public static partial int MessageBoxW(IntPtr hWnd, string text, string caption, uint type);
}

C#, F#, VB.NET 같은 .NET 언어끼리는 공통 언어 런타임(CLR) 위에서 extern 같은 추가 코드 없이 프로젝트 참조만으로 서로 부르므로 보통 FFI라고 부르지 않는다. 다만 한 프로젝트(.csproj, .fsproj 등)에는 한 언어만 쓸 수 있어 언어별로 프로젝트를 나눠야 하고, 프로젝트 참조는 순환이 없어야 한다. C++ 라이브러리를 함수 수준보다 풍부하게 감쌀 때는 C++/CLI를 쓰고, IronPython이나 Python.NET으로 파이썬 코드를 .NET에서 돌리기도 한다.

Node.js의 네이티브 애드온은 Node-API(예전 이름 N-API)로 만든다. Node-API는 V8 같은 자바스크립트 엔진의 내부 API와 분리된 ABI 안정 C API라서, 한 번 컴파일한 애드온이 이후 Node 버전에서도 다시 컴파일 없이 돈다. Node.js 8.6.0에서 도입되어 8.12.0에서 안정 기능이 되었다.[7]

LuaJIT, Haskell, Go

편집 원본 편집
  • LuaJIT의 ffi 라이브러리는 C 헤더에서 복사한 선언을 ffi.cdef로 그대로 파싱하므로 따로 바인딩 코드를 짤 필요가 없다. JIT 컴파일러가 C 자료 구조 접근과 C 함수 호출을 C 컴파일러 수준의 기계어로 바꾸고 인라이닝까지 한다.[8]
local ffi = require("ffi")
ffi.cdef("int printf(const char *fmt, ...);")
ffi.C.printf("Hello %s!\n", "world")
  • Haskell 2010 보고서는 FFI를 표준의 한 장으로 정했다. foreign import로 외부 함수를 가져오고 foreign export로 Haskell 함수를 내보내며, 호출 규약으로 ccall(필수), stdcall, cplusplus, jvm, dotnet을 정의한다. safe 호출은 느리지만 외부 코드가 Haskell로 되부를 수 있게 해 주고, unsafe 호출은 가볍지만 콜백을 해서는 안 된다.[9]
foreign import ccall "math.h sin" c_sin :: CDouble -> CDouble
  • Go는 소스 파일의 import "C" 바로 위 주석에 C 코드를 적는 cgo를 쓴다. cgo 호출은 Go 스케줄러와 C 스택 사이를 오가므로 일반 Go 함수 호출보다 훨씬 비싼 것으로 알려져 있고, cgo를 쓰면 크로스 컴파일과 정적 링크가 번거로워진다.[10]

마샬링(marshalling)은 한 언어의 값을 다른 언어가 이해하는 메모리 표현으로 바꾸는 일이다. FFI 버그의 대부분이 여기서 생긴다.

  • 정수와 실수: C의 long은 64비트 리눅스에서 8바이트, 64비트 Windows에서 4바이트다. 고정 크기 타입(int32_t, c_int32 등)을 쓰는 편이 안전하다.
  • 문자열: C 문자열은 NUL로 끝나는 바이트 배열이다. 파이썬 str, Java String, .NET string은 내부 표현이 달라 UTF-8이나 UTF-16으로 인코딩해 복사해야 하고, 문자열 안에 NUL이 있으면 잘린다.
  • 구조체: 필드 순서, 정렬(alignment)과 패딩이 C 컴파일러와 같아야 한다. Rust의 #[repr(C)], .NET의 StructLayout, ctypes의 Structure, FFM의 MemoryLayout이 이를 지정한다.
  • 배열과 포인터: 길이를 따로 넘겨야 하고, 이동하는 가비지 컬렉터가 있는 언어는 호출 동안 객체를 고정(pinning)하거나 힙 밖 메모리에 복사한다.
  • 콜백: 게스트가 호스트 함수를 되부르려면 호스트 함수를 C 함수 포인터로 바꾸는 트램펄린이 필요하다. .NET에서는 네이티브 쪽이 콜백을 계속 쥐고 있는 동안 델리게이트가 가비지 컬렉션되지 않게 참조를 붙들어 두어야 한다.
  • 오류: C 함수는 errno나 GetLastError로 오류를 알리므로, 다른 호출이 끼어들기 전에 바로 읽어 와야 한다(.NET의 SetLastError 옵션 등).

메모리 관리와 안전성

편집 원본 편집
  • 가비지 컬렉션과 수동 관리의 충돌: 한쪽만 가비지 컬렉션을 쓰면, 수동 관리 쪽 코드가 컬렉터를 방해하지 않게 해야 한다. JNI에서 C 코드가 Java로부터 받은 객체 참조를 계속 쥐고 있으려면 이를 전역 참조(global reference)로 JVM에 알려야 한다. 그러지 않으면 C가 쓰는 도중 JVM이 객체를 지울 수 있고, 다 쓰면 명시적으로 풀어야 한다.[3]
  • 소유권: 네이티브 함수가 돌려준 포인터를 누가 free할지, 어느 할당자로 해제할지(다른 C 런타임의 malloc과 free를 섞으면 안 됨) 계약을 지켜야 한다.
  • 메모리 안전성 상실: 경계 너머의 코드는 호스트 언어의 타입 검사와 경계 검사를 받지 않는다. 잘못된 포인터나 틀린 시그니처 선언 하나로 버퍼 오버플로우, 해제 후 사용, 프로세스 전체 충돌이 생긴다. 파이썬이나 Java 프로그램이라도 네이티브 확장에서 난 메모리 오류는 인터프리터나 JVM을 통째로 죽인다.
  • 예외와 패닉: C++ 예외나 Rust 패닉이 C 경계를 넘어 풀려 나가는 것은 대개 정의되지 않은 동작이다. 경계에서 잡아 오류 코드로 바꾼다.
  • 스레드: 네이티브 코드가 만든 스레드에서 호스트 언어로 되부르려면 스레드를 런타임에 등록해야 한다(JNI의 AttachCurrentThread, 파이썬의 GIL 획득 등).
  • 보안 정책: Java는 FFM의 제한된 메서드를 쓰려면 --enable-native-access로 허용하게 하는 등, 네이티브 접근을 명시적인 선택으로 만든다.[4]
  • 성능: 경계를 넘을 때마다 인자 변환, 스레드 상태 전환, 가비지 컬렉터와의 협조 비용이 든다. 작은 함수를 수백만 번 부르기보다 큰 작업을 한 번에 넘기는 편이 낫다.
  • 이식성: 호출 규약과 자료형 크기는 CPU와 운영체제마다 달라, 플랫폼마다 따로 빌드·검증해야 한다.

FFI로 보지 않는 것

편집 원본 편집
  • CLR처럼 여러 언어가 같은 실행 기반을 공유하는 다중 언어 런타임. 이때는 공통 기반이 서로의 서비스를 쓰게 해 준다. 다만 CLR 밖을 부를 때 쓰는 P/Invoke는 FFI다.
  • Java RMI, RPC, CORBA, SOAP, D-Bus 같은 분산 호출 구조. 서비스마다 다른 언어로 짤 수 있지만 같은 프로세스 안의 함수 호출이 아니어서 FFI로 보지 않는다.
  • Clojure와 Java, Elixir와 Erlang처럼 같은 바이트코드 VM으로 컴파일되는 언어끼리의 호출. 사용자에게는 같은 기능을 주지만 엄밀히 인터페이스가 따로 없다.
  • 초기 고급 언어들도 어셈블리 루틴을 부르는 방법을 제공했고, Common Lisp 구현들이 "foreign function"이라는 이름을 널리 썼다.
  • 1990년대: Java의 JDK 1.0 네이티브 메서드 인터페이스와 이를 대체한 JNI가 나왔고, 1990년대 중반 SWIG이 여러 스크립트 언어용 C·C++ 바인딩을 자동 생성하는 도구로 등장했다.
  • 2000년대: 파이썬 2.5(2006)가 ctypes를 표준 라이브러리에 넣었고[11], Haskell 98 FFI 부록을 거쳐 Haskell 2010이 FFI를 표준에 포함했다.
  • 2010년대 이후: LuaJIT FFI, cffi, Rust의 extern과 bindgen, Node-API(2017~2018)처럼 C 헤더를 그대로 쓰거나 ABI 안정을 보장하는 방식이 퍼졌다. Java는 2024년 Java 22에서 FFM API를 정식으로 넣어 JNI 없이 네이티브 호출을 할 수 있게 되었다.[4]
  • API: 소스 코드 수준의 인터페이스
  • 응용 프로그램 이진 인터페이스(ABI): 컴파일된 바이너리 사이의 인터페이스
  • 링커: 같은 ABI를 쓰는 목적 파일을 분석해 서로 참조하는 함수를 연결하는 프로그램
  • 호출 규약: 바이너리 코드끼리 호출할 때의 규칙
  • 이름 장식(name mangling, name decoration)
  • COM, DCOM, OLE, ActiveX: Windows의 이진 수준 구성 요소 규약
  • RPC: 원격 프로시저 호출
  • SWIG(Simplified Wrapper and Interface Generator): 여러 언어용 인터페이스 코드를 생성하는 오픈 소스 도구
  • libffi: 실행 중에 임의 시그니처의 C 함수를 부르게 해 주는 이식성 라이브러리
  • 트랜스컴파일러: 한 언어의 소스 코드를 다른 언어나 같은 언어의 다른 버전으로 바꾸는 컴파일러(Babel, Scala.js 등)
  • WebAssembly: 언어 중립적인 모듈 형식으로, 호스트와 가져오기·내보내기 함수로 상호 작용한다