🤖

본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.

⚠️

본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.

이미지 로딩 중...

StreamProvider로 실시간 데이터 처리 완벽 가이드 - 슬라이드 1/7
A

AI Generated

2025. 11. 30. · 16 Views

StreamProvider로 실시간 데이터 처리 완벽 가이드

Flutter에서 실시간 데이터를 우아하게 처리하는 StreamProvider의 모든 것을 다룹니다. Firebase, WebSocket 연동부터 에러 처리, 구독 관리까지 실무에 필요한 핵심 개념을 이북처럼 술술 읽히는 스타일로 설명합니다.


목차

  1. StreamProvider란?
  2. Stream 기본 개념
  3. Firebase Firestore 연동 예제
  4. WebSocket 연동
  5. 스트림 에러 처리
  6. 스트림 취소와 재구독

1. StreamProvider란?

어느 날 김개발 씨는 채팅 앱을 만들다가 고민에 빠졌습니다. 새 메시지가 올 때마다 화면을 어떻게 자동으로 업데이트해야 할까요?

매번 새로고침 버튼을 누르게 할 수는 없는 노릇입니다.

StreamProvider는 한마디로 흐르는 데이터를 위젯에 자동으로 전달해주는 배달부입니다. 마치 신문 구독 서비스처럼, 한 번 등록해두면 새 소식이 올 때마다 알아서 문 앞까지 배달해줍니다.

이것을 제대로 이해하면 실시간 데이터를 다루는 앱을 훨씬 깔끔하게 만들 수 있습니다.

다음 코드를 살펴봅시다.

// 카운터 값을 1초마다 증가시키는 스트림 프로바이더
final counterStreamProvider = StreamProvider<int>((ref) {
  // Stream.periodic으로 주기적인 스트림 생성
  return Stream.periodic(
    const Duration(seconds: 1),
    (count) => count,  // 0, 1, 2, 3... 순서대로 방출
  );
});

// 위젯에서 스트림 데이터 사용하기
class CounterWidget extends ConsumerWidget {
  Widget build(BuildContext context, WidgetRef ref) {
    // watch로 스트림 구독 - 새 값이 오면 자동 리빌드
    final asyncValue = ref.watch(counterStreamProvider);
    return asyncValue.when(
      data: (count) => Text('현재 카운트: $count'),
      loading: () => CircularProgressIndicator(),
      error: (err, stack) => Text('에러: $err'),
    );
  }
}

김개발 씨는 입사 6개월 차 주니어 개발자입니다. 오늘 팀장님이 새로운 과제를 주셨습니다.

"우리 앱에 실시간 알림 기능 좀 넣어줘요." 김개발 씨는 고개를 끄덕였지만, 속으로는 걱정이 앞섰습니다. 실시간이라니, 데이터가 계속 바뀌면 어떻게 처리해야 하지?

선배 개발자 박시니어 씨가 김개발 씨의 모니터를 슬쩍 보더니 말했습니다. "StreamProvider 써봤어요?

Riverpod에서 실시간 데이터 다룰 때 정말 편해요." 그렇다면 StreamProvider란 정확히 무엇일까요? 쉽게 비유하자면, StreamProvider는 마치 신문 구독 서비스와 같습니다.

우리가 매일 아침 신문을 사러 가게에 가는 대신, 구독을 신청해두면 배달부가 알아서 문 앞에 놓아주잖아요. 새 소식이 있을 때마다 자동으로 배달이 옵니다.

StreamProvider도 마찬가지입니다. 한 번 구독해두면 새 데이터가 올 때마다 위젯에 자동으로 전달됩니다.

StreamProvider가 없던 시절에는 어땠을까요? 개발자들은 setState를 직접 호출하며 화면을 수동으로 갱신해야 했습니다.

스트림을 구독하고, 데이터가 오면 상태를 업데이트하고, 화면을 다시 그리고... 코드가 길어지고 복잡해졌습니다.

더 큰 문제는 메모리 누수였습니다. 스트림 구독을 제대로 해제하지 않으면 앱이 점점 느려졌습니다.

바로 이런 문제를 해결하기 위해 StreamProvider가 등장했습니다. StreamProvider를 사용하면 구독 관리가 자동화됩니다.

위젯이 화면에서 사라지면 알아서 구독을 해제합니다. 또한 loading, data, error 세 가지 상태를 명확하게 구분해서 처리할 수 있습니다.

무엇보다 코드가 선언적이 됩니다. "이 데이터를 보여줘"라고만 하면 나머지는 Riverpod이 알아서 처리합니다.

위의 코드를 한 줄씩 살펴보겠습니다. 먼저 **StreamProvider<int>**를 선언합니다.

꺾쇠 안의 int는 이 스트림이 정수형 데이터를 흘려보낸다는 뜻입니다. Stream.periodic은 지정한 시간 간격으로 데이터를 방출하는 스트림을 만듭니다.

