Wednesday, July 26, 2023

플러터 StreamBuilder 심층 분석: 비동기 UI의 완성

현대 애플리케이션 개발에서 사용자의 상호작용, 네트워크 응답, 데이터베이스 변경 등 비동기적으로 발생하는 데이터를 처리하고 이를 UI에 실시간으로 반영하는 것은 핵심적인 과제입니다. Flutter는 이러한 과제를 해결하기 위해 강력한 도구들을 제공하며, 그 중심에는 'Stream'과 'StreamBuilder' 위젯이 있습니다. StreamBuilder는 데이터의 흐름을 위젯 트리와 자연스럽게 연결하여, 선언적이고 반응적인 UI를 구축할 수 있도록 돕는 핵심적인 구성 요소입니다.

이 글에서는 StreamBuilder의 기본적인 개념부터 시작하여, 그 내부 동작 원리, 다양한 실전 활용 사례, 그리고 애플리케이션의 성능을 극대화하기 위한 최적화 기법까지 심도 있게 탐구합니다. 단순히 위젯의 사용법을 나열하는 것을 넘어, 왜 StreamBuilder가 Flutter의 비동기 프로그래밍 패러다임에서 중요한 위치를 차지하는지, 그리고 개발자가 이를 어떻게 효과적으로 활용하여 견고하고 반응성이 뛰어난 애플리케이션을 만들 수 있는지에 대한 깊이 있는 통찰을 제공할 것입니다.

1. 비동기 UI의 시작: Dart의 Stream 이해하기

StreamBuilder를 제대로 이해하기 위해서는 그 기반이 되는 Dart의 Stream에 대한 명확한 이해가 선행되어야 합니다. Stream은 비동기적으로 발생하는 일련의 데이터 이벤트의 흐름을 나타냅니다. 흔히 수도꼭지에서 흘러나오는 물줄기나 공장의 컨베이어 벨트에 비유되곤 합니다. 데이터(물방울, 물건)가 한 번에 모두 도착하는 것이 아니라, 시간이 지남에 따라 순차적으로 도착하는 개념입니다.

1.1. Stream의 두 가지 유형: 단일 구독과 브로드캐스트

Stream은 구독하는 리스너(listener)의 수에 따라 두 가지 유형으로 나뉩니다.

  • 단일 구독 스트림 (Single-subscription Stream): 오직 하나의 리스너만이 스트림을 구독할 수 있습니다. 스트림은 리스너가 구독을 시작할 때 데이터를 생성하기 시작하며, 데이터 시퀀스 전체를 순서대로 전달합니다. 파일에서 데이터를 읽거나 웹 요청을 보내는 것과 같이, 데이터 전체를 순차적으로 전달해야 하는 경우에 적합합니다. 구독이 취소되면 스트림은 멈추고, 다시 구독할 수 없습니다.
  • 브로드캐스트 스트림 (Broadcast Stream): 여러 개의 리스너가 동시에 구독할 수 있습니다. 스트림은 리스너의 존재 여부와 상관없이 이벤트를 발생시킬 수 있으며, 각 리스너는 구독을 시작한 시점 이후에 발생하는 이벤트를 수신합니다. 마우스 이벤트, 버튼 클릭, 실시간 센서 데이터 등 여러 곳에서 동시에 데이터에 반응해야 하는 경우에 사용됩니다.

일반적으로 StreamController나 대부분의 Firebase 스트림은 브로드캐스트 스트림으로, 여러 위젯에서 동일한 데이터 소스를 구독하는 Flutter UI 환경에 매우 적합합니다.

1.2. Stream 생성 방법

Stream을 생성하는 방법은 다양하며, 용도에 따라 적절한 방법을 선택할 수 있습니다.

1.2.1. Stream.periodic

일정 시간 간격으로 이벤트를 발생시키는 스트림을 생성합니다. 실시간 시계나 주기적인 데이터 폴링(polling)을 구현할 때 유용합니다.


// 1초마다 정수 값을 0부터 순차적으로 발생시키는 스트림
Stream<int> countStream() {
  return Stream.periodic(const Duration(seconds: 1), (count) => count);
}

1.2.2. Stream.fromFuture

하나의 비동기 작업(Future)이 완료되었을 때 단일 데이터 이벤트를 발생시키는 스트림을 생성합니다. FutureBuilder와 유사한 동작을 구현할 수 있습니다.


