더 나은 isolate 관리: Isolate.run()
더 나은 isolate 관리: Isolate.run()
Dart 2.19에 동시성(concurrency)을 단 한 줄의 코드처럼 간단하게 구현할 수 있게 해 주는 새 함수가 도입됐어요. 모든 Dart 코드는 isolate에서 실행돼요. 여러 isolate를 구현해서 Dart 프로그램에 동시성을 활성화할지는 여러분의 몫이지만, 여러 isolate를 어떻게 구현할 수 있는지는 Dart 팀, 즉 우리의 몫이에요. 그리고 Dart 2.19에서 우리는 그 과정을 크게 업그레이드했는데, 그 소식을 전해 드리게 되어 기쁘네요. 바로 **Isolate.run()**입니다!
run()은 isolate를 설정하고 관리하는 모든 복잡함을 하나의 함수 호출로 완전히 추상화해 줘요. 몇 가지 기본 요소(primitive)로 isolate를 쓰는 건 이전부터 가능했어요. 하지만 최근의 isolate 성능 개선 덕분에도 그 과정은 잘해야 지루하고, 최악의 경우 오류가 나기 쉬웠죠. run()이 얼마나 큰 개선인지 감상하려면, 이 글에서 이전 방식이 저수준 기본 요소로 기능을 하나하나 조립하는 방법이었는지 살펴본 다음, Isolate.run()을 쓰는 것과 대조하고 내부에서 어떻게 동작하는지 보여 드릴게요. isolate를 써 본 적이 없더라도 run()을 보면 꼭 시도해 보고 싶어질 거예요!
본문
Isolate란?
Isolate는 비교적 직관적인 개념이에요. isolate는 본질적으로 Dart의 단일 실행 스레드예요. 코드의 일부를 병렬로 실행할 수 있게 해 주죠. 원하는 만큼 새 병렬 실행을 시작하고 그들에게 무엇을 할지 지시할 수 있어요. 바로 main(메인 스레드 또는 메인 isolate)에서요. isolate는 메모리를 공유하지 않고, 대신 메시지를 주고받으며 통신해요. 그래서 경쟁 조건(race condition)이나 뮤텍스, 락 같은 전형적인 멀티스레딩 문제를 걱정할 필요가 없어요.
좋아 보이죠? 그런데 어떻게 사용할까요? Isolate.run() 이전에는 바로 여기가 까다로워지던 부분이에요.
isolate API는 광범위한 기능을 제공하는 저수준 기본 요소들로 이루어져 있어요. 그런 세밀함은 isolate의 기능을 커스터마이즈해야 할 때 훌륭하죠. 하지만 isolate를 아예 써야만 할 때는 그 세밀함이 별로 좋지 않아요. 특히 isolate 사용 사례의 거의 전부가 설정과 관리를 위해 같은 기본 구성을 요구하니까요. 이는 사실상 모든 구현 세부 사항을 사용자인 여러분이 직접 처리하도록 노출하는 것이었어요.
Isolate.run()이 해결하는 그 지루한 과정을 이해하기 위해 전형적인 isolate 설정을 살펴볼게요.
Isolate 사용하기 (이전 방식)
Isolate.spawn()을 isolate의 옛 시작점이라고 생각하면 돼요. Flutter의 compute 함수도 spawn() 위에 만들어졌죠. entrypoint 인자로 호출할 메서드를 받고, 그 메서드의 인자들과 isolate 자체에 대한 다른 설정도 받아요. 과거에는 이 entrypoint가 최상위(top-level) 메서드나 정적(static) 메서드로만 제한되었어요.
Isolate.spawn(_readAndParseJson, filename);
isolate가 생성되었고, 끝! 농담이에요. 훨씬 부족하죠. spawn을 호출하면 실제로 쓸 수 있는 결과가 반환되지 않아요. isolate가 시작되었음을 확인시켜 주는 isolate 객체만 반환할 뿐이죠.
메인 isolate와 생성된 isolate는 생성 시 전달된 초기 인자 외에는 직접 통신할 수 없어요. 현실적으로, 생성된 isolate에서 계산 결과를 되돌려받을 필요가 없더라도, 계산이 성공했는지 어떤 형태로든 검증을 원할 테니 항상 메시지를 돌려받고 싶어 해요.
통신을 활성화하려면 포트(port)를 설정해야 해요. spawn을 호출하기 전에 ReceivePort 객체를 만들어요. ReceivePort 객체의 sendPort 멤버가 spawn의 또 다른 인자로 생성된 isolate에 전달돼요.
void main() async {
final resultPort = ReceivePort();
final jsonData = await Isolate.spawn(_readAndParseJson, [resultPort.sendPort, filename]);
}
즉 spawn에 전달하는 함수는 그 sendPort로 무언가를 하도록 특별히 구성되어 있어야 해요. 다시 말해, 기존 함수를 isolate와 곧바로 재사용할 수는 없어요. 그래서 spawn에 JSON 파일을 읽고 파싱하는 그냥 평범한 함수를 넘기는 대신, 다음과 같은 것을 만들어야 해요.
Future<void> _readAndParseJson(List<dynamic> args) async {
SendPort responsePort = args[0];
String fileName = args[1];
final fileData = await File(filename).readAsString();
final result = jsonDecode(fileData);
// ...
}
이 특별하고 isolate 친화적인 JSON 디코딩 함수는 그냥 "return result" 하면 끝나면 좋겠죠? 그렇지 않아요. 결과는 responsePort를 통해 보내야 해요. isolate가 포트로 통신하는 방식이니까요. Isolate.exit()이라는 또 다른 기본 요소를 사용해 결과를 효율적으로 반환하면서 생성된 isolate를 동시에 닫을 수 있어요.
Isolate.exit(responsePort, result);
exit() 함수는 생성된 isolate에서 메시지를 담고 있는 메모리를 메인 isolate로(복사하는 대신) 전송하고, isolate를 안전하게 닫아요.
이걸 모두 엮어 볼게요. 이 예시에서 result는 파싱된 JSON이므로 실제로 사용하려면 조금 구조 분해(destructure)하고 싶을 거예요. 깔끔한 코드를 위해 receivePort와 isolate를 만들고 그 응답을 기다리는 그 세 줄을 main()에 그대로 두지 말고, 별도의 함수로 옮기는 게 좋아요.
void main() async {
final jsonData = await _spawnAndReceive(filename);
print('Received JSON with ${jsonData.length} keys');
}
// Spawns an isolate and waits for the first message
Future<Map<String, dynamic>> _spawnAndReceive(String filename) async {
final resultPort = ReceivePort();
await Isolate.spawn(_readAndParseJson, [resultPort.sendPort, filename]);
return (await resultPort.first) as Map<String, dynamic>;
}
// The entrypoint that runs on the spawned isolate.
void _readAndParseJson(List<dynamic> args) async {
SendPort responsePort = args[0];
String fileName = args[1];
final fileData = await File(filename).readAsString();
final result = jsonDecode(fileData) as Map<String, dynamic>;
Isolate.exit(responsePort, result);
}
이루어지는 일은 비교적 단순해요. 복잡해 보이게 만드는 건 메시지 전달용 포트나, 포트를 다루는 전용 함수가 isolate와 무관하게 필요하다는 것 같은 구현 세부 사항의 노출이에요.
오류 처리
지금까지의 예시는 아직 진짜 "완성된" 생산 준비 구현이 아니에요. 어떤 형태의 오류 처리를 빼먹는 건 자기 자신에게 손해를 주는 일이지만, 이미 꽤 방대한 과정의 추가 단계라 흔히 잊히곤 해요. 오류 처리가 없으면, 예를 들어 잡히지 않은 비동기 오류로 isolate가 죽는다 해도 무슨 이유로 오류가 났는지, 심지어 무슨 일이 일어났는지조차 알 수 없을 거예요.
isolate의 모든 오류 처리 가능성을 다루는 건 방대하겠지만, 일반적으로 코드에 몇 가지만 추가하면 돼요.
최소한 errorsAreFatal, onExit, onError 파라미터를 spawn 호출에 추가할 수 있어요.
await Isolate.spawn(_readAndParseJson, [resultPort.sendPort, filename],
onError: resultPort.sendPort,
onExit: resultPort.sendPort,
errorsAreFatal: true
);
이렇게 하면 생성된 isolate가 결과를 보내지 않고 종료하거나 잡히지 않은 오류가 있더라도 resultPort가 메시지를 받을 수 있어요. 오류를 치명적으로 만들면 잡히지 않은 오류가 안전 조치로 isolate를 종료시켜, isolate가 아예 종료되도록 보장해요.
onExit 인자는 isolate가 종료되면서 포트에 null을 보내게 해요. onError 인자는 잡히지 않은 오류가 포트에 문자열 두 개의 리스트(오류와 스택 트레이스 각각의 toString)를 보내게 하죠.
결과 포트를 재사용하면 더 많은 포트를 만들지 않아도 되고, 메시지를 한 곳에서만 찾으면 돼요. 하지만 그만큼 onExit과 onError 메시지를 결과 값과 구분해야 해요. 여기서는 JSON이 반드시 Map이라고 가정하므로, 리스트나 null일 수 없어요. 그렇지 않았다면 결과를 알아볼 수 있는 무언가로 감싸야 했을 거예요. 포트 메시지 위에 (사소한) 메시지 프로토콜을 만들어야 한다는 뜻이에요.
그 최소한을 넘어서면, 특정 오류에 대해 response를 확인할 수 있어요. 한 가지 경우는 resultPort가 null인지 확인하는 것인데, 이는 isolate가 결과를 보내지 않고 종료되었다는 뜻이에요.
final response = await resultPort.first;
if (response == null) {
// Isolate exited. Throw some error, for example:
throw RemoteError("Isolate terminated without result");
}
또 다른 경우는 결과가 리스트인지 확인하는 것인데, 잡히지 않은 오류가 발생했다는 뜻이에요.
if (response is List<dynamic>) {
// Uncaught error, list of two strings.
var remoteError = RemoteError(response[0], response[1]);
await Future.error(remoteError); // Rethrow.
}
그다음 마지막으로 실제 결과를 처리해요.
// Otherwise it's a result value, which is a map.
return message as Map<String, dynamic>;
어떤 경우든 entrypoint를 새 isolate에 보내는 게 실패했는지 확인하려면 spawn을 try 블록에 두고 싶을 거예요. 실패했다면 결과 포트는 메시지를 받지 못하므로 닫아야 해요.
try {
await Isolate.spawn( ... );
} on Object {
resultPort.close();
rethrow;
}
최소한의 오류 처리를 제공하면, 생성된 isolate가 어떻게 종료되든 결과 포트가 항상 닫히고 _spawnAndReceive가 항상 완료되도록 보장돼요. 더 좋게 만들 수도 있어요. 예를 들어 오류와 스택 트레이스를 문자열이 아니라 실제 객체로 잡아 보내는 것처럼요. 하지만 그렇게 하려면 결국 다른 객체를 다시 감싸거나 프로토콜을 만들어야 해요.
오류 처리는 당연히 많은 변형을 도입하고, 그에 따라 어떻게 접근할지, 무엇을 고려해야 할지 결정하는 정신적 부담도 생겨요. 그래서 기본 요소 방식의 isolate 설정에서 흔히 빠뜨리게 되는 것도 이해가 돼요.
Isolate 사용하기 (이후 방식)
Isolate.run()은 이전에 기본 요소로 직접 구현해야 했던 isolate 구현의 모든 부분을 단 하나의 함수 호출로 설정해 줘요.
void main() async {
final jsonData = await Isolate.run(_readAndParseJson);
print('Received JSON with ${jsonData.length} keys');
}
포트도, 별도의 스폰이나 종료, 오류 처리도, 특별한 반환 구조도 없어요. 아마 가장 좋은 점은, run에 전달하는 entrypoint가 기존의 어떤 함수든 될 수 있다는 거예요.
Future<Map<String, dynamic>> _readAndParseJson() async {
final fileData = await File(filename).readAsString();
return jsonDecode(fileData);
}
이 예시는 비동기 함수를 보여 주지만, run은 동기 함수도 얼마든지 실행할 수 있어요. run 함수 자체는 항상 비동기로 반환되는데, 그게 전부에요.
entrypoint는 run을 호출하는 곳에 직접 인라인으로 작성하는 함수 표현식이 될 수도 있어요. isolate와 그 위에 만들어진 고수준 API는 더는 정적 또는 최상위 함수만 실행하도록 제한되지 않아요.
final jsonData = await Isolate.run(() async {
final fileData = await File(filename).readAsString();
final jsonData = jsonDecode(fileData) as Map<String, dynamic>;
return jsonData;
});
추가 메시지 파라미터도 더는 필요 없고, 리스트 같은 데이터 구조에 인자를 담고 푸는 일도 피할 수 있어요.
오류 처리에 대해 크게 고민하지 않아도 돼요. run 함수는 로컬 및 원격 오류 캡처와 처리, cross-isolate 통신을 결합해서 결과를 표준 try/catch에서 잡을 수 있는 하나의 평범한 (비동기) 오류로 노출해요. isolate를 잊어버리고 평범한 함수처럼 취급하면 돼요.
try {
await Isolate.run(_readAndParseJson);
} on FormatException catch (e, s) {
print(e.message);
}
Isolate.run()은 훨씬 깔끔하고 사용하기 좋은(ergonomic) 코드를 가능하게 해요. Flutter의 compute 함수도 spawn 대신 run을 쓰도록 바뀌었죠!
Isolate.run() 내부 들여다보기
run의 구현 자체를 살펴볼까요? 이전에 여러분이 직접 구현해야 했을 모든 저수준 isolate 관련 API를 파고들어 "완벽한" 종합 isolate 설정을 구성해요. 실행할 메서드(computation)를 받아 isolate 간 효율적인 메시지 전달을 위해 모든 포트와 그 반환을 설정해요.
static Future<R> run<R>(FutureOr<R> computation(), {String? debugName}) {
var result = Completer<R>();
var resultPort = RawReceivePort();
// ...
result.complete; // or result.completeError for error handling blocks
// ...
모든 잠재적 경우에 대한 철저한 오류 처리가 있어요. run 함수는 isolate가 계산을 끝내기 전에 죽는지 확인해요. 계산이 예외를 던지면 isolate가 종료되고 같은 오류를 메인 isolate에 던져요.
// ...
resultPort.handler = (response) {
resultPort.close();
if (response == null) {
// onExit handler message, isolate terminated without sending result.
result.completeError(
RemoteError("Computation ended without result", ""),
StackTrace.empty);
return;
}
// ...
잡히지 않은 비동기 오류가 발생하면 isolate가 종료되고 그 오류를 메인 isolate에 비동기적으로 보고해요. 메인 isolate가 먼저 종료되면 생성된 isolate도 종료되고 그 상황을 잡히지 않은 비동기 오류로 취급해요.
// ...
void _run() async {
R result;
try {
var potentiallyAsyncResult = computation();
if (potentiallyAsyncResult is Future<R>) {
result = await potentiallyAsyncResult;
} else {
result = potentiallyAsyncResult;
}
} catch (e, s) {
// If sending fails, the error becomes an uncaught error.
Isolate.exit(resultPort, _list2(e, s));
}
Isolate.exit(resultPort, _list1(result));
}
마지막으로 run은 항상 exit를 사용해 안전하게 종료해요. 즉 데이터가 실제로 복사되지 않고 isolate에서 isolate로 효율적으로 전송된다는 뜻이에요.
요약
run 함수는 하나의 계산을 시작하고 결과를 기다리는 데 이상적이에요. 메시지를 여러 번 주고받을 수 있는 장기 실행 isolate처럼 run이 다루지 않는 무언가를 위해 자신만의 isolate 설정을 만들고 싶다면 기본 요소들이 여전히 남아 있어요. 하지만 대부분의 경우, spawn과 그 모든 지원 구성을 하나의 run 문으로 바꾸는 게 어떤 다른 구성보다 우선되어야 해요.
run 이전에 isolate 관리를 시도해 본 적이 없다면, 이 모든 기능이 이전에 사용자가 직접 구현해야 했다는 걸 믿기 어려울 거예요! Dart 2.19와 Flutter 3.7에서 사용할 수 있는 Isolate.run()은 코드를 훨씬 더 사용하기 좋게 만들고 isolate를 훨씬 더 쉽게 만들어 줘요. run이 아껴 주는 시간으로 무엇을 하시겠어요?
더 알아보기
- Isolate.run() API 문서에서 구현 전체 살펴보기
- Dart 동시성(concurrency) 가이드에서 isolate와 동시성 더 배우기