여기서는 1초마다 카운트 값을 증가시킵니다. 위젯에서는 ref.watch로 스트림을 구독합니다.

이 한 줄이 핵심입니다. 새 데이터가 오면 위젯이 자동으로 다시 그려집니다.

asyncValue.when은 스트림의 세 가지 상태를 우아하게 처리합니다. 데이터가 로딩 중일 때, 데이터가 도착했을 때, 에러가 발생했을 때 각각 다른 UI를 보여줄 수 있습니다.

실제 현업에서는 어떻게 활용할까요? 예를 들어 주식 거래 앱을 개발한다고 가정해봅시다.

주가는 실시간으로 변합니다. StreamProvider로 주가 스트림을 구독해두면, 가격이 바뀔 때마다 화면이 자동으로 업데이트됩니다.

별도의 새로고침 버튼 없이도 사용자는 항상 최신 가격을 볼 수 있습니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 너무 자주 데이터를 방출하는 것입니다. 1밀리초마다 데이터를 보내면 위젯이 1초에 1000번 다시 그려집니다.

이렇게 하면 앱 성능이 급격히 저하됩니다. 따라서 적절한 간격으로 데이터를 방출하거나, debounce 같은 기법을 활용해야 합니다.

다시 김개발 씨의 이야기로 돌아가 봅시다. 박시니어 씨의 설명을 들은 김개발 씨는 눈이 반짝였습니다.

"아, 이거면 실시간 알림 기능 금방 만들겠는데요!" StreamProvider를 제대로 이해하면 실시간 데이터를 다루는 앱을 훨씬 우아하게 만들 수 있습니다. 복잡한 구독 관리 코드는 잊어버리고, 비즈니스 로직에만 집중하세요.

실전 팁

💡 - StreamProvider는 위젯이 dispose될 때 자동으로 구독을 해제합니다

  • autoDispose 수정자를 활용하면 메모리 관리가 더욱 효율적입니다
  • when 대신 maybeWhen을 사용하면 일부 상태만 처리할 수 있습니다

2. Stream 기본 개념

김개발 씨가 StreamProvider를 사용하려고 했는데, 문득 궁금해졌습니다. "그런데 Stream이 정확히 뭐죠?" 박시니어 씨가 웃으며 대답했습니다.

"Stream을 모르고 StreamProvider를 쓰는 건, 수도관을 모르고 수도꼭지만 틀어보는 거랑 같아요."

Stream은 한마디로 시간에 따라 연속으로 발생하는 데이터의 흐름입니다. 마치 컨베이어 벨트 위의 상자들처럼, 데이터가 하나씩 순서대로 도착합니다.

Future가 단 하나의 값을 전달한다면, Stream은 여러 개의 값을 시간 순서대로 전달할 수 있습니다.

다음 코드를 살펴봅시다.

// 기본 스트림 생성 방법 1: StreamController 사용
final controller = StreamController<String>();

// 데이터 추가하기
controller.sink.add('첫 번째 메시지');
controller.sink.add('두 번째 메시지');

// 스트림 구독하기
controller.stream.listen((message) {
  print('받은 메시지: $message');
});

// 기본 스트림 생성 방법 2: async* 제너레이터 사용
Stream<int> countStream(int max) async* {
  for (int i = 1; i <= max; i++) {
    await Future.delayed(Duration(seconds: 1));
    yield i;  // yield로 값을 하나씩 방출
  }
}

// 사용 예시
countStream(5).listen((num) => print('카운트: $num'));

김개발 씨는 호기심 가득한 눈으로 박시니어 씨를 바라봤습니다. "Future는 알겠는데, Stream은 뭐가 다른 거예요?" 박시니어 씨가 화이트보드 앞으로 걸어갔습니다.

"자, 쉽게 설명해줄게요." FutureStream의 차이를 이해하는 가장 좋은 비유는 택배와 컨베이어 벨트입니다. Future는 마치 택배와 같습니다.

주문하면 한 번 배송이 옵니다. 상자 하나가 도착하면 끝입니다.

API를 호출해서 사용자 정보를 한 번 받아오는 것이 바로 Future입니다. 반면 Stream은 공장의 컨베이어 벨트와 같습니다.

벨트 위로 상자가 계속해서 흘러옵니다. 첫 번째 상자, 두 번째 상자, 세 번째 상자...

언제 끝날지 모릅니다. 채팅 메시지가 계속 도착하는 상황, 주가가 실시간으로 변하는 상황이 바로 Stream입니다.

Dart에서 Stream을 만드는 방법은 크게 두 가지입니다. 첫 번째는 StreamController를 사용하는 방법입니다.

StreamController는 수도꼭지와 같습니다. sink로 물을 넣고, stream으로 물이 나옵니다.

controller.sink.add()로 데이터를 넣으면, stream.listen()으로 데이터를 받을 수 있습니다. 두 번째는 *async 제너레이터**를 사용하는 방법입니다.

