티스토리 뷰
안녕하세요 Diana 입니다.
근래 Flutter를 학습하며 Swift와 비교해 이해하고 있는데, 이 과정에서 Swift를 좀 더 잘 알게 되는 것 같아 흥미롭게 학습하고 있습니다.
아무튼 오늘은 Flutter의 Isolate에 대해 알아보려고 합니다.
우리는 프로그램을 구현하며 늘 비동기 처리에 대해 신경 씁니다. 파일을 다운로드하거나 센서 값을 받아오는 과정에서 앱이 멈춰버린다면 유저 경험에 꽤나 좋지 못한 영향을 줄 것입니다.
Swift에서는 이를 위해 Combine, Swift Concurrency 등의 기능을 제공하고, 여기서 발생하는 Data Race를 예방하기 위해 Sendable과 Actor 등의 기능을 제공해주고 있습니다.
하지만 Flutter의 경우 학습해본 결과 Swift와는 조금 다른 경향이 있어 정리해보겠습니다.
✅ Flutter의 계층 구조
Flutter 애플리케이션의 계층 구조는 Flutter Application 하위에 Framework Layer(Dart)와 Engine Layer(C/C++)가 존재하며, Framework Layer은 우리에게 익숙한 Widgets(UI 컴포넌트), Material/Cupertino(디자인 시스템), Rendering(레이아웃, 페인팅) 그리고 Foundation(기본 클래스, 유틸리티)들을 포함하고 있습니다.
Flutter Application
├── Framework Layer (Dart)
│ ├── Material / Cupertino 디자인 시스템
│ ├── Widgets UI 컴포넌트
│ ├── Rendering 레이아웃, 페인팅
│ └── Foundation 기본 클래스, 유틸리티
├── Engine Layer (C/C++) 렌더링, Dart 런타임
└── Embedder 플랫폼(iOS, Android)과 연결
여기서 우리가 눈여겨 볼 점은 Dart 런타임이 Engine 안에 들어있다는 것 입니다. 우리가 작성한 Dart 코드와 Framework는 모두 이 런타임 위에서 실행되고, 그 실행 단위가 오늘의 주제인 Isolate 입니다.
✅ 이벤트 루프와 async/await
Flutter의 경우 이벤트 루프 기반의 실행 모델을 제공하고, 해당 Queue에 담긴 Task 들을 순차적으로 처리하는 구조를 가지고 있습니다.
이 부분은 Swift의 Run Loop를 생각했을 때 꽤나 유사한 것을 알 수 있습니다.
화면을 다시 그리라는 요청, 사용자의 터치, 네트워크 응답이 모두 이벤트로 Queue에 쌓이고, 이벤트 루프가 이를 하나씩 꺼내 처리합니다.
Flutter의 경우 하나의 프로세스로 실행되며 각각의 Task 들은 하나의 스레드로만 실행됩니다.
그렇다면 스레드 하나로 어떻게 비동기 처리를 할까요? 답은 await에 있습니다. 처음에는 await가 작업이 끝날 때까지 스레드를 막는다고 오해했는데, 실제로는 Swift와 마찬가지로 함수가 그 자리에서 일시 중단(suspend)되고 제어권이 이벤트 루프로 돌아갑니다.
void main() {
fetchData();
print('B');
}
Future<void> fetchData() async {
print('A');
await Future.delayed(Duration(seconds: 2));
print('C');
}
// 출력: A → B → (2초 뒤) C
await가 스레드를 막는다면 B는 2초 뒤에 찍혀야 하지만 실제로는 바로 찍힙니다.
fetchData가 await에서 멈춘 동안 이벤트 루프가 다음 일을 처리했기 때문입니다.
다만 Swift와 다른 점이 하나 있습니다.
Swift는 중단된 작업을 스레드 풀의 다른 스레드가 이어받을 수 있지만, Dart는 같은 스레드가 번갈아 처리합니다.
그래서 네트워크 요청처럼 기다리기만 하는 작업은 await로 충분하지만, 큰 JSON 파싱처럼 CPU를 계속 쓰는 작업은 async 함수 안에 넣어도 UI가 멈춥니다. 이때 필요한 것이 Isolate입니다.
✅ Isolate 란?
하나의 프로세스 내에서 여러 스레드들이 돌아가며 Heap 영역을 공유하는 Swift의 Concurrency와 달리, Flutter는 Isolate를 지원하며 Isolate는 각각 독립된 구조로 서로 힙 메모리조차 공유하지 않는 모습을 띄고 있습니다.
따라서 Data Race의 걱정이 없지만, 데이터를 직접 공유할 수 없어 Messaging 방식으로만 주고받습니다.
모든 Dart 코드는 Isolate 안에서 실행됩니다. 앱을 실행하면 Main Isolate 하나가 만들어지고, 따로 만들지 않는 한 우리가 작성한 코드는 전부 여기서 돕니다.
Isolate 하나는 자기만의 메모리(Heap)와, 이벤트 루프를 돌리는 스레드 하나를 가집니다.
전 이 부분에서 이해가 잘 안되었는데 흔히 "Flutter는 단일 스레드"라고 하는 것은 앱 전체가 아니라 Isolate 하나에 대한 이야기였습니다.
Isolate를 추가로 만들면 앱은 여러 스레드를 쓰게 됩니다.
Flutter 애플리케이션 (단일 OS 프로세스)
├── Main Isolate (UI 스레드)
│ ├── Heap
│ └── Event Loop
└── Background Isolates (별도 OS 스레드)
├── Isolate 1: Heap + Event Loop
└── Isolate 2: Heap + Event Loop
여기서 OS 프로세스는 실행 중인 앱 하나를, OS 스레드는 그 안에서 코드를 실제로 실행하는 흐름을 말합니다.
원래 같은 프로세스의 스레드들은 메모리를 공유하지만, Isolate는 Dart 런타임이 Heap을 Isolate별로 나눠 서로 넘어가지 못하게 막습니다. 스레드인데 메모리는 별도 프로세스처럼 동작하는 셈입니다.
이 부분은 Swift의 Actor와 비교하며 이해하였습니다. Actor가 내부 상태를 격리하고 Sendable한 값만 주고받게 한다면, Isolate는 메모리 자체를 분리해 이를 강제합니다.
다만 메시지가 도착하는 순서에 따라 결과가 달라지는 Race Condition까지 막아주는 것은 아닙니다.
✅ Isolate의 사용
그럼 이 Isolate는 언제 필요할까요?
Isolate가 매번 필요한 것은 아닙니다. 대부분의 앱은 Main Isolate 하나와 async/await만으로 충분합니다.
공식 문서가 제시하는 기준은 하나입니다. 큰 연산 때문에 UI가 끊길 때(jank)입니다.
Flutter는 60Hz 기기에서 1초에 60번 화면을 그리므로, 한 프레임에 쓸 수 있는 시간은 약 16ms입니다. 어떤 연산이 이 시간 안에 끝나지 않으면 뒤따르는 프레임 이벤트가 밀리면서 화면이 버벅입니다.
대표적으로 이런 작업들이 해당합니다.
- 큰 JSON이나 데이터 파일 파싱
- 이미지, 오디오, 영상의 가공이나 압축
- 복잡한 목록의 필터링과 정렬
반대로 네트워크 요청이나 타이머처럼 기다리는 것이 전부인 작업은 Isolate 없이 await만으로도 UI가 멈추지 않습니다.
그럼 이제 Isolate을 사용해보도록 하겠습니다.
1. Isolate를 사용할 때 결과를 하나만 받는 경우는 Isolate.run()을 사용합니다.
import 'dart:isolate';
int test(int n) {
var sum = 0;
for (var i = 0; i < n; i++) {
sum += i;
}
return sum;
}
Future<void> onButtonTap() async {
final result = await Isolate.run(() => test(1000));
print(result);
}
onButtonTap의 경우 버튼을 선택했을 때 한번 동작하는 함수 입니다.
Isolate.run()은 Future을 반환하므로 호출하는 쪽에서는 다른 async 함수와 똑같이 await 로 사용합니다.
참고로 웹에서는 Isolate를 지원하지 않습니다. 이 경우에는 메인 스레드에서 실행됩니다.
2. 여러 번 메시지를 주고 받는 경우에는 Isolate.spawn()을 사용합니다.
Isolate를 살려놓고 여러 번 메시지를 주고 받는 경우에는 ReceivePort와 SendPort를 다뤄야합니다.
이게 앞서 말한 Messaging 방식의 창구입니다.
Future<void> main() async {
final receivePort = ReceivePort(); // 1. 우편함 만들기
await Isolate.spawn(worker, receivePort.sendPort); // 2. 주소를 건네며 Isolate 시작
final result = await receivePort.first; // 3. 도착한 메시지 받기
print(result);
}
void worker(SendPort sendPort) {
final result = heavySum(1000000000);
sendPort.send(result); // 받은 주소로 결과 보내기
}
ReceivePort는 Stream이라서 메시지를 하나만 받을 때는 .first, 계속해서 받을 때는 .listen()을 사용합니다.
대신 한 가지 제약이 있는데 새로 만든 Isolate 에서는 위젯을 다루거나 rootBundle로 Asset을 읽는 등 UI와 묶인 작업을 할 수 없습니다.
✅ 주의할 점
Isolate를 사용할 때는 주의할 점이 있습니다.
1. 데이터는 복사됩니다.
SendPort.send()로 보낸 객체는 받는 쪽 Heap에 복사됩니다. 즉, 받은 쪽에서 값을 바꿔도 원본은 그대로라는 이야기죠.
class Counter {
int value = 0;
}
Future<void> main() async {
final counter = Counter();
await Isolate.run(() {
counter.value = 100; // 복사본을 수정
});
print(counter.value); // 0
}
Swift라면 클래스 인스턴스를 다른 스레드에서 수정했을 때 참조를 가져가는 형식이다 보니 원본이 바뀌고 Data Race의 위험이 발생합니다.
하지만 Dart에서는 클래스 인스턴스도 struct 처럼 값이 복사됩니다.
2. 보낼 수 없는 객체가 있습니다.
Isolate.spawn() 이나 Isolate.run() 으로 만든 Isolate 끼리는 직접 만든 클래스를 포함해 거의 모든 객체를 보낼 수 있습니다.
다만, Socket 처럼 네이티브 자원에 묶인 객체나 ReceivePort 등은 보낼 수 없습니다.
3. 큰 데이터는 복사 비용이 듭니다.
복사에는 데이터 크기에 비례하는 시간이 걸립니다.
물론 이를 피하는 방법 또한 존재합니다.
첫째, Isolate.exit() 입니다. Isolate가 종료되면서 메시지를 보내면, 보낸 쪽이 사라지므로 복사 없이 소유권을 넘길 수 있습니다.
Flutter에서 기본으로 제공하는 compute()가 이 방식을 사용하기 때문에 결과를 돌려받을 때 복사 비용을 걱정하지 않아도 됩니다.
둘째, TransgerableTypedData 입니다. 이미지나 파일 같은 바이트 데이터를 담아 보내면 전송이 데이터 크기와 상관 없이 끝나고, 대신 보낸 쪽은 더 이상 그 데이터를 꺼낼 수 없습니다.
// 보내는 쪽
final data = TransferableTypedData.fromList([bytes]);
sendPort.send(data);
// 받는 쪽
final bytes = data.materialize().asUint8List();
✅ 정리
- Dart 코드는 이벤트 루프 위에서 돌고, await는 스레드를 막지 않고 함수를 일시 중단한다.
- 모든 Dart 코드는 Isolate 안에서 실행되며, 기본적으로 Main Isolate 하나만 쓴다.
- Isolate는 자기만의 Heap과 스레드를 가지고 서로 메시지로만 통신한다. 그래서 Data Race가 생기지 않는다.
- Isolate는 한 프레임(약 16ms) 안에 끝나지 않는 CPU 연산이 있을 때 쓴다.
- 결과 하나만 받으면 Isolate.run(), 계속 주고받으면 Isolate.spawn()과 Port를 쓴다.
- 메시지는 기본적으로 복사되므로, 큰 데이터는 Isolate.exit()이나 TransferableTypedData로 복사 비용을 줄인다.
오늘은 이렇게 Isolate에 대해 알아봤습니다. 감사합니다.
출처
- https://dart.dev/language/concurrency
- https://docs.flutter.dev/perf/isolates
- https://docs.flutter.dev/flutter-for/dart-swift-concurrency
- https://docs.flutter.dev/resources/architectural-overview
- https://api.dart.dev/dart-isolate/SendPort/send.html
- https://api.dart.dev/dart-isolate/TransferableTypedData-class.html
- Total
- Today
- Yesterday
- SwiftUI
- 알고리즘
- tuist v4
- ios
- webrtc
- 동시성
- Algorithm
- opaque
- iphone
- ClaudeCode
- swift
- GradientDescent
- ComtextEngineering
- Tuist
- kakaomapssdk
- ViewBuilder
- 경사하강법
- BoxedType
- 인공신경망
- CD/CI
- 스위프트
- 코어데이터
- Concurrency
- Xcode
- AI
- opaquetype
- SocialLogin
- Actor
- coredata
- 인공지능
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | ||||
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |