사용자에게 부드럽고 반응성이 뛰어난 애플리케이션 경험을 제공하는 것은 현대 모바일 앱 개발의 핵심 과제입니다. 사용자가 버튼을 탭하고, 목록을 스크롤하고, 애니메이션을 볼 때 애플리케이션은 멈춤 없이 즉각적으로 반응해야 합니다. 그러나 네트워크에서 데이터를 가져오거나, 디스크에서 파일을 읽거나, 복잡한 계산을 수행하는 작업은 시간이 오래 걸릴 수 있습니다. 만약 이러한 작업들이 사용자 인터페이스(UI)를 그리는 메인 스레드를 차단한다면, 앱은 그대로 '얼어버리고' 말 것입니다. Flutter에서는 이러한 문제를 해결하고 비동기(Asynchronous) 작업을 효율적으로 처리하기 위해 강력한 두 가지 도구, Future와 Stream을 제공합니다.
이 글에서는 Flutter의 비동기 프로그래밍을 지탱하는 이 두 기둥에 대해 깊이 있게 탐구합니다. 단순히 문법을 나열하는 것을 넘어, 각 개념이 왜 필요한지, 내부적으로 어떻게 동작하는지, 그리고 실제 Flutter 애플리케이션에서 어떻게 효과적으로 활용할 수 있는지 구체적인 예제와 함께 살펴보겠습니다. Future
와 async/await
를 통해 단일 비동기 작업의 결과를 우아하게 처리하는 방법부터, Stream
을 사용하여 시간에 따라 발생하는 연속적인 이벤트를 다루는 방법까지, Flutter 비동기 프로그래밍의 정수를 경험하게 될 것입니다.
1부: 미래의 값, Future와 async/await
비동기 프로그래밍의 가장 기본적인 단위는 미래의 특정 시점에 완료될 하나의 작업입니다. 예를 들어, API 서버에 사용자 프로필을 요청하는 작업은 즉시 완료되지 않습니다. 요청을 보내고 서버가 응답하기까지는 수십, 수백 밀리초가 걸릴 수 있습니다. Dart 언어는 이러한 종류의 작업을 표현하기 위해 Future
객체를 사용합니다.
Future란 무엇인가?
Future
는 이름 그대로 '미래'에 완료될 작업의 결과를 담는 객체입니다. 커피숍에서 주문을 하고 진동벨을 받는 상황을 상상해 보세요. 주문 즉시 커피를 받는 것이 아니라, 커피가 준비되면 울릴 '진동벨'을 받습니다. 이 진동벨이 바로 Future
입니다. 진동벨은 지금 당장은 커피가 아니지만, 미래에 커피를 받을 수 있다는 약속입니다.
Future
는 세 가지 상태를 가집니다:
- 미완료 (Uncompleted): 작업이 아직 진행 중인 상태입니다. 커피 주문이 들어가고 바리스타가 커피를 만들고 있는 상태와 같습니다.
- 값으로 완료 (Completed with a value): 작업이 성공적으로 완료되어 결과값을 사용할 수 있는 상태입니다. 진동벨이 울리고 주문한 커피를 받은 상태입니다.
- 오류로 완료 (Completed with an error): 작업 수행 중 문제가 발생하여 실패한 상태입니다. 커피 머신이 고장 나서 주문이 취소되었다는 알림을 받은 상태입니다.
Flutter/Dart에서는 네트워크 요청, 파일 I/O, 데이터베이스 쿼리 등 시간이 걸리는 대부분의 작업이 Future
를 반환합니다.
전통적인 방식: .then() 콜백 체이닝
Future
가 완료되었을 때 특정 코드를 실행하기 위해 .then()
메서드를 사용할 수 있습니다. 이는 콜백(callback) 함수를 등록하는 방식입니다.
// 사용자 데이터를 가져오는 가상의 함수
Future<String> fetchUserData() {
return Future.delayed(Duration(seconds: 2), () => 'John Doe');
}
void main() {
print('사용자 데이터 로딩 시작...');
fetchUserData().then((userData) {
print('받은 데이터: $userData');
}).catchError((error) {
print('에러 발생: $error');
}).whenComplete(() {
print('데이터 로딩 완료.');
});
print('main 함수 종료. (데이터 로딩은 백그라운드에서 계속됨)');
}
위 코드에서 fetchUserData()
는 2초 후에 'John Doe'라는 문자열을 결과로 내놓는 Future
를 반환합니다. .then()
은 Future
가 성공적으로 완료되었을 때, .catchError()
는 오류가 발생했을 때, 그리고 .whenComplete()
는 성공 여부와 관계없이 작업이 끝났을 때 호출됩니다. 이 방식은 여러 비동기 작업을 순차적으로 연결해야 할 때 코드가 깊어지고 복잡해지는 '콜백 지옥(Callback Hell)'을 유발할 수 있습니다.
현대적인 방식: async와 await
Dart는 콜백 지옥 문제를 해결하고 동기 코드처럼 보이는 비동기 코드를 작성할 수 있도록 async
와 await
라는 강력한 키워드를 제공합니다. 이는 'Syntactic Sugar'(문법적 설탕)의 일종으로, 내부적으로는 .then()
과 유사하게 동작하지만 개발자에게는 훨씬 직관적인 코드를 선물합니다.
- async: 함수 선언부 뒤에 붙여 해당 함수가 비동기 함수임을 명시합니다.
async
함수는 항상Future
를 반환합니다. 만약 함수가String
을 반환하면, 실제로는Future<String>
이 반환됩니다. - await:
async
함수 내에서만 사용할 수 있으며,Future
가 완료될 때까지 함수의 실행을 '일시 중지'시킵니다. 작업이 완료되면Future
의 결과값을 반환하고 다음 코드를 실행합니다. 중요한 점은,await
가 앱 전체를 멈추는 것이 아니라 해당async
함수의 실행 흐름만 잠시 멈춘다는 것입니다. 그동안 Flutter의 이벤트 루프는 다른 작업(UI 렌더링, 사용자 입력 처리 등)을 계속 수행합니다.
.then()
예제를 async/await
로 다시 작성해 보겠습니다.
Future<String> fetchUserData() {
// 실제 네트워크 요청이라고 가정
return Future.delayed(Duration(seconds: 2), () => 'John Doe');
}
void printUserData() async { // 1. async 키워드 추가
try {
print('사용자 데이터 로딩 시작...');
// 2. await 키워드로 Future가 완료될 때까지 기다림
String userData = await fetchUserData();
print('받은 데이터: $userData');
} catch (error) {
print('에러 발생: $error');
} finally {
print('데이터 로딩 완료.');
}
}
void main() {
printUserData();
print('main 함수 종료. (printUserData 함수는 백그라운드에서 실행됨)');
}
코드가 훨씬 간결하고 동기적인 코드처럼 읽힙니다. 에러 처리도 익숙한 try-catch
구문을 그대로 사용할 수 있어 가독성과 유지보수성이 크게 향상됩니다.
실용 예제: 네트워크 요청과 FutureBuilder
Flutter 앱에서 Future
의 가장 흔한 사용 사례는 네트워크 요청입니다. http
패키지를 사용하여 JSON 데이터를 가져오고, 이를 UI에 표시하는 방법을 살펴보겠습니다. 이때 FutureBuilder
위젯을 사용하면 Future
의 상태 변화에 따라 UI를 선언적으로 손쉽게 구성할 수 있습니다.
먼저, pubspec.yaml
파일에 http
패키지를 추가합니다.
dependencies:
flutter:
sdk: flutter
http: ^1.2.1 # 최신 버전 확인 후 추가
다음은 데이터를 가져오고 화면을 구성하는 전체 코드입니다.
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
// 1. 데이터를 담을 모델 클래스 정의
class Post {
final int userId;
final int id;
final String title;
final String body;
Post({required this.userId, required this.id, required this.title, required this.body});
// JSON 데이터를 Post 객체로 변환하는 factory 생성자
factory Post.fromJson(Map<String, dynamic> json) {
return Post(
userId: json['userId'],
id: json['id'],
title: json['title'],
body: json['body'],
);
}
}
// 2. 네트워크 요청을 수행하는 async 함수
Future<Post> fetchPost() async {
final response = await http.get(Uri.parse('https://jsonplaceholder.typicode.com/posts/1'));
if (response.statusCode == 200) {
// 요청이 성공하면 JSON을 파싱하여 Post 객체를 반환
return Post.fromJson(jsonDecode(response.body));
} else {
// 요청이 실패하면 예외 발생
throw Exception('Failed to load post');
}
}
// 3. UI 부분
void main() {
runApp(MyApp());
}
class MyApp extends StatefulWidget {
@override
_MyAppState createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
late Future<Post> futurePost;
@override
void initState() {
super.initState();
// initState에서 Future를 한 번만 호출하여 저장
futurePost = fetchPost();
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'FutureBuilder Example',
home: Scaffold(
appBar: AppBar(
title: Text('FutureBuilder Example'),
),
body: Center(
// 4. FutureBuilder 위젯 사용
child: FutureBuilder<Post>(
future: futurePost, // 추적할 Future를 지정
builder: (context, snapshot) {
// snapshot은 Future의 현재 상태와 데이터를 담고 있음
// 상태 1: 데이터가 아직 로딩 중일 때
if (snapshot.connectionState == ConnectionState.waiting) {
return CircularProgressIndicator();
}
// 상태 2: 에러가 발생했을 때
else if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
}
// 상태 3: 데이터 로딩이 성공적으로 완료되었을 때
else if (snapshot.hasData) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
snapshot.data!.title,
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
SizedBox(height: 10),
Text(snapshot.data!.body),
],
),
);
}
// 기타 상태 (보통 표시될 일 없음)
else {
return Text('No data');
}
},
),
),
),
);
}
}
FutureBuilder
는 future
프로퍼티로 받은 Future
의 상태가 변경될 때마다 builder
함수를 다시 호출하여 UI를 갱신합니다. snapshot
객체를 통해 로딩 중(ConnectionState.waiting
), 에러 발생(snapshot.hasError
), 데이터 수신 성공(snapshot.hasData
) 등 다양한 상태에 맞는 위젯을 반환할 수 있어 비동기 데이터에 기반한 UI를 매우 효율적으로 만들 수 있습니다.
중요: Future
를 build
메서드 안에서 직접 호출하면 setState
등으로 위젯이 리빌드될 때마다 네트워크 요청이 반복적으로 발생할 수 있습니다. 예제처럼 initState
에서 Future
를 호출하여 변수에 저장해두고, FutureBuilder
는 그 변수를 참조하도록 하는 것이 올바른 패턴입니다.
2부: 이벤트의 강, Stream
Future
가 단 한 번의 비동기 작업 결과를 나타낸다면, Stream은 시간에 따라 연속적으로 발생하는 여러 비동기 이벤트의 흐름(sequence)을 나타냅니다. 수도꼭지에서 계속 흘러나오는 물줄기나, 유튜브 라이브 방송을 생각하면 이해하기 쉽습니다. 데이터가 한 번에 '완료'되는 것이 아니라, 시간이 지남에 따라 여러 번 전달될 수 있습니다.
Stream은 다음과 같은 상황에서 유용합니다:
- 사용자의 연속적인 입력 처리 (예: 검색창 타이핑)
- 파일 다운로드 진행 상태 업데이트
- 웹소켓을 통한 실시간 데이터 수신
- 센서 데이터(가속도, 자이로스코프 등)의 지속적인 감지
- Firebase와 같은 실시간 데이터베이스의 변경 사항 구독
Stream의 구성 요소
Stream은 세 가지 종류의 이벤트를 전달할 수 있습니다.
- 데이터 이벤트 (Data Event): 스트림을 통해 전달되는 실제 값입니다. 물줄기에서 흘러나오는 물방울 하나하나에 해당합니다.
- 에러 이벤트 (Error Event): 스트림에서 데이터 생성 또는 전달 중 오류가 발생했음을 알립니다. 수도관이 터져 녹물이 나오는 상황과 비슷합니다.
- 완료 이벤트 (Done Event): 스트림이 모든 데이터를 성공적으로 보내고 닫혔음을 알립니다. 수도꼭지를 잠그는 것과 같습니다.
Stream 생성하기
Stream을 만드는 방법은 여러 가지가 있습니다. 대표적인 두 가지는 async*
생성자 함수와 StreamController
를 사용하는 것입니다.
1. async*와 yield를 이용한 생성
async*
(async star)는 스트림을 생성하는 비동기 생성자 함수를 의미합니다. 이 함수 내에서는 yield
키워드를 사용하여 데이터를 스트림으로 하나씩 방출(emit)할 수 있습니다.
// 1초마다 1부터 max까지 숫자를 방출하는 스트림을 생성
Stream<int> countStream(int max) async* {
for (int i = 1; i <= max; i++) {
// 1초 대기. 이 동안 다른 작업 수행 가능
await Future.delayed(Duration(seconds: 1));
// 에러 이벤트 예시
if (i == 4) {
yield* Stream.error(Exception('의도된 에러!'));
}
// yield를 통해 데이터를 스트림으로 보냄
yield i;
}
}
void main() {
Stream<int> stream = countStream(5);
// 스트림 구독 및 이벤트 처리
stream.listen(
(data) => print('데이터 수신: $data'),
onError: (err) => print('에러 발생: $err'),
onDone: () => print('스트림 완료!'),
cancelOnError: false // 에러 발생 시 구독을 취소할지 여부
);
}
// 출력:
// 데이터 수신: 1
// 데이터 수신: 2
// 데이터 수신: 3
// 에러 발생: Exception: 의도된 에러!
// 데이터 수신: 5
// 스트림 완료!
async*
함수는 코드가 동기적인 `for` 루프처럼 보여 매우 직관적입니다. `yield*`를 사용하면 다른 스트림의 모든 이벤트를 현재 스트림으로 전달할 수도 있습니다.
2. StreamController를 이용한 생성
StreamController
는 보다 직접적으로 스트림을 제어할 수 있는 클래스입니다. 외부 이벤트나 사용자 입력에 따라 프로그래매틱하게 데이터를 스트림에 추가해야 할 때 유용합니다. 컨트롤러는 데이터를 넣는 입구(sink
)와 데이터가 나오는 출구(stream
)를 제공합니다.
import 'dart:async';
void main() {
// 정수형 데이터를 다루는 StreamController 생성
final controller = StreamController<int>();
// 1. stream 출구를 통해 이벤트를 구독
final subscription = controller.stream.listen(
(data) => print('데이터 수신: $data'),
onDone: () => print('컨트롤러 닫힘'),
);
// 2. sink 입구를 통해 데이터를 추가
controller.sink.add(1);
controller.sink.add(2);
controller.sink.add(3);
// 스트림에 더 이상 데이터가 없음을 알리고 리소스를 해제
// onDone 콜백이 호출됨
controller.close();
// 중요: close()된 컨트롤러에 데이터를 추가하면 에러 발생
// controller.sink.add(4); // Bad state: Cannot add event after closing
}
중요: StreamController
는 다 사용한 후에 반드시 close()
를 호출하여 메모리 누수를 방지해야 합니다. `StatefulWidget`에서는 주로 dispose()
메서드 안에서 이 작업을 수행합니다.
Broadcast Stream vs. Single-subscription Stream
기본적으로 스트림은 '단일 구독(Single-subscription)' 스트림입니다. 한 번에 오직 하나의 리스너(listener)만 가질 수 있으며, 첫 번째 데이터가 생성되기 전까지 구독해야 합니다. 마치 이어폰 잭처럼 한 명만 들을 수 있는 것과 같습니다.
반면, 여러 리스너가 동시에 스트림을 구독해야 하는 경우가 있습니다. 이때는 '브로드캐스트(Broadcast)' 스트림을 사용합니다. 라디오 방송처럼 여러 사람이 동시에 들을 수 있습니다. StreamController.broadcast()
생성자를 사용하거나 기존 스트림에 asBroadcastStream()
메서드를 호출하여 만들 수 있습니다.
final controller = StreamController<int>.broadcast();
// 여러 리스너가 구독 가능
controller.stream.listen((data) => print('리스너 1: $data'));
controller.stream.listen((data) => print('리스너 2: $data'));
controller.sink.add(100);
// 출력:
// 리스너 1: 100
// 리스너 2: 100
실용 예제: 실시간 시계와 StreamBuilder
FutureBuilder
와 마찬가지로 Flutter는 스트림을 위한 StreamBuilder
위젯을 제공합니다. 스트림에서 새로운 데이터가 방출될 때마다 UI를 자동으로 갱신해주어 실시간 UI를 만드는 데 매우 유용합니다. 1초마다 현재 시간을 업데이트하는 간단한 시계 앱을 만들어 보겠습니다.
import 'package:flutter/material.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
// 1초마다 현재 시간을 방출하는 스트림 생성
Stream<DateTime> clockStream() {
return Stream.periodic(Duration(seconds: 1), (_) => DateTime.now());
}
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: Text('StreamBuilder Clock')),
body: Center(
child: StreamBuilder<DateTime>(
stream: clockStream(), // 추적할 스트림 지정
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Text('시간을 기다리는 중...', style: TextStyle(fontSize: 24));
}
if (snapshot.hasError) {
return Text('에러 발생!', style: TextStyle(fontSize: 24, color: Colors.red));
}
if (snapshot.hasData) {
// 데이터가 있을 때마다 UI 업데이트
final time = snapshot.data!;
return Text(
'${time.hour}:${time.minute.toString().padLeft(2, '0')}:${time.second.toString().padLeft(2, '0')}',
style: TextStyle(fontSize: 48, fontWeight: FontWeight.bold),
);
}
return Container();
},
),
),
),
);
}
}
Stream.periodic
은 지정된 시간 간격으로 이벤트를 생성하는 편리한 생성자입니다. StreamBuilder
는 이 스트림을 구독하고, 새로운 DateTime
객체가 도착할 때마다 builder
함수를 실행하여 화면의 시간을 갱신합니다. 별도의 setState
호출이나 타이머 관리가 필요 없어 코드가 매우 깔끔하고 선언적입니다.
Stream 변환 (Transforming Streams)
Stream의 진정한 강력함은 파이프라인처럼 데이터를 가공하고 변환하는 능력에 있습니다. map
, where
, take
등 다양한 고차 함수를 사용하여 스트림을 원하는 형태로 바꿀 수 있습니다.
- map: 각 데이터 이벤트를 다른 형태로 변환합니다. (예:
Stream<int>
->Stream<String>
) - where: 특정 조건에 맞는 데이터 이벤트만 통과시킵니다. (필터링)
- take: 지정된 개수만큼의 데이터 이벤트만 받고 스트림을 종료합니다.
- skip: 처음 몇 개의 데이터 이벤트를 무시합니다.
- debounceTime (from rxdart): 특정 시간 동안 새로운 이벤트가 없으면 마지막 이벤트를 방출합니다. (검색어 자동완성에 유용)
void main() {
Stream.fromIterable([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
.where((number) => number % 2 == 0) // 짝수만 필터링
.map((evenNumber) => '짝수 발견: $evenNumber') // 문자열로 변환
.take(3) // 처음 3개만 취함
.listen(print);
}
// 출력:
// 짝수 발견: 2
// 짝수 발견: 4
// 짝수 발견: 6
이러한 변환 메서드들은 연쇄적으로 호출(chaining)할 수 있어 복잡한 비동기 데이터 처리 로직을 선언적이고 읽기 쉽게 구성할 수 있습니다.
3부: 고급 주제 및 모범 사례
Future
와 Stream
의 기본을 이해했다면, 이제 더 효율적이고 안정적인 비동기 코드를 작성하기 위한 몇 가지 고급 주제와 모범 사례를 알아볼 시간입니다.
Future vs. Stream: 언제 무엇을 써야 할까?
두 개념은 비슷해 보이지만 사용 목적이 명확히 다릅니다. 선택은 간단합니다. "하나의 결과가 필요한가, 아니면 여러 개의 결과가 필요한가?"
- Future를 사용해야 할 때:
- API 호출로 사용자 정보 가져오기
- 데이터베이스에서 단일 레코드 읽기
- 파일을 디스크에 저장하고 성공/실패 여부 확인하기
- 사용자가 로그인 버튼을 눌렀을 때 인증 요청 보내기
- Stream을 사용해야 할 때:
- 실시간 채팅 메시지 수신
- 주식 가격 변동 데이터 표시
- GPS 위치 정보의 지속적인 업데이트
- 다운로드 진행률(0%, 10%, ..., 100%) 표시
자원 관리: 구독 취소와 컨트롤러 닫기
비동기 프로그래밍에서 가장 흔한 실수 중 하나는 자원 누수(resource leak)입니다. 특히 Stream의 경우, 더 이상 필요 없는 구독을 취소하지 않으면 메모리 누수로 이어질 수 있습니다.
- StreamSubscription.cancel():
stream.listen()
을 호출하면StreamSubscription
객체가 반환됩니다. 이 객체의cancel()
메서드를 호출하여 스트림 구독을 중지할 수 있습니다.StatefulWidget
에서는 보통dispose()
메서드 내에서 구독을 취소합니다. - StreamController.close():
StreamController
를 직접 생성했다면, 더 이상 사용하지 않을 때 반드시close()
를 호출해야 합니다. 이 역시dispose()
메서드가 적절한 위치입니다.
class MyWidget extends StatefulWidget {
@override
_MyWidgetState createState() => _MyWidgetState();
}
class _MyWidgetState extends State<MyWidget> {
late StreamSubscription _subscription;
final _controller = StreamController<int>.broadcast();
@override
void initState() {
super.initState();
_subscription = _controller.stream.listen((data) {
// 데이터 처리
});
}
@override
void dispose() {
// 위젯이 파괴될 때 자원을 해제
_subscription.cancel(); // 구독 취소
_controller.close(); // 컨트롤러 닫기
super.dispose();
}
@override
Widget build(BuildContext context) {
// ...
return Container();
}
}
CPU 집약적 작업과 Isolate
async/await
는 UI 스레드를 차단하지 않고 I/O(네트워크, 파일) 작업을 기다리는 데 효과적입니다. 하지만 매우 복잡한 계산(예: 이미지 처리, 대용량 데이터 파싱, 암호화)과 같은 CPU 집약적인 작업을 동일한 스레드에서 수행하면 I/O 작업과 마찬가지로 UI가 멈추게 됩니다.
이러한 문제는 Dart의 Isolate를 사용하여 해결할 수 있습니다. Isolate는 자체적인 메모리와 이벤트 루프를 가진 독립적인 실행 스레드와 같습니다. Flutter에서는 compute
함수를 사용하여 복잡한 계산을 백그라운드 Isolate로 쉽게 보낼 수 있습니다.
import 'package:flutter/foundation.dart';
// CPU를 많이 사용하는 무거운 함수
int veryComplexCalculation(int value) {
int result = 0;
for (var i = 0; i < value * 100000000; i++) {
result += i;
result -= i;
}
return result + value;
}
void main() async {
print('계산 시작...');
// compute 함수는 백그라운드 Isolate에서 함수를 실행하고
// 그 결과를 Future로 반환합니다.
int result = await compute(veryComplexCalculation, 10);
print('계산 결과: $result'); // UI 스레드는 멈추지 않음
}
앱의 반응성이 중요하다면, 수십 밀리초 이상 걸릴 것으로 예상되는 모든 순수 계산 작업은 compute
를 사용하여 백그라운드로 보내는 것을 고려해야 합니다.
결론
Future
와 Stream
은 Flutter에서 반응형 애플리케이션을 구축하기 위한 필수적인 도구입니다. Future
와 async/await
는 단일 비동기 작업을 동기 코드처럼 명확하고 간결하게 처리할 수 있게 해주며, Stream
은 시간에 따라 발생하는 연속적인 이벤트를 효과적으로 다룰 수 있는 강력한 패러다임을 제공합니다. FutureBuilder
와 StreamBuilder
를 활용하면 이러한 비동기 데이터 흐름을 UI에 손쉽게 연결하여 선언적이고 관리하기 쉬운 코드를 작성할 수 있습니다.
이 글에서 다룬 개념들을 바탕으로 실제 프로젝트에 비동기 로직을 적용하고, 에러 처리와 자원 관리에 유의하며 코드를 작성한다면, 사용자에게는 부드러운 경험을, 개발자에게는 즐거운 개발 경험을 선사하는 훌륭한 Flutter 애플리케이션을 만들 수 있을 것입니다.
0 개의 댓글:
Post a Comment