async는 async의 스트림 버전입니다. 일반 함수가 return으로 값을 반환한다면, async 함수는 yield로 값을 방출합니다.

return은 한 번만 실행되지만, yield는 여러 번 실행될 수 있습니다. 코드를 자세히 살펴보겠습니다.

countStream 함수를 보세요. async*로 선언되어 있습니다.

for 루프 안에서 1초를 기다린 후 yield i로 값을 방출합니다. 이 함수를 호출하면 1, 2, 3, 4, 5가 1초 간격으로 나옵니다.

yield가 핵심입니다. 값을 내보내되 함수를 끝내지 않습니다.

스트림을 구독하는 방법도 알아야 합니다. listen 메서드가 가장 기본적인 구독 방법입니다.

스트림에서 데이터가 나올 때마다 콜백 함수가 실행됩니다. 하지만 listen을 직접 사용하면 구독 해제를 수동으로 관리해야 합니다.

이게 바로 StreamProvider가 해결해주는 문제입니다. 실무에서 Stream이 쓰이는 대표적인 상황을 알아봅시다.

실시간 채팅이 가장 흔한 예입니다. 새 메시지가 올 때마다 스트림으로 전달됩니다.

실시간 위치 추적도 마찬가지입니다. GPS 좌표가 변할 때마다 스트림으로 방출됩니다.

센서 데이터, 주가 변동, 실시간 알림 모두 Stream으로 처리하기에 적합합니다. 주의할 점이 있습니다.

스트림은 기본적으로 단일 구독입니다. 하나의 스트림에 listen을 두 번 호출하면 에러가 발생합니다.

여러 곳에서 같은 스트림을 구독하려면 Broadcast Stream으로 변환해야 합니다. StreamController를 만들 때 StreamController.broadcast()를 사용하면 됩니다.

김개발 씨가 고개를 끄덕였습니다. "아, Future는 한 번 오는 택배고, Stream은 계속 흘러오는 컨베이어 벨트군요!" 박시니어 씨가 미소 지었습니다.

"맞아요. 이제 StreamProvider가 왜 필요한지 더 잘 이해됐죠?

컨베이어 벨트에서 상자를 받아서 화면에 보여주는 게 StreamProvider의 역할이에요."

실전 팁

💡 - async* 함수에서는 yield로 값을 방출하고, yield*로 다른 스트림을 위임할 수 있습니다

  • StreamController 사용 후에는 반드시 close()를 호출해야 메모리 누수를 방지합니다
  • 단일 구독 스트림을 여러 곳에서 쓰려면 asBroadcastStream()으로 변환하세요

3. Firebase Firestore 연동 예제

김개발 씨가 드디어 본격적인 실무 과제를 받았습니다. "우리 앱 채팅방 메시지를 실시간으로 보여줘야 해요.

Firebase Firestore 쓸 거예요." 박시니어 씨가 말했습니다. "Firestore의 snapshots()랑 StreamProvider 조합이면 완벽해요."

Firebase Firestore는 실시간 데이터베이스로, 데이터가 변경될 때마다 자동으로 알림을 보내줍니다. snapshots() 메서드가 바로 이 실시간 업데이트를 Stream으로 제공합니다.

StreamProvider와 결합하면 데이터베이스 변경이 즉시 UI에 반영됩니다.

다음 코드를 살펴봅시다.

// 채팅 메시지 모델
class ChatMessage {
  final String id;
  final String content;
  final String senderId;
  final DateTime timestamp;

  ChatMessage({required this.id, required this.content,
               required this.senderId, required this.timestamp});

  factory ChatMessage.fromFirestore(DocumentSnapshot doc) {
    final data = doc.data() as Map<String, dynamic>;
    return ChatMessage(
      id: doc.id,
      content: data['content'] ?? '',
      senderId: data['senderId'] ?? '',
      timestamp: (data['timestamp'] as Timestamp).toDate(),
    );
  }
}

// Firestore 실시간 스트림 프로바이더
final chatMessagesProvider = StreamProvider.family<List<ChatMessage>, String>(
  (ref, roomId) {
    return FirebaseFirestore.instance
        .collection('chatRooms')
        .doc(roomId)
        .collection('messages')
        .orderBy('timestamp', descending: true)
        .limit(50)
        .snapshots()  // 실시간 스트림 반환
        .map((snapshot) => snapshot.docs
            .map((doc) => ChatMessage.fromFirestore(doc))
            .toList());
  },
);

김개발 씨는 Firebase 콘솔을 열어놓고 생각에 잠겼습니다. Firestore에 메시지를 저장하는 건 알겠는데, 새 메시지가 왔을 때 어떻게 알 수 있지?

박시니어 씨가 옆에서 설명을 시작했습니다. "Firestore가 왜 실시간 데이터베이스인지 알아요?" Firestore의 마법은 snapshots() 메서드에 있습니다.

일반적인 데이터베이스 조회는 마치 사진을 찍는 것과 같습니다. get()을 호출하면 그 순간의 데이터를 한 장 찍어서 가져옵니다.

