iBetter Books
수정

이벤트 루프의 한계

FutureStream으로 I/O 작업은 훌륭하게 처리할 수 있습니다. 네트워크 요청은 "기다리는 동안 다른 일을 하면 돼"의 전형적인 사례이기 때문입니다.

그런데 다른 종류의 문제가 있습니다.

void main() async {
  print('앱 시작');
  
  // 1억 번 반복하는 계산 — CPU를 집중적으로 사용
  int sum = 0;
  for (int i = 0; i < 100000000; i++) {
    sum += i;
  }
  
  print('합계: $sum');
  print('이 줄은 계산이 끝나야 출력됩니다');
}

이 코드는 await도 없고 Future도 없습니다. 그냥 반복문입니다. 이 반복문이 돌아가는 동안 이벤트 루프는 완전히 멈춥니다. 버튼 클릭, 화면 갱신, 타이머 실행 — 모든 것이 정지합니다.

Flutter 앱에서 이런 작업을 메인 스레드에서 실행하면 화면이 뚝뚝 끊기거나 "앱이 응답하지 않습니다" 경고가 뜹니다. 비동기(await)는 대기(I/O waiting)를 다루지만, CPU 집약적 작업(연산)에는 도움이 되지 않습니다.

해결책이 바로 Isolate입니다.

Isolate란

Isolate는 독립된 메모리 공간을 가진 실행 단위입니다.

Java나 Python의 스레드와 비슷해 보이지만 중요한 차이가 있습니다. 일반적인 스레드는 메모리를 공유합니다. 그래서 같은 데이터를 동시에 수정하려 할 때 "경쟁 조건(Race Condition)" 같은 복잡한 문제가 생깁니다.

Isolate는 메모리를 공유하지 않습니다. 각 Isolate는 완전히 독립된 힙(Heap) 메모리를 가집니다. 데이터를 주고받으려면 메시지 패싱(Message Passing)을 사용해야 합니다. 덕분에 동시성 관련 버그가 원천적으로 차단됩니다.

메인 Isolate                    작업 Isolate
┌─────────────────────┐        ┌─────────────────────┐
│  메모리 (힙)          │        │  메모리 (힙)          │
│  - 메인 앱 데이터     │        │  - 독립 데이터        │
│  - UI 상태           │        │  - 무거운 계산        │
│                     │        │                     │
│  이벤트 루프         │        │  이벤트 루프          │
└─────────┬───────────┘        └───────────┬─────────┘
          │  메시지 패싱                     │
          └──────────────────────────────────┘
               (데이터 복사로 전달)

Isolate.run() — 가장 쉬운 방법

Dart 2.19부터 Isolate.run()이 추가되어 간단한 작업을 Isolate에서 실행하는 게 아주 쉬워졌습니다.

import 'dart:isolate';

// CPU 집약적 함수
int heavyCalculation(int n) {
  int sum = 0;
  for (int i = 0; i < n; i++) {
    sum += i;
  }
  return sum;
}

void main() async {
  print('계산 시작...');
  
  // 별도 Isolate에서 실행 — 메인 이벤트 루프는 계속 동작
  var result = await Isolate.run(() => heavyCalculation(100000000));
  
  print('결과: $result');
  print('메인 스레드는 계속 반응할 수 있었습니다!');
}

Isolate.run()은 다음 두 가지를 자동으로 처리합니다.

  1. 새 Isolate를 생성합니다.
  2. 함수 실행이 끝나면 Isolate를 종료합니다.

일회성 무거운 작업에 딱 맞는 방법입니다.

메시지 패싱 — SendPort와 ReceivePort

더 세밀하게 제어하거나, 작업이 진행 중에 중간 결과를 받고 싶다면 SendPortReceivePort를 사용합니다.

import 'dart:isolate';

// Isolate에서 실행할 함수
// 반드시 최상위 함수(top-level function)이거나 static 메서드여야 합니다
void workerIsolate(SendPort sendPort) {
  print('작업 Isolate 시작');
  
  int sum = 0;
  for (int i = 0; i < 100000000; i++) {
    sum += i;
  }
  
  // 결과를 메인 Isolate로 전송
  sendPort.send(sum);
}

void main() async {
  print('메인 시작');
  
  // 메인 Isolate에서 메시지를 받을 포트 생성
  var receivePort = ReceivePort();
  
  // 새 Isolate 생성, receivePort.sendPort를 전달
  await Isolate.spawn(workerIsolate, receivePort.sendPort);
  
  // 결과 기다리기
  var result = await receivePort.first;
  print('결과: $result');
  
  receivePort.close();
}

양방향 통신. 메인에서 작업 Isolate로, 작업 Isolate에서 메인으로 모두 통신하려면 포트를 두 개 만들어야 합니다.

import 'dart:isolate';

// 작업 Isolate 함수
void workerIsolate(SendPort mainSendPort) {
  // 작업 Isolate의 수신 포트
  var workerReceivePort = ReceivePort();
  
  // 메인에게 작업 Isolate의 SendPort 전달
  mainSendPort.send(workerReceivePort.sendPort);
  
  // 메인에서 오는 메시지 처리
  workerReceivePort.listen((message) {
    if (message is int) {
      int result = message * message; // 제곱 계산
      mainSendPort.send(result);
    } else if (message == 'exit') {
      workerReceivePort.close();
    }
  });
}