// 2초 후에 "Data Loaded" 문자열을 한 번 발생시키고 종료되는 스트림
Stream<String> dataFromFuture() {
  return Stream.fromFuture(
    Future.delayed(const Duration(seconds: 2), () => "Data Loaded from Future"),
  );
}

1.2.3. StreamController

가장 유연하고 강력한 방법으로, 개발자가 원하는 시점에 데이터를 직접 스트림에 추가하거나 에러를 발생시킬 수 있습니다. BLoC 패턴과 같은 복잡한 상태 관리 로직에서 핵심적인 역할을 합니다.


import 'dart:async';

class CounterBloc {
  // private StreamController
  final _counterController = StreamController<int>();

  // 외부에서 접근할 수 있도록 Stream을 노출
  Stream<int> get counterStream => _counterController.stream;

  void increment(int currentCount) {
    // stream에 데이터 추가
    _counterController.sink.add(currentCount + 1);
  }

  // 리소스 해제를 위해 반드시 close 호출
  void dispose() {
    _counterController.close();
  }
}

StreamController를 사용할 때는 위젯이 dispose될 때 컨트롤러의 close() 메서드를 호출하여 리소스를 해제하는 것이 매우 중요합니다. 이를 지키지 않으면 메모리 누수(memory leak)의 원인이 됩니다.

2. StreamBuilder의 구조와 동작 원리

StreamBuilder는 이름에서 알 수 있듯이, Stream을 받아 UI(위젯)를 빌드하는 위젯입니다. 이는 Stream에서 새로운 이벤트가 발생할 때마다 자신의 builder 함수를 다시 호출하여 화면을 갱신하는 방식으로 동작합니다. 이를 통해 개발자는 setState를 명시적으로 호출할 필요 없이, 데이터의 흐름에 따라 UI가 자동으로 변경되는 반응형 코드를 작성할 수 있습니다.

2.1. 주요 속성(Properties)

StreamBuilder를 구성하는 핵심 속성은 다음과 같습니다.

  • stream: 위젯이 구독할 Stream 객체입니다. 이 스트림에서 발생하는 데이터와 상태 변화를 감지합니다.
  • builder: 위젯 트리를 구축하는 함수입니다. BuildContextAsyncSnapshot 두 개의 인자를 받으며, 현재 스트림의 상태에 맞는 위젯을 반환해야 합니다.
  • initialData: 스트림에서 첫 번째 데이터가 도착하기 전에 화면에 표시할 초기 데이터입니다. 이 값을 설정하면 사용자는 빈 화면이나 로딩 인디케이터 대신 초기 데이터를 즉시 볼 수 있어 사용자 경험(UX)을 향상시킬 수 있습니다.

2.2. 생명주기의 핵심, AsyncSnapshot

builder 함수에 전달되는 AsyncSnapshot 객체는 스트림과의 상호작용에 대한 모든 정보를 담고 있는 스냅샷입니다. 이 객체를 통해 우리는 현재 스트림의 상태를 파악하고 그에 맞는 UI를 구성할 수 있습니다.

AsyncSnapshot의 주요 속성은 다음과 같습니다.

속성 타입 설명
connectionState ConnectionState 스트림과의 연결 상태를 나타내는 열거형(enum)입니다. 가장 중요한 속성 중 하나입니다.
data T? 스트림으로부터 가장 최근에 수신된 데이터입니다. 데이터가 아직 없거나 에러가 발생한 경우 null일 수 있습니다.
error Object? 스트림에서 발생한 가장 최근의 에러 객체입니다.
stackTrace StackTrace? 에러가 발생했을 때의 스택 트레이스 정보입니다. 디버깅에 유용합니다.
hasData bool data가 null이 아닐 경우 true를 반환하는 편의 속성입니다.
hasError bool error가 null이 아닐 경우 true를 반환하는 편의 속성입니다.

2.3. ConnectionState의 흐름