하지만 사진은 과거의 기록일 뿐입니다. 1초 후에 데이터가 바뀌어도 알 수 없습니다.

반면 **snapshots()**는 마치 CCTV와 같습니다. 한 번 설치해두면 계속해서 실시간 영상을 보여줍니다.

데이터가 바뀔 때마다 새 스냅샷이 자동으로 전달됩니다. 이것이 바로 Stream입니다.

코드를 단계별로 살펴보겠습니다. 먼저 ChatMessage 모델을 정의합니다.

fromFirestore 팩토리 생성자는 Firestore 문서를 Dart 객체로 변환합니다. DocumentSnapshot에서 데이터를 추출해서 우리가 사용하기 편한 형태로 만듭니다.

다음으로 StreamProvider.family를 주목하세요. 일반 StreamProvider와 다른 점이 있습니다.

.family 수정자가 붙으면 매개변수를 받을 수 있습니다. 여기서는 roomId를 받습니다.

채팅방마다 다른 스트림이 필요하기 때문입니다. Firestore 쿼리 체인을 보겠습니다.

collection()으로 컬렉션을 선택하고, doc()으로 특정 문서를 선택하고, 다시 collection()으로 하위 컬렉션에 접근합니다. orderBy로 타임스탬프 기준 정렬, limit로 최근 50개만 가져옵니다.

그리고 마지막에 **snapshots()**를 호출합니다. snapshots()가 반환하는 것은 QuerySnapshot의 Stream입니다.

하지만 우리가 원하는 건 ChatMessage 리스트입니다. 그래서 .map() 변환을 적용합니다.

스트림의 각 스냅샷을 ChatMessage 리스트로 변환하는 것입니다. 위젯에서 사용하는 방법도 알아봅시다.

dart final messages = ref.watch(chatMessagesProvider('room123')); 이 한 줄로 room123 채팅방의 메시지 스트림을 구독합니다. 누군가 새 메시지를 보내면 Firestore가 알림을 보내고, 스트림이 새 데이터를 방출하고, 위젯이 자동으로 다시 그려집니다.

사용자는 새로고침 버튼을 누를 필요가 없습니다. 실무에서 주의할 점이 있습니다.

limit을 꼭 설정하세요. 채팅방에 메시지가 10만 개 있으면 어떻게 될까요?

모두 다운로드하면 앱이 멈춥니다. 또한 orderBy와 함께 사용할 때는 복합 인덱스가 필요할 수 있습니다.

Firestore 콘솔에서 인덱스를 생성해야 합니다. 비용도 고려해야 합니다.

Firestore는 읽기 횟수에 따라 요금이 청구됩니다. snapshots()는 변경이 있을 때마다 전체 문서를 다시 읽습니다.

대규모 서비스에서는 비용 최적화 전략이 필요합니다. 김개발 씨가 코드를 따라 치면서 말했습니다.

"와, 생각보다 간단하네요. snapshots() 하나면 실시간 업데이트가 되는 거잖아요!" 박시니어 씨가 고개를 끄덕였습니다.

"맞아요. Firebase와 StreamProvider의 조합은 실시간 앱을 만들 때 정말 강력해요.

하지만 항상 비용과 성능을 염두에 두세요."

실전 팁

💡 - limit()을 항상 설정해서 불필요한 데이터 다운로드를 방지하세요

  • 오프라인 지원이 필요하면 Firestore의 enablePersistence를 활성화하세요
  • family 프로바이더는 동일한 매개변수로 호출하면 캐시된 인스턴스를 반환합니다

4. WebSocket 연동

다음 주, 김개발 씨에게 새로운 과제가 떨어졌습니다. "이번엔 WebSocket으로 실시간 주가 정보를 받아와야 해요.

Firebase 없이요." 김개발 씨는 긴장했습니다. WebSocket이라니, 뭔가 복잡할 것 같았습니다.

WebSocket은 서버와 클라이언트 사이에 양방향 통신 채널을 여는 프로토콜입니다. HTTP가 편지라면 WebSocket은 전화 통화와 같습니다.

한 번 연결되면 서로 실시간으로 메시지를 주고받을 수 있습니다. Dart의 WebSocketChannel을 StreamProvider와 연결하면 실시간 데이터를 우아하게 처리할 수 있습니다.

다음 코드를 살펴봅시다.

import 'package:web_socket_channel/web_socket_channel.dart';

// WebSocket 채널 프로바이더
final webSocketChannelProvider = Provider<WebSocketChannel>((ref) {
  final channel = WebSocketChannel.connect(
    Uri.parse('wss://api.example.com/realtime'),
  );
  // 프로바이더가 dispose될 때 채널 닫기
  ref.onDispose(() => channel.sink.close());
  return channel;
});

