개발

Flutter에서 Stream과 WebSocket 완벽 이해: 실시간 데이터 처리의 모든 것

Future부터 WebSocket까지, 실시간 통신의 A to Z

2025년 9월 18일
25분 읽기
Flutter에서 Stream과 WebSocket 완벽 이해: 실시간 데이터 처리의 모든 것

Stream이 대체 뭔가요?

Stream을 이해하는 가장 쉬운 방법은 Future와 비교하는 것입니다.

Future는 "미래의 한 시점에 도착할 하나의 값"입니다. 예를 들어, 서버에서 사용자 정보를 가져오는 것은 Future입니다. 요청하고, 기다리고, 한 번 받고 끝입니다.
Dart
// Future: 한 번의 결과
Future<User> getUser() async {
  final response = await http.get('/user/123');
  return User.fromJson(response.body);  // 한 번 반환하고 끝
}

// 사용
final user = await getUser();  // 한 번 받고 끝
print(user.name);
Stream은 "시간에 걸쳐 도착하는 여러 개의 값"입니다. 예를 들어, 주식 시세는 계속 변하죠? 이런 연속적인 데이터가 Stream입니다.
Dart
// Stream: 연속된 결과들
Stream<Quote> subscribeQuotes() async* {
  while (true) {
    await Future.delayed(Duration(seconds: 1));
    yield Quote(
      symbol: 'AAPL',
      price: Random().nextDouble() * 100,
      timestamp: DateTime.now(),
    );  // 계속해서 새 값을 방출
  }
}

// 사용
subscribeQuotes().listen((quote) {
  print('새 시세: ${quote.price}');  // 계속 받음
});
쉬운 비유로 설명하면:

Future: 피자 배달 (한 번 주문, 한 번 배달)
Stream: 넷플릭스 (계속해서 영상 데이터가 흘러옴)

Stream의 종류와 특성

Single Subscription Stream vs Broadcast Stream

Stream에는 두 가지 종류가 있습니다.
1. Single Subscription Stream (일대일)
Dart
// 한 명만 들을 수 있는 스트림
Stream<int> countStream() async* {
  for (int i = 0; i < 5; i++) {
    await Future.delayed(Duration(seconds: 1));
    yield i;
  }
}

// 사용
final stream = countStream();
stream.listen((data) => print('Listener 1: $data'));  // OK
stream.listen((data) => print('Listener 2: $data'));  // 에러! 이미 누가 듣고 있음
2. Broadcast Stream (일대다)
Dart
// 여러 명이 들을 수 있는 스트림
final controller = StreamController<int>.broadcast();
final stream = controller.stream;

// 여러 리스너 가능
stream.listen((data) => print('Listener 1: $data'));  // OK
stream.listen((data) => print('Listener 2: $data'));  // OK
stream.listen((data) => print('Listener 3: $data'));  // OK

controller.add(1);  // 모든 리스너가 받음
언제 무엇을 쓸까?

Single: 파일 읽기, HTTP 응답 등 한 번만 소비되는 데이터
Broadcast: 실시간 시세, 채팅 메시지 등 여러 곳에서 동시에 필요한 데이터

Hot Stream vs Cold Stream

이것도 중요한 개념입니다.
Cold Stream: 구독할 때마다 처음부터 시작
Dart
// Cold Stream - 각 구독자마다 새로 시작
Stream<int> coldStream() async* {
  print('Stream 시작!');
  for (int i = 0; i < 3; i++) {
    yield i;
  }
}

coldStream().listen((data) => print('A: $data'));
// 출력: Stream 시작! -> A: 0 -> A: 1 -> A: 2

await Future.delayed(Duration(seconds: 1));

coldStream().listen((data) => print('B: $data'));
// 출력: Stream 시작! -> B: 0 -> B: 1 -> B: 2 (처음부터 다시)
Hot Stream: 이미 진행 중인 스트림에 중간 참여
Dart
// Hot Stream - 진행 중인 스트림에 참여
final controller = StreamController<DateTime>.broadcast();

// 1초마다 현재 시간 방출
Timer.periodic(Duration(seconds: 1), (timer) {
  controller.add(DateTime.now());
});

// 첫 번째 구독자
controller.stream.listen((time) => print('A: $time'));