connectionState는 StreamBuilder의 생명주기를 이해하는 데 가장 중요합니다. 스트림의 상태에 따라 다음과 같은 값을 가집니다.

  • ConnectionState.none: 스트림이 null이거나 아직 연결되지 않은 초기 상태입니다. stream 속성에 null을 전달하면 이 상태가 됩니다.
  • ConnectionState.waiting: 스트림에 연결되었지만 아직 첫 번째 데이터를 기다리는 중인 상태입니다. 일반적으로 이 상태에서는 로딩 인디케이터(CircularProgressIndicator)를 표시합니다.
  • ConnectionState.active: 스트림이 활성화되어 데이터를 지속적으로 받고 있는 상태입니다. 데이터가 성공적으로 수신되면 snapshot.data를 사용하여 UI를 그리고, 에러가 발생하면 snapshot.error를 처리합니다. 대부분의 스트림은 이 상태에 머무릅니다.
  • ConnectionState.done: 스트림이 모든 데이터를 보내고 정상적으로 닫힌 상태입니다. 더 이상 새로운 데이터가 발생하지 않습니다. Stream.fromFuture와 같이 유한한 스트림의 경우 이 상태에 도달할 수 있습니다.

2.4. 기본적인 StreamBuilder 구현 패턴

위에서 설명한 AsyncSnapshot의 속성들을 조합하면 다음과 같은 안정적인 기본 구현 패턴을 만들 수 있습니다.


StreamBuilder<DateTime>(
  // 1초마다 현재 시간을 방출하는 스트림
  stream: Stream.periodic(const Duration(seconds: 1), (_) => DateTime.now()),
  builder: (BuildContext context, AsyncSnapshot<DateTime> snapshot) {
    // 1. 에러 상태를 가장 먼저 확인
    if (snapshot.hasError) {
      return Center(
        child: Text('에러 발생: ${snapshot.error}'),
      );
    }

    // 2. 연결 상태에 따라 분기 처리
    switch (snapshot.connectionState) {
      case ConnectionState.none:
        return const Center(child: Text('스트림에 연결되지 않았습니다.'));
      case ConnectionState.waiting:
        return const Center(child: CircularProgressIndicator());
      case ConnectionState.active:
        // 3. 데이터가 있는 경우 UI를 그림
        if (snapshot.hasData) {
          return Center(
            child: Text(
              '현재 시간: ${snapshot.data}',
              style: Theme.of(context).textTheme.headlineMedium,
            ),
          );
        } else {
          // 데이터가 없는 경우 (이론적으로 active 상태에서 발생하기 어려움)
          return const Center(child: Text('데이터가 없습니다.'));
        }
      case ConnectionState.done:
        return Center(child: Text('스트림이 종료되었습니다. 마지막 데이터: ${snapshot.data}'));
    }
  },
)

이 패턴은 모든 가능한 상태(에러, 로딩, 데이터 수신, 종료)를 처리하므로, 예기치 않은 UI 오류를 방지하고 사용자에게 명확한 피드백을 제공하는 견고한 코드를 작성하는 데 도움이 됩니다.

3. StreamBuilder 실전 활용 사례

StreamBuilder는 이론적인 개념을 넘어, 실제 애플리케이션의 다양한 시나리오에서 강력한 힘을 발휘합니다. 특히 실시간 데이터 동기화가 필요한 경우에 그 진가가 드러납니다.

3.1. Firebase와 실시간 연동

Firebase는 실시간 데이터베이스(Firestore, Realtime Database)와 인증 서비스를 제공하며, 이들의 상태 변화는 모두 Stream을 통해 노출됩니다. 따라서 StreamBuilder는 Firebase와 완벽한 조합을 이룹니다.

3.1.1. Firestore 컬렉션 실시간 업데이트

Firestore의 snapshots() 메서드는 특정 컬렉션이나 문서의 변경사항을 감지하는 Stream을 반환합니다. 이를 StreamBuilder와 연결하면 데이터베이스에서 데이터가 추가, 수정, 삭제될 때마다 UI가 자동으로 갱신되는 기능을 손쉽게 구현할 수 있습니다.


