📚 학습 기록/Dart & Flutter 기초

Dart 비동기 프로그래밍 심화: Stream, Future, Timer 완전정복

zenjoydev 2025. 5. 22. 23:18

안녕하세요! 오늘은 Dart의 비동기 프로그래밍에서 한 단계 더 나아가 Stream 변환, Future 체이닝, Timer 활용, 오류 처리 패턴까지 심화 내용을 다뤄보겠습니다. 실무에서 자주 사용되는 패턴들을 실제 코드와 함께 살펴보겠습니다.

목차

  1. Stream 값 소비와 변환
  2. Future 체이닝 패턴
  3. Timer를 활용한 비동기 이벤트
  4. 비동기 오류 처리 전략
  5. 동기 vs 비동기 명확한 구분
  6. 실전 활용 팁

1. Stream 값 소비와 변환

Stream에서 데이터를 처리하는 다양한 방법을 살펴보겠습니다.

await for vs where vs listen

각 방법은 서로 다른 특성과 용도를 가지고 있습니다.

await for (블로킹 방식)

void filterEvenNumbers() async {
  final stream = countStream(10);
  
  // 직접 필터링
  await for (final value in stream) {
    if (value % 2 == 0) {
      print("짝수: $value");
    }
  }
}

where 메서드 (함수형 접근)

void filterWithWhere() async {
  final stream = countStream(10);
  
  // where를 사용한 필터링 + await for
  await for (var value in stream.where((n) => n % 2 == 0)) {
    print("짝수: $value");
  }
  
  // 또는 toList()로 한번에 수집
  var evenNumbers = await stream.where((n) => n % 2 == 0).toList();
  print("모든 짝수: $evenNumbers");
}

listen (논블로킹 방식)

void filterWithListen() {
  final stream = countStream(10);
  
  // 논블로킹으로 필터링된 값 처리
  stream.where((n) => n % 2 == 0).listen(
    (value) => print("짝수: $value"),
    onDone: () => print("스트림 완료!"),
    onError: (error) => print("오류: $error"),
  );
  
  print("이 메시지는 즉시 출력됩니다!"); // 논블로킹이므로 즉시 실행
}

각 방식의 비교

방식실행 타입장점단점적합한 상황

await for 블로킹 직관적, 예외 처리 쉬움 다른 작업 차단 순차적 처리 필요시
where + await for 블로킹 함수형 스타일, 가독성 다른 작업 차단 필터링 후 순차 처리
listen 논블로킹 다른 작업 방해하지 않음 콜백 기반, 복잡할 수 있음 실시간 이벤트 처리

2. Future 체이닝 패턴

비동기 작업을 연결하여 처리하는 다양한 방법을 알아보겠습니다.

then을 활용한 체이닝

Future<String> fetchUsername() async {
  return Future.delayed(Duration(seconds: 1), () => "김철수");
}

Future<int> fetchUserAge(String name) async {
  Map<String, int> userInfos = {"홍길동": 20, "김철수": 30};
  return Future.delayed(
    Duration(seconds: 1), 
    () => userInfos[name] ?? 0
  );
}

void chainWithThen() {
  fetchUsername().then((name) {
    fetchUserAge(name).then((age) {
      print("${name}님의 나이는 ${age}세 입니다!");
    });
  });
}

async/await를 활용한 체이닝

void chainWithAsync() async {
  final name = await fetchUsername();
  final age = await fetchUserAge(name);
  print("$name님의 나이는 $age세 이네요!");
}

체이닝 방식 비교

방식장점단점언제 사용할까

then 논블로킹, 함수형 스타일 중첩 시 콜백 지옥 간단한 체이닝, 논블로킹 필요시
async/await 가독성 좋음, 디버깅 쉬움 블로킹 복잡한 로직, 순차 처리 필요시

유연한 반환 로직 구현

Future<int> fetchUserAge(String name) async {
  Map<String, int> userInfos = {"홍길동": 20, "김철수": 30};
  
  // ?? 연산자로 기본값 제공
  return userInfos[name] ?? 0;
  
  // 또는 삼항 연산자 활용
  // return userInfos.containsKey(name) ? userInfos[name]! : 0;
}

3. Timer를 활용한 비동기 이벤트

Timer는 주기적인 작업이나 지연된 실행에 유용한 도구입니다.

Timer.periodic 활용

void simpleTimerEvents() {
  int count = 0;
  
  Timer.periodic(Duration(seconds: 1), (timer) {
    count++;
    print("$count초 경과!");
    
    if (count == 5) {
      timer.cancel(); // 타이머 정지
      print("타이머 종료!");
    }
  });
}

Timer의 실무 활용 예시

class AutoSaveManager {
  Timer? _autoSaveTimer;
  
  void startAutoSave() {
    _autoSaveTimer = Timer.periodic(Duration(minutes: 5), (timer) {
      saveData();
      print("자동 저장 완료: ${DateTime.now()}");
    });
  }
  
  void stopAutoSave() {
    _autoSaveTimer?.cancel();
    _autoSaveTimer = null;
  }
  
  void saveData() {
    // 실제 저장 로직
  }
}

StreamController와 Timer 조합

Stream<int> timerBasedStream() {
  final controller = StreamController<int>();
  int count = 0;
  
  Timer.periodic(Duration(seconds: 1), (timer) {
    count++;
    controller.add(count);
    
    if (count == 10) {
      timer.cancel();
      controller.close();
    }
  });
  
  return controller.stream;
}

4. 비동기 오류 처리 전략

비동기 작업에서 오류를 효과적으로 처리하는 다양한 패턴을 살펴보겠습니다.