// 주가 스트림 프로바이더
final stockPriceProvider = StreamProvider<StockPrice>((ref) {
  final channel = ref.watch(webSocketChannelProvider);

  // 구독 메시지 전송
  channel.sink.add(jsonEncode({'action': 'subscribe', 'symbol': 'AAPL'}));

  // 스트림 변환: String -> StockPrice 객체
  return channel.stream
      .map((data) => jsonDecode(data as String))
      .map((json) => StockPrice.fromJson(json));
});

// 위젯에서 사용
class StockWidget extends ConsumerWidget {
  Widget build(BuildContext context, WidgetRef ref) {
    final stockAsync = ref.watch(stockPriceProvider);
    return stockAsync.when(
      data: (stock) => Text('${stock.symbol}: \$${stock.price}'),
      loading: () => Text('연결 중...'),
      error: (e, _) => Text('연결 끊김'),
    );
  }
}

김개발 씨는 WebSocket이라는 단어만 들어도 머리가 아팠습니다. "소켓이요?

그거 진짜 어렵지 않아요?" 박시니어 씨가 웃으며 말했습니다. "생각보다 간단해요.

HTTP랑 비교해서 설명해줄게요." HTTPWebSocket의 차이를 이해하는 것이 핵심입니다. HTTP는 마치 편지와 같습니다.

클라이언트가 요청을 보내면 서버가 응답을 보냅니다. 그리고 연결이 끊깁니다.

새 정보가 필요하면 다시 편지를 보내야 합니다. 서버가 먼저 연락할 방법이 없습니다.

반면 WebSocket은 전화 통화와 같습니다. 한 번 연결되면 양쪽 모두 언제든지 말할 수 있습니다.

서버에서 새 데이터가 생기면 즉시 클라이언트에게 보낼 수 있습니다. 연결이 유지되는 한 계속 대화가 가능합니다.

Dart에서 WebSocket을 사용하려면 web_socket_channel 패키지를 사용합니다. WebSocketChannel.connect()로 서버에 연결합니다.

반환된 채널에는 두 가지 중요한 속성이 있습니다. sink는 서버로 메시지를 보내는 통로입니다.

stream은 서버에서 메시지를 받는 통로입니다. 바로 이 stream을 StreamProvider에 연결하는 것입니다.

코드를 자세히 살펴보겠습니다. 먼저 webSocketChannelProvider를 만듭니다.

이건 일반 Provider입니다. WebSocket 연결을 생성하고 관리합니다.

ref.onDispose가 중요합니다. 프로바이더가 더 이상 사용되지 않을 때 채널을 닫아서 리소스를 정리합니다.

다음으로 stockPriceProvider를 보세요. 먼저 channel을 watch합니다.

그리고 sink.add()로 구독 메시지를 보냅니다. 많은 WebSocket API는 이런 식으로 관심 있는 데이터를 구독합니다.

channel.stream이 핵심입니다. WebSocket에서 오는 모든 메시지가 이 스트림을 통해 흐릅니다.

보통 JSON 문자열로 오기 때문에 **.map()**으로 파싱합니다. 첫 번째 map에서 JSON 문자열을 Map으로 변환하고, 두 번째 map에서 StockPrice 객체로 변환합니다.

위젯에서는 이전과 동일하게 사용합니다. ref.watch로 구독하고, when으로 상태별 UI를 그립니다.

WebSocket이든 Firebase든 사용법이 똑같습니다. 이것이 추상화의 힘입니다.

실무에서 WebSocket을 사용할 때 주의할 점이 있습니다. 첫째, 연결이 끊어질 수 있습니다.

네트워크가 불안정하면 WebSocket 연결이 끊깁니다. 재연결 로직을 구현해야 합니다.

다음 섹션에서 에러 처리를 다룰 때 이 부분을 더 자세히 살펴보겠습니다. 둘째, 하트비트가 필요할 수 있습니다.

일부 서버나 프록시는 일정 시간 동안 데이터가 없으면 연결을 끊습니다. 주기적으로 ping 메시지를 보내서 연결을 유지해야 합니다.

셋째, 보안을 고려하세요. ws:// 대신 **wss://**를 사용하세요.

HTTPS처럼 암호화된 연결입니다. 김개발 씨가 코드를 실행하며 감탄했습니다.

"와, 진짜 실시간으로 주가가 업데이트되네요!" 박시니어 씨가 덧붙였습니다. "WebSocket은 실시간 기능의 핵심이에요.

채팅, 게임, 주식 앱 모두 WebSocket을 사용해요. StreamProvider와 함께 쓰면 코드가 정말 깔끔해지죠."

실전 팁

💡 - wss:// 프로토콜을 사용해서 암호화된 연결을 유지하세요

  • 연결 끊김에 대비한 재연결 로직을 반드시 구현하세요
  • 대량의 데이터가 오는 경우 throttle이나 debounce로 UI 업데이트 빈도를 조절하세요

5. 스트림 에러 처리

어느 날 김개발 씨의 앱이 갑자기 멈췄습니다. 사용자가 지하철에서 앱을 쓰다가 터널에 들어갔고, 네트워크가 끊겼습니다.

