Flutter에서 동시성을 위한 Isolates 사용에 대해 설명해주세요.

질문

Flutter 애플리케이션에서 Isolates를 사용하는 방법과 이점에 대해 설명해주세요. 언제 Isolates를 사용해야 하며 어떻게 구현하는지 예제 코드와 함께 알려주세요.

답변

Flutter(그리고 Dart)에서 Isolates는 메모리를 공유하지 않는 별도의 실행 스레드로, 무거운 연산을 메인 UI 스레드(메인 Isolate)에서 분리하여 실행함으로써 애플리케이션의 반응성을 유지하는 데 중요한 역할을 합니다.

1. Isolates의 필요성

Flutter 앱은 기본적으로 단일 스레드에서 실행됩니다. 이 스레드는 UI 렌더링, 이벤트 처리, 비즈니스 로직 실행 등 모든 작업을 담당합니다. 이런 구조에서 CPU 집약적 작업을 수행하면 프레임 드롭이 발생하여 사용자 경험이 저하될 수 있습니다.

다음과 같은 상황에서 Isolates 사용을 고려해야 합니다:

  1. 무거운 연산 처리: JSON 파싱, 암호화/복호화, 이미지 처리
  2. CPU 집약적 알고리즘: 데이터 정렬, 검색, 필터링
  3. 긴 시간이 소요되는 작업: 대용량 파일 처리, 복잡한 계산

2. Isolates의 기본 개념

Dart의 Isolates는 다음과 같은 특징이 있습니다:

  • 각 Isolate는 자체 메모리 힙을 가지며 다른 Isolate와 메모리를 공유하지 않습니다.
  • Isolate 간 통신은 메시지 패싱(메시지 전달)을 통해서만 이루어집니다.
  • 각 Isolate는 자체 이벤트 루프를 가지고 있습니다.

3. Isolates 사용 방법

3.1 기본 Isolate 생성 및 사용

import 'dart:isolate';

Future<List<int>> computeInBackground(List<int> data) async {
  // 메시지를 주고받을 포트 생성
  final receivePort = ReceivePort();

  // 새 Isolate 생성
  await Isolate.spawn(
    _processDataIsolate,
    [receivePort.sendPort, data]
  );

  // 결과 대기
  final result = await receivePort.first;
  return result as List<int>;
}

// Isolate에서 실행될 함수
void _processDataIsolate(List<dynamic> params) {
  // 매개변수 해석
  final SendPort sendPort = params[0];
  final List<int> data = params[1];

  // CPU 집약적 작업 수행
  final result = data.map((e) => e * e).toList();

  // 결과를 메인 Isolate로 전송
  sendPort.send(result);
}

// 사용 예시
void main() async {
  final data = List.generate(10000000, (i) => i);

  // UI 스레드를 차단하지 않고 작업 수행
  final result = await computeInBackground(data);
  print('처리 완료: ${result.length} 항목');
}

3.2 Flutter의 compute 함수 사용 (간소화된 API)

Flutter는 Isolate 생성을 간소화한 compute 함수를 제공합니다:

import 'package:flutter/foundation.dart';

// 별도 Isolate에서 실행될 함수
// 중요: 이 함수는 최상위 함수이거나 static 메서드여야 함
List<int> _processData(List<int> data) {
  // CPU 집약적 작업 수행
  return data.map((e) => e * e).toList();
}

// 사용 예시
void processDataWithCompute() async {
  final data = List.generate(10000000, (i) => i);

  // compute 함수를 사용하여 별도 Isolate에서 작업 수행
  final result = await compute(_processData, data);

  print('처리 완료: ${result.length} 항목');
}

4. Isolates 통신 패턴

4.1 단일 응답 패턴

가장 기본적인 패턴으로, Isolate가 작업을 완료하고 결과를 반환한 후 종료됩니다.

import 'dart:isolate';

Future<int> sumInIsolate(List<int> numbers) async {
  final receivePort = ReceivePort();
  await Isolate.spawn(_sum, [receivePort.sendPort, numbers]);

  // 단일 결과를 기다림
  return await receivePort.first as int;
}

void _sum(List<dynamic> params) {
  SendPort sendPort = params[0];
  List<int> numbers = params[1];

  int sum = numbers.reduce((a, b) => a + b);
  sendPort.send(sum);
}

