들어가며: 반응형 데이터 스트림의 필요성
현대 모바일 애플리케이션 개발에서 실시간 데이터 업데이트는 필수적인 요소입니다. 예를 들어 채팅 애플리케이션에서 새로운 메시지를 즉시 표시해야 하거나, 주가 애플리케이션에서 가격 변동을 실시간으로 반영해야 하는 경우가 있습니다. 또한 파일 다운로드 시 진행 상황을 사용자에게 보여주거나, GPS 센서에서 위치를 지속적으로 업데이트해야 하는 상황도 마찬가지입니다.
이러한 요구사항을 충족하기 위해 StreamBuilder 컴포넌트가 제공됩니다. StreamBuilder는 Dart의 Stream을 감시하다가 데이터가 변경되면 자동으로 UI를 재구성하는 Flutter의 반응형 위젯입니다.
반응형 데이터 스트림의 활용 분야
실시간 데이터 스트림은 다양한 시나리오에서 활용됩니다:
채팅 및 메시징 애플리케이션: 새로운 메시지가 도착하면 즉시 화면에 표시해야 합니다. WebSocket, Firebase 또는 다른 실시간 데이터 소스에서 메시지를受信할 수 있습니다.
주식 및 시세 조회 애플리케이션: 가격이 실시간으로 변동되면 가격 표시, 변동 폭, 상승/하락 색상 등을 즉시 업데이트해야 합니다.
파일 전송 관리자: 파일 다운로드나 업로드 시 진행률, 속도, 남은 시간 등을 실시간으로 표시해야 합니다.
IoT 및 센서 데이터: GPS 위치, 가속도계, 자이로스코프 등의 센서에서 지속적인 데이터가 출력되며 이를 실시간으로 처리하고 표시해야 합니다.
자동완성 검색: 사용자가 검색어를 입력하면 실시간으로 검색 제안을 표시하며, 디바운싱과 요청 취소 로직을 처리해야 합니다.
타이머 및 카운트다운: 스톱워치, 카운트다운 타이머 등이精确하게 시간을 표시해야 합니다.
StreamBuilder와 다른 반응형 접근법 비교
Flutter는 여러 종류의 반응형 프로그래밍 접근법을 제공합니다:
| 방식 | 适用 시나리오 | 학습 곡선 | 유연성 | 성능 |
|---|---|---|---|---|
| setState | 간단한 상태 업데이트 | 낮음 | 낮음 | 보통 |
| StreamBuilder | 비동기 데이터 스트림 | 중간 | 높음 | 높음 |
| FutureBuilder | 일회성 비동기 작업 | 낮음 | 보통 | 높음 |
| ValueNotifier | 단일 값 상태 관리 | 낮음 | 보통 | 높음 |
| ChangeNotifier | 복잡한 상태 관리 | 중간 | 보통 | 보통 |
| Provider/Riverpod | 전역 상태 관리 | 중간 | 높음 | 높음 |
| BLoC/Cubit | 엔터프라이즈급 상태 관리 | 높음 | 매우 높음 | 높음 |
비동기 데이터 스트림 시나리오에서 StreamBuilder가 최적의 선택인 이유는 다음과 같습니다:
선언적 UI: 데이터 상태에 따라 UI가 어떻게 표시되어야 하는지만 정의하면 StreamBuilder가 자동으로 데이터 업데이트와 UI 재구성을 처리합니다.
자동 구독 관리: StreamBuilder가 Stream의 구독과 구독 취소를 자동으로 관리하여 메모리 누수를 방지합니다.
상태 처리: connectionState 속성을 통해 로딩 중, 오류 등의 상태를 쉽게 처리할 수 있습니다.
Flutter 생태계 통합: StreamBuilder는 Flutter 프레임워크의 일부이므로 다른 Flutter 컴포넌트와 원활하게协同 작동합니다.
Stream 핵심 개념 필수 이해
StreamBuilder를 마스터하려면 Stream의 핵심 개념을 제대로 이해해야 합니다:
Stream(스트림): Stream은 비동기 이벤트의 시퀀스입니다. 데이터를 한쪽에서的另一쪽으로 흐르는 파이프라고 생각할 수 있습니다. Future와 달리 Stream은 여러 개의 값을 생성할 수 있습니다.
StreamController: Stream 컨트롤러로, Stream을 생성하고 제어합니다. Stream과 Sink 두 속성을 제공하며, 각각 데이터 수신과 데이터 추가에 사용됩니다.
StreamSubscription: Stream 구독으로, Stream에 대한 구독을 관리합니다. 일시 정지, 재개 또는 구독 취소가 가능합니다.
StreamTransformer: Stream 변환기로, Stream의 데이터를 변환합니다. 예를 들어, 숫자 Stream을 문자열 Stream으로 변환할 수 있습니다.
Sink: 데이터 수신기로, Stream에 데이터를 추가하는 데 사용됩니다.
기술 아키텍처 설계
코드를 작성하기 전에 명확한 아키텍처를 설계하는 것이 중요합니다. 좋은 아키텍처 설계는 코드를 더 이해하기 쉽고, 유지보수하기 좋고, 확장하기 쉽게 만듭니다.
데이터 흐름 아키텍처 설계
┌─────────────────────────────────────────────────────────────┐
│ 데이터 소스 계층 │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ WebSocket │ │ Timer │ │ Sensor │ │
│ │ 네트워크 │ │ 타이머 │ │ 센서 │ │
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ StreamController │ │
│ │ - Stream 생성 │ │
│ │ - 데이터 추가를 위한 Sink 제공 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Stream<T> │ │
│ │ - 비동기 데이터 시퀀스 │ │
│ │ - 여러 리스너가 구독 가능 │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
│ 구독
▼
┌─────────────────────────────────────────────────────────────┐
│ UI 계층 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ StreamBuilder<T> │ │
│ │ - Stream 변화 감시 │ │
│ │ - 자동 구독 관리 │ │
│ │ - 상태에 따라 UI 구성 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ AsyncSnapshot<T> │ │
│ │ - connectionState: 연결 상태 │ │
│ │ - data: 최신 데이터 │ │
│ │ - error: 오류 정보 │ │
│ │ - hasData / hasError: 상태 판단 │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
AsyncSnapshot 상태 이해
StreamBuilder는 AsyncSnapshot을 통해 데이터 상태를 제공합니다:
| 상태 | 설명 | 일반적인 용도 |
|---|---|---|
| ConnectionState.none | Stream에 연결되지 않음 | 초기 상태 표시 |
| ConnectionState.waiting | 데이터 대기 중 | 로딩 인디케이터 표시 |
| ConnectionState.active | 연결됨, 새 데이터 대기 중 | 데이터 표시 |
| ConnectionState.done | Stream이 닫힘 | 최종 상태 표시 |
데이터 흐름 처리 프로세스
StreamController 생성
│
▼
Stream을 가져와서 StreamBuilder에 전달
│
▼
StreamBuilder가 Stream 구독
│
├──▶ ConnectionState.waiting: 로딩 중 표시
│
├──▶ 데이터 도착: ConnectionState.active
│ │
│ └──▶ 데이터 표시
│
├──▶ 오류 발생: hasError = true
│ │
│ └──▶ 오류 메시지 표시
│
└──▶ Stream 종료: ConnectionState.done
│
└──▶ 최종 상태 표시
│
▼
dispose()로 자동 구독 취소
핵심 기능 구현
3.1 기본 StreamBuilder 사용법
import 'dart:async';
import 'package:flutter/material.dart';
class SimpleCounterPage extends StatefulWidget {
const SimpleCounterPage({super.key});
@override
State<SimpleCounterPage> createState() => _SimpleCounterPageState();
}
class _SimpleCounterPageState extends State<SimpleCounterPage> {
late StreamController<int> _dataStream;
int _currentCount = 0;
@override
void initState() {
super.initState();
_dataStream = StreamController<int>.broadcast();
}
@override
void dispose() {
_dataStream.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('간단한 카운터')),
body: Center(
child: StreamBuilder<int>(
stream: _dataStream.stream,
initialData: 0,
builder: (ctx, snapshot) {
// 다양한 상태 처리
if (snapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
}
if (snapshot.hasError) {
return Text('오류: ${snapshot.error}');
}
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('현재 카운트:', style: TextStyle(fontSize: 18)),
const SizedBox(height: 8),
Text(
'${snapshot.data}',
style: const TextStyle(fontSize: 48, fontWeight: FontWeight.bold),
),
],
);
},
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
_currentCount++;
_dataStream.add(_currentCount);
},
child: const Icon(Icons.add),
),
);
}
}
3.2 스톱워치 구현
/// 스톱워치 기능 제공
class StopwatchTimer extends StatefulWidget {
final VoidCallback? onReset;
const StopwatchTimer({super.key, this.onReset});
@override
State<StopwatchTimer> createState() => _StopwatchTimerState();
}
class _StopwatchTimerState extends State<StopwatchTimer> {
late StreamController<Duration> _timerController;
late Duration _elapsedTime;
Timer? _ticker;
bool _isRunning = false;
@override
void initState() {
super.initState();
_elapsedTime = Duration.zero;
_timerController = StreamController<Duration>.broadcast();
_timerController.add(_elapsedTime);
}
void _start() {
if (_isRunning) return;
_isRunning = true;
final stopwatch = Stopwatch()..start();
_ticker = Timer.periodic(const Duration(milliseconds: 100), (_) {
_elapsedTime = stopwatch.elapsed;
_timerController.add(_elapsedTime);
});
}
void _pause() {
_ticker?.cancel();
_isRunning = false;
}
void _reset() {
_ticker?.cancel();
_isRunning = false;
_elapsedTime = Duration.zero;
_timerController.add(_elapsedTime);
widget.onReset?.call();
}
@override
void dispose() {
_ticker?.cancel();
_timerController.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
StreamBuilder<Duration>(
stream: _timerController.stream,
initialData: Duration.zero,
builder: (ctx, snapshot) {
final time = snapshot.data!;
final minutes = time.inMinutes.remainder(60).toString().padLeft(2, '0');
final seconds = time.inSeconds.remainder(60).toString().padLeft(2, '0');
final millis = (time.inMilliseconds.remainder(1000) ~/ 10).toString().padLeft(2, '0');
return Text(
'$minutes:$seconds.$millis',
style: const TextStyle(
fontSize: 64,
fontWeight: FontWeight.bold,
fontFeatures: [FontFeature.tabularFigures()],
fontFamily: 'monospace',
),
);
},
),
const SizedBox(height: 32),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
FilledButton.icon(
onPressed: _start,
icon: const Icon(Icons.play_arrow),
label: const Text('시작'),
),
const SizedBox(width: 12),
FilledButton.tonalIcon(
onPressed: _pause,
icon: const Icon(Icons.pause),
label: const Text('일시정지'),
),
const SizedBox(width: 12),
OutlinedButton.icon(
onPressed: _reset,
icon: const Icon(Icons.refresh),
label: const Text('초기화'),
),
],
),
],
);
}
}
3.3 파일 업로드 진행률 구현
/// 파일 업로드 관리자
class FileUploadManager {
final StreamController<UploadState> _stateController =
StreamController<UploadState>.broadcast();
Stream<UploadState> get stateStream => _stateController.stream;
Future<void> uploadFile(String fileName) async {
_stateController.add(UploadState(
status: UploadStatus.uploading,
progress: 0.0,
fileName: fileName,
));
// 실제 업로드 시뮬레이션
for (int i = 1; i <= 10; i++) {
await Future.delayed(const Duration(milliseconds: 300));
_stateController.add(UploadState(
status: UploadStatus.uploading,
progress: i / 10,
fileName: fileName,
));
}
_stateController.add(UploadState(
status: UploadStatus.completed,
progress: 1.0,
fileName: fileName,
));
}
void cancel() {
_stateController.add(UploadState(
status: UploadStatus.cancelled,
progress: 0.0,
fileName: '',
));
}
void dispose() {
_stateController.close();
}
}
enum UploadStatus { idle, uploading, completed, cancelled }
class UploadState {
final UploadStatus status;
final double progress;
final String fileName;
UploadState({
required this.status,
required this.progress,
required this.fileName,
});
}
/// 파일 업로드 화면
class FileUploadScreen extends StatefulWidget {
const FileUploadScreen({super.key});
@override
State<FileUploadScreen> createState() => _FileUploadScreenState();
}
class _FileUploadScreenState extends State<FileUploadScreen> {
final FileUploadManager _uploader = FileUploadManager();
@override
void dispose() {
_uploader.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('파일 업로드')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
StreamBuilder<UploadState>(
stream: _uploader.stateStream,
builder: (ctx, snapshot) {
final state = snapshot.data ?? UploadState(
status: UploadStatus.idle,
progress: 0.0,
fileName: '',
);
final progressPercent = (state.progress * 100).toInt();
return Column(
children: [
// 진행률 표시줄
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: LinearProgressIndicator(
value: state.progress,
minHeight: 16,
backgroundColor: Colors.grey.shade200,
valueColor: AlwaysStoppedAnimation(
state.status == UploadStatus.completed
? Colors.green
: Colors.blue,
),
),
),
const SizedBox(height: 20),
// 퍼센트 표시
Text(
'$progressPercent%',
style: const TextStyle(
fontSize: 36,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
// 상태 메시지
_buildStatusMessage(state),
],
);
},
),
const SizedBox(height: 32),
// 업로드 버튼
FilledButton.icon(
onPressed: () => _uploader.uploadFile('document.pdf'),
icon: const Icon(Icons.cloud_upload),
label: const Text('업로드 시작'),
),
],
),
),
);
}
Widget _buildStatusMessage(UploadState state) {
String message;
Color color;
IconData icon;
switch (state.status) {
case UploadStatus.idle:
message = '파일을 선택하세요';
color = Colors.grey;
icon = Icons.info_outline;
break;
case UploadStatus.uploading:
message = '${state.fileName} 업로드 중...';
color = Colors.blue;
icon = Icons.sync;
break;
case UploadStatus.completed:
message = '업로드 완료!';
color = Colors.green;
icon = Icons.check_circle;
break;
case UploadStatus.cancelled:
message = '업로드 취소됨';
color = Colors.orange;
icon = Icons.cancel;
break;
}
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: color, size: 20),
const SizedBox(width: 8),
Text(message, style: TextStyle(color: color, fontSize: 16)),
],
);
}
}
3.4 실시간 상품 검색 구현
/// 상품 검색 서비스
class ProductSearchService {
final StreamController<List<Product>> _resultsController =
StreamController<List<Product>>.broadcast();
Stream<List<Product>> get results => _resultsController.stream;
Timer? _debounceTimer;
// 테스트용 상품 데이터
final List<Product> _allProducts = List.generate(
50,
(i) => Product(id: i, name: '상품 ${i + 1}', price: (i + 1) * 1000),
);
void search(String keyword) {
_debounceTimer?.cancel();
_debounceTimer = Timer(const Duration(milliseconds: 400), () {
if (keyword.trim().isEmpty) {
_resultsController.add([]);
return;
}
final results = _allProducts
.where((p) => p.name.toLowerCase().contains(keyword.toLowerCase()))
.toList();
_resultsController.add(results);
});
}
void dispose() {
_debounceTimer?.cancel();
_resultsController.close();
}
}
class Product {
final int id;
final String name;
final int price;
Product({required this.id, required this.name, required this.price});
}
/// 상품 검색 화면
class ProductSearchScreen extends StatefulWidget {
const ProductSearchScreen({super.key});
@override
State<ProductSearchScreen> createState() => _ProductSearchScreenState();
}
class _ProductSearchScreenState extends State<ProductSearchScreen> {
final ProductSearchService _searchService = ProductSearchService();
final TextEditingController _inputController = TextEditingController();
@override
void dispose() {
_searchService.dispose();
_inputController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: TextField(
controller: _inputController,
autofocus: true,
decoration: const InputDecoration(
hintText: '상품명을 입력하세요',
border: InputBorder.none,
prefixIcon: Icon(Icons.search),
),
onChanged: _searchService.search,
),
),
body: StreamBuilder<List<Product>>(
stream: _searchService.results,
initialData: const [],
builder: (ctx, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
final products = snapshot.data ?? [];
final keyword = _inputController.text;
if (keyword.isEmpty) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.search, size: 64, color: Colors.grey),
SizedBox(height: 16),
Text('검색어를 입력하여 상품을 찾아보세요',
style: TextStyle(color: Colors.grey)),
],
),
);
}
if (products.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.search_off, size: 64, color: Colors.grey.shade400),
const SizedBox(height: 16),
Text('"$keyword" 검색 결과가 없습니다',
style: TextStyle(color: Colors.grey.shade600)),
],
),
);
}
return ListView.separated(
padding: const EdgeInsets.all(8),
itemCount: products.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (ctx, index) {
final product = products[index];
return ListTile(
leading: CircleAvatar(
backgroundColor: Colors.blue.shade100,
child: Text('${index + 1}',
style: TextStyle(color: Colors.blue.shade800)),
),
title: Text(product.name),
subtitle: Text('₩${product.price}'),
trailing: const Icon(Icons.chevron_right),
onTap: () {},
);
},
);
},
),
);
}
}
완전한 애플리케이션 예제
이제 실시간 주가 모니터링 애플리케이션의 전체 예제를 살펴보겠습니다:
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
void main() {
runApp(const StockMonitorApp());
}
class StockMonitorApp extends StatelessWidget {
const StockMonitorApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: '주가 모니터링',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
),
home: const StockDashboard(),
);
}
}
/// 주가 데이터 모델
class StockPrice {
final String symbol;
final double price;
final double changePercent;
final DateTime timestamp;
StockPrice({
required this.symbol,
required this.price,
required this.changePercent,
required this.timestamp,
});
bool get isUp => changePercent >= 0;
}
/// 주가 시뮬레이션 서비스
class StockService {
final StreamController<StockPrice> _priceController =
StreamController<StockPrice>.broadcast();
Stream<StockPrice> get priceStream => _priceController.stream;
Timer? _priceTimer;
final Random _random = Random();
final String _symbol;
double _basePrice;
StockService(this._symbol, this._basePrice);
void startStreaming() {
_priceTimer?.cancel();
_priceTimer = Timer.periodic(const Duration(seconds: 2), (_) {
// 가격 변동 시뮬레이션 (-5% ~ +5%)
final change = ((_random.nextDouble() - 0.5) * 10);
_basePrice = (_basePrice * (1 + change / 100)).clamp(1000.0, 50000.0);
_priceController.add(StockPrice(
symbol: _symbol,
price: _basePrice,
changePercent: change,
timestamp: DateTime.now(),
));
});
}
void stopStreaming() {
_priceTimer?.cancel();
}
void dispose() {
stopStreaming();
_priceController.close();
}
}
/// 주가 대시보드
class StockDashboard extends StatefulWidget {
const StockDashboard({super.key});
@override
State<StockDashboard> createState() => _StockDashboardState();
}
class _StockDashboardState extends State<StockDashboard> {
final StockService _stockService = StockService('AAPL', 15000);
final List<StockPrice> _priceHistory = [];
@override
void initState() {
super.initState();
_stockService.startStreaming();
}
@override
void dispose() {
_stockService.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('📈 실시간 주가'),
centerTitle: true,
actions: [
StreamBuilder<StockPrice>(
stream: _stockService.priceStream,
builder: (ctx, snapshot) {
final isActive = snapshot.connectionState == ConnectionState.active;
return Switch(
value: isActive,
onChanged: (value) {
if (value) {
_stockService.startStreaming();
} else {
_stockService.stopStreaming();
}
},
);
},
),
],
),
body: StreamBuilder<StockPrice>(
stream: _stockService.priceStream,
builder: (ctx, snapshot) {
if (snapshot.hasData) {
_priceHistory.insert(0, snapshot.data!);
if (_priceHistory.length > 15) {
_priceHistory.removeLast();
}
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildPriceCard(snapshot),
const SizedBox(height: 20),
_buildChangeIndicator(snapshot),
const SizedBox(height: 24),
_buildHistorySection(),
],
),
);
},
),
);
}
Widget _buildPriceCard(AsyncSnapshot<StockPrice> snapshot) {
final hasData = snapshot.hasData;
final price = snapshot.data?.price ?? 0;
final isUp = snapshot.data?.isUp ?? true;
return Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(hasData ? snapshot.data!.symbol : 'AAPL',
style: const TextStyle(fontSize: 16, color: Colors.grey)),
const SizedBox(height: 4),
Text(
hasData ? '₩${price.toStringAsFixed(0)}' : '₩0',
style: const TextStyle(
fontSize: 40,
fontWeight: FontWeight.bold,
),
),
],
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: (isUp ? Colors.green : Colors.red).withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
),
child: Row(
children: [
Icon(
isUp ? Icons.trending_up : Icons.trending_down,
color: isUp ? Colors.green : Colors.red,
),
const SizedBox(width: 4),
Text(
hasData
? '${snapshot.data!.changePercent >= 0 ? '+' : ''}${snapshot.data!.changePercent.toStringAsFixed(2)}%'
: '0%',
style: TextStyle(
color: isUp ? Colors.green : Colors.red,
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
],
),
),
],
),
],
),
),
);
}
Widget _buildChangeIndicator(AsyncSnapshot<StockPrice> snapshot) {
if (!snapshot.hasData) {
return const Card(
child: ListTile(
leading: Icon(Icons.info, color: Colors.grey),
title: Text('데이터를 수신 중입니다...'),
),
);
}
final data = snapshot.data!;
final color = data.isUp ? Colors.green : Colors.red;
final icon = data.isUp ? Icons.arrow_upward : Icons.arrow_downward;
return Card(
color: color.withOpacity(0.05),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(color: color.withOpacity(0.1), shape: BoxShape.circle),
child: Icon(icon, color: color, size: 20),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
data.isUp ? '상승세' : '하락세',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: color,
),
),
const SizedBox(height: 4),
Text(
'마지막 업데이트: ${_formatTime(data.timestamp)}',
style: TextStyle(color: Colors.grey.shade600, fontSize: 13),
),
],
),
),
],
),
),
);
}
Widget _buildHistorySection() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.history, size: 20),
SizedBox(width: 8),
Text('변동 이력', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
],
),
const SizedBox(height: 16),
if (_priceHistory.isEmpty)
const Padding(
padding: EdgeInsets.all(20),
child: Center(child: Text('아직 데이터가 없습니다')),
)
else
...(_priceHistory.take(10).map((price) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_formatTime(price.timestamp),
style: TextStyle(color: Colors.grey.shade600, fontSize: 13),
),
Text(
'₩${price.price.toStringAsFixed(0)}',
style: const TextStyle(fontWeight: FontWeight.w500),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: (price.isUp ? Colors.green : Colors.red).withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
'${price.isUp ? '+' : ''}${price.changePercent.toStringAsFixed(1)}%',
style: TextStyle(
fontSize: 12,
color: price.isUp ? Colors.green : Colors.red,
),
),
),
],
),
))),
],
),
),
);
}
String _formatTime(DateTime time) {
return '${time.hour.toString().padLeft(2, '0')}:'
'${time.minute.toString().padLeft(2, '0')}:'
'${time.second.toString().padLeft(2, '0')}';
}
}
고급 기법
5.1 Stream 조합과 변환
/// 여러 Stream 조합
class MultiStreamCombiner {
final StreamController<String> _outputController =
StreamController<String>.broadcast();
Stream<String> get combined => _outputController.stream;
StreamSubscription? _sub1;
StreamSubscription? _sub2;
void merge<T>(Stream<T> streamA, Stream<T> streamB) {
_sub1 = streamA.listen((value) {
_outputController.add('A: $value');
});
_sub2 = streamB.listen((value) {
_outputController.add('B: $value');
});
}
void dispose() {
_sub1?.cancel();
_sub2?.cancel();
_outputController.close();
}
}
/// Stream 변환 유틸리티
class StreamTransformUtils {
// 짝수만 필터링
Stream<int> filterOdd(Stream<int> input) {
return input.where((v) => v.isOdd);
}
// 값을 두 배로 변환
Stream<int> doubleValues(Stream<int> input) {
return input.map((v) => v * 2);
}
// 디바운스 구현
Stream<T> throttle<T>(Stream<T> input, Duration delay) {
return input.transform(
StreamTransformer<T, T>.fromHandlers(
handleData: (data, sink) {
Future.delayed(delay, () => sink.add(data));
},
),
);
}
// 고유 값만 통과
Stream<T> uniqueOnly<T>(Stream<T> input) {
return input.distinct();
}
}
5.2 브로드캐스트 Stream
/// 브로드캐스트 Stream 데모
class BroadcastDemo extends StatefulWidget {
const BroadcastDemo({super.key});
@override
State<BroadcastDemo> createState() => _BroadcastDemoState();
}
class _BroadcastDemoState extends State<BroadcastDemo> {
late StreamController<int> _broadcaster;
late Stream<int> _sharedStream;
@override
void initState() {
super.initState();
// 브로드캐스트 모드로 생성
_broadcaster = StreamController<int>.broadcast();
_sharedStream = _broadcaster.stream;
// 주기적으로 데이터 발생
Timer.periodic(const Duration(seconds: 1), (timer) {
_broadcaster.add(timer.tick);
});
}
@override
void dispose() {
_broadcaster.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('브로드캐스트 Stream')),
body: Column(
children: [
// 같은 Stream을 여러 StreamBuilder에서 구독
Expanded(
child: StreamBuilder<int>(
stream: _sharedStream,
builder: (ctx, snap) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('구독자 1', style: TextStyle(color: Colors.grey)),
Text('${snap.data ?? 0}',
style: const TextStyle(fontSize: 32)),
],
),
),
),
),
const Divider(),
Expanded(
child: StreamBuilder<int>(
stream: _sharedStream,
builder: (ctx, snap) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('구독자 2', style: TextStyle(color: Colors.grey)),
Text('${snap.data ?? 0}',
style: const TextStyle(fontSize: 32)),
],
),
),
),
),
],
),
);
}
}
5.3 오류 처리
/// 안전한 StreamBuilder 래퍼
class ResilientStreamBuilder<T> extends StatelessWidget {
final Stream<T> stream;
final T? initialValue;
final Widget Function(BuildContext, T) renderWidget;
final Widget Function(BuildContext, Object?)? onError;
final Widget? whileLoading;
const ResilientStreamBuilder({
super.key,
required this.stream,
this.initialValue,
required this.renderWidget,
this.onError,
this.whileLoading,
});
@override
Widget build(BuildContext context) {
return StreamBuilder<T>(
stream: stream,
initialData: initialValue,
builder: (ctx, snapshot) {
// 로딩 상태
if (snapshot.connectionState == ConnectionState.waiting) {
return whileLoading ??
const Center(child: CircularProgressIndicator.adaptive());
}
// 오류 상태
if (snapshot.hasError) {
return onError?.call(ctx, snapshot.error) ??
Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.redAccent),
const SizedBox(height: 12),
Text('오류 발생: ${snapshot.error}'),
],
),
);
}
// 데이터 없음
if (!snapshot.hasData) {
return const Center(child: Text('데이터가 없습니다'));
}
// 정상 표시
return renderWidget(ctx, snapshot.data as T);
},
);
}
}
최선 사례와 주의사항
성능 최적화 권장사항
-
브로드캐스트 Stream 활용: 여러 컴포넌트가同一个 Stream을 구독해야 할 때 브로드캐스트 Stream을 사용하여 중복 생성을 피하세요.
-
** StreamController 해제**: dispose에서 StreamController를 해제하여 메모리 누수를 방지하세요.
-
initialData 설정: 초기 데이터를 설정하여 불필요한 로딩 상태를 피하세요.
-
과도한 재구성 방지: StreamBuilder는 데이터 변경 시에만 재구성하므로 추가 최적화가 필요 없습니다.
-
StreamTransformer 활용: 복잡한 데이터 변환은 StreamTransformer를 사용하고, builder 내부에서 처리하지 마세요.
일반적인 문제와 해결 방법
| 문제 | 원인 | 해결 방법 |
|---|---|---|
| 메모리 누수 | StreamController 미해제 | dispose에서 close() 호출 |
| 다중 구독 실패 | 단일 구독 Stream 사용 | broadcast Stream으로 변경 |
| 데이터 미업데이트 | Stream에서 데이터 미전송 | Sink.add() 호출 확인 |
| 상태 불일치 | 연결 상태 잘못 처리 | connectionState 확인 |
| 디바운스无效 | Stream 재생성 빈번 | Stream을 State 위로 이동 |
코드 표준화 권장사항
-
Stream 서비스 캡슐화: Stream 관련 로직을 별도의 서비스 클래스로 캡슐화하세요.
-
타입 파라미터 명시: Stream과 StreamBuilder에明确的 타입 파라미터를 지정하세요.
-
모든 상태 처리: builder에서 가능한 모든 연결 상태를 처리하세요.
-
주석 추가: 복잡한 Stream 로직에는 설명 주석을 추가하세요.
-
오류 처리: 항상 가능한 오류 상황을 처리하세요.
정리
本文에서는 Flutter에서 StreamBuilder 컴포넌트의 사용법에 대해 다루었습니다. 기본 개념부터 고급 기법까지, 반응형 데이터 스트림의 핵심 역량을 마스터할 수 있도록 했습니다.
핵심 내용 정리:
-
Stream 기초: Stream, StreamController, Sink의 개념과 사용법 이해
-
StreamBuilder 활용: Stream 변화를 감시하고 상태에 따라 UI 구성
-
상태 처리: waiting, active, done 등의 연결 상태를 올바르게 처리
-
실제 적용: 스톱워치, 진행률 표시, 실시간 검색 등 일반적인 시나리오
-
고급 기법: Stream 조합, 브로드캐스트 Stream, 오류 처리
이 글을 통해 실시간 데이터 애플리케이션을 독립적으로 개발하고, 반응형 프로그래밍을 더 많은 시나리오에 적용할 수 있게 되기를 바랍니다.