동기 vs 비동기 오류 처리 패턴

동기: try-on-catch-finally

void synchronousErrorHandling() {
  try {
    // 위험한 동기 작업
    int result = 10 ~/ 0; // 0으로 나누기
  } on IntegerDivisionByZeroException {
    print("0으로 나눌 수 없습니다!");
  } catch (e) {
    print("예상치 못한 오류: $e");
  } finally {
    print("정리 작업 수행");
  }
}

비동기: then-catchError-whenComplete

void asynchronousErrorHandling() {
  fetchDataWithPossibleError()
    .then((data) => print("성공: $data"))
    .catchError((error) => print("오류: $error"))
    .whenComplete(() => print("작업 완료"));
}

랜덤 오류 시뮬레이션

import 'dart:math';

Future<String> fetchDataWithPossibleError() async {
  final randomResult = Random.secure().nextBool();
  
  if (randomResult) {
    return "데이터 로드 성공!";
  } else {
    throw Exception("네트워크 오류 발생!");
  }
}

void handleAsyncError() async {
  try {
    final result = await fetchDataWithPossibleError();
    print("성공: $result");
  } catch (e) {
    print("실패했으니 다시 시도해보세요!");
    print("오류 내용: $e");
  } finally {
    print("작업이 완료되었습니다.");
  }
}

Future의 onError 파라미터 활용

void handleErrorWithOnError() {
  fetchDataWithPossibleError().then(
    (data) => print("성공: $data"),
    onError: (error) => print("onError로 처리: $error")
  );
}

5. 동기 vs 비동기 명확한 구분

개념 정리

동기(Synchronous)

  • 특징: 작업이 완료될 때까지 기다림
  • 예시: 커피 주문 후 카운터 앞에서 대기
  • 코드: 일반적인 함수 호출, await 사용

비동기(Asynchronous)

  • 특징: 작업을 시작하고 다른 일을 처리
  • 예시: 세탁기를 돌리고 TV 시청
  • 코드: then, listen, Timer 등

실제 코드로 비교

// 동기적 처리 (블로킹)
void synchronousExample() async {
  print("1. 시작");
  
  String result = await fetchUsername(); // 대기
  print("2. 결과: $result");
  
  print("3. 완료");
}

// 비동기적 처리 (논블로킹)
void asynchronousExample() {
  print("1. 시작");
  
  fetchUsername().then((result) {
    print("2. 결과: $result");
  });
  
  print("3. 이 메시지가 먼저 출력될 수 있음");
}

6. 실전 활용 팁

StreamController 완전 활용

class EventManager {
  final _controller = StreamController<String>.broadcast();
  
  // 데이터 추가
  void addEvent(String event) {
    _controller.add(event);
  }
  
  // 오류 추가
  void addError(Object error) {
    _controller.addError(error);
  }
  
  // 스트림 노출
  Stream<String> get events => _controller.stream;
  
  // 리소스 정리
  void dispose() {
    _controller.close();
  }
}

// 사용 예시
void useEventManager() {
  final manager = EventManager();
  
  // 이벤트 구독
  manager.events.listen(
    (event) => print("이벤트: $event"),
    onError: (error) => print("오류: $error"),
    onDone: () => print("스트림 종료"),
  );
  
  // 이벤트 발생
  manager.addEvent("사용자 로그인");
  manager.addEvent("데이터 업데이트");
  
  // 정리
  manager.dispose();
}

Sink.add vs add의 실무 활용

class DataProcessor {
  final _controller = StreamController<int>();
  
  // 외부에는 Sink만 노출 (캡슐화)
  StreamSink<int> get inputSink => _controller.sink;
  
  // 스트림은 읽기 전용으로 노출
  Stream<int> get outputStream => _controller.stream;
  
  // 내부에서는 직접 add 사용
  void _processInternally(int data) {
    _controller.add(data * 2); // 내부 처리
  }
  
  void dispose() {
    _controller.close();
  }
}

Future 체이닝 베스트 프랙티스

// 나쁜 예: 값을 직접 사용
void badExample() async {
  print("${await fetchUsername()}님의 나이는 ${await fetchUserAge(await fetchUsername())}세");
  // fetchUsername()이 두 번 호출됨!
}

// 좋은 예: 변수에 저장하여 재사용
void goodExample() async {
  final name = await fetchUsername();
  final age = await fetchUserAge(name);
  print("${name}님의 나이는 ${age}세입니다");
}

결론

Dart의 비동기 프로그래밍은 다양한 패턴과 도구를 제공합니다:

  1. Stream 처리: await for (블로킹), listen (논블로킹), where (함수형) 적절히 선택
  2. Future 체이닝: 간단한 경우 then, 복잡한 로직은 async/await 사용
  3. Timer 활용: 주기적 작업이나 지연 실행에 Timer.periodic 활용
  4. 오류 처리: 동기는 try-catch, 비동기는 then-catchError 또는 async/await + try-catch
  5. 동기 vs 비동기: 대기 vs 병렬 처리의 차이 명확히 구분

비동기 프로그래밍은 처음에는 복잡해 보이지만, 실제로 코드를 작성하고 실행해보면서 각 패턴의 특성과 흐름을 이해하는 것이 중요합니다. 특히 언제 블로킹 방식을, 언제 논블로킹 방식을 사용할지 판단하는 능력이 실무에서 매우 중요합니다.

여러분은 어떤 비동기 패턴을 가장 자주 사용하시나요? 실제 프로젝트에서 비동기 처리할 때 겪었던 어려움이나 해결책이 있다면 댓글로 공유해주세요! 함께 배우고 성장하는 개발자 커뮤니티를 만들어가요! 😊