// 3초 후 두 번째 구독자 (중간부터 받음)
await Future.delayed(Duration(seconds: 3));
controller.stream.listen((time) => print('B: $time'));
// B는 3초 후부터의 데이터만 받음

Stream 생성하는 다양한 방법

1. async* Generator (가장 직관적)

Dart
Stream<String> messageStream() async* {
  yield 'Hello';
  await Future.delayed(Duration(seconds: 1));
  yield 'How are';
  await Future.delayed(Duration(seconds: 1));
  yield 'you?';
}

// 사용
await for (final message in messageStream()) {
  print(message);
}

2. StreamController (가장 유연함)

Dart
class QuoteService {
  final _controller = StreamController<Quote>.broadcast();

  Stream<Quote> get stream => _controller.stream;

  void addQuote(Quote quote) {
    if (!_controller.isClosed) {
      _controller.add(quote);
    }
  }

  void addError(String error) {
    _controller.addError(error);
  }

  void close() {
    _controller.close();
  }
}

// 사용
final service = QuoteService();

service.stream.listen(
  (quote) => print('시세: ${quote.price}'),
  onError: (error) => print('에러: $error'),
  onDone: () => print('스트림 종료'),
);

service.addQuote(Quote(price: 100));
service.addError('네트워크 에러');
service.close();

3. Stream.periodic (주기적 이벤트)

Dart
// 1초마다 이벤트 발생
Stream<int> tickStream = Stream.periodic(
  Duration(seconds: 1),
  (count) => count,  // 0, 1, 2, 3...
);

// 5개만 받기
tickStream.take(5).listen((tick) {
  print('Tick: $tick');
});

4. Stream.fromFuture/fromIterable (변환)

Dart
// Future를 Stream으로
Stream<User> userStream = Stream.fromFuture(fetchUser());

// List를 Stream으로
Stream<int> numberStream = Stream.fromIterable([1, 2, 3, 4, 5]);

Stream 변환과 연산자

Stream의 진정한 힘은 변환 연산자에 있습니다.
Dart
class StreamOperations {
  Stream<Quote> processQuotes(Stream<Quote> input) {
    return input
      // 1. 필터링: 거래량 1000 이상만
      .where((quote) => quote.volume > 1000)

      // 2. 변환: 가격에 수수료 추가
      .map((quote) => quote.copyWith(
        price: quote.price * 1.001,  // 0.1% 수수료
      ))

      // 3. 중복 제거: 같은 가격은 한 번만
      .distinct((prev, next) => prev.price == next.price)

      // 4. 디바운스: 300ms 동안 새 값이 없을 때만 방출
      .debounceTime(Duration(milliseconds: 300))

      // 5. 에러 처리
      .handleError((error) {
        print('에러 발생: $error');
        return Quote.empty();  // 기본값 반환
      })

      // 6. 처음 10개만
      .take(10)

      // 7. 타임아웃: 5초 동안 데이터 없으면 종료
      .timeout(
        Duration(seconds: 5),
        onTimeout: (sink) => sink.close(),
      );
  }
}
유용한 Stream 연산자들:
Dart
// 1. expand: 하나를 여러 개로
stream.expand((item) => [item, item * 2]);  // 1 -> [1, 2]

// 2. scan: 누적 계산 (reduce의 stream 버전)
stream.scan((accumulated, value, index) => accumulated + value);

// 3. buffer: 여러 개를 모아서 리스트로
stream.buffer(Duration(seconds: 1));  // 1초 동안 모은 것을 리스트로

// 4. switchMap: 새 스트림으로 교체
stream.switchMap((value) => fetchDetailsStream(value));

// 5. combineLatest: 여러 스트림 합치기
Rx.combineLatest2(stream1, stream2, (a, b) => '$a - $b');

WebSocket: 실시간 양방향 통신

WebSocket은 HTTP와 다르게 지속적인 연결을 유지하는 프로토콜입니다.

HTTP vs WebSocket 비교

Dart
// HTTP: 요청-응답 모델 (일회성)
class HttpQuoteService {
  Future<Quote> getQuote(String symbol) async {
    // 1. 연결 수립
    // 2. 요청 전송
    // 3. 응답 대기
    // 4. 연결 종료
    final response = await http.get('/api/quote/$symbol');
    return Quote.fromJson(response.body);
  }

  // 실시간으로 하려면? 폴링 필요
  Stream<Quote> pollQuotes(String symbol) async* {
    while (true) {
      yield await getQuote(symbol);  // 매번 연결
      await Future.delayed(Duration(seconds: 1));
    }
  }
}

