Flutter Isolate.run과 Worker Pool로 대용량 JSON/AI 연산 스레드 분리해 60fps 유지하기

Flutter는 기본적으로 싱글 스레드(Main Isolate) 기반으로 동작합니다. 즉, UI 렌더링, 이벤트 처리, 그리고 우리가 작성한 로직까지 모두 하나의 스레드에서 실행됩니다. 그렇기 때문에 대용량 JSON을 파싱하거나 AI 모델의 추론과 같은 무거운 연산을 메인 스레드에서 수행하면, 화면이 멈추거나 버벅이는 ‘Jank’ 현상이 발생하게 됩니다.
이러한 문제를 해결하고 부드러운 60fps(또는 120fps) 환경을 유지하려면 무거운 작업을 별도의 스레드로 분리해야 합니다. Dart에서는 이를 Isolate라고 부릅니다. 이번 글에서는 간단한 Isolate.run부터, 반복적인 작업에 효율적인 Worker Pool을 구축하는 방법까지 알아보겠습니다.
단발성 무거운 작업에는 Isolate.run
Dart 2.15부터 Isolate.run이 도입되면서, 별도의 스레드를 생성하고 결과값을 받아오는 과정이 매우 간단해졌습니다. 네트워크 통신 후 받아온 대용량 JSON을 객체 리스트로 변환하는 작업을 예로 들어보겠습니다.
import 'dart:convert';
import 'dart:isolate';
// 변환할 데이터 모델
class HeavyData {
final int id;
final String content;
HeavyData({required this.id, required this.content});
factory HeavyData.fromJson(Map<String, dynamic> json) {
return HeavyData(id: json['id'], content: json['content']);
}
}
// 메인 스레드에서 실행되는 함수
Future<List<HeavyData>> fetchAndParseData(String jsonString) async {
// 별도의 Isolate에서 파싱 로직 실행
return await Isolate.run(() {
final List<dynamic> parsed = jsonDecode(jsonString);
return parsed.map((e) => HeavyData.fromJson(e)).toList();
});
}
위의 예시에서 Isolate.run 블록 안에 있는 코드는 새로운 Isolate에서 실행됩니다. 연산이 완료되면 결과값이 메인 Isolate로 안전하게 전달되고, 임시로 생성된 Isolate는 즉시 종료됩니다. 복잡한 포트(Port) 설정 없이도 깔끔하게 스레드를 분리할 수 있습니다.
반복적인 작업에는 Worker Pool 패턴
Isolate.run은 매번 새로운 Isolate를 생성하고 종료하기 때문에, 오버헤드가 발생할 수 있습니다. 만약 온디바이스(On-device) AI 추론이나, 이미지 필터링처럼 짧고 반복적인 무거운 작업이 지속적으로 발생한다면, Isolate를 미리 생성해두고 재사용하는 Worker Pool 패턴이 유리합니다.
Worker Pool 구현하기
isolate 패키지의 포트를 사용하여 메인 Isolate와 백그라운드 Isolate 간의 통신 채널을 구축합니다.
import 'dart:isolate';
class ComputationWorker {
late SendPort _sendPort;
late Isolate _isolate;
bool _isReady = false;
Future<void> init() async {
final receivePort = ReceivePort();
_isolate = await Isolate.spawn(_workerEntry, receivePort.sendPort);
// 첫 번째 메시지로 SendPort를 받습니다.
_sendPort = await receivePort.first as SendPort;
_isReady = true;
}
// 백그라운드에서 실행될 진입점
static void _workerEntry(SendPort mainSendPort) {
final workerReceivePort = ReceivePort();
mainSendPort.send(workerReceivePort.sendPort);
workerReceivePort.listen((message) {
if (message is List) {
final SendPort replyPort = message[0];
final dynamic data = message[1];
// 무거운 연산 수행 (예시)
final result = data.toString().toUpperCase();
// 결과 반환
replyPort.send(result);
}
});
}
// 메인 스레드에서 작업을 요청하는 함수
Future<dynamic> compute(dynamic data) async {
if (!_isReady) throw Exception('Worker is not ready');
final responsePort = ReceivePort();
_sendPort.send([responsePort.sendPort, data]);
final result = await responsePort.first;
responsePort.close();
return result;
}
void dispose() {
_isolate.kill();
}
}
이제 이 Worker 객체를 싱글턴이나 프로바이더로 유지하면서 필요할 때마다 compute 메서드를 호출하면 됩니다. 매번 스레드를 생성하는 비용을 아끼고, 끊김없는 60fps 애니메이션을 유지할 수 있습니다.
결론
Flutter 앱에서 성능 이슈가 발생할 때 대부분의 원인은 메인 스레드를 블로킹하는 동기적 연산에 있습니다.
- 1회성 로딩이나 파싱에는 직관적인
Isolate.run을 적극적으로 사용하세요. - 지속적으로 발생하는 연산에는
Worker Pool을 직접 구성하거나 관련 패키지를 활용하는 것을 추천합니다.
무거운 짐은 별도의 스레드에 맡기고, Flutter의 메인 스레드가 오직 UI 렌더링에만 집중하도록 설계해보세요!