Flutter 튜토리얼 53편: Isolate와 동시성
Flutter Isolate와 동시성
무거운 작업이 UI를 멈추게 하면 사용자 경험이 크게 저하됩니다. 이 튜토리얼에서는 Dart의 Isolate를 사용하여 백그라운드에서 작업을 처리하는 방법을 배웁니다.
학습 목표
- Isolate의 개념과 동작 원리 이해하기
- Isolate.run()으로 간단한 백그라운드 작업 처리하기
- 장기 실행 Isolate 구현하기
- 플랫폼 플러그인과 Isolate 함께 사용하기
1. Isolate의 개념
Why: Isolate가 필요한 이유
Flutter 앱은 단일 스레드(UI 스레드)에서 실행됩니다. 만약 이 스레드에서 무거운 작업을 실행하면, 그 동안 UI가 멈춰버립니다. 사용자에게 앱이 “멈춤” 상태로 보이게 됩니다.
What: Isolate란?
Isolate1는 Dart에서 동시성을 구현하는 방식입니다. 각 Isolate는 독립적인 메모리 공간을 가지며, 다른 Isolate와 메모리를 공유하지 않습니다.
| 특징 | 설명 |
|---|---|
| 독립 메모리 | 각 Isolate는 별도의 메모리 힙을 가짐 |
| 메시지 전달 | 데이터는 복사되어 전달됨 |
| 안전성 | 메모리 공유 문제 없음 |
| 오버헤드 | 생성 비용이 있음 |
What: 이벤트 루프와 프레임 갭
Flutter는 이벤트 루프2를 통해 작업을 처리합니다. 각 프레임 사이에 약간의 유휴 시간(프레임 갭)이 있습니다.
짧은 비동기 작업은 이 프레임 갭에서 처리할 수 있습니다. 하지만 무거운 작업은 여러 프레임을 건너뛰게 만듭니다.
How: 언제 Isolate를 사용해야 하나요?
| 상황 | Isolate 필요 여부 | 이유 |
|---|---|---|
| JSON 파싱 (작은 데이터) | 불필요 | 프레임 갭에서 처리 가능 |
| JSON 파싱 (큰 데이터) | 필요 | 16ms 초과 가능 |
| 이미지 처리 | 필요 | CPU 집약적 작업 |
| 암호화/복호화 | 필요 | CPU 집약적 작업 |
| 파일 읽기 | 불필요 | 이미 비동기로 처리됨 |
| HTTP 요청 | 불필요 | 이미 비동기로 처리됨 |
Watch out: 주의사항
Isolate 생성에는 비용이 듭니다. 작은 작업에 Isolate를 사용하면 오히려 성능이 저하될 수 있습니다. 작업이 약 50ms 이상 걸릴 것으로 예상될 때 Isolate를 고려하세요.
2. Isolate.run() 사용하기
Why: 가장 간단한 방법
Isolate.run()은 Flutter 3.7/Dart 2.19에서 도입된 간편한 API입니다.
일회성 백그라운드 작업을 쉽게 처리할 수 있습니다.
What: Isolate.run()의 동작 방식
How: 기본 사용법
import 'dart:isolate';
// 무거운 작업을 수행하는 함수int heavyComputation(int value) { // 복잡한 계산 시뮬레이션 int result = 0; for (int i = 0; i < value * 1000000; i++) { result += i; } return result;}
// Isolate에서 실행Future<void> runInBackground() async { final result = await Isolate.run(() { return heavyComputation(100); });
print('결과: $result');}How: JSON 파싱 예제
큰 JSON 데이터를 파싱하는 실제 예제입니다:
import 'dart:convert';import 'dart:isolate';
import 'package:flutter/services.dart';
class Photo { final int id; final String title; final String url;
Photo({ required this.id, required this.title, required this.url, });
factory Photo.fromJson(Map<String, dynamic> json) { return Photo( id: json['id'] as int, title: json['title'] as String, url: json['url'] as String, ); }}
Future<List<Photo>> getPhotos() async { // 1. 메인 Isolate에서 파일 읽기 (rootBundle은 메인에서만 사용 가능) final String jsonString = await rootBundle.loadString('assets/photos.json');
// 2. 워커 Isolate에서 파싱 final List<Photo> photos = await Isolate.run<List<Photo>>(() { final List<dynamic> photoData = jsonDecode(jsonString) as List<dynamic>; return photoData .cast<Map<String, dynamic>>() .map(Photo.fromJson) .toList(); });
return photos;}How: 위젯에서 사용하기
class PhotoListScreen extends StatefulWidget { const PhotoListScreen({super.key});
@override State<PhotoListScreen> createState() => _PhotoListScreenState();}
class _PhotoListScreenState extends State<PhotoListScreen> { List<Photo>? _photos; bool _isLoading = true;
@override void initState() { super.initState(); _loadPhotos(); }
Future<void> _loadPhotos() async { final photos = await getPhotos(); setState(() { _photos = photos; _isLoading = false; }); }
@override Widget build(BuildContext context) { if (_isLoading) { return const Center(child: CircularProgressIndicator()); }
return ListView.builder( itemCount: _photos!.length, itemBuilder: (context, index) { final photo = _photos![index]; return ListTile( title: Text(photo.title), subtitle: Text(photo.url), ); }, ); }}Watch out: 주의사항
Isolate.run()에 전달하는 함수는 최상위 함수이거나 정적 메서드여야 합니다.
클래스의 인스턴스 메서드나 클로저는 사용할 수 없습니다.
// ❌ 잘못된 예: 인스턴스 메서드class MyClass { int value = 10;
Future<int> compute() async { return await Isolate.run(() { return value * 2; // 에러! value에 접근 불가 }); }}
// ✅ 올바른 예: 값을 직접 전달class MyClass { int value = 10;
Future<int> compute() async { final localValue = value; // 값 복사 return await Isolate.run(() { return localValue * 2; // 복사된 값 사용 }); }}3. 장기 실행 Isolate
Why: 반복 작업에 Isolate를 재사용해야 하는 이유
Isolate.run()은 매번 새 Isolate를 생성하고 종료합니다.
반복적인 작업이 있다면 Isolate를 유지하고 재사용하는 것이 효율적입니다.
What: SendPort와 ReceivePort
Isolate 간 통신은 포트3를 통해 이루어집니다.
How: 장기 실행 Isolate 구현
import 'dart:isolate';
class BackgroundWorker { late final Isolate _isolate; late final SendPort _sendPort; late final ReceivePort _receivePort; bool _isReady = false;
// 워커 초기화 Future<void> initialize() async { _receivePort = ReceivePort();
// Isolate 생성 _isolate = await Isolate.spawn( _workerEntryPoint, _receivePort.sendPort, );
// 워커로부터 SendPort 받기 _sendPort = await _receivePort.first as SendPort; _isReady = true; }
// 워커 진입점 (최상위 함수) static void _workerEntryPoint(SendPort sendPort) { final receivePort = ReceivePort();
// 메인에 SendPort 전달 sendPort.send(receivePort.sendPort);
// 메시지 수신 대기 receivePort.listen((message) { if (message is Map<String, dynamic>) { final result = _processMessage(message); sendPort.send(result); } }); }
// 메시지 처리 로직 static dynamic _processMessage(Map<String, dynamic> message) { final type = message['type'] as String; final data = message['data'];
switch (type) { case 'compute': return _heavyComputation(data as int); case 'parse': return _parseJson(data as String); default: return null; } }
static int _heavyComputation(int value) { int result = 0; for (int i = 0; i < value * 1000000; i++) { result += i; } return result; }
static List<Map<String, dynamic>> _parseJson(String json) { // JSON 파싱 로직 return []; }
// 작업 요청 Future<dynamic> compute(String type, dynamic data) async { if (!_isReady) { throw StateError('Worker not initialized'); }
final responsePort = ReceivePort(); _sendPort.send({ 'type': type, 'data': data, 'responsePort': responsePort.sendPort, });
return await responsePort.first; }
// 정리 void dispose() { _receivePort.close(); _isolate.kill(); }}How: 사용 예제
class MyApp extends StatefulWidget { const MyApp({super.key});
@override State<MyApp> createState() => _MyAppState();}
class _MyAppState extends State<MyApp> { final _worker = BackgroundWorker();
@override void initState() { super.initState(); _worker.initialize(); }
@override void dispose() { _worker.dispose(); super.dispose(); }
Future<void> _doHeavyWork() async { final result = await _worker.compute('compute', 100); print('결과: $result'); }
@override Widget build(BuildContext context) { return ElevatedButton( onPressed: _doHeavyWork, child: const Text('무거운 작업 실행'), ); }}Watch out: 주의사항
장기 실행 Isolate는 반드시 앱 종료 시 정리해야 합니다.
dispose() 메서드에서 Isolate.kill()을 호출하지 않으면 메모리 누수가 발생합니다.
4. 플랫폼 플러그인과 Isolate
Why: 플러그인을 Isolate에서 사용해야 하는 경우
일부 무거운 작업은 플러그인 기능과 함께 사용해야 합니다. 예를 들어, 백그라운드에서 SharedPreferences에 접근하거나 네이티브 코드를 호출해야 할 수 있습니다.
What: BackgroundIsolateBinaryMessenger
기본적으로 플랫폼 채널4은 메인 Isolate에서만 동작합니다.
BackgroundIsolateBinaryMessenger를 사용하면 다른 Isolate에서도 플랫폼 플러그인을 사용할 수 있습니다.
How: 설정 방법
import 'dart:isolate';
import 'package:flutter/services.dart';import 'package:shared_preferences/shared_preferences.dart';
void main() { // 메인 Isolate에서 RootIsolateToken 획득 final RootIsolateToken rootIsolateToken = RootIsolateToken.instance!;
// 워커 Isolate에 토큰 전달 Isolate.spawn(_isolateMain, rootIsolateToken);}
Future<void> _isolateMain(RootIsolateToken rootIsolateToken) async { // BackgroundIsolateBinaryMessenger 초기화 BackgroundIsolateBinaryMessenger.ensureInitialized(rootIsolateToken);
// 이제 플랫폼 플러그인 사용 가능 final SharedPreferences prefs = await SharedPreferences.getInstance(); final bool? isDebug = prefs.getBool('isDebug'); print('isDebug: $isDebug');}How: 실제 사용 예제
class BackgroundPrefsWorker { late final Isolate _isolate; late final SendPort _sendPort; late final ReceivePort _receivePort;
Future<void> initialize() async { _receivePort = ReceivePort(); final rootToken = RootIsolateToken.instance!;
_isolate = await Isolate.spawn( _workerMain, _WorkerConfig( sendPort: _receivePort.sendPort, rootToken: rootToken, ), );
_sendPort = await _receivePort.first as SendPort; }
static Future<void> _workerMain(_WorkerConfig config) async { // 플러그인 초기화 BackgroundIsolateBinaryMessenger.ensureInitialized(config.rootToken);
final receivePort = ReceivePort(); config.sendPort.send(receivePort.sendPort);
await for (final message in receivePort) { if (message is Map<String, dynamic>) { final action = message['action'] as String; final key = message['key'] as String; final responsePort = message['responsePort'] as SendPort;
final prefs = await SharedPreferences.getInstance();
switch (action) { case 'getString': responsePort.send(prefs.getString(key)); break; case 'setString': final value = message['value'] as String; await prefs.setString(key, value); responsePort.send(true); break; } } } }
Future<String?> getString(String key) async { final responsePort = ReceivePort(); _sendPort.send({ 'action': 'getString', 'key': key, 'responsePort': responsePort.sendPort, }); return await responsePort.first as String?; }
Future<void> setString(String key, String value) async { final responsePort = ReceivePort(); _sendPort.send({ 'action': 'setString', 'key': key, 'value': value, 'responsePort': responsePort.sendPort, }); await responsePort.first; }
void dispose() { _receivePort.close(); _isolate.kill(); }}
class _WorkerConfig { final SendPort sendPort; final RootIsolateToken rootToken;
_WorkerConfig({ required this.sendPort, required this.rootToken, });}Watch out: 주의사항
모든 플러그인이 백그라운드 Isolate를 지원하는 것은 아닙니다. 플러그인 문서를 확인하고, 지원하지 않는 경우 메인 Isolate에서 처리해야 합니다.
5. Isolate의 제한사항
Why: 제한사항을 알아야 하는 이유
Isolate를 사용할 때 몇 가지 제한사항이 있습니다. 이를 모르면 런타임 오류가 발생할 수 있습니다.
What: 주요 제한사항
| 제한사항 | 설명 | 해결 방법 |
|---|---|---|
| rootBundle | 에셋에 직접 접근 불가 | 메인에서 데이터 로드 후 전달 |
| dart | 일부 메서드 사용 불가 | 메인 Isolate에서 처리 |
| 플랫폼 채널 | 기본적으로 비활성화 | BackgroundIsolateBinaryMessenger 사용 |
| 웹 플랫폼 | 실제 병렬 처리 안됨 | 웹 워커 또는 다른 접근 필요 |
How: rootBundle 제한 우회
// ❌ 잘못된 예: Isolate에서 직접 rootBundle 접근Future<List<Photo>> badExample() async { return await Isolate.run(() async { // 에러 발생! final json = await rootBundle.loadString('assets/data.json'); return parsePhotos(json); });}
// ✅ 올바른 예: 메인에서 데이터 로드 후 전달Future<List<Photo>> goodExample() async { // 1. 메인 Isolate에서 파일 읽기 final json = await rootBundle.loadString('assets/data.json');
// 2. 워커 Isolate에서 파싱 return await Isolate.run(() { return parsePhotos(json); });}What: 웹 플랫폼의 특수성
웹에서 Isolate.run()과 compute()는 메인 스레드에서 실행됩니다.
실제 병렬 처리가 필요하면 웹 워커를 사용해야 합니다.
import 'package:flutter/foundation.dart';
Future<List<Photo>> parsePhotosOptimal(String json) async { if (kIsWeb) { // 웹: 청크 단위로 나눠서 처리 return _parseInChunks(json); } else { // 네이티브: Isolate 사용 return await Isolate.run(() => parsePhotos(json)); }}
List<Photo> _parseInChunks(String json) { // 웹에서 UI 차단을 최소화하기 위한 처리 // 실제로는 웹 워커 사용을 권장 return parsePhotos(json);}Watch out: 주의사항
웹 앱에서 무거운 작업이 있다면 웹 워커 사용을 고려하세요. Flutter의 기본 Isolate API는 웹에서 병렬 처리를 지원하지 않습니다.
6. 실전 패턴
compute() 함수 활용
compute()는 Isolate.run()보다 먼저 존재한 헬퍼 함수입니다.
import 'package:flutter/foundation.dart';
// compute 사용 예Future<List<Photo>> parsePhotosWithCompute(String json) async { return await compute(_parsePhotos, json);}
// 최상위 함수로 정의List<Photo> _parsePhotos(String json) { final List<dynamic> data = jsonDecode(json) as List<dynamic>; return data.cast<Map<String, dynamic>>().map(Photo.fromJson).toList();}작업 큐 패턴
여러 작업을 순차적으로 처리하는 패턴입니다:
class TaskQueue { final _queue = <Future<void> Function()>[]; bool _isProcessing = false;
void add(Future<void> Function() task) { _queue.add(task); _processNext(); }
Future<void> _processNext() async { if (_isProcessing || _queue.isEmpty) return;
_isProcessing = true; while (_queue.isNotEmpty) { final task = _queue.removeAt(0); await task(); } _isProcessing = false; }}마무리
이번 튜토리얼에서는 Dart의 Isolate를 사용하여 무거운 작업을 백그라운드에서 처리하는 방법을 배웠습니다.
핵심 정리
| 주제 | 핵심 내용 |
|---|---|
| Isolate 개념 | 독립 메모리, 메시지 전달 방식 |
| Isolate.run() | 일회성 작업에 적합 |
| 장기 실행 | SendPort/ReceivePort로 통신 |
| 플러그인 사용 | BackgroundIsolateBinaryMessenger 필요 |
| 제한사항 | rootBundle, dart |
다음 단계
- Flutter 튜토리얼 54편: 웹 성능 최적화에서 웹 앱의 성능을 최적화하는 방법을 배워보세요.
참고 자료
Footnotes
공유
이 글이 도움이 되었다면 다른 사람과 공유해주세요!