void main() async {
  var mainReceivePort = ReceivePort();
  
  // Isolate 생성
  await Isolate.spawn(workerIsolate, mainReceivePort.sendPort);
  
  // 작업 Isolate의 SendPort 받기
  SendPort workerSendPort = await mainReceivePort.first;
  
  // 여러 숫자의 제곱 계산 요청
  for (int i = 1; i <= 5; i++) {
    workerSendPort.send(i);
    var result = await mainReceivePort.first;
    print('$i² = $result');
  }
  
  // 종료 메시지
  workerSendPort.send('exit');
  mainReceivePort.close();
}

메시지 패싱의 규칙

Isolate 간에 전달할 수 있는 데이터 타입에는 제한이 있습니다.

전달 가능한 타입입니다.

  • 기본 타입: int, double, bool, String, null
  • 컬렉션: List, Map, Set (원소가 전달 가능한 타입이어야 함)
  • SendPort 자체
  • 일부 특수 타입: TransferableTypedData

전달 불가능한 타입입니다.

  • 클로저(함수 객체)
  • 특정 다트 오브젝트

데이터는 복사(copy)되어 전달됩니다. 메인 Isolate의 객체를 수정해도 작업 Isolate에는 영향이 없고, 반대도 마찬가지입니다.

언제 Isolate를 사용할까

모든 작업에 Isolate를 쓸 필요는 없습니다. 다음 기준으로 판단합니다.

Isolate가 필요한 작업.

// 대용량 JSON 파싱
Map<String, dynamic> parseHugeJson(String jsonString) {
  // json.decode는 동기 작업 — 큰 JSON은 이벤트 루프를 막음
  return json.decode(jsonString) as Map<String, dynamic>;
}

// Isolate에서 실행
var data = await Isolate.run(() => parseHugeJson(hugeJsonString));
// 이미지 처리 (크기 조정, 필터 적용)
Uint8List resizeImage(Uint8List imageBytes) {
  // CPU 집약적 픽셀 연산
  // ...
}
// 암호화/복호화
String encryptData(String data) {
  // 복잡한 수학 연산
  // ...
}
// 대용량 파일 파싱 (CSV, XML 등)
List<Map<String, String>> parseCsvFile(String csvContent) {
  // 수천 줄의 파싱
  // ...
}

Isolate가 불필요한 작업.

  • HTTP 요청 → async/await 사용
  • 파일 읽기/쓰기 → async/await 사용
  • 데이터베이스 쿼리 → async/await 사용
  • 짧은 계산 (100ms 미만) → 메인 스레드에서 처리

실전 예제: 대용량 데이터 처리

실제 앱에서 자주 만나는 패턴을 구현해보겠습니다.

import 'dart:isolate';
import 'dart:convert';

// 대용량 데이터 처리 함수 (Isolate에서 실행)
List<Map<String, dynamic>> processData(String rawData) {
  var rows = rawData.split('\n');
  var result = <Map<String, dynamic>>[];
  
  for (var row in rows) {
    if (row.trim().isEmpty) continue;
    
    var parts = row.split(',');
    if (parts.length < 3) continue;
    
    result.add({
      'id': int.tryParse(parts[0].trim()) ?? 0,
      'name': parts[1].trim(),
      'score': double.tryParse(parts[2].trim()) ?? 0.0,
    });
  }
  
  // 점수 기준 정렬
  result.sort((a, b) => (b['score'] as double).compareTo(a['score'] as double));
  
  return result;
}

// 가상의 대용량 CSV 데이터 생성
String generateCsvData(int rows) {
  var buffer = StringBuffer();
  for (int i = 1; i <= rows; i++) {
    var score = (i * 7 % 100) + (i % 10) * 0.5;
    buffer.writeln('$i, 학생$i, $score');
  }
  return buffer.toString();
}

void main() async {
  print('데이터 생성 중...');
  var csvData = generateCsvData(10000); // 1만 명 데이터
  
  print('데이터 처리 시작...');
  var stopwatch = Stopwatch()..start();
  
  // Isolate에서 처리 — 메인 스레드 블록 없음
  var processed = await Isolate.run(() => processData(csvData));
  
  print('처리 완료: ${stopwatch.elapsed.inMilliseconds}ms');
  print('총 ${processed.length}개 레코드');
  print('상위 3위:');
  
  for (int i = 0; i < 3 && i < processed.length; i++) {
    var record = processed[i];
    print('  ${i + 1}위: ${record['name']} (${record['score']}점)');
  }
}

정리

Isolate는 CPU 집약적 작업을 메인 스레드에서 분리하는 도구입니다.

  • 이벤트 루프는 I/O 대기에는 강하지만 CPU 연산에는 블록됩니다.
  • Isolate는 독립된 메모리 공간을 가진 실행 단위입니다.
  • Isolate.run()으로 간단하게 일회성 작업을 분리합니다.
  • SendPortReceivePort로 Isolate 간 메시지를 주고받습니다.
  • JSON 파싱, 이미지 처리, 암호화, 대용량 파일 파싱에 적합합니다.

비동기 프로그래밍의 마지막 퍼즐 조각이 남았습니다. 지금까지 배운 Future, Stream, Isolate에서 에러가 발생했을 때 어떻게 우아하게 처리할까요.

다음 챕터에서는 try-catch부터 비동기 에러 처리, 커스텀 예외 클래스까지 에러 처리의 모든 것을 배웁니다.