// 'users' 컬렉션의 모든 문서를 실시간으로 감시
StreamBuilder<QuerySnapshot>(
  stream: FirebaseFirestore.instance.collection('users').snapshots(),
  builder: (context, AsyncSnapshot<QuerySnapshot> snapshot) {
    if (snapshot.hasError) {
      return Text('오류: ${snapshot.error}');
    }
    if (snapshot.connectionState == ConnectionState.waiting) {
      return CircularProgressIndicator();
    }

    // 데이터가 없는 경우
    if (!snapshot.hasData || snapshot.data!.docs.isEmpty) {
        return Center(child: Text("사용자 데이터가 없습니다."));
    }

    // ListView를 사용하여 사용자 목록 표시
    return ListView(
      children: snapshot.data!.docs.map((DocumentSnapshot document) {
        // 실제 앱에서는 모델 클래스로 변환하는 것이 좋음
        Map<String, dynamic> data = document.data()! as Map<String, dynamic>;
        return ListTile(
          title: Text(data['name'] ?? '이름 없음'),
          subtitle: Text(data['email'] ?? '이메일 없음'),
        );
      }).toList(),
    );
  },
)

위 코드는 별도의 상태 관리 로직이나 새로고침 로직 없이도, Firestore 콘솔에서 데이터를 변경하면 앱 화면이 즉시 반영되는 완전한 실시간 UI를 구현합니다.

3.1.2. 사용자 인증 상태 감지

사용자의 로그인/로그아웃 상태에 따라 다른 화면을 보여주는 것은 모든 앱의 기본 기능입니다. Firebase Authentication의 authStateChanges() 스트림은 이 기능을 매우 우아하게 구현할 수 있도록 돕습니다.


class AuthWrapper extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return StreamBuilder<User?>(
      stream: FirebaseAuth.instance.authStateChanges(),
      builder: (context, snapshot) {
        // 연결 중일 때는 로딩 화면
        if (snapshot.connectionState == ConnectionState.waiting) {
          return SplashScreen();
        }
        
        // snapshot.hasData는 사용자가 로그인했음을 의미
        if (snapshot.hasData) {
          return HomeScreen(); // 로그인 시 홈 화면으로
        }
        
        // 데이터가 없으면 로그아웃 상태
        return LoginScreen(); // 로그아웃 시 로그인 화면으로
      },
    );
  }
}

AuthWrapper 위젯을 앱의 최상단에 배치하면, 사용자가 로그인하거나 로그아웃할 때마다 스트림이 새로운 상태(User 객체 또는 `null`)를 전달하고, StreamBuilder가 이에 반응하여 적절한 화면으로 자동 전환해줍니다.

3.2. BLoC 패턴과의 결합

BLoC(Business Logic Component) 패턴은 Flutter에서 가장 널리 사용되는 상태 관리 아키텍처 중 하나입니다. BLoC의 핵심은 UI로부터 비즈니스 로직을 분리하는 것이며, UI와 로직 간의 통신은 Stream을 통해 이루어집니다. 따라서 StreamBuilder는 BLoC 패턴의 상태(state)를 UI에 표시하기 위한 이상적인 위젯입니다.


// 간단한 카운터 BLoC
class CounterBloc {
  final _stateController = StreamController<int>.broadcast();
  Stream<int> get state => _stateController.stream;
  int _counter = 0;

  CounterBloc() {
    _stateController.add(_counter); // 초기값 발행
  }

  void increment() {
    _counter++;
    _stateController.add(_counter);
  }

  void dispose() {
    _stateController.close();
  }
}

// BLoC을 사용하는 UI
class CounterPage extends StatelessWidget {
  final CounterBloc bloc;
  CounterPage({required this.bloc});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text("BLoC with StreamBuilder")),
      body: Center(
        child: StreamBuilder<int>(
          stream: bloc.state,
          initialData: 0, // 초기 데이터 제공으로 UX 향상
          builder: (context, snapshot) {
            return Text(
              'Count: ${snapshot.data}',
              style: Theme.of(context).textTheme.headlineMedium,
            );
          },
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => bloc.increment(),
        child: Icon(Icons.add),
      ),
    );
  }
}

이 예시에서 UI(CounterPage)는 BLoC의 상태 스트림(bloc.state)을 구독하고, 사용자가 버튼을 누르면 BLoC의 메서드(bloc.increment())를 호출하여 이벤트를 전달합니다. BLoC은 내부 로직을 처리한 후 새로운 상태를 스트림에 추가하고, StreamBuilder는 이를 감지하여 화면의 숫자를 업데이트합니다. 이처럼 관심사를 명확히 분리함으로써 코드의 테스트 용이성과 유지보수성이 크게 향상됩니다.

4. 성능 최적화 및 주요 함정 피하기