// WebSocket: 지속 연결 (실시간)
class WebSocketQuoteService {
  late WebSocketChannel channel;

  void connect() {
    // 1. 한 번 연결
    channel = WebSocketChannel.connect(
      Uri.parse('wss://api.example.com/quotes'),
    );

    // 2. 계속 데이터 주고받기 가능
    channel.sink.add('{"subscribe": "AAPL"}');  // 서버로 전송

    channel.stream.listen((message) {
      print('받은 데이터: $message');  // 서버에서 오는 데이터
    });
  }
}
WebSocket의 장점:

낮은 지연시간: 연결을 유지하므로 즉시 전송
양방향 통신: 서버도 클라이언트로 먼저 보낼 수 있음
효율성: HTTP 헤더 오버헤드 없음
실시간성: 푸시 기반, 폴링 불필요

WebSocket 실제 구현

기본 WebSocket 연결

Dart
import 'package:web_socket_channel/web_socket_channel.dart';

class BasicWebSocket {
  WebSocketChannel? _channel;

  void connect() {
    try {
      // WebSocket 연결
      _channel = WebSocketChannel.connect(
        Uri.parse('wss://stream.example.com'),
      );

      print('WebSocket 연결 성공');

      // 메시지 수신
      _channel!.stream.listen(
        (message) {
          print('받은 메시지: $message');
          _handleMessage(message);
        },
        onError: (error) {
          print('에러 발생: $error');
        },
        onDone: () {
          print('연결 종료');
        },
      );

    } catch (e) {
      print('연결 실패: $e');
    }
  }

  void sendMessage(String message) {
    _channel?.sink.add(message);
  }

  void _handleMessage(dynamic message) {
    // JSON 파싱 등 처리
    final data = json.decode(message);
    print('파싱된 데이터: $data');
  }

  void disconnect() {
    _channel?.sink.close();
  }
}

실무용 강화된 WebSocket Manager

실제 프로덕션에서는 재연결, 핑퐁, 상태 관리 등이 필요합니다.
Dart
enum ConnectionState {
  disconnected,
  connecting,
  connected,
  reconnecting,
  error,
}

class RobustWebSocketManager {
  final String url;
  final Duration pingInterval;
  final Duration reconnectDelay;

  WebSocketChannel? _channel;
  StreamController<dynamic>? _messageController;
  Timer? _pingTimer;
  Timer? _reconnectTimer;

  bool _isConnected = false;
  int _reconnectAttempts = 0;
  final int maxReconnectAttempts = 5;

  // 연결 상태를 스트림으로 제공
  final _connectionStateController = StreamController<ConnectionState>.broadcast();

  Stream<ConnectionState> get connectionState => _connectionStateController.stream;
  Stream<dynamic> get messages => _messageController?.stream ?? Stream.empty();

  RobustWebSocketManager({
    required this.url,
    this.pingInterval = const Duration(seconds: 30),
    this.reconnectDelay = const Duration(seconds: 5),
  }) {
    _messageController = StreamController<dynamic>.broadcast();
  }

  Future<void> connect() async {
    if (_isConnected) {
      print('이미 연결되어 있습니다');
      return;
    }

    _connectionStateController.add(ConnectionState.connecting);
    print('WebSocket 연결 시도: $url');

    try {
      _channel = WebSocketChannel.connect(Uri.parse(url));

      // 연결 성공 대기
      await _channel!.ready;

      _isConnected = true;
      _reconnectAttempts = 0;
      _connectionStateController.add(ConnectionState.connected);
      print('WebSocket 연결 성공');

      // 메시지 수신 리스너
      _channel!.stream.listen(
        _handleMessage,
        onError: _handleError,
        onDone: _handleDone,
        cancelOnError: false,
      );

      // 핑 타이머 시작
      _startPingTimer();

    } catch (e) {
      print('연결 실패: $e');
      _handleError(e);
    }
  }

  void _handleMessage(dynamic data) {
    try {
      // JSON 파싱 시도
      final message = json.decode(data);

      // Pong 응답은 무시 (연결 확인용)
      if (message['type'] == 'pong') {
        print('Pong 받음');
        return;
      }

      // 일반 메시지는 스트림으로 전달
      _messageController?.add(message);

    } catch (e) {
      print('메시지 파싱 에러: $e');
      // 파싱 실패해도 원본 전달
      _messageController?.add(data);
    }
  }