4.2 지속적 통신 패턴

Isolate가 여러 메시지를 주고받을 수 있는 패턴입니다.

import 'dart:isolate';

class WorkerIsolate {
  late Isolate _isolate;
  late ReceivePort _receivePort;
  late SendPort _sendPort;

  Future<void> start() async {
    _receivePort = ReceivePort();
    _isolate = await Isolate.spawn(
      _isolateEntry,
      _receivePort.sendPort
    );

    // Worker의 SendPort 수신
    _sendPort = await _receivePort.first;

    // 이후 메시지 처리
    _receivePort.listen((message) {
      if (message is! SendPort) {
        print('결과 수신: $message');
      }
    });
  }

  void processData(List<int> data) {
    _sendPort.send(data);
  }

  void dispose() {
    _receivePort.close();
    _isolate.kill();
  }

  static void _isolateEntry(SendPort mainSendPort) {
    // Worker의 ReceivePort 생성
    final receivePort = ReceivePort();

    // Worker의 SendPort를 메인 Isolate로 전송
    mainSendPort.send(receivePort.sendPort);

    // 메시지 수신 및 처리
    receivePort.listen((message) {
      if (message is List<int>) {
        final result = message.map((e) => e * 2).toList();
        mainSendPort.send(result);
      }
    });
  }
}

// 사용 예시
Future<void> useWorkerIsolate() async {
  final worker = WorkerIsolate();
  await worker.start();

  worker.processData([1, 2, 3, 4, 5]);
  worker.processData([10, 20, 30]);

  // 작업이 끝나면 정리
  await Future.delayed(Duration(seconds: 2));
  worker.dispose();
}

5. Flutter에서 실제 활용 사례

5.1 이미지 처리

import 'package:flutter/foundation.dart';
import 'package:image/image.dart' as img;
import 'dart:io';
import 'dart:typed_data';

Future<Uint8List> applyFilterToImage(String imagePath) async {
  // 이미지 파일 읽기
  final File file = File(imagePath);
  final Uint8List bytes = await file.readAsBytes();

  // 별도 Isolate에서 이미지 처리
  final Uint8List processedImageBytes = await compute(
    _applyFilter,
    bytes
  );

  return processedImageBytes;
}

// 별도 Isolate에서 실행될 함수
Uint8List _applyFilter(Uint8List bytes) {
  // 이미지 디코딩
  final img.Image? image = img.decodeImage(bytes);
  if (image == null) return bytes;

  // 흑백 필터 적용
  final img.Image grayscaleImage = img.grayscale(image);

  // 이미지 인코딩 및 반환
  return Uint8List.fromList(img.encodePng(grayscaleImage));
}

// UI에서 사용 예시
class ImageProcessingScreen extends StatefulWidget {
  @override
  _ImageProcessingScreenState createState() => _ImageProcessingScreenState();
}

class _ImageProcessingScreenState extends State<ImageProcessingScreen> {
  Uint8List? _processedImage;
  bool _isProcessing = false;

  Future<void> _processImage(String path) async {
    setState(() {
      _isProcessing = true;
    });

    try {
      final processedBytes = await applyFilterToImage(path);
      setState(() {
        _processedImage = processedBytes;
        _isProcessing = false;
      });
    } catch (e) {
      print('이미지 처리 오류: $e');
      setState(() {
        _isProcessing = false;
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('이미지 처리 데모')),
      body: Center(
        child: _isProcessing
            ? CircularProgressIndicator()
            : _processedImage != null
                ? Image.memory(_processedImage!)
                : Text('이미지를 선택하세요'),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          // 이미지 선택 및 처리 시작
          _processImage('path/to/image.jpg');
        },
        child: Icon(Icons.add_photo_alternate),
      ),
    );
  }
}

5.2 JSON 파싱

import 'package:flutter/foundation.dart';
import 'dart:convert';

// 데이터 모델
class Product {
  final int id;
  final String name;
  final double price;

  Product({required this.id, required this.name, required this.price});

  factory Product.fromJson(Map<String, dynamic> json) {
    return Product(
      id: json['id'] as int,
      name: json['name'] as String,
      price: (json['price'] as num).toDouble(),
    );
  }
}