StreamBuilder는 매우 강력하지만, 부주의하게 사용하면 성능 저하나 예기치 않은 동작을 유발할 수 있습니다. 높은 성능과 안정성을 유지하기 위한 몇 가지 핵심 전략과 흔히 발생하는 실수들을 알아보겠습니다.

4.1. 가장 흔한 실수: `build` 메서드 내에서 Stream 생성

절대 `build` 메서드 안에서 직접 Stream을 생성하면 안 됩니다. 이는 StreamBuilder를 사용할 때 가장 흔하게 저지르는 실수입니다.


// ❌ 잘못된 예시: build 메서드 내에서 Stream 생성
@override
Widget build(BuildContext context) {
  return StreamBuilder(
    // 매번 build가 호출될 때마다 새로운 스트림이 생성되고 구독됨
    stream: FirebaseFirestore.instance.collection('items').snapshots(),
    builder: (context, snapshot) { ... },
  );
}

build 메서드는 위젯이 화면에 다시 그려질 때마다 호출됩니다. (예: 부모 위젯의 상태 변경, 화면 회전 등) 위 코드처럼 `build` 메서드 안에서 스트림을 생성하면, 재빌드가 일어날 때마다 기존 스트림과의 구독은 끊어지고 새로운 스트림을 다시 구독하게 됩니다. 이는 불필요한 네트워크 요청, 데이터베이스 읽기를 유발하고, 화면이 깜빡이거나 상태가 초기화되는 등 심각한 문제를 일으킵니다.

올바른 해결책: Stream을 `State` 객체의 인스턴스 변수로 관리하거나, 상위 위젯(또는 상태 관리 솔루션)으로부터 주입받아야 합니다. `StatefulWidget`에서는 `initState` 메서드에서 스트림을 초기화하는 것이 일반적인 패턴입니다.


// ✅ 올바른 예시: initState에서 Stream 초기화
class ItemList extends StatefulWidget {
  @override
  _ItemListState createState() => _ItemListState();
}

class _ItemListState extends State<ItemList> {
  late final Stream<QuerySnapshot> _itemStream;

  @override
  void initState() {
    super.initState();
    // initState는 한 번만 호출되므로 스트림도 한 번만 생성됨
    _itemStream = FirebaseFirestore.instance.collection('items').snapshots();
  }

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<QuerySnapshot>(
      stream: _itemStream, // 상태 변수에 저장된 스트림을 사용
      builder: (context, snapshot) { ... },
    );
  }
}

4.2. 불필요한 리빌드 최소화

StreamBuilder는 스트림에서 새로운 데이터가 올 때마다 builder 함수 내의 모든 위젯을 다시 빌드합니다. 만약 `builder`가 매우 크고 복잡한 위젯 트리를 반환한다면 이는 성능 저하의 원인이 될 수 있습니다.

최적화의 핵심은 StreamBuilder가 가능한 한 작은 범위의 위젯 트리만 감싸도록 하는 것입니다.


// ❌ 비효율적인 예시: StreamBuilder가 전체 Scaffold를 감싸고 있음
StreamBuilder<String>(
  stream: aStream,
  builder: (context, snapshot) {
    return Scaffold(
      appBar: AppBar(
        title: Text("My App"), // 이 부분은 데이터와 상관없이 불필요하게 리빌드됨
      ),
      body: Center(
        child: Text(snapshot.data ?? "Loading..."), // 실제로 데이터가 필요한 부분
      ),
      floatingActionButton: FloatingActionButton(...), // 이 부분도 불필요하게 리빌드됨
    );
  }
)

// ✅ 효율적인 예시: 데이터가 필요한 Text 위젯만 감싸기
Scaffold(
  appBar: AppBar(
    title: Text("My App"),
  ),
  body: Center(
    child: StreamBuilder<String>( // 정확히 필요한 부분만 감싼다
      stream: aStream,
      builder: (context, snapshot) {
        return Text(snapshot.data ?? "Loading...");
      },
    ),
  ),
  floatingActionButton: FloatingActionButton(...),
)

이처럼 리빌드가 필요한 최소한의 영역만 StreamBuilder로 감싸면, 앱의 전반적인 렌더링 성능을 크게 향상시킬 수 있습니다.

4.3. 스트림 데이터 가공 및 필터링

