Ch03-2. Dart 비동기 - Future, Stream

Dart의 Future, async/await, Stream, BroadcastStream을 Swift의 비동기 패턴과 비교하며 정리한 기록

Ch03 두 번째는 비동기 프로그래밍이다. Future와 Stream이 핵심인데, Swift의 async/await이랑 비교하면 이해가 빠르다.

Future - 미래의 값

Future<T>는 “아직 안 끝난 비동기 작업의 결과"를 나타낸다. Dart 공식 문서에 따르면 두 가지 상태가 있다:

  • Uncompleted: 작업 진행 중
  • Completed: 값 또는 에러로 완료

Swift에서는 async 함수가 값을 직접 반환하고 런타임이 내부적으로 관리하는데, Dart는 Future<T>라는 래퍼 객체를 명시적으로 반환한다. 타입 시그니처에 비동기 여부가 드러나는 셈이다.

1
2
3
4
5
// Dart: 반환 타입이 Future<String>
Future<String> fetchUserName() async {
  await Future.delayed(Duration(seconds: 1));
  return 'jHoon';
}
1
2
3
4
5
// Swift: 반환 타입이 그냥 String, async가 키워드로 붙음
func fetchUserName() async -> String {
    try await Task.sleep(nanoseconds: 1_000_000_000)
    return "jHoon"
}

async/await

사용법은 Swift랑 거의 똑같다. async 붙이고 await으로 기다리면 된다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
void main() async {
  print('주문 시작');
  var order = await fetchOrder();
  print('주문 완료: $order');
}

Future<String> fetchOrder() async {
  await Future.delayed(Duration(seconds: 2));
  return '아이스 아메리카노';
}
// 주문 시작
// (2초 후)
// 주문 완료: 아이스 아메리카노

한 가지 차이: Dart에서 async 함수는 첫 번째 await을 만나기 전까지 동기적으로 실행된다. await 이전 코드는 바로 실행되고, await에서 비로소 비동기로 전환된다.

에러 처리

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// try-catch (권장)
try {
  var data = await fetchData();
  print(data);
} catch (e) {
  print('에러: $e');
}

// then + catchError (콜백 스타일)
fetchData()
    .then((data) => print(data))
    .catchError((e) => print('에러: $e'))
    .whenComplete(() => print('완료'));

Swift의 do-catch/try await과 구조적으로 거의 같다. then/catchError 체이닝은 Swift의 옛날 completion handler 패턴이랑 비슷한 느낌인데, async/await이 훨씬 읽기 좋으니까 try-catch 쓰는 게 낫다.

타임아웃

1
2
3
4
5
6
try {
  var result = await fetchData().timeout(Duration(seconds: 3));
  print(result);
} on TimeoutException {
  print('3초 안에 응답이 없음');
}

순차 vs 병렬 실행

1
2
3
4
5
6
7
// 순차: 총 3초 (1+1+1)
var a = await fetchA();  // 1초
var b = await fetchB();  // 1초
var c = await fetchC();  // 1초

// 병렬: 총 1초 (동시 실행, 가장 긴 것 기준)
var results = await Future.wait([fetchA(), fetchB(), fetchC()]);

Swift에서는 async let으로 병렬 실행하고 tuple로 받는 반면, Dart는 Future.wait으로 List에 담아서 받는다.

1
2
3
4
// Swift 병렬 실행
async let a = fetchA()
async let b = fetchB()
let results = try await (a, b)  // tuple

서로 의존성이 없는 API 호출은 Future.wait으로 묶으면 체감 속도가 확 빨라진다.

Stream - 연속된 비동기 데이터

Future가 한 번의 결과라면, Stream여러 번 값을 받을 수 있는 비동기 시퀀스다. Dart 공식 가이드에서 자세히 다루고 있다.

채팅 메시지, 센서 데이터, 실시간 주가처럼 시간에 따라 계속 들어오는 데이터를 처리할 때 쓴다.

만드는 방법 1: async* + yield

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
Stream<int> countDown(int from) async* {
  for (int i = from; i >= 0; i--) {
    await Future.delayed(Duration(seconds: 1));
    yield i;  // 값을 하나씩 내보냄
  }
}