  void _handleError(dynamic error) {
    print('WebSocket 에러: $error');
    _isConnected = false;
    _connectionStateController.add(ConnectionState.error);
    _scheduleReconnect();
  }

  void _handleDone() {
    print('WebSocket 연결 종료');
    _isConnected = false;
    _connectionStateController.add(ConnectionState.disconnected);
    _scheduleReconnect();
  }

  void _startPingTimer() {
    _pingTimer?.cancel();
    _pingTimer = Timer.periodic(pingInterval, (_) {
      if (_isConnected) {
        print('Ping 전송');
        send({'type': 'ping'});
      }
    });
  }

  void _scheduleReconnect() {
    if (_reconnectAttempts >= maxReconnectAttempts) {
      print('최대 재연결 시도 횟수 초과');
      return;
    }

    _reconnectTimer?.cancel();

    // Exponential backoff: 재시도할 때마다 대기 시간 증가
    final delay = reconnectDelay * (1 << _reconnectAttempts);
    _reconnectAttempts++;

    print('${delay.inSeconds}초 후 재연결 시도 (${_reconnectAttempts}/${maxReconnectAttempts})');
    _connectionStateController.add(ConnectionState.reconnecting);

    _reconnectTimer = Timer(delay, () {
      connect();
    });
  }

  void send(dynamic message) {
    if (!_isConnected) {
      print('연결되지 않음. 메시지 전송 실패');
      return;
    }

    try {
      final jsonMessage = message is String ? message : json.encode(message);
      _channel?.sink.add(jsonMessage);
    } catch (e) {
      print('메시지 전송 실패: $e');
    }
  }

  void dispose() {
    print('WebSocket 정리');
    _pingTimer?.cancel();
    _reconnectTimer?.cancel();
    _channel?.sink.close();
    _messageController?.close();
    _connectionStateController.close();
  }
}

대체 기술들

1. Server-Sent Events (SSE)

단방향 실시간 통신이 필요할 때 WebSocket의 대안입니다.
Dart
// SSE: 서버 -> 클라이언트 단방향
class SseClient {
  void connect() {
    final client = http.Client();
    final request = http.Request('GET', Uri.parse('https://api.example.com/sse'));

    client.send(request).then((response) {
      response.stream
        .transform(utf8.decoder)
        .transform(LineSplitter())
        .listen((line) {
          if (line.startsWith('data: ')) {
            final data = line.substring(6);
            print('SSE 데이터: $data');
          }
        });
    });
  }
}
SSE vs WebSocket:

SSE: 단방향(서버→클라이언트), HTTP 기반, 자동 재연결
WebSocket: 양방향, 별도 프로토콜, 수동 재연결 구현 필요

2. Long Polling

WebSocket을 지원하지 않는 환경에서의 대안입니다.
Dart
// Long Polling: 긴 대기 시간을 가진 HTTP 요청
class LongPollingClient {
  bool _isPolling = true;

  Future<void> startPolling() async {
    while (_isPolling) {
      try {
        // 30초 타임아웃으로 대기
        final response = await http.get(
          Uri.parse('https://api.example.com/poll'),
        ).timeout(Duration(seconds: 30));

        if (response.statusCode == 200) {
          // 데이터 처리
          print('받은 데이터: ${response.body}');
        }
      } catch (e) {
        print('폴링 에러: $e');
        await Future.delayed(Duration(seconds: 5));
      }
    }
  }

  void stopPolling() {
    _isPolling = false;
  }
}

3. gRPC Streaming

더 고급 요구사항이 있을 때 사용합니다.
Dart
// gRPC: Protocol Buffers 기반 고성능 RPC
class GrpcStreamClient {
  late ClientChannel channel;
  late QuoteServiceClient stub;

  void connect() {
    channel = ClientChannel(
      'api.example.com',
      port: 50051,
      options: const ChannelOptions(
        credentials: ChannelCredentials.insecure(),
      ),
    );

    stub = QuoteServiceClient(channel);
  }

  Stream<Quote> subscribeQuotes(String symbol) {
    final request = SubscribeRequest()..symbol = symbol;
    return stub.subscribeQuotes(request);
  }
}
각 기술 선택 기준:

WebSocket: 양방향 실시간 통신 (채팅, 트레이딩)
SSE: 서버 푸시만 필요 (알림, 뉴스 피드)
Long Polling: WebSocket 불가능한 환경
gRPC: 고성능, 타입 안전성 필요

Riverpod에서 Stream과 WebSocket 활용

StreamProvider 기본 사용

Dart
// 1. WebSocket Manager Provider
final webSocketManagerProvider = Provider<RobustWebSocketManager>((ref) {
  final manager = RobustWebSocketManager(
    url: 'wss://stream.example.com',
  );

  // Provider 생성 시 연결
  manager.connect();

  // Provider 폐기 시 정리
  ref.onDispose(() {
    manager.dispose();
  });

  return manager;
});

// 2. 연결 상태 Provider
final connectionStateProvider = StreamProvider<ConnectionState>((ref) {
  final manager = ref.watch(webSocketManagerProvider);
  return manager.connectionState;
});

// 3. 메시지 스트림 Provider
final messageStreamProvider = StreamProvider<dynamic>((ref) {
  final manager = ref.watch(webSocketManagerProvider);
  return manager.messages;
});

// 4. 특정 심볼의 시세만 필터링하는 Provider
final quoteStreamProvider = StreamProvider.family<Quote, String>((ref, symbol) {
  final messages = ref.watch(messageStreamProvider.stream);

  return messages
      .where((message) => message['type'] == 'quote' && message['symbol'] == symbol)
      .map((message) => Quote.fromJson(message));
});

StateNotifier와 Stream 조합

더 복잡한 상태 관리가 필요할 때는 StateNotifier를 사용합니다.
Dart
// 상태 클래스
@freezed
class QuoteState with _$QuoteState {
  const factory QuoteState({
    @Default({}) Map<String, Quote> quotes,
    @Default(ConnectionState.disconnected) ConnectionState connectionState,
    String? errorMessage,
    @Default([]) List<String> subscribedSymbols,
  }) = _QuoteState;
}

// StateNotifier
class QuoteNotifier extends StateNotifier<QuoteState> {
  final RobustWebSocketManager _wsManager;
  final Map<String, StreamSubscription> _subscriptions = {};

  QuoteNotifier(this._wsManager) : super(const QuoteState()) {
    _initialize();
  }

  void _initialize() {
    // 연결 상태 구독
    _subscriptions['connection'] = _wsManager.connectionState.listen((status) {
      state = state.copyWith(connectionState: status);

      // 재연결 시 자동으로 심볼 재구독
      if (status == ConnectionState.connected && state.subscribedSymbols.isNotEmpty) {
        _resubscribeAll();
      }
    });

    // 메시지 구독
    _subscriptions['messages'] = _wsManager.messages.listen(
      _handleMessage,
      onError: (error) {
        state = state.copyWith(errorMessage: error.toString());
      },
    );
  }

  void _handleMessage(dynamic message) {
    if (message['type'] == 'quote') {
      final quote = Quote.fromJson(message);
      state = state.copyWith(
        quotes: {...state.quotes, quote.symbol: quote},
        errorMessage: null,
      );
    } else if (message['type'] == 'error') {
      state = state.copyWith(errorMessage: message['message']);
    }
  }

  void subscribeSymbol(String symbol) {
    if (!state.subscribedSymbols.contains(symbol)) {
      state = state.copyWith(
        subscribedSymbols: [...state.subscribedSymbols, symbol],
      );

      _wsManager.send({
        'action': 'subscribe',
        'symbol': symbol,
      });
    }
  }

  void unsubscribeSymbol(String symbol) {
    state = state.copyWith(
      subscribedSymbols: state.subscribedSymbols.where((s) => s != symbol).toList(),
      quotes: Map.from(state.quotes)..remove(symbol),
    );

    _wsManager.send({
      'action': 'unsubscribe',
      'symbol': symbol,
    });
  }

  void _resubscribeAll() {
    for (final symbol in state.subscribedSymbols) {
      _wsManager.send({
        'action': 'subscribe',
        'symbol': symbol,
      });
    }
  }

  @override
  void dispose() {
    for (final subscription in _subscriptions.values) {
      subscription.cancel();
    }
    super.dispose();
  }
}

