Flutter 에서의 비동기 프로그래밍 Future, async/await
맨 처음 iOS를 공부하면서 가장 어렵게 느껴졌던 개념이 비동기 프로그래밍이었다.
내용만 듣고 이걸 왜 쓰는 거지?라는 의문이 들어서 대충 흘려보냈다가 프로젝트에 들어가면서 그 중요성에 대해서 알게 되었다.
그렇게 비동기 프로그래밍의 중요성을 알게 되었고 Flutter를 시작하면서도 다행히 iOS와 크게 다른 점이 없어서 적응할 수 있었지만,
Flutter, Dart에 특화된 비동기 프로그래밍에 대해서는 자세히 알지 못해서 공부하면서 알아보려고 한다.
비동기 프로그래밍 이란?
비동기
앞에서 행하여진 사상(事象)이나 연산이 완료되었다는 신호를 받고 비로소 특정한 사상이나 연상이 시작되는 방식. [네이버 국어사전]
즉 비동기 프로그래밍이란 특정 코드의 처리가 완료되기 전, 처리하는 도중에도 아래로 계속 내려가며 수행하는 것이다.
동기 vs 비동기
- 동기 처리 : 태스크를 순차적으로 실행하며, 한 작업이 완료될 때까지 다음 작업을 대기
- 예 : 맛집에서 줄을 서서 차례대로 주문하기
- 비동기 처리 : 태스크의 완료를 기다리지 않고 다음 작업을 진행
- 예 : 카페에서 주문 후 진동벨을 받고 자리에더 다른일 하기
https://dart.dev/libraries/async/async-await
코드랩의 내용에 따르면 비동기 프로그래밍은 다음과 같은 경우에 많이 쓰인다고 한다.
- 네트워크를 통해 데이터 가져오기
- 데이터베이스에 쓰기
- 파일에서 데이터 입출력
- 이미지 다운로드
- 위치 정보 가져오기
- 블루투스 통신
Future란?
- Future<T> 인스턴스는 T 타입의 값을 생성한다.
- Future가 사용 가능한 값을 생성하지 않으면 Future의 유형은 Future<void>로 한다
- Future를 반환하는 함수를 호출하면 함수는 완료해야 할 작업을 대기열에 추가한다.
- Future 연산이 완료되면 Future는 값 또는 오류와 함께 완료된다.
예를 들어 Future<int> 라는 선언이 있을 때 지금은 아무 동작도 하지 않지만
나중에 사용을 하게 되면 int의 값이나 error 가 나오게 될 것이다.
error가 나올 경우를 대비해서 catchError 메서드를 같이 사용해 주는 게 좋다.
우선 Future를 기본적으로 사용하는 예시를 보면
Future<void> fetchUserOrder() {
// 이 함수가 다른 서비스나 데이터베이스에서 사용자 정보를 가져온다고 가정해 보겠습니다.
return Future.delayed(const Duration(seconds: 2), () => print('Large Latte'));
}
void main() {
fetchUserOrder();
print('Fetching user order...');
}
fetchUserOrder() 라는 함수를 만들어서 main 에서 실행한다고 가정해 보자.
이렇게 되면 어느 부분이 먼저 출력될까?
동기프로그래밍이라면 fetchUserOrder 가 점유하고 있기 때문에 Large Latte, Fetching user order... 이 순차적으로 출력될 것이다.
하지만 이번엔 Future를 적용했기 때문에 비동기 프로그래밍이므로 fetchUserOrder() 가 완료되기 전에 Fetching user order... 이 먼저 출력된 후 Large Latte가 출력될 것이다.
여기서 만약 오류가 발생한다면 어떻게 될까?
오류를 대비하는 방법도 코드 예시를 통해 확인할 수 있다.
import 'dart:async';
Future<int> futureNumber() {
// 3초 후 100이 상자에서 나온다
return Future<int>.delayed(Duration(seconds: 3), () {
return 100;
});
// 오류가 발생하는 코드
// return Future<int>.delayed(Duration(seconds: 3), () {
// throw 'Error'!;
//})
}
void main() {
// future 라는 변수에서 미래에(3초 후에) int가 나올 것 이다
Future<int> future = futureNumber();
future.then((val) {
// int가 나오면 해당 값을 출력
print('val: $val');
}).catchError((error) {
// error가 해당 에러를 출력
print('error: $error');
});
print('기다리는 중');
}
futureNumber 함수는 3초가 되기 전까지는 닫혀있다가 3초가 되면 100이 나오며 Future<int> 를 반환해야 한다.
메인 함수에서 future 변수에 해당 함수의 반환 값을 넣어 저장하면 3초 후에 future 는 int로 값이 바뀌는 것이 아니다.
future 는 계속해서 Future<int> 이다. 그렇게 때문에 future 가 100으로 바뀌는 것이 아니다.
그렇다면 어떻게 그 Future<int> 에서 나오는 값을 다룰수 있을까?
그럴 땐 then 함수를 통해서 다룰 수 있다.
위 코드에서 보면 future.then(...) 을 통해서 다루고 있다.
then 내부에는 또 다른 함수가 들어가 있으며 이 함수로 Future<int> 로 부터 나오는 값을 다룰 수 있는 것이다.
then 내부 함수에서 val 에 Future<int> 라는 상자가 열렸을 때 나오는 값이 들어갈 것이므로 val: 100 이 출력된다.
위 코드를 수행하면
기다리는 중, val: 100 의 순서대로 출력되고 그 외의 값이 생기게 되면 catchError 를 통해서 에러값이 나오게 될 것이다.
그래서 Future 는?
예를 들어서 main 함수의 가장 마지막 줄에 print 가 있고 해당 코드가 1000줄의 코드라고 가정해 보자.
동기적으로 처리했을 경우 Future<int> 에서 값이 나올 때까지 1000줄의 코드는 동작하지 않고 정지해 있을 것이다.
하지만 비동기로 처리를 한다면 어떻게 될까? Future<int>에서 값이 나오지 않아도 계속해서 동작해서 계속해서 print 문이 출력될 것이다.
이것이 비동기 함수를 사용하는 가장 큰 이유이다.
Future 에 대한 공식문서
https://api.dart.dev/dart-async/Future-class.html
async / await 이란?
async 와 await 또한 Dart에서 비동기 처리를 위한 것으로 Future 를 조금 더 용이하게 다루기 위해서 사용된다.
비동기 함수를 정의하고 그 결과를 사용할 수 있는 선언적 방법을 제공하며, async 와 await 을 사용할 때는 다음 두가지 기본 조건을 명심해야 한다.
비동기 함수를 정의하려면 함수의 본문 앞에 async 를 추가해야 한다.
void main() async {
...
}
Future<void> main() async {
...
}
awit 키워드는 async 함수에만 작동한다.
print(await doSomeThing());
함수의 본문 앞에 async를 추가해 주었다면
이제 async 함수가 생겼으므로 await 키워드를 사용하여 나중에 완료될 때까지 기다리게 할 수 있다.
동기식 함수의 경우
String createOrderMessage() {
var order = fetchUserOrder();
return 'Your order is: $order';
}
Future<String> fetchUserOrder() =>
// 이 기능이 더 복잡하고 느리다고 가정해보자
Future.delayed(
const Duration(seconds: 2),
() => 'Large Latte',
);
void main() {
print('Fetching user order...');
print(createOrderMessage());
}
Fetch user order... Your order is: Instance of '_Future<String>' |
비동기 함수의 경우
Future<String> createOrderMessage() async {
var order = await fetchUserOrder();
return 'Your order is: $order';
}
Future<String> fetchUserOrder() =>
// 이 기능이 더 복잡하고 느리다고 가정해보자
Future.delayed(
const Duration(seconds: 2),
() => 'Large Latte',
);
Future<void> main() async {
print('Fetching user order...');
print(await createOrderMessage());
}
Fetching user order ... Your order is: Large Latte |
동기와 비동기의 예제는 크게 세 가지의 포인트가 다르다.
- createOrderMessage() 의 반환 유형이 String 에서 Future<String> 으로 변경 된다.
- async 키워드는 createOrderMessage() 와 main() 의 함수 본문 앞에 추가된다.
- await 키워드는 비동기 함수인 fetchUserOrder() 와 createOrderMessage() 앞에 사용한다.
async 는 함수의 본문 앞에 사용하여 함수를 비동기식으로 표현할 수 있다.
await 은 async 와 짝꿍으로 async 함수 내에서만 작동을 할 수 있다.
async 와 await을 사용한 예제
다음 예제를 보면서 async 함수 내부에서 어떻게 실행되는지 한번 살펴보자
Future<void> printOrderMessage() async {
print('Awaiting user order...');
var order = await fetchUserOrder();
print('Your order is: $order');
}
Future<String> fetchUserOrder() {
// 이 기능이 더 복잡하고 느리다
return Future.delayed(const Duration(seconds: 4), () => 'Large Latte');
}
void main() async {
countSeconds(4);
await printOrderMessage();
}
void countSeconds(int s) {
for (var i = 1; i <= s; i++) {
Future.delayed(Duration(seconds: i), () => print(i));
}
}
이 함수를 실행하게 되면 다음의 결과를 얻게 된다.
Awaiting user order ... 1 2 3 4 Your order is: Large Latte |
그러면 앞의 예제에서 printOrderMessage() 에서
var order = await fetchUserOrder();
print('Awaiting user order...');
두 개의 순서를 바꿔서 실행해 보자.
그렇게 되면 다음의 결과를 갖게 된다.
1 2 3 4 Awaiting user order ... Your order is: Large Latte |
print('Awaiting user order') 가 printOrderMessage() 의 첫 번째 await 키워드 뒤에 나타나므로 출력 타이밍이 바뀌게 되는 것이다.
플러터에서의 활용
실제 플러터에서는 다음과 같이 비동기 함수를 활용해서 유저의 정보를 받아오고
그 데이터를 표현해 줄 수가 있다.
// 사용자 데이터 모델
class User {
final String name;
final String email;
User({required this.name, required this.email});
}
// 사용자 관련 서비스 클래스
class UserService {
// 비동기로 사용자 정보를 가져오는 메서드
Future<User> fetchUser() async {
try {
// 실제로는 여기서 API 호출이 일어난다
// 예시를 위해 2초 지연을 시뮬레이션을 설정
await Future.delayed(const Duration(seconds: 2));
// 서버에서 받아온 데이터를 시뮬레이션
return User(
name: '홍길동',
email: 'hong@example.com'
);
} catch (e) {
// 에러가 발생하면 더 구체적인 에러 메시지와 함께 예외를 던진다
throw Exception('사용자 조회 실패: $e');
}
}
}
class UserProfileWidget extends StatelessWidget {
final UserService userService;
const UserProfileWidget({required this.userService, super.key});
@override
Widget build(BuildContext context) {
return FutureBuilder<User>(
// 비동기 데이터를 가져오는 Future를 지정
future: userService.fetchUser(),
builder: (context, snapshot) {
// 데이터를 기다리는 동안 로딩 표시
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(
child: CircularProgressIndicator(),
);
}
// 에러가 발생한 경우 에러 메시지 표시
if (snapshot.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error, color: Colors.red),
const SizedBox(height: 16),
Text(
'오류가 발생했습니다\n${snapshot.error}',
textAlign: TextAlign.center,
),
],
),
);
}
// 데이터가 성공적으로 로드된 경우 사용자 정보 표시
final user = snapshot.data!;
return Card(
margin: const EdgeInsets.all(16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Text('이름: ${user.name}'),
const SizedBox(height: 8),
Text('이메일: ${user.email}'),
],
),
),
);
},
);
}
}
에러 처리(try-catch 구문의 사용)
비동기 작업에서는 다양한 에러가 발생할 수 있다.
그 에러가 어떤 에러인지 알수있으면 해당 에러에 대한 처리가 훨씬 수월해진다.
Future<void> handleAsyncOperation() async {
try {
await riskyOperation();
} on NetworkException catch (e) {
print('네트워크 오류: $e');
} on TimeoutException catch (e) {
print('시간 초과: $e');
} catch (e) {
print('예상치 못한 오류: $e');
} finally {
print('작업 완료');
}
}
안정적인 비동기 함수 설계 방법
타임아웃 설정
긴 작업이 실행된 경우 무한 대기를 방지하기 위해서 타임아웃을 설정해 줄 수 있다.
Future<void> fetchWithTimeout() async {
try {
await fetchData().timeout(
const Duration(seconds: 5),
onTimeout: () => throw TimeoutException('요청 시간 초과'),
);
} catch (e) {
print('오류: $e');
}
}
취소 가능한 작업
사용자가 화면을 벗어나가거나 작업을 중단할 수 있을 경우에 작업을 취소할 수 있어야 한다.
Future<void> cancelableOperation() async {
final completer = Completer<void>();
// 작업 취소 가능하도록 설정
final operation = CancelableOperation.fromFuture(
longRunningOperation(),
onCancel: () => print('작업이 취소되었습니다'),
);
try {
await operation.value;
} catch (e) {
print('오류: $e');
}
}
동시 실행 제어
여러 비동기 함수를 실행해야 되는경우 다음과 같이 관리할 수 있다.
Future<void> loadDashboardData() async {
try {
// 여러 비동기 작업을 동시에 실행
final results = await Future.wait([
getUserProfile(), // 사용자 프로필
getRecentActivity(), // 최근 활동
getNotifications(), // 알림
], eagerError: true); // 첫 번째 에러 발생 시 즉시 중단
// 결과 처리
final profile = results[0];
final activities = results[1];
final notifications = results[2];
// UI 업데이트
updateDashboard(
profile: profile,
activities: activities,
notifications: notifications,
);
} catch (e) {
// 에러 처리
handleError(e);
}
}
결론
비동기 프로그래밍은 앱 개발에 있어서 필수적인 요소이다.
Flutter와 Dart 에서는 Future, async/await, Stream 등의 도구들을 사용하여 효율적이면서 반응성이 좋은 앱을 만들 수 있다.
이러한 개념들을 잘 이해하고 적용하면 유저의 입장에서 사용성이 크게 향상될 수 있을 것이다.
특히 네트워크 통신이나 파일 처리와 같이 시간이 걸리는 작업에 대해서 더 효율적으로 관리할 수 있게 된다.
참고
https://dart.dev/libraries/async/async-await
https://velog.io/@jintak0401/FlutterDart-%EC%97%90%EC%84%9C%EC%9D%98-Future-asyncawait