스트림에서 에러가 발생했는데, 아무런 처리가 없었습니다. 박시니어 씨가 말했습니다.

"에러 처리 없는 스트림은 시한폭탄이에요."

스트림 에러 처리는 실시간 앱의 안정성을 결정합니다. 네트워크 끊김, 서버 오류, 파싱 실패 등 다양한 에러가 발생할 수 있습니다.

StreamProvider는 AsyncValue.error 상태를 통해 에러를 우아하게 처리할 수 있게 해줍니다. 에러가 발생해도 앱이 죽지 않고 적절한 UI를 보여줄 수 있습니다.

다음 코드를 살펴봅시다.

// 에러 처리가 포함된 스트림 프로바이더
final robustStreamProvider = StreamProvider<Data>((ref) {
  return someStream()
      .handleError((error, stackTrace) {
        // 에러 로깅
        debugPrint('스트림 에러: $error');
        // 에러를 다시 던져서 UI에서 처리하게 함
        throw error;
      })
      .timeout(
        Duration(seconds: 30),
        onTimeout: (sink) {
          sink.addError(TimeoutException('30초 동안 데이터 없음'));
        },
      );
});

// 위젯에서 세밀한 에러 처리
class RobustWidget extends ConsumerWidget {
  Widget build(BuildContext context, WidgetRef ref) {
    final asyncData = ref.watch(robustStreamProvider);

    return asyncData.when(
      data: (data) => DataView(data: data),
      loading: () => LoadingSpinner(),
      error: (error, stack) {
        // 에러 타입별 다른 UI
        if (error is SocketException) {
          return NetworkErrorView(onRetry: () => ref.invalidate(robustStreamProvider));
        }
        if (error is TimeoutException) {
          return TimeoutView(onRetry: () => ref.invalidate(robustStreamProvider));
        }
        return GenericErrorView(message: error.toString());
      },
    );
  }
}

김개발 씨는 앱 리뷰를 확인하다가 얼굴이 하얘졌습니다. "앱이 갑자기 꺼져요", "인터넷 끊기면 아무것도 안 돼요"...

별점 1점 리뷰가 쏟아지고 있었습니다. 박시니어 씨가 코드를 살펴보더니 한숨을 쉬었습니다.

"에러 처리가 하나도 없네요. 스트림에서 에러가 나면 앱이 그냥 죽어버리는 거예요." 스트림에서 에러가 발생하는 상황은 생각보다 많습니다.

네트워크 끊김이 가장 흔합니다. 사용자가 지하철을 타거나, 와이파이에서 LTE로 전환되거나, 비행기 모드를 켜면 연결이 끊깁니다.

서버 오류도 있습니다. 서버가 다운되거나, 점검 중이거나, 과부하 상태일 수 있습니다.

데이터 파싱 실패도 발생합니다. 서버에서 예상과 다른 형식의 데이터를 보내면 JSON 파싱이 실패합니다.

스트림 에러를 처리하는 방법을 알아봅시다. 첫 번째 방법은 handleError입니다.

스트림에 handleError를 체이닝하면 에러가 발생할 때 호출됩니다. 여기서 로깅을 하거나, 에러를 변환하거나, 기본값으로 대체할 수 있습니다.

두 번째 방법은 timeout입니다. 일정 시간 동안 데이터가 없으면 타임아웃 에러를 발생시킵니다.

WebSocket 연결이 끊어졌는데 클라이언트가 모르는 경우를 방지합니다. 세 번째이자 가장 중요한 방법은 AsyncValue.when의 error 콜백입니다.

StreamProvider는 에러가 발생하면 자동으로 AsyncValue.error 상태로 전환됩니다. when 메서드의 error 파라미터에서 에러 UI를 정의합니다.

코드에서 에러 타입별 처리를 주목하세요. SocketException은 네트워크 연결 문제를 나타냅니다.

"네트워크 연결을 확인해주세요" 같은 메시지를 보여줍니다. TimeoutException은 응답 지연을 나타냅니다.

"서버가 응답하지 않습니다" 같은 메시지가 적절합니다. 그 외의 에러는 일반적인 에러 화면을 보여줍니다.

ref.invalidate가 재시도의 핵심입니다. 사용자가 재시도 버튼을 누르면 ref.invalidate(robustStreamProvider)를 호출합니다.

이렇게 하면 프로바이더가 무효화되고, 새로운 스트림 연결이 시작됩니다. 간단하지만 강력한 재시도 메커니즘입니다.

에러 처리에서 흔히 하는 실수가 있습니다. 첫째, 모든 에러를 같은 메시지로 처리하는 것입니다.

"오류가 발생했습니다"는 사용자에게 아무 도움이 되지 않습니다. 네트워크 문제인지, 서버 문제인지 구분해서 알려줘야 합니다.