// Provider 정의
final quoteNotifierProvider = StateNotifierProvider<QuoteNotifier, QuoteState>((ref) {
  final wsManager = ref.watch(webSocketManagerProvider);
  return QuoteNotifier(wsManager);
});

실전 팁과 주의사항

1. 메모리 누수 방지

Dart
class SafeStreamWidget extends ConsumerStatefulWidget {
  @override
  _SafeStreamWidgetState createState() => _SafeStreamWidgetState();
}

class _SafeStreamWidgetState extends ConsumerState<SafeStreamWidget> {
  StreamSubscription? _subscription;

  @override
  void initState() {
    super.initState();
    // 수동 구독 시 반드시 저장
    _subscription = someStream.listen((data) {
      if (mounted) {  // mounted 체크 중요!
        setState(() {
          // UI 업데이트
        });
      }
    });
  }

  @override
  void dispose() {
    // 반드시 구독 해제
    _subscription?.cancel();
    super.dispose();
  }
}

2. 에러 처리 철저히

Dart
Stream<Quote> robustQuoteStream() {
  return _originalStream
    .handleError((error, stack) {
      print('Stream 에러: $error');
      // 에러 로깅
      FirebaseCrashlytics.instance.recordError(error, stack);

      // 기본값 반환 또는 재시도
      return Quote.empty();
    })
    .timeout(
      Duration(seconds: 30),
      onTimeout: (sink) {
        sink.addError(TimeoutException('Stream timeout'));
      },
    );
}

3. 백프레셔(Backpressure) 관리

데이터가 너무 빨리 들어올 때 처리:
Dart
// Throttle: 일정 시간마다 하나씩만
stream.throttleTime(Duration(milliseconds: 100));

// Debounce: 마지막 값만
stream.debounceTime(Duration(milliseconds: 300));

// Sample: 주기적으로 샘플링
stream.sampleTime(Duration(seconds: 1));

// Buffer: 모아서 처리
stream.bufferTime(Duration(seconds: 1));

4. 테스트 작성

Dart
void main() {
  group('WebSocket Manager Tests', () {
    test('연결 성공 시 connected 상태', () async {
      final manager = MockWebSocketManager();

      expectLater(
        manager.connectionState,
        emitsInOrder([
          ConnectionState.connecting,
          ConnectionState.connected,
        ]),
      );

      await manager.connect();
    });

    test('메시지 수신 테스트', () async {
      final manager = MockWebSocketManager();
      await manager.connect();

      expectLater(
        manager.messages,
        emits({'type': 'quote', 'price': 100}),
      );

      manager.simulateMessage({'type': 'quote', 'price': 100});
    });
  });
}

성능 최적화

1. Stream 캐싱

Dart
// 여러 위젯이 같은 Stream을 구독할 때
final cachedStream = originalStream.shareValue();

// 또는 Riverpod Provider 사용 (자동 캐싱)
final sharedStreamProvider = StreamProvider((ref) {
  return expensiveStream();
});

2. 선택적 구독

Dart
// 필요한 데이터만 구독
class SelectiveSubscription extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 화면에 보이는 심볼만 구독
    final visibleSymbols = ref.watch(visibleSymbolsProvider);

    for (final symbol in visibleSymbols) {
      ref.listen(quoteStreamProvider(symbol), (prev, next) {
        // 업데이트 처리
      });
    }

    return Container();
  }
}

3. 배치 처리

Dart
// 개별 처리 대신 배치로
Stream<List<Quote>> batchedQuotes = quoteStream
  .bufferTime(Duration(milliseconds: 100))
  .where((batch) => batch.isNotEmpty);

마무리

Stream과 WebSocket은 Flutter에서 실시간 애플리케이션을 만들 때 핵심 기술입니다.

Stream은 비동기 데이터의 시퀀스를 우아하게 처리하는 Dart의 기본 기능이고, WebSocket은 서버와 실시간 양방향 통신을 가능하게 하는 프로토콜입니다.

Riverpod과 함께 사용하면:

StreamProvider로 간편하게 Stream 관리
StateNotifier로 복잡한 상태 처리
자동 dispose와 메모리 관리
테스트 가능한 구조

핵심은 적절한 에러 처리, 재연결 로직, 그리고 메모리 관리입니다. 실시간 데이터는 예측 불가능하므로 항상 worst case를 대비해야 합니다.
#Flutter
#Stream
#WebSocket
#Riverpod
#Dart
#Real-time
#Async