Flutter 앱을 만들다 보면 서버 API 요청, DB 조회, 파일 읽기, 타이머 같은 작업을 자주 처리한다.
이런 작업들은 결과가 바로 나오는 것이 아니라 시간이 걸릴 수 있다.
예를 들면 다음과 같다.
시간이 걸리는 작업
- 서버에서 데이터 가져오기
- Firebase 로그인 처리
- 로컬 DB 조회
- 파일 읽기 / 저장
- 일정 시간 기다리기
- Stream으로 실시간 데이터 받기
Dart에서는 이런 비동기 작업을 처리하기 위해 Future, async, await, Stream을 사용한다.
이번 글에서는 다음 내용을 정리한다.
1. Future의 개념
2. 동기와 비동기 방식
3. 이벤트 루프
4. async와 await
5. try / catch
6. 앱에서 Future 출력하기
7. Stream과 StreamBuilder
Future는 나중에 값이 들어올 것이라는 약속이다.
지금 당장 값이 있는 것은 아니지만, 시간이 지난 뒤 결과를 받을 수 있다.
Future<String> // 나중에 String을 받음
Future<int> // 나중에 int를 받음
Future<double> // 나중에 double을 받음
예를 들어 서버에서 사용자 이름을 받아온다고 생각해보자.
Future<String> fetchUserName() {
return Future.delayed(
const Duration(seconds: 2),
() => 'Kim',
);
}
이 함수는 바로 "Kim"을 반환하지 않는다.
2초 뒤에 "Kim"이라는 값을 반환하는 Future<String>을 반환한다.
fetchUserName() 호출
|
v
Future<String> 반환
|
v
2초 대기
|
v
"Kim" 결과 반환
Future는 두 가지 결과를 가질 수 있다.
성공
- 데이터 반환
실패
- 에러 반환
즉, Future는 “나중에 성공 데이터 또는 에러를 받을 수 있는 비동기 결과”라고 이해하면 된다.
Dart에서 코드는 동기 방식 또는 비동기 방식으로 실행될 수 있다.
동기 방식은 작업이 순서대로 진행된다.
현재 작업이 끝나야 다음 작업으로 넘어갈 수 있다.
void main() {
print('1번 작업');
print('2번 작업');
print('3번 작업');
}
실행 결과는 다음과 같다.
1번 작업
2번 작업
3번 작업
동기 방식의 흐름은 다음과 같다.
1번 작업 실행
|
v
1번 작업 완료
|
v
2번 작업 실행
|
v
2번 작업 완료
|
v
3번 작업 실행
비동기 방식은 시간이 걸리는 작업을 기다리는 동안 다른 작업을 먼저 실행할 수 있다.
void main() {
print('1번 작업');
Future.delayed(
const Duration(seconds: 2),
() {
print('2번 작업');
},
);
print('3번 작업');
}
실행 결과는 다음과 같다.
1번 작업
3번 작업
2번 작업
2번 작업은 2초 뒤에 실행되기 때문에, 그동안 3번 작업이 먼저 실행된다.
1번 작업 실행
|
v
2번 작업 예약
|
v
3번 작업 실행
|
v
2초 뒤 2번 작업 실행
Dart는 이벤트 루프를 통해 비동기 작업을 처리한다.
이벤트 루프는 앱이 실행되는 동안 계속 이벤트를 확인하고 처리한다.
이벤트 큐
- 처리해야 할 이벤트들이 대기하는 공간
이벤트 루프
- 이벤트 큐에 있는 작업을 하나씩 꺼내 실행하는 구조
Future 관련 비동기 작업도 이벤트로 처리된다.
흐름은 다음과 같다.
Future 비동기 작업 시작
|
v
시간이 걸리는 작업은 따로 처리
|
v
작업 완료
|
v
결과 또는 에러를 이벤트 큐에 추가
|
v
이벤트 루프가 결과 처리
예를 들어 서버 요청을 보낸다고 하면 다음처럼 이해할 수 있다.
서버 요청 시작
|
v
앱은 멈추지 않고 다른 UI 처리 가능
|
v
서버 응답 도착
|
v
이벤트 큐에 결과 추가
|
v
Dart가 결과 처리
비동기 처리를 사용하면 앱이 멈추지 않고 사용자 입력이나 화면 업데이트를 계속 처리할 수 있다.
async와 await는 Future를 더 읽기 쉽게 다루기 위한 문법이다.
async는 함수가 비동기 함수라는 것을 표시한다.
async가 붙은 함수는 항상 Future를 반환한다.
Future<String> fetchData() async {
return Future.delayed(
const Duration(seconds: 2),
() => 'Data',
);
}
위 함수는 2초 뒤에 "Data"를 반환하는 Future<String> 함수다.
await는 Future가 완료될 때까지 기다렸다가 결과값을 꺼낸다.
await는 반드시 async 함수 안에서 사용한다.
Future<String> fetchUser() async {
String userName = await Future.delayed(
const Duration(seconds: 2),
() => 'I am Groot',
);
return userName;
}
void main() async {
String name = await fetchUser();
print(name);
}
흐름은 다음과 같다.
fetchUser() 호출
|
v
Future.delayed 실행
|
v
await로 2초 대기
|
v
"I am Groot" 결과 받기
|
v
print(name)
먼저 await를 사용하지 않은 코드를 보자.
String createOrderMessage() {
var order = fetchUserOrder();
return 'Your order is: $order';
}
Future<String> fetchUserOrder() {
return Future.delayed(
const Duration(seconds: 2),
() => 'Large Latte',
);
}
void main() {
print('Fetching user order...');
print(createOrderMessage());
}
실행 결과는 다음과 같다.
Fetching user order...
Your order is: Instance of '_Future<String>'
이유는 fetchUserOrder()가 실제 주문 이름을 바로 반환하는 것이 아니라 Future<String>을 반환하기 때문이다.
fetchUserOrder()
|
v
Future<String> 반환
|
v
아직 "Large Latte" 값은 준비되지 않음
|
v
문자열에 Future 객체가 출력됨
즉, await를 사용하지 않으면 Future 안의 실제 값을 꺼내지 못한다.
이번에는 await를 사용해서 Future가 끝날 때까지 기다려보자.
Future<String> createOrderMessage() async {
print('Start');
var order = await fetchUserOrder();
return 'Your order is: $order';
}
Future<String> fetchUserOrder() {
return Future.delayed(
const Duration(seconds: 2),
() => 'Large Latte',
);
}
void main() async {
print('Fetching user order...');
print(await createOrderMessage());
}
실행 결과는 다음과 같다.
Fetching user order...
Start
Your order is: Large Latte
흐름은 다음과 같다.
main() 실행
|
v
"Fetching user order..." 출력
|
v
createOrderMessage() 호출
|
v
"Start" 출력
|
v
await fetchUserOrder()
|
v
2초 대기
|
v
"Large Latte" 결과 받기
|
v
"Your order is: Large Latte" 출력
await를 사용하면 Future<String> 자체가 아니라 Future가 완료된 뒤의 String 값을 사용할 수 있다.
async와 await를 사용하는 이유는 다음과 같다.
async / await를 쓰는 이유
- 비동기 코드를 동기 코드처럼 읽기 쉽게 작성 가능
- Future 결과를 쉽게 꺼낼 수 있음
- try / catch로 에러 처리하기 쉬움
- 서버 요청, DB 조회 같은 작업을 깔끔하게 처리 가능
예시는 다음과 같다.
void main() async {
print('Before the Future');
await Future(() {
print('Running the Future');
}).then((_) {
print('Future is complete');
});
print('After the Future');
}
실행 결과는 다음과 같다.
Before the Future
Running the Future
Future is complete
After the Future
await가 있기 때문에 Future 작업이 완료된 뒤에 "After the Future"가 출력된다.
비동기 작업은 실패할 수 있다.
예를 들어 서버 요청 실패, 파일 읽기 실패, 잘못된 인덱스 접근 등이 발생할 수 있다.
이런 오류를 처리할 때 try / catch를 사용한다.
try
- 예외가 발생할 가능성이 있는 코드 작성
catch
- try 블록에서 발생한 예외 처리
void main() {
try {
List<int> numbers = [1, 2, 3];
print(numbers[3]);
} catch (e) {
print('숫자가 존재하지 않습니다.');
}
}
numbers에는 index 0, 1, 2만 존재한다.
그런데 numbers[3]에 접근했기 때문에 예외가 발생한다.
예외가 발생하면 catch 블록이 실행된다.
numbers[3] 접근
|
v
예외 발생
|
v
catch 블록 실행
|
v
"숫자가 존재하지 않습니다." 출력
await를 사용할 때도 try / catch로 에러를 처리할 수 있다.
void main() async {
try {
String data = await fetchData();
print(data);
} catch (error) {
print('데이터 처리 중에 에러가 발생했습니다.');
}
}
Future<String> fetchData() {
return Future.delayed(
const Duration(seconds: 2),
() {
return 'Data';
},
);
}
성공하면 Data가 출력된다.
만약 fetchData() 안에서 에러가 발생하면 catch 블록이 실행된다.
fetchData() 실행
|
v
성공
|
v
Data 출력
fetchData() 실행
|
v
실패
|
v
catch 블록 실행
실제 앱에서는 서버 API 요청을 할 때 자주 사용한다.
Future<void> loadUser() async {
try {
final user = await userRepository.getUser();
print(user.name);
} catch (e) {
print('유저 정보를 불러오지 못했습니다.');
}
}
Flutter 화면에서 Future 결과를 보여줄 때는 보통 FutureBuilder를 사용할 수 있다.
FutureBuilder는 Future의 상태에 따라 화면을 다르게 그릴 수 있는 위젯이다.
FutureBuilder 상태
- 대기 중
- 성공
- 실패
Future<String> fetchUserName() {
return Future.delayed(
const Duration(seconds: 2),
() => 'Kim',
);
}
class FutureExampleScreen extends StatelessWidget {
const FutureExampleScreen({super.key});
Widget build(BuildContext context) {
return FutureBuilder<String>(
future: fetchUserName(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
}
if (snapshot.hasError) {
return const Text('에러가 발생했습니다.');
}
final name = snapshot.data ?? '';
return Text('사용자 이름: $name');
},
);
}
}
흐름은 다음과 같다.
Future 실행
|
v
로딩 중이면 Progress 표시
|
v
성공하면 데이터 표시
|
v
실패하면 에러 UI 표시
간단한 예제에서는 FutureBuilder를 사용할 수 있다.
다만 실제 MVVM 구조에서는 ViewModel에서 Future 결과를 처리하고, 화면은 상태만 보여주는 방식도 많이 사용한다.
Future는 한 번만 결과를 받는 비동기 작업에 사용한다.
반면 Stream은 여러 번 들어오는 비동기 데이터에 사용한다.
Future<int> // 기다렸다가 한 번 int를 받음
Stream<int> // 기다리면서 여러 번 int를 받음
차이는 다음과 같다.
Future
- 결과가 한 번만 옴
- 서버 API 요청
- 파일 읽기
- 로그인 처리
Stream
- 결과가 여러 번 옴
- 실시간 채팅
- 타이머
- Firebase 실시간 데이터
- 위치 정보 업데이트
Stream<int> countStream() async* {
for (int i = 1; i <= 5; i++) {
await Future.delayed(
const Duration(seconds: 1),
);
yield i;
}
}
이 Stream은 1초마다 숫자를 하나씩 보낸다.
1초 뒤 1
1초 뒤 2
1초 뒤 3
1초 뒤 4
1초 뒤 5
StreamBuilder는 Flutter에서 제공하는 위젯이다.
Stream 데이터가 새로 들어올 때마다 builder 함수가 다시 호출되고 화면이 갱신된다.
Stream
|
v
새 데이터 emit
|
v
StreamBuilder builder 호출
|
v
화면 다시 그림
class StreamExampleScreen extends StatelessWidget {
const StreamExampleScreen({super.key});
Stream<int> countStream() async* {
for (int i = 1; i <= 5; i++) {
await Future.delayed(
const Duration(seconds: 1),
);
yield i;
}
}
Widget build(BuildContext context) {
return StreamBuilder<int>(
stream: countStream(),
initialData: 0,
builder: (context, snapshot) {
if (snapshot.hasError) {
return const Text('에러가 발생했습니다.');
}
final count = snapshot.data ?? 0;
return Text('count: $count');
},
);
}
}
StreamBuilder의 주요 속성은 다음과 같다.
stream
- 수신하고 반응할 Stream
initialData
- Stream에서 첫 데이터가 오기 전 사용할 초기값
builder
- Stream 상태와 데이터에 따라 UI를 만드는 함수
StreamBuilder는 다음과 같은 상황에서 사용하기 좋다.
StreamBuilder 사용 상황
- 실시간 채팅 메시지
- 타이머
- Firebase 실시간 데이터
- 센서 데이터
- 위치 정보 업데이트
- 계속 바뀌는 데이터 표시
Future와 Stream은 둘 다 비동기 데이터를 다루지만 목적이 다르다.
Future
- 결과를 한 번만 받음
- API 요청, 로그인, 파일 읽기
Stream
- 결과를 여러 번 받음
- 실시간 데이터, 타이머, 채팅, 위치 업데이트
간단히 구분하면 다음과 같다.
한 번만 결과를 받으면 된다
-> Future
계속 들어오는 데이터를 받아야 한다
-> Stream
예시는 다음과 같다.
서버에서 사용자 정보 한 번 가져오기
-> Future
Firebase 채팅 메시지를 실시간으로 받기
-> Stream
로그인 요청 처리
-> Future
타이머 숫자 계속 표시
-> Stream
Dart와 Flutter에서 비동기 처리는 앱이 멈추지 않게 하기 위해 중요하다.
핵심은 다음과 같다.
Future
- 나중에 한 번 결과가 들어오는 비동기 값
- 성공하면 데이터 반환
- 실패하면 에러 반환
async
- 비동기 함수임을 표시
- Future를 반환
await
- Future가 완료될 때까지 기다렸다가 실제 값을 꺼냄
try / catch
- 비동기 작업 중 발생한 에러 처리
FutureBuilder
- Future 상태에 따라 화면 표시
Stream
- 여러 번 들어오는 비동기 데이터
StreamBuilder
- Stream 데이터가 바뀔 때마다 화면 갱신
마지막으로 정리하면 다음과 같다.
Future<String>
-> 나중에 String 하나를 받음
await Future<String>
-> Future가 완료될 때까지 기다린 뒤 String을 꺼냄
FutureBuilder
-> Future 결과를 화면에 표시
Stream<int>
-> int 값이 여러 번 들어옴
StreamBuilder
-> Stream 값이 들어올 때마다 화면 갱신
즉, Flutter에서 서버 요청처럼 한 번 끝나는 작업은 Future,
실시간 채팅이나 타이머처럼 계속 값이 들어오는 작업은 Stream을 사용하면 된다.