FFI(Foreign Function Interface)는 dart:ffi 패키지에서 제공하는 Dart 언어의 메커니즘으로, Kotlin, Swift 또는 Java의 중간 계층 없이 네이티브 C 라이브러리의 함수를 직접 호출할 수 있습니다. 개발자는 동적 라이브러리(Android에서는 .so, iOS에서는 .dylib, Windows에서는 .dll)를 로드하고, C 함수 시그니처를 선언하여 일반 Dart 함수처럼 호출합니다. Dart API Reference(2025)에 따르면, FFI는 언어 간 호출의 오버헤드를 0.1 µs로 줄여 Method Channel보다 수십 배 빠릅니다.
주요 내용
FFI(Foreign Function Interface)는 프로그래밍 언어가 다른 언어로 작성된 함수를 호출할 수 있게 하는 메커니즘입니다. Dart와 Flutter의 맥락에서 FFI는 Java(Android) 또는 Swift/Objective-C(iOS)로 플랫폼별 코드를 작성하지 않고 Dart 코드에서 직접 C/C++ 라이브러리의 함수를 호출하는 기능을 의미합니다.
dart:ffi 패키지는 Dart 2.12(2021)에 등장했으며 그 이후로 Flutter를 네이티브 코드와 통합하는 핵심 도구가 되었습니다. dart:ffi 이전에 Dart에서 C 함수를 호출하는 유일한 방법은 Method Channel이었습니다 — Dart와 네이티브 측 간에 JSON 직렬화를 통해 메시지를 전달하는 비동기 메커니즘입니다. FFI는 다르게 작동합니다: Dart 코드가 C 라이브러리 메모리에 직접 액세스하여 직렬화나 컨텍스트 스위칭 없이 네이티브 ABI(애플리케이션 바이너리 인터페이스)를 통해 함수를 호출합니다.
FFI는 성능이 중요한 시나리오에서 특히 요구됩니다: 이미지 처리(OpenCV), 오디오(FFmpeg), 암호화(OpenSSL), 머신 러닝(TensorFlow Lite) 및 데이터베이스(SQLite). 이러한 모든 경우에 Method Channel은 허용할 수 없는 지연을 발생시키는 반면, FFI는 네이티브 C/C++ 코드에 필적하는 성능을 제공합니다. dart:ffi 라이브러리는 메모리 관리(할당, 해제 및 포인터 조작)도 지원합니다.
Method Channel은 비동기적으로 작동합니다: Dart가 네이티브 코드에 메시지를 보내면 네이티브 코드가 이를 처리하고 결과를 다시 보냅니다. 각 호출은 Map으로 인수 직렬화, 큐를 통한 전달 및 역직렬화가 필요합니다. 호출당 0.5~5ms가 소요됩니다. FFI는 동기식으로 직렬화 없이 작동합니다 — C 함수 호출은 0.01~0.1 µs가 소요됩니다. 50~500배의 차이로 고주파 작업에 중요합니다.
dart:ffi 작업은 세 단계로 구성됩니다: 라이브러리 로드, 시그니처 선언 및 함수 호출. 각 단계는 Dart의 엄격한 타이핑을 사용하여 런타임 오류를 최소화합니다.
첫 번째 단계에서는 DynamicLibrary 클래스를 통해 동적 라이브러리가 로드됩니다. 라이브러리는 이름(libxyz.so, libxyz.dylib, xyz.dll) 또는 전체 경로로 로드할 수 있습니다. Dart는 자동으로 시스템의 표준 경로에서 라이브러리를 검색합니다. DynamicLibrary는 lookupFunction 메서드를 제공하여 심볼 이름으로 Dart 함수를 C 함수에 바인딩합니다.
두 번째 단계에서는 C 시그니처에 해당하는 유형 주석이 있는 Dart 함수가 선언됩니다. dart:ffi의 특수 유형(Int32, Float, Double, Pointer, NativeFunction, Handle 등)이 사용됩니다. lookupFunction 주석은 두 개의 제네릭 매개변수를 받습니다: Dart 함수 유형(Dart에서 어떻게 보일지)과 네이티브 C 함수 유형(C에서 어떻게 선언되는지).
세 번째 단계에서는 생성된 Dart 함수가 일반 함수처럼 호출됩니다. 인수는 직접 전달되고 결과는 즉시 반환됩니다. C 함수가 포인터를 통해 메모리를 수정하는 경우 Dart는 Pointer 클래스를 통해 이러한 변경 사항을 읽을 수 있습니다. C 측의 메모리 관리는 개발자의 책임입니다 — dart:ffi는 C에서 malloc으로 할당된 메모리를 관리하지 않습니다.
import 'dart:ffi'
import 'package:ffi/ffi.dart'
// C 함수 선언: int add(int a, int b)
typedef AddNative = Int32 Function(Int32, Int32)
typedef AddDart = int Function(int, int)
void main() {
final lib = DynamicLibrary.open('libcalculator.so')
final AddDart add = lib
.lookupFunction<AddNative, AddDart>('add')
print(add(5, 3)) // 8
}
이 예제에서 add는 두 개의 int를 받아 int를 반환하는 C 함수입니다. AddNative typedef는 dart:ffi 유형으로 C 시그니처를 설명하고, AddDart는 이 함수가 Dart에서 어떻게 보일지 설명합니다. lookupFunction이 이를 바인딩하고 일반 함수처럼 호출할 수 있는 Dart 함수를 반환합니다.
dart:ffi는 C 유형에 해당하는 유형 집합을 제공합니다. 각 유형은 고정된 크기와 Dart와 C 간의 변환 규칙이 있습니다. FFI의 올바른 작동을 위해서는 유형 매핑을 이해하는 것이 매우 중요합니다 — 유형의 크기나 부호 오류는 애플리케이션 충돌을 일으킬 수 있습니다.
| C 유형 | dart:ffi 유형 | Dart 유형 | 크기(바이트) |
|---|---|---|---|
| int | Int32 | int | 4 |
| long | Int64 | int | 8 |
| float | Float | double | 4 |
| double | Double | double | 8 |
| char* | Pointer<Int8> | Pointer | 8(포인터) |
| void* | Pointer<Void> | Pointer | 8(포인터) |
| struct | Pointer<T>(Struct) | Pointer | 필드에 따라 다름 |
C 문자열(char*) 작업을 위해 dart:ffi는 Pointer<Int8>을 사용합니다. Dart String에서 C char*로의 변환 및 역변환은 toNativeUtf8(ffi 패키지)과 fromUtf8을 통해 수행됩니다. 메모리 누수를 방지하기 위해 사용 후 calloc.free를 통해 C 문자열을 해제하는 것이 중요합니다.
dart:ffi는 Struct를 확장하는 Dart 클래스로 C 구조체 선언을 지원합니다. 구조체 필드는 @Int32(), @Float(), @Array() 등의 주석으로 선언됩니다. 필드의 크기와 오프셋은 플랫폼의 ABI에 따라 자동으로 계산됩니다. Pointer<Point>는 구조체에 대한 포인터를 반환하는 C 함수에서 얻거나 Dart에서 calloc을 통해 할당할 수 있습니다.
// C 구조체: typedef struct { int x; int y; } Point;
final class Point extends Struct {
@Int32()
external int x
@Int32()
external int y
}
// Point*를 반환하는 C 함수 호출
typedef CreatePointNative = Pointer<Point> Function(Int32, Int32)
typedef CreatePointDart = Pointer<Point> Function(int, int)
final Pointer<Point> p = createPoint(10, 20)
print('x: ${p.ref.x}, y: ${p.ref.y}')
calloc.free(p) // 메모리 해제
Point 클래스는 Struct를 확장하고 @Int32() 주석으로 x 및 y 필드를 선언합니다. 생성된 C 코드는 정확히 동일한 메모리 레이아웃을 갖습니다. Pointer.ref는 게터와 세터를 통해 구조체 필드에 대한 액세스를 제공합니다.
더 복잡한 예제를 살펴보겠습니다 — SHA256 해시 계산을 위한 C 라이브러리와의 통합입니다. 이는 FFI가 Method Channel에 비해 상당한 성능 이점을 제공하는 일반적인 작업입니다.
OpenSSL 라이브러리는 문자열의 해시를 계산하는 SHA256 함수를 제공합니다. dart:ffi를 통해 Java나 Swift 래퍼를 작성하지 않고 직접 호출할 수 있습니다. 이는 FFI가 Flutter에서 기존 C 라이브러리를 재사용하는 방법을 보여주는 예입니다.
import 'dart:ffi'
import 'package:ffi/ffi.dart'
// 시그니처: unsigned char* SHA256(
// const unsigned char *d, size_t n, unsigned char *md)
typedef Sha256Native = Pointer<Uint8> Function(
Pointer<Uint8>, Size, Pointer<Uint8>)
typedef Sha256Dart = Pointer<Uint8> Function(
Pointer<Uint8>, int, Pointer<Uint8>)
String sha256(String input) {
final lib = DynamicLibrary.open('libcrypto.so')
final Sha256Dart sha256Fn = lib
.lookupFunction<Sha256Native, Sha256Dart>('SHA256')
final inputPtr = input.toNativeUtf8()
final outputPtr = calloc(Uint8)(32) // SHA256 = 32바이트
sha256Fn(inputPtr, input.length, outputPtr)
final digest = outputPtr.asTypedList(32)
final hex = digest.map((b) => b.toRadixString(16)
.padLeft(2, '0')).join()
calloc.free(inputPtr)
calloc.free(outputPtr)
return hex
}
이 예제에서 sha256 함수는 libcrypto.so 라이브러리를 로드하고 SHA256 심볼을 찾아 입력 및 출력 데이터에 대한 포인터로 호출합니다. toNativeUtf8은 Dart String을 C 문자열로 변환하고(메모리 할당), asTypedList는 결과 바이트 배열을 읽을 수 있습니다. 사용 후 메모리가 해제됩니다 — 누수를 방지하기 위한 필수 단계입니다.
ffi 패키지는 C 호환 메모리 할당을 위한 calloc 함수를 제공합니다. 할당된 메모리는 calloc.free를 통해 해제해야 합니다, 그렇지 않으면 누수가 발생합니다. 자동 메모리 관리를 위해 ffi 패키지의 Arena 클래스를 사용할 수 있으며, arena.release()가 호출되면 내부에 할당된 모든 메모리를 해제합니다. 이는 많은 수의 임시 할당에 특히 편리합니다.
FFI의 강력함에도 불구하고 Flutter 애플리케이션 아키텍처를 설계할 때 고려해야 할 제한 사항이 있습니다. 주요 제한 사항은 유형 안전성, 메모리 관리 및 플랫폼 호환성과 관련됩니다.
FFI는 런타임에 유형 검사를 수행하지 않습니다. C 함수가 포인터를 예상했지만 숫자를 받으면 애플리케이션이 세그멘테이션 폴트와 함께 충돌합니다. C 헤더 파일(.h)을 기반으로 유형 안전 Dart 래퍼를 생성하는 도구인 FFIgen을 사용하는 것이 좋습니다. FFIgen은 C 함수 선언을 분석하고 올바른 유형으로 Dart 코드를 생성하여 코드 작성 단계에서 오류를 제거합니다.
동적 라이브러리의 이름과 경로는 플랫폼마다 다릅니다: Android/Linux에서는 libxyz.so, iOS/macOS에서는 libxyz.dylib, Windows에서는 xyz.dll입니다. 크로스 플랫폼 라이브러리의 경우 dart:io(Platform.isAndroid, Platform.isIOS) 또는 package:ffi와 같은 추상화를 통한 조건부 컴파일이 사용됩니다. 현재 플랫폼에 맞는 라이브러리를 반환하는 팩토리 메서드를 만드는 것이 좋습니다.
FFI는 C 측의 메모리를 관리하지 않습니다. C 함수가 malloc을 통해 메모리를 할당한 경우 free를 통해 해제해야 합니다, 그렇지 않으면 누수가 발생합니다. Dart에는 C 메모리용 가비지 컬렉터가 없습니다. 권장 사항: 항상 메모리가 할당된 동일한 메서드에서 해제하거나 그룹 해제를 위해 Arena를 사용하세요.
FFI 호출은 Dart 코드와 동일한 스레드에서 실행됩니다. 긴 동기 작업(10ms 이상)은 UI 스레드를 차단하고 프레임 드롭을 유발합니다. 긴 작업의 경우 Isolate에서 C 함수를 호출하거나 C 함수가 백그라운드 스레드에서 작업을 실행하고 Port 또는 콜백을 통해 Dart에 알리도록 해야 합니다.
// 긴 작업을 위한 Isolate의 FFI
import 'dart:isolate'
Future<String> computeHash(String input) async {
final port = ReceivePort()
await Isolate.spawn((SendPort sendPort) {
final result = sha256(input) // FFI 호출
sendPort.send(result)
}, port.sendPort)
return await port.first as String
}
FFI 호출을 Isolate로 이동하면 UI 스레드가 차단되지 않습니다. 그러나 Isolate 간 대량의 데이터 전송에는 메모리 복사가 필요합니다. 큰 버퍼(>10 MB)의 경우 SharedMemory 또는 메모리 매핑 파일을 사용하는 것이 좋습니다.
자주 묻는 질문
FFI는 C 함수를 직접, 동기식으로, 직렬화 없이 호출합니다 — 지연 시간 0.01~0.1 µs. Method Channel은 JSON 직렬화를 통해 비동기식으로 0.5~5ms의 지연 시간으로 작동합니다. FFI는 고성능 작업에 적합하고, Method Channel은 간단한 플랫폼 API 호출에 적합합니다.
직접적으로는 — 아니요, dart:ffi는 C 함수만 지원합니다. C++를 호출하려면 extern “C”(C 심볼로 내보내는 진입점)로 C 래퍼를 만들어야 합니다. C++ 클래스는 메서드 호출을 C 함수로 변환하는 추가 계층이 필요합니다.
FFI는 예외를 지원하지 않습니다 — C 함수가 오류 코드를 반환하면 수동으로 확인해야 합니다. Dart에서 FFI 호출을 try-catch로 감싸고 C 함수의 반환 코드를 확인하는 것이 좋습니다. 심각한 오류(segfault)는 잡을 수 없습니다.
FFI는 복잡한 Java(JNI) 또는 Objective-C(Message Dispatch) 초기화가 필요한 라이브러리와 작동하지 않습니다. 예를 들어 UIKit 및 Android Views는 FFI를 통해 액세스할 수 없습니다. 제한 사항은 FFI가 C ABI 수준에서 작동하는 반면 이러한 API는 특정 런타임이 필요하기 때문입니다.
네, C 라이브러리는 각 대상 플랫폼에 대해 별도로 컴파일됩니다. Android의 경우 .so가 다른 ABI(armeabi-v7a, arm64-v8a, x86_64)용으로 빌드됩니다. iOS의 경우 — 유니버설 .dylib(arm64). Windows의 경우 — .dll. Flutter는 빌드 중에 자동으로 올바른 라이브러리 버전을 패키징합니다.
요약
턴키 방식의 모바일 애플리케이션을 개발해 드립니다
IT Sectr는 2017년부터 스타트업과 기업을 위한 iOS 및 Android 애플리케이션을 만듭니다. 저희가 상담해 드리고 최적의 솔루션을 제안하겠습니다.