모든 데이터 변경에 대해 UI를 업데이트할 필요는 없을 수 있습니다. 예를 들어, 사용자가 검색창에 입력할 때마다 API를 호출하면 너무 많은 요청이 발생할 수 있습니다. 이럴 때는 스트림의 데이터를 가공하여 UI 업데이트 빈도를 조절할 수 있습니다.

4.3.1. 필터링 (`where`)

where 연산자를 사용하면 특정 조건을 만족하는 데이터만 스트림을 통과시키고, 나머지는 무시하여 불필요한 리빌드를 막을 수 있습니다.


// 짝수만 통과시키는 스트림
Stream<int> evenNumbersStream = originalStream.where((number) => number % 2 == 0);

4.3.2. 디바운싱 & 스로틀링 (RxDart 활용)

rxdart 패키지는 Dart의 기본 Stream API를 확장하여 강력한 연산자들을 제공합니다.

  • Debounce: 특정 시간 동안 이벤트가 발생하지 않아야 마지막 이벤트를 통과시킵니다. 사용자 입력이 끝났을 때 API를 호출하는 검색 기능에 매우 유용합니다.
  • Throttle: 지정된 시간 간격 동안 첫 번째 이벤트만 통과시키고 나머지는 무시합니다. 버튼 중복 클릭 방지 등에 사용됩니다.

import 'package:rxdart/rxdart.dart';

// 사용자가 500ms 동안 타이핑을 멈추면 검색 쿼리를 전달하는 스트림
Stream<String> debouncedSearchStream = searchInputController.stream
    .debounceTime(const Duration(milliseconds: 500));

이러한 기법들을 활용하면 리소스 사용을 최적화하고 더 나은 사용자 경험을 제공할 수 있습니다.

4.4. 메모리 누수 방지: 스트림 구독 해제

직접 생성한 StreamController는 반드시 위젯이 파괴될 때(dispose 메서드가 호출될 때) close() 메서드를 호출하여 관련된 리소스를 모두 해제해야 합니다. 이를 잊으면 '메모리 누수'가 발생하여 앱이 비정상적으로 종료될 수 있습니다.


class MyWidget extends StatefulWidget {
  @override
  _MyWidgetState createState() => _MyWidgetState();
}

class _MyWidgetState extends State<MyWidget> {
  final _myController = StreamController<int>();

  @override
  void initState() {
    super.initState();
    // ... 컨트롤러 사용 로직
  }

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<int>(
      stream: _myController.stream,
      builder: (context, snapshot) { ... },
    );
  }

  // 위젯이 위젯 트리에서 영구적으로 제거될 때 호출됨
  @override
  void dispose() {
    _myController.close(); // ⭐️ 매우 중요! 컨트롤러를 닫아 리소스를 해제합니다.
    super.dispose();
  }
}

다행히도 Firebase와 같은 외부 라이브러리에서 제공하는 스트림은 대부분 내부적으로 생명주기를 관리해주므로 개발자가 직접 구독을 해제할 필요는 없습니다. 하지만 직접 StreamController를 생성했다면, dispose에서 close하는 것을 절대 잊지 말아야 합니다.

5. 결론: 반응형 UI의 핵심 동력

Flutter의 StreamBuilder는 단순히 비동기 데이터를 화면에 표시하는 위젯을 넘어, Flutter가 추구하는 선언적이고 반응적인 UI 패러다임을 실현하는 핵심적인 도구입니다. Stream의 강력한 데이터 처리 능력과 결합하여, 개발자는 실시간으로 변화하는 데이터에 유연하게 대응하는 동적인 애플리케이션을 간결하고 직관적인 코드로 구축할 수 있습니다.

이 글에서 다룬 Stream의 기본 개념, StreamBuilder의 동작 원리, 다양한 실전 사례, 그리고 성능 최적화 기법들을 숙지한다면, 여러분은 복잡한 비동기 로직을 능숙하게 처리하고, 사용자에게 끊김 없는 실시간 경험을 제공하는 고품질의 Flutter 애플리케이션을 개발할 수 있을 것입니다. Stream과 StreamBuilder를 마스터하는 것은 Flutter 개발자로서 한 단계 더 성장하는 중요한 발판이 될 것입니다.


0 개의 댓글:

Post a Comment