둘째, 에러 상태에서 벗어날 방법을 제공하지 않는 것입니다. 에러 화면만 덩그러니 보여주면 사용자는 앱을 강제 종료할 수밖에 없습니다.

반드시 재시도 버튼을 제공하세요. 셋째, 에러 로깅을 하지 않는 것입니다.

프로덕션에서 발생하는 에러를 추적하지 못하면 문제를 해결할 수 없습니다. Firebase Crashlytics 같은 도구로 에러를 수집하세요.

김개발 씨가 에러 처리 코드를 추가한 후 말했습니다. "이제 지하철에서 터널 들어가도 앱이 안 죽어요!" 박시니어 씨가 고개를 끄덕였습니다.

"좋은 앱과 나쁜 앱의 차이는 에러 처리에서 갈려요. 사용자는 에러가 발생해도 부드럽게 복구되는 앱을 신뢰해요."

실전 팁

💡 - 사용자에게 보여주는 에러 메시지는 기술적 용어 대신 이해하기 쉬운 말로 작성하세요

  • 중요한 에러는 Firebase Crashlytics 등으로 수집해서 분석하세요
  • 재시도 버튼에 쿨다운을 적용해서 서버에 과부하를 주지 않도록 하세요

6. 스트림 취소와 재구독

김개발 씨가 앱 성능을 분석하다가 이상한 점을 발견했습니다. 화면을 벗어났는데도 스트림이 계속 돌아가고 있었습니다.

배터리는 빠르게 닳고, 데이터는 계속 소모되고 있었습니다. 박시니어 씨가 말했습니다.

"스트림 관리를 제대로 해야 해요."

스트림 취소와 재구독은 앱의 성능과 리소스 관리에 직결됩니다. 사용자가 화면을 벗어나면 불필요한 스트림은 취소해야 합니다.

다시 돌아오면 재구독해야 합니다. Riverpod의 autoDisposeref.invalidate, ref.refresh를 활용하면 이 과정을 우아하게 처리할 수 있습니다.

다음 코드를 살펴봅시다.

// autoDispose로 자동 구독 해제
final autoDisposeStreamProvider = StreamProvider.autoDispose<Message>((ref) {
  debugPrint('스트림 구독 시작');

  // 프로바이더가 dispose될 때 호출
  ref.onDispose(() {
    debugPrint('스트림 구독 해제');
  });

  // 구독을 유지하고 싶다면 keepAlive 사용
  final link = ref.keepAlive();

  // 5분 후 자동 dispose 허용
  Timer(Duration(minutes: 5), () {
    link.close();
  });

  return messageStream();
});

// 수동으로 재구독하기
class MessageScreen extends ConsumerWidget {
  Widget build(BuildContext context, WidgetRef ref) {
    final messages = ref.watch(autoDisposeStreamProvider);

    return Column(
      children: [
        // 새로고침 버튼
        ElevatedButton(
          onPressed: () {
            // invalidate: 캐시 무효화, 다음 watch에서 새로 시작
            ref.invalidate(autoDisposeStreamProvider);
          },
          child: Text('새로고침'),
        ),
        ElevatedButton(
          onPressed: () {
            // refresh: 즉시 재시작하고 새 값 반환
            ref.refresh(autoDisposeStreamProvider);
          },
          child: Text('즉시 재시작'),
        ),
        // 메시지 표시
        messages.when(
          data: (msg) => Text(msg.content),
          loading: () => CircularProgressIndicator(),
          error: (e, _) => Text('에러: $e'),
        ),
      ],
    );
  }
}

김개발 씨는 프로파일러를 보며 당황했습니다. 사용자가 채팅방을 나갔는데, 메시지 스트림이 여전히 동작하고 있었습니다.

서버에 계속 연결되어 있고, 데이터를 계속 받고 있었습니다. 박시니어 씨가 설명을 시작했습니다.

"스트림 구독 관리는 정말 중요해요. 안 쓰는 스트림이 계속 돌면 배터리가 빨리 닳고, 데이터 요금도 나가고, 서버에도 부담이 가요." 스트림을 취소해야 하는 상황을 알아봅시다.

첫째, 화면을 벗어날 때입니다. 채팅방 목록으로 돌아갔는데 특정 채팅방의 스트림이 계속 돌 필요가 없습니다.

둘째, 앱이 백그라운드로 갈 때입니다. 사용자가 다른 앱을 쓰는데 실시간 데이터를 받을 필요가 없습니다.

셋째, 로그아웃할 때입니다. 인증된 스트림은 로그아웃하면 무효화됩니다.

autoDispose가 이 문제를 해결합니다. StreamProvider.autoDispose를 사용하면, 해당 프로바이더를 watch하는 위젯이 모두 사라질 때 자동으로 dispose됩니다.

스트림 구독이 해제되고 리소스가 정리됩니다. 다시 watch하면 새로운 스트림 연결이 시작됩니다.