// Isolate에서 실행될 JSON 파싱 함수
List<Product> _parseProductsJson(String jsonStr) {
  final parsed = jsonDecode(jsonStr) as List;
  return parsed
      .map((json) => Product.fromJson(json as Map<String, dynamic>))
      .toList();
}

Future<List<Product>> fetchProducts() async {
  // 네트워크 요청은 메인 스레드에서 수행 (I/O 작업이므로)
  final response = await http.get(Uri.parse('https://api.example.com/products'));

  if (response.statusCode == 200) {
    // JSON 파싱은 계산 집약적이므로 별도 Isolate에서 수행
    final products = await compute(_parseProductsJson, response.body);
    return products;
  } else {
    throw Exception('Failed to load products');
  }
}

// UI에서 사용 예시
class ProductListScreen extends StatefulWidget {
  @override
  _ProductListScreenState createState() => _ProductListScreenState();
}

class _ProductListScreenState extends State<ProductListScreen> {
  Future<List<Product>>? _productsFuture;

  @override
  void initState() {
    super.initState();
    _productsFuture = fetchProducts();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('제품 목록')),
      body: FutureBuilder<List<Product>>(
        future: _productsFuture,
        builder: (context, snapshot) {
          if (snapshot.connectionState == ConnectionState.waiting) {
            return Center(child: CircularProgressIndicator());
          } else if (snapshot.hasError) {
            return Center(child: Text('오류: ${snapshot.error}'));
          } else if (snapshot.hasData) {
            final products = snapshot.data!;
            return ListView.builder(
              itemCount: products.length,
              itemBuilder: (context, index) {
                final product = products[index];
                return ListTile(
                  title: Text(product.name),
                  subtitle: Text('₩${product.price}'),
                );
              },
            );
          } else {
            return Center(child: Text('데이터 없음'));
          }
        },
      ),
    );
  }
}

6. Isolates 사용 시 주의사항

  1. 메모리 복사 오버헤드: Isolate 간 데이터 전송 시 메모리 복사가 발생하므로, 매우 큰 데이터를 자주 주고받는 것은 비효율적일 수 있습니다.

  2. 직렬화 가능한 데이터: Isolate 간에는 직렬화 가능한 데이터만 전송할 수 있습니다(기본 타입, List, Map 등).

  3. 최상위 함수 필요: Isolate에서 실행되는 함수는 최상위 함수이거나 static 메서드여야 합니다.

  4. 디버깅 어려움: 별도 Isolate에서 발생하는 오류는 디버깅이 더 어려울 수 있습니다.

  5. 오버헤드: Isolate 생성 자체에도 오버헤드가 있으므로, 매우 간단한 작업에는 적합하지 않을 수 있습니다.

7. 고급 Isolate 패턴: 워커 풀

여러 작업을 병렬로 처리하기 위한 Isolate 워커 풀 구현:

import 'dart:isolate';
import 'dart:collection';

class IsolateWorkerPool {
  final int _numWorkers;
  final List<Worker> _workers = [];
  final Queue<Task> _taskQueue = Queue<Task>();
  int _nextWorkerId = 0;

  IsolateWorkerPool(this._numWorkers) {
    _initWorkers();
  }

  Future<void> _initWorkers() async {
    for (var i = 0; i < _numWorkers; i++) {
      final worker = Worker(i);
      await worker.init();
      _workers.add(worker);
    }
  }

  Future<T> scheduleTask<T>(Function(dynamic) function, dynamic params) {
    final task = Task<T>(function, params);
    final completer = task.completer;

    _taskQueue.add(task);
    _scheduleNext();

    return completer.future;
  }

  void _scheduleNext() {
    if (_taskQueue.isEmpty) return;

    // 사용 가능한 워커 찾기
    final availableWorker = _workers.firstWhere(
      (worker) => !worker.isBusy,
      orElse: () => _workers[(_nextWorkerId++) % _workers.length],
    );

    if (!availableWorker.isBusy) {
      final task = _taskQueue.removeFirst();
      availableWorker.executeTask(task).then((_) => _scheduleNext());
    }
  }

  void dispose() {
    for (var worker in _workers) {
      worker.dispose();
    }
  }
}