// 사용
void main() async {
  await for (var count in countDown(5)) {
    print(count);  // 5, 4, 3, 2, 1, 0 (1초 간격)
  }
}

async*는 Stream을 생성하는 제너레이터 함수다. yield로 값을 하나씩 내보낸다. Swift의 AsyncStream + continuation 방식보다 훨씬 간결하다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// Swift: continuation 기반이라 좀 장황함
let stream = AsyncStream<Int> { continuation in
    for i in stride(from: 5, through: 0, by: -1) {
        try? await Task.sleep(nanoseconds: 1_000_000_000)
        continuation.yield(i)
    }
    continuation.finish()
}

for await count in stream {
    print(count)
}

만드는 방법 2: StreamController

직접 이벤트를 push하고 싶을 때는 StreamController를 쓴다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
var controller = StreamController<String>();

// 구독
controller.stream.listen(
  (data) => print('받음: $data'),
  onError: (e) => print('에러: $e'),
  onDone: () => print('스트림 종료'),
);

// 이벤트 push
controller.add('첫 번째');
controller.add('두 번째');
controller.addError(Exception('문제 발생'));
controller.add('세 번째');
controller.close();

StreamController는 Flutter에서 위젯 간 데이터 전달이나 상태 변경 알림에도 많이 쓴다. StreamBuilder 위젯이랑 조합하면 실시간 UI 업데이트가 가능하다.

Stream 변환

Stream도 map, where 같은 변환 메서드를 지원한다:

1
2
3
4
5
countDown(10)
    .where((n) => n.isEven)         // 짝수만
    .map((n) => '$n초 남았습니다')   // 문자열로 변환
    .listen((msg) => print(msg));
// 10초 남았습니다, 8초 남았습니다, ...

BroadcastStream - 여러 리스너

기본 Stream은 단일 구독만 가능하다. 두 번 listen하면 에러가 난다. 여러 곳에서 동시에 구독하려면 BroadcastStream을 써야 한다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 방법 1: StreamController.broadcast()
var controller = StreamController<int>.broadcast();

controller.stream.listen((data) => print('A: $data'));
controller.stream.listen((data) => print('B: $data'));

controller.add(1);
// A: 1
// B: 1

// 방법 2: 기존 스트림을 변환
var broadcast = countDown(3).asBroadcastStream();
broadcast.listen((n) => print('위젯1: $n'));
broadcast.listen((n) => print('위젯2: $n'));

주의할 점: broadcast 스트림은 리스너가 없을 때 발생한 이벤트는 그냥 버린다. 늦게 구독하면 이전 데이터를 못 받는다.

Swift Combine으로 비교하면:

DartSwift Combine설명
StreamController()-단일 구독 스트림
StreamController.broadcast()PassthroughSubject다중 구독, 이전 값 안 줌
직접 구현 필요CurrentValueSubject최신 값 유지 + 다중 구독

yield* - 다른 스트림/이터러블 위임

yield*는 다른 스트림이나 이터러블의 모든 값을 그대로 내보낸다. 재귀적 스트림을 만들 때 유용하다.

1
2
3
4
5
6
7
8
Stream<String> countStream(int max) async* {
  for (int i = 1; i <= max; i++) {
    await Future.delayed(Duration(seconds: 1));
    yield '$i';
  }
  yield '완료!';
  yield* countStream(max);  // 재귀: 다시 처음부터
}

yield가 값 하나를 내보내는 거라면, yield*는 통째로 위임하는 거다. for 루프로 하나씩 yield하는 것보다 효율적이다.

Future vs Stream 정리

FutureStream
데이터한 번여러 번
사용 시점API 호출, 파일 읽기실시간 데이터, 이벤트
소비awaitawait for 또는 listen
Swift 대응async 함수 반환값AsyncStream / Combine
생성async 함수async* + yield 또는 StreamController

Swift에서 넘어오면서 느낀 건, Dart의 async* + yield가 Swift의 AsyncStream continuation 방식보다 확실히 간결하다는 거다. 스트림을 만드는 코드 자체가 직관적이라 좋았다. 반면 Future.wait vs async let 같은 병렬 처리는 Swift 쪽이 tuple로 받으니까 타입이 더 명확한 느낌이다.