Zones
Zones (존)
비동기 코드를 관리해 볼게요. 처리되지 않은 오류 다루기, print()나 작업 스케줄링 같은 동작 덮어쓰기 등 다양한 일을 zone으로 할 수 있어요.
출처: Zones
본문
비동기 동적 범위 (Asynchronous dynamic extents)
이 글은 dart:async 라이브러리의 zone 관련 API를 다루며, 특히 최상위 함수인 runZoned()과 runZonedGuarded()에 초점을 맞춰요. 이 글을 읽기 전에 Futures와 오류 처리에서 다룬 기법들을 복습해 두세요.
Zone은 다음 작업들을 가능하게 해 줘요:
- 앱이 잡히지 않은 예외로 종료되는 것을 막기. 예를 들어 간단한 HTTP 서버는 다음 비동기 코드를 사용할 수 있어요:
HTTP 서버를 zone에서 실행하면 서버의 비동기 코드에 잡히지 않은(치명적이지 않은) 오류가 있어도 앱이 계속 실행될 수 있게 돼요.runZonedGuarded(() { HttpServer.bind('0.0.0.0', port).then((server) { server.listen(staticFiles.serveRequest); }); }, (error, stackTrace) => print('Oh noes! $error $stackTrace')); - 데이터를 개별 zone과 연결하기. 이를 zone-로컬 값(zone-local values) 이라고 불러요.
- 코드의 일부 또는 전체에서 print()나 scheduleMicrotask() 같은 제한된 메서드 집합을 덮어쓰기(override).
- 코드가 zone에 들어가거나 나올 때마다 작업 수행하기. 이런 작업에는 타이머 시작·중지, 스택 트레이스 저장 같은 것이 포함될 수 있어요.
다른 언어에서 zone과 비슷한 것을 접해 봤을 수도 있어요. Node.js의 Domains는 Dart zone의 영감이 되었어요. Java의 thread-local 저장소도 어느 정도 비슷한 점이 있어요. 가장 비슷한 것은 Brian Ford가 만든 Dart zone의 JavaScript 포팅 zone.js인데, 이 비디오에서 설명하고 있어요.
Zone 기본 (Zone basics)
zone은 호출(call)의 비동기 동적 범위(asynchronous dynamic extent) 를 나타내요. 호출의 일부로 수행되는 계산과, 그 코드가 (전이적으로) 등록한 비동기 콜백들이 바로 그것이에요.
예를 들어 HTTP 서버 예시에서 bind(), then(), 그리고 then()의 콜백은 모두 같은 zone — runZoned()으로 만든 그 zone — 안에서 실행돼요.
다음 예시에서는 코드가 3개의 서로 다른 zone에서 실행돼요: zone #1(루트 zone), zone #2, zone #3.
import 'dart:async';
main() {
foo();
var future;
runZoned(() { // Starts a new child zone (zone #2).
future = new Future(bar).then(baz);
});
future.then(qux);
}
foo() => ...foo-body... // Executed twice (once each in two zones).
bar() => ...bar-body...
baz(x) => runZoned(() => foo()); // New child zone (zone #3).
qux(x) => ...qux-body...
다음 그림은 코드의 실행 순서와 코드가 어떤 zone에서 실행되는지 보여 줘요.
[그림] 프로그램 실행을 나타내는 일러스트레이션
runZoned() 호출은 각각 새 zone을 만들고 그 zone 안에서 코드를 실행해요. 그 코드가 작업을 스케줄하면(예: baz() 호출) 그 작업은 스케줄된 위치의 zone에서 실행돼요. 예를 들어 qux() 호출(main()의 마지막 줄)은 그 자체가 zone #2에서 실행되는 future에 붙어 있음에도 zone #1(루트 zone) 에서 실행돼요.
자식 zone이 부모 zone을 완전히 대체하지는 않아요. 새 zone은 자신을 둘러싼 zone 안에 중첩돼요. 예를 들어 zone #2는 zone #3을 포함하고, zone #1(루트 zone)은 zone #2와 zone #3 둘 다를 포함해요.
모든 Dart 코드는 루트 zone에서 실행돼요. 코드는 다른 중첩된 자식 zone에서도 실행될 수 있지만, 최소한 항상 루트 zone에서는 실행돼요.
잡히지 않은 오류 처리하기 (Handling uncaught errors)
Zone은 잡히지 않은 오류를 잡고 처리할 수 있어요.
잡히지 않은 오류는 종종 throw로 예외를 발생시키는데 그것을 처리할 catch 문이 없을 때 생겨요. 잡히지 않은 오류는 또한 async 함수에서 Future가 오류 결과로 완료되는데 그 오류를 처리할 대응되는 await가 없을 때도 생길 수 있어요.
잡히지 않은 오류는 그것을 잡지 못한 현재 zone에 보고돼요. 기본적으로 zone은 잡히지 않은 오류에 대응해 프로그램을 크래시시켜요. 새 zone에 자신만의 커스텀 잡히지 않은 오류 핸들러를 설치하면 잡히지 않은 오류를 원하는 방식으로 가로채서 처리할 수 있어요.
잡히지 않은 오류 핸들러가 있는 새 zone을 도입하려면 runZonedGuarded 메서드를 사용해요. 그 onError 콜백이 새 zone의 잡히지 않은 오류 핸들러가 돼요. 이 콜백은 호출이 throw하는 모든 동기 오류를 처리해요.
runZonedGuarded(() {
Timer.run(() { throw 'Would normally kill the program'; });
}, (error, stackTrace) {
print('Uncaught error: $error');
});
잡히지 않은 오류 처리를 돕는 다른 zone API로는 Zone.fork, Zone.runGuarded, ZoneSpecification.uncaughtErrorHandler이 있어요.
앞의 코드에는 예외를 throw하는 비동기 콜백(Timer.run()을 통한)이 있어요. 보통 이 예외는 처리되지 않은 오류가 되어 최상위 레벨까지 도달하며(독립형 Dart 실행 파일에서는 실행 중인 프로세스를 죽임), 하지만 zone 오류 핸들러가 있으면 그 오류는 오류 핸들러에게 전달되고 프로그램은 종료되지 않아요.
try-catch와 zone 오류 핸들러의 한 가지 주목할 만한 차이는, zone은 잡히지 않은 오류가 발생한 뒤에도 계속 실행된다는 점이에요. zone 안에서 다른 비동기 콜백이 스케줄되어 있다면 그것들은 여전히 실행돼요. 결과적으로 zone 오류 핸들러는 여러 번 호출될 수 있어요.
잡히지 않은 오류 핸들러가 있는 모든 zone을 오류 zone(error zone) 이라고 불러요. 오류 zone은 그 zone의 자손(descendant)에서 발생한 오류를 처리할 수 있어요. future 변환 연쇄(순서)에서 오류가 어디에서 처리되는지를 결정하는 단순한 규칙이 있어요(then() 또는 catchError() 사용): Future 체인의 오류는 오류 zone의 경계를 절대 넘지 않아요.
오류가 오류 zone 경계에 도달하면 그 지점에서 처리되지 않은 오류로 취급돼요.
API 참고 잡히지 않은 오류를 처리하려고 zone이 꼭 필요한 건 아니에요. isolate API Isolate.run()도 잡히지 않은 오류 듣기를 처리해요.
예시: 오류는 오류 zone 안으로 들어갈 수 없어요
다음 예시에서 첫 줄이 발생시킨 오류는 오류 zone 안으로 들어갈 수 없어요.
var f = new Future.error(499);
f = f.whenComplete(() { print('Outside of zones'); });
runZoned(() {
f = f.whenComplete(() { print('Inside non-error zone'); });
});
runZonedGuarded(() {
f = f.whenComplete(() { print('Inside error zone (not called)'); });
}, (error) { print(error); });
예시를 실행하면 나오는 출력이에요:
Outside of zones
Inside non-error zone
Uncaught Error: 499
Unhandled exception:
499
...stack trace...
runZoned() 또는 runZonedGuarded() 호출을 제거하면 이 출력이 나와요:
Outside of zones
Inside non-error zone
Inside error zone (not called)
Uncaught Error: 499
Unhandled exception:
499
...stack trace...
zone이나 오류 zone 중 하나를 제거하면 오류가 더 멀리 전파되는 것에 주목하세요.
스택 트레이스는 오류가 오류 zone 밖에서 발생했기 때문에 나타나요. 코드 전체 조각 주위에 오류 zone을 추가하면 스택 트레이스를 피할 수 있어요.
예시: 오류는 오류 zone 밖으로 나갈 수 없어요
앞의 코드가 보여 주듯이 오류는 오류 zone 안으로 들어갈 수 없어요. 마찬가지로 오류는 오류 zone 밖으로 나갈 수 없어요. 다음 예시를 보세요:
var completer = new Completer();
var future = completer.future.then((x) => x + 1);
var zoneFuture;
runZonedGuarded(() {
zoneFuture = future.then((y) => throw 'Inside zone');
}, (error) { print('Caught: $error'); });
zoneFuture.catchError((e) { print('Never reached'); });
completer.complete(499);
future 체인이 catchError()로 끝나더라도 비동기 오류는 오류 zone 밖으로 나갈 수 없어요. runZonedGuarded() 안의 zone 오류 핸들러가 그 오류를 처리해요. 그 결과 zoneFuture는 값으로도 오류로도 완료되지 않아요.
stream과 함께 zone 사용하기 (Using zones with streams)
zone과 stream의 규칙은 futures보다 더 단순해요:
참고 변환과 그 외 콜백은 stream이 들리는(생성되는) zone에서 실행돼요.
이 규칙은 stream은 들리기 전까지 부수 효과(side effect)가 없어야 한다는 지침에서 나와요. 동기 코드에서 비슷한 상황은 요청하기 전까지 값이 평가되지 않는 Iterable의 동작이에요.
예시: runZonedGuarded()와 함께 stream 사용하기
다음 예시는 콜백을 가진 stream을 준비하고, 그 stream을 runZonedGuarded()로 새 zone에서 실행해요:
var stream = new File('stream.dart').openRead()
.map((x) => throw 'Callback throws');
runZonedGuarded(() { stream.listen(print); },
(e) { print('Caught error: $e'); });
runZonedGuarded()의 오류 핸들러가 콜백이 throw한 오류를 잡아요. 출력이에요:
Caught error: Callback throws
출력이 보여 주듯이, 콜백은 map()이 호출된 zone이 아니라 듣는(리슨하는) zone 과 연결돼요.
zone-로컬 값 저장하기 (Storing zone-local values)
정적 변수를 쓰고 싶은데 동시에 실행 중인 여러 계산이 서로 간섭해서 쓸 수 없을 때, zone-로컬 값을 고려해 보세요. 디버깅을 돕기 위해 zone-로컬 값을 추가할 수도 있고요. 또 다른 사용 사례는 HTTP 요청을 다루는 것인데, 사용자 ID와 그 인증 토큰을 zone-로컬 값으로 가질 수 있어요.
새로 만든 zone에 값을 저장하려면 runZoned()의 zoneValues 인자를 사용해요:
runZoned(() {
print(Zone.current[#key]);
}, zoneValues: { #key: 499 });
zone-로컬 값을 읽으려면 zone의 인덱스 연산자와 값의 키를 사용해요: [key]. 호환되는 operator ==와 hashCode 구현이 있으면 어떤 객체든 키로 쓸 수 있어요. 보통 키는 심볼 리터럴인 #identifier예요.
키가 가리키는 객체는 바꿀 수 없지만, 그 객체 자체를 조작할 수는 있어요. 예를 들어 다음 코드는 zone-로컬 리스트에 항목을 추가해요:
runZoned(() {
Zone.current[#key].add(499);
print(Zone.current[#key]); // [499]
}, zoneValues: { #key: [] });
zone은 부모 zone에서 zone-로컬 값을 상속하므로 중첩 zone을 추가해도 기존 값을 실수로 잃지 않아요. 다만 중첩 zone이 부모 값을 가릴(섀도잉) 수는 있어요.
중요 키에는 고유한 객체를 사용하도록 노력하세요. 그래야 다른 라이브러리와 충돌할 가능성이 줄어들어요.
예시: 디버그 로그에 zone-로컬 값 사용하기
foo.txt와 bar.txt 두 파일이 있고 그 모든 줄을 출력하고 싶다고 해 볼게요. 프로그램은 다음과 같을 수 있어요:
import 'dart:async';
import 'dart:convert';
import 'dart:io';
Future splitLinesStream(stream) {
return stream
.transform(ASCII.decoder)
.transform(const LineSplitter())
.toList();
}
Future splitLines(filename) {
return splitLinesStream(new File(filename).openRead());
}
main() {
Future.forEach(['foo.txt', 'bar.txt'],
(file) => splitLines(file)
.then((lines) { lines.forEach(print); }));
}
이 프로그램은 동작해요. 그런데 이제 각 줄이 어느 파일에서 왔는지 알고 싶고, splitLinesStream()에 파일 이름 인자를 그냥 추가할 수는 없다고 가정해 볼게요. zone-로컬 값으로 반환 문자열에 파일 이름을 추가할 수 있어요 (새 줄이 강조돼 있어요):
import 'dart:async';
import 'dart:convert';
import 'dart:io';
Future splitLinesStream(stream) {
return stream
.transform(ASCII.decoder)
.transform(const LineSplitter())
.map((line) => '${Zone.current[#filename]}: $line')
.toList();
}
Future splitLines(filename) {
return runZoned(() {
return splitLinesStream(new File(filename).openRead());
}, zoneValues: { #filename: filename });
}
main() {
Future.forEach(['foo.txt', 'bar.txt'],
(file) => splitLines(file)
.then((lines) { lines.forEach(print); }));
}
새 코드는 함수 시그니처를 수정하거나 splitLines()에서 splitLinesStream()으로 파일 이름을 전달하지 않는다는 점에 주목하세요. 대신 zone-로컬 값을 사용해 비동기 컨텍스트에서 동작하는 정적 변수와 비슷한 기능을 구현했어요.
기능 덮어쓰기 (Overriding functionality)
zone이 관리하는 기능을 덮어쓰려면 runZoned()의 zoneSpecification 인자를 사용해요. 이 인자의 값은 ZoneSpecification 객체이며, 다음 기능 중 어느 것이든 덮어쓸 수 있어요:
- 자식 zone 포크(fork)하기.
- zone 안에서 콜백 등록하고 실행하기.
- 마이크로태스크와 타이머 스케줄링하기.
- 잡히지 않은 비동기 오류 처리하기(
runZonedGuarded()는 이것의 단축키예요). - 출력(printing)하기.
예시: print 덮어쓰기
기능을 덮어쓰는 간단한 예시로, zone 안의 모든 print를 조용히 없애는 방법이 있어요:
import 'dart:async';
main() {
runZoned(() {
print('Will be ignored');
}, zoneSpecification: new ZoneSpecification(
print: (self, parent, zone, message) {
// Ignore message.
}));
}
포크된 zone 안에서 print() 함수는 지정된 print 인터셉터로 덮어써지며, 그 인터셉터는 단순히 메시지를 버려요. print()가 (scheduleMicrotask()와 Timer 생성자처럼) 작업을 하기 위해 현재 zone(Zone.current)을 사용하기 때문에 print를 덮어쓰는 게 가능해요.
인터셉터와 델리게이트의 인자들
print 예시가 보여 주듯이 인터셉터는 Zone 클래스의 해당 메서드에 정의된 인자에 인자 세 개를 더해요. 예를 들어 Zone의 print() 메서드는 인자가 하나예요: print(String line). ZoneSpecification이 정의하는 print()의 인터셉터 버전은 인자가 네 개예요: print(Zone self, ZoneDelegate parent, Zone zone, String line).
인자 세 개(인터셉터 인자)는 항상 다른 인자들보다 앞에 같은 순서로 나타나요.
- self: 콜백을 처리 중인 zone.
- parent: 부모 zone을 나타내는 ZoneDelegate. 연산을 부모로 전달(forward)할 때 사용해요.
- zone: 연산이 시작된 zone. 일부 연산은 연산이 어떤 zone에서 호출됐는지 알아야 해요. 예를 들어
zone.fork(specification)은 zone의 자식으로 새 zone을 만들어야 해요. 또 다른 예로,scheduleMicrotask()를 다른 zone에 위임하더라도 마이크로태스크를 실행하는 쪽은 원래 zone이어야 해요.
인터셉터가 메서드를 부모에 위임할 때, 부모(ZoneDelegate) 버전의 그 메서드는 인자가 하나 더 있어요: 원래 호출이 시작된 zone인 zone. 예를 들어 ZoneDelegate의 print() 메서드 시그니처는 print(Zone zone, String line)이에요.
다른 인터셉트 가능한 메서드인 scheduleMicrotask()의 인자 예시를 볼게요:
| 정의된 곳 | 메서드 시그니처 |
|---|---|
Zone |
void scheduleMicrotask(void f()) |
ZoneSpecification |
void scheduleMicrotask(Zone self, ZoneDelegate parent, Zone zone, void f()) |
ZoneDelegate |
void scheduleMicrotask(Zone zone, void f()) |
예시: 부모 zone에 위임하기
부모 zone에 위임하는 방법을 보여 주는 예시예요:
import 'dart:async';
main() {
runZoned(() {
var currentZone = Zone.current;
scheduleMicrotask(() {
print(identical(currentZone, Zone.current)); // prints true.
});
}, zoneSpecification: new ZoneSpecification(
scheduleMicrotask: (self, parent, zone, task) {
print('scheduleMicrotask has been called inside the zone');
// The origin `zone` needs to be passed to the parent so that
// the task can be executed in it.
parent.scheduleMicrotask(zone, task);
}));
}
예시: zone에 들어가고 나올 때 코드 실행하기
일부 비동기 코드가 실행에 얼마나 시간을 쓰는지 알고 싶다고 해 볼게요. 코드를 zone에 넣고, zone에 들어갈 때마다 타이머를 시작하고, zone을 나갈 때마다 타이머를 멈추면 돼요.
ZoneSpecification에 run* 매개변수를 제공하면 zone이 실행하는 코드를 지정할 수 있어요.
API 참고 미래에는 zone 코드를 감싸는 흔한 경우에 대해 더 간단한 대안(onEnter/onLeave API)을 zone이 제공할 수도 있어요. 자세한 내용은 issue 17532를 참고하세요.
run* 매개변수 — run, runUnary, runBinary — 는 zone이 코드를 실행하도록 요청받을 때마다 실행할 코드를 지정해요. 이 매개변수는 각각 인자가 0개, 1개, 2개인 콜백에 대해 동작해요. run 매개변수는 runZoned()를 호출한 직후 실행되는 최초의 동기 코드에도 적용돼요.
run*을 사용한 프로파일링 코드 예시예요:
final total = new Stopwatch();
final user = new Stopwatch();
final specification = new ZoneSpecification(
run: (self, parent, zone, f) {
user.start();
try { return parent.run(zone, f); } finally { user.stop(); }
},
runUnary: (self, parent, zone, f, arg) {
user.start();
try { return parent.runUnary(zone, f, arg); } finally { user.stop(); }
},
runBinary: (self, parent, zone, f, arg1, arg2) {
user.start();
try {
return parent.runBinary(zone, f, arg1, arg2);
} finally {
user.stop();
}
});
runZoned(() {
total.start();
// ... Code that runs synchronously...
// ... Then code that runs asynchronously ...
.then((...) {
print(total.elapsedMilliseconds);
print(user.elapsedMilliseconds);
});
}, zoneSpecification: specification);
이 코드에서 각 run* 덮어쓰기는 user 타이머를 시작하고, 지정된 함수를 실행하고, 그리고 user 타이머를 멈춰요.
예시: 콜백 처리하기
ZoneSpecification에 register*Callback 매개변수를 제공하면 콜백 코드 — zone에서 비동기적으로 실행되는 코드 — 를 감싸거나 바꿀 수 있어요. run* 매개변수처럼 register*Callback 매개변수도 세 가지 형태가 있어요: registerCallback(인자 없는 콜백), registerUnaryCallback(인자 하나), registerBinaryCallback(인자 두 개).
다음은 zone이 코드가 비동기 컨텍스트로 사라지기 전에 스택 트레이스를 저장하게 만드는 예시예요.
import 'dart:async';
get currentStackTrace {
try {
throw 0;
} catch(_, st) {
return st;
}
}
var lastStackTrace = null;
bar() => throw "in bar";
foo() => new Future(bar);
main() {
final specification = new ZoneSpecification(
registerCallback: (self, parent, zone, f) {
var stackTrace = currentStackTrace;
return parent.registerCallback(zone, () {
lastStackTrace = stackTrace;
return f();
});
},
registerUnaryCallback: (self, parent, zone, f) {
var stackTrace = currentStackTrace;
return parent.registerUnaryCallback(zone, (arg) {
lastStackTrace = stackTrace;
return f(arg);
});
},
registerBinaryCallback: (self, parent, zone, f) {
var stackTrace = currentStackTrace;
return parent.registerBinaryCallback(zone, (arg1, arg2) {
lastStackTrace = stackTrace;
return f(arg1, arg2);
});
},
handleUncaughtError: (self, parent, zone, error, stackTrace) {
if (lastStackTrace != null) print("last stack: $lastStackTrace");
return parent.handleUncaughtError(zone, error, stackTrace);
});
runZoned(() {
foo();
}, zoneSpecification: specification);
}
예시를 직접 실행해 보세요. foo()가 동기적으로 호출됐으므로 그 함수를 포함하는 "last stack" 트레이스(lastStackTrace)가 보일 거예요. 다음 스택 트레이스(stackTrace)는 비동기 컨텍스트에서 온 것으로, bar()는 알지만 foo()는 몰라요.
비동기 콜백 구현하기
비동기 API를 구현하더라도 zone을 전혀 다룰 필요가 없을 수도 있어요. 예를 들어 dart:io 라이브러리가 현재 zone을 추적할 거라고 예상할 수 있지만, 실제로는 Future와 Stream 같은 dart:async 클래스의 zone 처리에 의존해요.
zone을 명시적으로 처리한다면, 모든 비동기 콜백을 등록하고 각 콜백이 등록된 zone에서 호출되도록 보장해야 해요. Zone의 bind*Callback 헬퍼 메서드들이 이 작업을 쉽게 만들어 줘요. 이것들은 register*Callback과 run*의 단축키로, 각 콜백이 그 Zone에 등록되고 실행되도록 보장해요.
bind*Callback보다 더 많은 제어가 필요하다면 register*Callback과 run*을 사용해야 해요. Zone의 run*Guarded 메서드를 사용하고 싶을 수도 있는데, 이것은 호출을 try-catch로 감싸고 오류가 발생하면 uncaughtErrorHandler를 호출해요.
요약 (Summary)
Zone은 비동기 코드의 잡히지 않은 예외로부터 코드를 보호하는 데 좋아요. 하지만 그것보다 훨씬 더 많은 일을 할 수 있어요. zone에 데이터를 연결할 수 있고, 출력과 작업 스케줄링 같은 핵심 기능을 덮어쓸 수도 있어요. zone은 더 나은 디버깅을 가능하게 하고 프로파일링 같은 기능에 쓸 수 있는 훅을 제공해요.
더 읽을거리 (More resources)
Zone 관련 API 문서
runZoned(), runZonedGuarded(), Zone, ZoneDelegate, ZoneSpecification의 문서를 읽어 보세요.
stack_trace
stack_trace 라이브러리의 Chain 클래스로 비동기적으로 실행되는 코드에 대해 더 나은 스택 트레이스를 얻을 수 있어요. 자세한 내용은 pub.dev 사이트의 stack_trace 패키지를 참고하세요.
더 많은 예시 (More examples)
zone을 사용하는 좀 더 복잡한 예시들을 몇 가지 볼게요.
task_interceptor 예시: task_interceptor.dart의 toy zone은 scheduleMicrotask, createTimer, createPeriodicTimer를 가로채서 이벤트 루프에 양보하지 않고 Dart 원시 기능의 동작을 시뮬레이션해요.
stack_trace 패키지의 소스 코드: stack_trace 패키지는 비동기 코드 디버깅을 위해 스택 트레이스 체인을 만드는 데 zone을 사용해요. 사용된 zone 기능에는 오류 처리, zone-로컬 값, 콜백이 포함돼요. stack_trace 소스 코드는 stack_trace GitHub 프로젝트에서 찾을 수 있어요.
dart:async의 소스 코드: 이 두 SDK 라이브러리는 비동기 콜백을 특징으로 하는 API를 구현하므로 zone을 다룬다는 점이 흥미로워요. Dart GitHub 프로젝트의 sdk/lib 디렉터리 아래에서 그 소스 코드를 살펴보거나 다운로드할 수 있어요.
이 글을 검토해 준 Anders Johnsen과 Lasse Reichstein Nielsen에게 감사드려요.