하지만 때로는 구독을 유지하고 싶을 때가 있습니다. 예를 들어 사용자가 잠시 다른 탭으로 갔다가 돌아오는 경우, 스트림을 처음부터 다시 시작하면 깜빡임이 생기고 로딩 시간이 발생합니다.

이럴 때 **ref.keepAlive()**를 사용합니다. 코드에서 keepAlive 패턴을 주목하세요.

ref.keepAlive()를 호출하면 KeepAliveLink가 반환됩니다. 이 링크가 살아있는 동안 프로바이더는 dispose되지 않습니다.

link.close()를 호출하면 다시 autoDispose가 활성화됩니다. 코드에서는 5분 후에 close하도록 타이머를 설정했습니다.

수동으로 스트림을 재시작하는 방법도 알아야 합니다. **ref.invalidate()**는 프로바이더의 캐시를 무효화합니다.

즉시 재시작하지는 않습니다. 다음에 누군가 watch하면 그때 새로 시작합니다.

에러가 발생했을 때 재시도 버튼에 적합합니다. **ref.refresh()**는 즉시 프로바이더를 재시작하고 새 값을 반환합니다.

pull-to-refresh 같은 기능에 적합합니다. 사용자가 아래로 당기면 즉시 새 데이터를 가져와야 하니까요.

두 메서드의 차이를 정리하면 이렇습니다. invalidate는 "다음에 필요할 때 새로 해줘"이고, refresh는 "지금 당장 새로 해줘"입니다.

실무에서 자주 쓰는 패턴을 하나 소개합니다. 앱이 백그라운드에서 포그라운드로 돌아올 때 스트림을 새로고침하는 패턴입니다.

AppLifecycleState를 감지해서, resumed 상태가 되면 ref.invalidate()를 호출합니다. 이렇게 하면 사용자가 앱으로 돌아왔을 때 최신 데이터를 볼 수 있습니다.

주의할 점도 있습니다. 너무 자주 재구독하면 서버에 부담이 갑니다.

특히 WebSocket은 연결 설정에 비용이 듭니다. 적절한 쿨다운을 적용하세요.

또한 keepAlive를 남용하면 autoDispose의 의미가 없어집니다. 정말 필요한 경우에만 사용하세요.

김개발 씨가 autoDispose를 적용한 후 배터리 소모가 크게 줄었습니다. "이제 사용자들이 배터리 걱정 안 해도 되겠어요!" 박시니어 씨가 마무리했습니다.

"리소스 관리는 사용자 경험의 보이지 않는 부분이에요. 앱이 버벅거리지 않고, 배터리가 빨리 닳지 않으면 사용자는 그냥 '좋은 앱이다'라고 느껴요.

그게 바로 우리가 목표로 해야 할 거예요."

실전 팁

💡 - autoDispose는 기본적으로 적용하고, 필요한 경우에만 keepAlive를 사용하세요

  • 앱이 백그라운드에서 포그라운드로 돌아올 때 invalidate로 데이터를 새로고침하세요
  • refresh와 invalidate의 차이를 이해하고 상황에 맞게 사용하세요

이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!

#Flutter#StreamProvider#Riverpod#RealtimeData#Firebase#WebSocket#Flutter,State Management

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.

함께 보면 좋은 카드 뉴스

Riverpod 3.0 쇼핑 앱 종합 프로젝트 완벽 가이드

Flutter와 Riverpod 3.0을 활용한 실무 수준의 쇼핑 앱 개발 과정을 단계별로 학습합니다. 상품 목록, 장바구니, 주문, 인증, 검색 기능까지 모든 핵심 기능을 구현하며 상태 관리의 실전 노하우를 익힙니다.

Riverpod 3.0 Retry 자동 재시도 완벽 가이드

Riverpod 3.0에 새로 추가된 Retry 기능을 활용하여 네트워크 오류나 일시적인 실패 상황에서 자동으로 재시도하는 방법을 배웁니다. 초급 개발자도 쉽게 따라할 수 있도록 실무 예제와 함께 설명합니다.

Riverpod 3.0 requireValue로 Provider 결합하기

Riverpod 3.0에 새로 추가된 requireValue를 활용하여 여러 Provider의 데이터를 효율적으로 결합하는 방법을 배웁니다. 비동기 데이터를 마치 동기 데이터처럼 다루는 실전 패턴을 소개합니다.

Flutter 3.0 Offline 데이터 영속화 완벽 가이드

Flutter 3.0에서 새롭게 추가된 Offline 데이터 영속화 기능을 배웁니다. Storage 인터페이스부터 SharedPreferences 활용, 실전 예제까지 실무에서 바로 사용할 수 있는 패턴을 배워봅시다.

Riverpod 3.0 Mutation으로 폼 제출 완벽 가이드

Riverpod 3.0의 새로운 Mutation 기능으로 로그인과 회원가입 폼을 우아하게 처리하는 방법을 배웁니다. 로딩 상태, 에러 처리, 성공 처리까지 실무에서 바로 쓸 수 있는 패턴을 익혀보세요.