class Worker {
  final int id;
  late Isolate _isolate;
  late ReceivePort _receivePort;
  late SendPort _sendPort;
  bool isBusy = false;
  Task? _currentTask;

  Worker(this.id);

  Future<void> init() async {
    _receivePort = ReceivePort();
    _isolate = await Isolate.spawn(_workerEntryPoint, _receivePort.sendPort);

    // 워커의 SendPort 수신
    _sendPort = await _receivePort.first;

    // 결과 처리
    _receivePort.listen((message) {
      if (message is WorkerResult && _currentTask != null) {
        _currentTask!.completer.complete(message.result);
        _currentTask = null;
        isBusy = false;
      }
    });
  }

  Future<void> executeTask(Task task) async {
    isBusy = true;
    _currentTask = task;

    // 직렬화 가능한 형태로 함수 이름을 전송
    // 참고: 실제 구현에서는 함수 자체를 전송할 수 없으므로
    // 미리 정의된 함수 셋을 사용하거나 다른 접근 방식이 필요함
    _sendPort.send(WorkerTask(
      functionName: task.function.toString(),
      params: task.params,
    ));
  }

  void dispose() {
    _receivePort.close();
    _isolate.kill();
  }

  static void _workerEntryPoint(SendPort mainSendPort) {
    final receivePort = ReceivePort();
    mainSendPort.send(receivePort.sendPort);

    receivePort.listen((message) {
      if (message is WorkerTask) {
        // 함수 이름을 기반으로 실행할 함수 결정
        // 참고: 실제 구현에서는 미리 정의된 함수 맵을 사용해야 함
        dynamic result;
        try {
          // 이 부분은 예시일 뿐, 실제로는 작동하지 않음
          // 실제 구현에서는 함수를 문자열 기반으로 매핑해야 함
          result = _executeFunction(message.functionName, message.params);
        } catch (e) {
          print('Worker error: $e');
          result = null;
        }

        mainSendPort.send(WorkerResult(result));
      }
    });
  }

  // 이것은 단순 예시로, 실제로는 이렇게 구현할 수 없음
  static dynamic _executeFunction(String functionName, dynamic params) {
    // 함수 이름에 따라 적절한 함수 실행 로직
    return null;
  }
}

class Task<T> {
  final Function(dynamic) function;
  final dynamic params;
  final completer = Completer<T>();

  Task(this.function, this.params);
}

class WorkerTask {
  final String functionName;
  final dynamic params;

  WorkerTask({required this.functionName, required this.params});
}

class WorkerResult {
  final dynamic result;

  WorkerResult(this.result);
}

// 사용 예시
void main() async {
  final pool = IsolateWorkerPool(4); // 4개의 워커 생성

  // 여러 작업 예약
  final futures = <Future>[];

  for (var i = 0; i < 10; i++) {
    // 참고: 이 예시는 개념적인 것으로, 실제로는 이렇게 작동하지 않음
    // 실제 구현에서는 미리 정의된 함수를 사용해야 함
    futures.add(pool.scheduleTask((params) {
      return params * 2;
    }, i));
  }

  // 모든 결과 대기
  final results = await Future.wait(futures);
  print('Results: $results');

  // 풀 정리
  pool.dispose();
}

결론

Flutter에서 Isolates는 UI 반응성을 유지하면서 무거운 연산을 처리할 수 있는 강력한 도구입니다. 메인 스레드가 차단되지 않도록 CPU 집약적인 작업을 별도의 Isolate로 분리함으로써 사용자 경험을 크게 향상시킬 수 있습니다.

기본적인 사용 방법은 간단하지만, 복잡한 통신 패턴이나 워커 풀과 같은 고급 패턴을 구현할 수도 있습니다. Flutter의 compute 함수는 간단한 사용 사례에 적합하며, 직접 Isolate API를 사용하면 더 복잡한 시나리오를 처리할 수 있습니다.

Isolate를 효과적으로 활용하면 앱의 성능과 사용자 경험을 모두 향상시킬 수 있으므로, 데이터 처리, 이미지 조작, 암호화 등의 무거운 작업을 수행할 때 적극적으로 고려해 보세요.

results matching ""

    No results matching ""