Dart 비동기 프로그래밍: Futures
Dart 비동기 프로그래밍: Futures
Dart가 비동기 프로그래밍을 위해 제공하는 가장 기초적인 API 중 하나가 바로 Future 타입의 객체인 future예요. Dart의 future는 다른 언어의 future나 promise API와 상당히 비슷해요. 이 글에서는 Dart future의 개념과 Future API 사용법, 그리고 future의 상태에 따라 Flutter UI를 비동기로 갱신해 주는 FutureBuilder 위젯까지 다룰게요.
본문
많은 Dart 비동기 API가 future를 반환해요. Dart가 비동기 프로그래밍을 위해 제공하는 가장 기초적인 API 중 하나가 Future 타입의 객체인 future인데, 대체로 다른 언어의 future나 promise API와 매우 유사해요.
이 글은 Dart future의 개념과 Future API 사용법을 다룹니다. 또한 future의 상태에 따라 Flutter UI를 비동기로 갱신하는 데 도움을 주는 FutureBuilder 위젯도 소개할게요.
async-await 같은 Dart 언어 기능 덕분에 Future API를 직접 쓸 일은 없을 수도 있어요. 하지만 Dart 코드를 다루다 보면 future를 만나게 될 가능성은 거의 확실하죠. 그리고 future를 직접 만들거나 Future API를 쓰는 코드를 읽어야 할 때도 있을 거예요.
이 글은 Flutter in Focus 비디오 시리즈 Asynchronous Programming in Dart를 바탕으로 한 두 번째 글이에요. 첫 번째 글인 Isolates and event loops는 백그라운드 작업을 지원하는 Dart의 기초를 다뤘어요. 보고 듣는 것으로 배우는 걸 더 좋아한다면, 이 글의 모든 내용이 다음 비디오에 다 들어 있어요.
새 탭에서 YouTube 재생: "Dart Futures - Flutter in Focus"
future는 데이터를 담는 작은 선물 상자라고 생각하면 돼요. 누군가 당신에게 닫혀 있는 선물 상자를 건네줘요. 잠시 후 상자가 열리면서 그 안에는 값 아니면 오류가 들어 있죠.
그래서 future는 다음의 3가지 상태 중 하나가 될 수 있어요.
- Uncompleted(미완료): 선물 상자가 닫혀 있어요.
- Completed with a value(값과 함께 완료): 상자가 열렸고, 선물(데이터)이 준비됐어요.
- Completed with an error(오류와 함께 완료): 상자가 열렸지만, 뭔가 잘못됐어요.
앞으로 보게 될 코드의 대부분은 이 세 가지 상태를 어떻게 처리할지에 관한 거예요. future를 받으면, 상자가 열릴 때까지 뭘 할지, 값과 함께 열리면 뭘 할지, 오류가 있으면 뭘 할지 결정해야 하죠. 이 1-2-3 패턴을 아주 자주 보게 될 거예요.
Future의 3가지 상태
아래 그림의 이벤트 루프(event loop)를 기억하실지 모르겠어요. 이벤트 루프에 대한 글에서 본 그대로예요. future에 대해 알아두면 좋은 점은, future가 사실은 이벤트 루프를 더 쉽게 쓰도록 만들어진 API라는 거예요.
Dart 이벤트 루프는 이벤트를 한 번에 하나씩 처리해요. 당신이 작성한 Dart 코드는 단일 스레드로 실행돼요. 앱이 실행되는 내내 그 하나의 작은 스레드가 이벤트 큐에서 이벤트를 집어 들고 처리하면서 계속 돌아가고 있죠.
다운로드 버튼을 위한 코드가 있다고 해볼게요(아래에서는 RaisedButton으로 구현). 사용자가 탭하면 버튼이 사진을 다운로드하기 시작해요.
RaisedButton(
onPressed: () {
final myFuture = http.get('https://my.image.url');
myFuture.then((resp) {
setImage(resp);
});
},
child: Text('Click me!'),
)
먼저 탭 이벤트가 발생해요. 이벤트 루프가 이벤트를 받아서 탭 핸들러를 호출해요(RaisedButton 생성자의 onPressed 파라미터로 설정한 함수). 핸들러는 http 라이브러리로 요청을 만들고(http.get()), 그 결과로 future(myFuture)를 받아요.
이제 myFuture라는 작은 상자를 가진 셈이에요. 처음에는 닫혀 있죠. 열릴 때 실행할 콜백을 등록하려면 then()을 사용해요.
선물 상자를 받았으니 기다립니다. 그 사이 다른 이벤트가 들어오고 사용자가 뭔가 조작할 수도 있어요. 이벤트 루프가 계속 돌아가는 동안 당신의 작은 상자는 그냥 거기 앉아 있죠.
결국 이미지 데이터가 다운로드되고, http 라이브러리가 "좋아! 여기 이 future가 있어." 하면서 데이터를 상자에 넣고 뚜껑을 열어요. 그러면 당신의 콜백이 실행되죠. then()에 건네준 그 짧은 코드가 실행돼서 이미지를 표시해요.
그 과정 내내 당신의 코드는 이벤트 루프를 직접 건드릴 필요가 없었어요. 다른 일이 진행 중이든, 다른 이벤트가 들어오든 상관없었죠. http 라이브러리에서 future를 받아서, future가 완료되면 뭘 할지만 말해 주면 됐어요.
실제 코드에서는 오류도 처리해야 해요. 그 방법은 조금 뒤에 보여드릴게요.
Future API를 좀 더 자세히 살펴볼게요. 그중 일부는 방금 사용하는 걸 봤죠.
자, 첫 번째 질문: Future 인스턴스는 어떻게 얻나요? 대부분의 경우 future를 직접 만들지는 않아요. 흔한 비동기 프로그래밍 작업에는 이미 future를 생성해 주는 라이브러리가 있기 때문이죠.
예를 들어 네트워크 통신은 future를 반환해요.
final myFuture = http.get('http://example.com');
공유 환경설정(shared preferences)에 접근하는 것도 future를 반환해요.
final myFuture = SharedPreferences.getInstance();
하지만 Future 생성자를 사용해 future를 만들 수도 있어요.
Future 생성자
가장 단순한 생성자는 Future()예요. 함수를 받아서 그 함수의 반환 타입과 일치하는 future를 반환하죠. 나중에 함수가 비동기로 실행되고, future는 함수의 반환 값으로 완료돼요. Future() 사용 예시를 볼게요.
void main() {
final myFuture = Future(() {
return 12;
});
}
비동기 부분을 명확히 보기 위해 print 문을 몇 개 추가해 볼게요.
void main() {
final myFuture = Future(() {
print('Creating the future.'); // 두 번째로 출력돼요.
return 12;
});
print('Done with main().'); // 첫 번째로 출력돼요.
}
이 코드를 DartPad(dartpad.dev)에서 실행하면, Future() 생성자에 준 함수보다 main 함수 전체가 먼저 끝나요. Future() 생성자는 처음에 미완료 future만 반환하기 때문이죠. 생성자가 "여기 상자 있어. 일단 들고 있어. 나중에 네 함수를 실행해서 데이터를 넣어줄게."라고 말하는 셈이에요. 위 코드의 출력은 다음과 같아요.
Done with main().
Creating the future.
또 다른 생성자 Future.value()는 future 값을 이미 알고 있을 때 유용해요. 캐싱을 사용하는 서비스를 만들 때 특히 그렇죠. 때로는 이미 필요한 값을 갖고 있으므로 바로 넣을 수 있어요.
final myFuture = Future.value(12);
Future.value() 생성자에는 오류와 함께 완료되는 대응 생성자가 있어요. 이름은 Future.error()인데, 동작은 기본적으로 같되 오류 객체와 선택적 stacktrace를 받아요.
final myFuture = Future.error(ArgumentError.notNull('input'));
아마 가장 편리한 생성자는 Future.delayed()일 거예요. Future()처럼 동작하지만, 함수를 실행하고 future를 완료하기 전에 지정된 시간만큼 기다린다는 점이 달라요.
Future.delayed()를 쓰는 한 가지 경우는 테스트용 mock 네트워크 서비스를 만들 때예요. 로딩 스피너가 올바르게 표시되는지 확인해야 한다면, 지연 future가 좋은 친구가 돼줘요.
final myFuture = Future.delayed(
const Duration(seconds: 5),
() => 12,
);
Futures 사용하기
future가 어디서 오는지 알았으니 이제 어떻게 쓰는지 이야기해 볼게요. 앞서 말했듯이 future를 사용한다는 건 대체로 그 세 가지 상태, 즉 미완료, 값과 함께 완료, 오류와 함께 완료를 어떻게 처리할지 다루는 일이에요.
다음 코드는 Future.delayed()로 3초 후 값 100과 함께 완료되는 future를 만들어요.
void main() {
Future.delayed(
const Duration(seconds: 3),
() => 100,
);
print('Waiting for a value...');
}
이 코드가 실행되면 main()은 위에서 아래로 실행되면서 future를 만들고 "Waiting for a value..."를 출력해요. 그 내내 future는 미완료 상태이죠. 3초가 지나기 전에는 완료되지 않아요.
완료된 값을 사용하려면 then()을 쓰면 돼요. then()은 각 future의 인스턴스 메서드로, future가 값과 함께 완료됐을 때 실행할 콜백을 등록하는 데 써요. future의 타입과 일치하는 단일 파라미터를 받는 함수를 넘겨주면 돼요. future가 값과 함께 완료되면 그 값과 함께 당신의 함수가 호출돼요.
void main() {
Future.delayed(
const Duration(seconds: 3),
() => 100,
).then((value) {
print('The value is $value.'); // 3초 후에 출력돼요.
});
print('Waiting for a value...'); // 먼저 출력돼요.
}
위 코드의 출력은 다음과 같아요.
Waiting for a value... *(콜백이 실행되기까지 3초 지남)*
The value is 100.
then()은 코드를 실행하는 것에 더해, 넘겨준 함수의 반환 값과 일치하는 자신만의 future를 반환해요. 그래서 비동기 호출을 여러 개 해야 한다면, 반환 타입이 서로 달라도 체인으로 이어 붙일 수 있어요.
_fetchNameForId(12)
.then((name) => _fetchCountForName(name))
.then((count) => print('The count is $count.'));
첫 번째 예시로 돌아가서, 그 초기의 future가 값으로 완료되지 않고 오류로 완료되면 어떻게 될까요? then() 메서드는 값을 기대하죠. 오류가 발생했을 때 실행할 다른 콜백을 등록할 방법이 필요해요.
그 답은 catchError()를 쓰는 거예요. then()과 비슷하게 동작하지만 값을 대신 오류를 받고, future가 오류와 함께 완료되면 실행돼요. then()과 마찬가지로 catchError() 메서드도 자신만의 future를 반환하므로, 서로를 기다리는 then()과 catchError() 메서드의 전체 체인을 구축할 수 있어요.
참고:
async-await언어 기능을 사용한다면then()이나catchError()를 호출할 필요가 없어요. 대신 완료된 값을await하고, 오류를 처리할 때는try-catch-finally를 사용하면 돼요. 자세한 내용은 Dart 언어 투어의 asynchrony 지원 섹션을 참고하세요.
catchError()로 future가 오류와 함께 완료되는 경우를 처리하는 예시를 볼게요.
void main() {
Future.delayed(
Duration(seconds: 3),
() => throw 'Error!', // 오류와 함께 완료돼요.
).then((value) {
print(value);
}).catchError((err) {
print('Caught $err'); // 오류를 처리해요.
});
print('Waiting for a value...');
}
catchError()에 테스트 함수를 넘겨서 콜백을 호출하기 전에 오류를 확인할 수도 있어요. 이렇게 하면 각각 다른 종류의 오류를 검사하는 여러 catchError() 함수를 가질 수 있어요. catchError()의 선택적 test 파라미터를 사용해 테스트 함수를 지정하는 예시를 볼게요.
void main() {
Future.delayed(
Duration(seconds: 3),
() => throw 'Error!',
).then((value) {
print(value);
}).catchError((err) {
print('Caught $err');
}, test: (err) { // 선택적 test 파라미터.
return err is String;
});
print('Waiting for a value...');
}
여기까지 읽었다면, future의 세 가지 상태가 코드 구조에 자주 반영된다는 걸 어렵지 않게 눈치챘을 거예요. 위 예시에는 세 개의 블록이 있어요. 첫 번째 블록은 미완료 future를 만들고, 그다음 future가 값과 함께 완료되면 호출할 함수가 있고, 마지막으로 future가 오류와 함께 완료되면 호출할 함수가 있어요.
사용할 만한 메서드가 하나 더 있어요. 바로 whenComplete()예요. 값이든 오류든 future가 완료되면 어떤 함수를 실행하도록 하는 데 써요. try-catch-finally의 finally 블록과 비슷하죠. 모든 게 잘 진행되면 실행되는 코드, 오류를 위한 코드, 그리고 무슨 일이 있어도 실행되는 코드가 있는 셈이에요.
Flutter에서 futures 사용하기
이렇게 future를 만들고 그 값의 일부를 사용하는 법을 알아봤어요. 이제 Flutter에서 실제로 활용하는 방법을 이야기해 볼게요.
JSON 데이터를 반환하는 네트워크 서비스가 있고 그 데이터를 표시하고 싶다고 해볼게요. future를 만들고 완료나 오류를 확인하며 setState()를 호출하는 등 배선(wiring)을 전부 수동으로 처리하는 StatefulWidget을 만들 수도 있어요.
아니면 FutureBuilder를 쓸 수도 있어요. Flutter SDK에 포함된 위젯으로, future와 builder 함수를 넘겨주면 future가 완료될 때 자식 위젯을 자동으로 다시 빌드해요.
FutureBuilder 위젯은 builder 함수를 호출해서 동작해요. 이 함수는 컨텍스트와 future 현 상태의 스냅샷을 받죠.
class MyWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
// FutureBuilder를 사용해요.
return FutureBuilder<String>(
future: _fetchNetworkData(),
builder: (context, snapshot) {},
);
}
}
스냅샷을 확인해서 future가 오류로 완료됐는지 볼 수 있어요.
return FutureBuilder<String>(
future: _fetchNetworkData(5),
builder: (context, snapshot) {
if (snapshot.hasError) {
// Future가 오류와 함께 완료됐어요.
return Text(
'There was an error',
);
}
throw UnimplementedError("Case not handled yet");
},
);
아니면 hasData 프로퍼티를 확인해서 값으로 완료됐는지 볼 수도 있어요.
return FutureBuilder<String>(
future: _fetchNetworkData(5),
builder: (context, snapshot) {
if (snapshot.hasError) {
// Future가 오류와 함께 완료됐어요.
return Text(
'There was an error',
);
} else if (snapshot.hasData) {
// Future가 값과 함께 완료됐어요.
return Text(
json.decode(snapshot.data)['field'],
);
}
throw UnimplementedError("Case not handled yet");
},
);
hasError와 hasData 둘 다 true가 아니라면 아직 기다리는 중이라는 뜻이므로, 그에 맞는 내용도 출력할 수 있어요.
return FutureBuilder<String>(
future: _fetchNetworkData(5),
builder: (context, snapshot) {
if (snapshot.hasError) {
// Future가 오류와 함께 완료됐어요.
return Text(
'There was an error',
);
} else if (snapshot.hasData) {
// Future가 값과 함께 완료됐어요.
return Text(
json.decode(snapshot.data)['field'],
);
} else {
// 미완료 상태예요.
return Text(
'No value yet!',
);
}
},
);
Flutter 코드에서도 그 세 가지 상태, 즉 미완료, 값과 함께 완료, 오류와 함께 완료가 계속 등장하는 걸 볼 수 있어요.
정리
이 글에서는 future가 무엇을 나타내는지, 그리고 Future와 FutureBuilder API로 future를 만들고 완료된 값을 사용하는 방법을 다뤘어요.
future 사용에 대해 더 배우고 싶다면 — 실행 가능한 예시와 상호작용 연습으로 이해도를 확인할 수 있는 — futures, async, await에 관한 비동기 codelab을 확인해 보세요.
아니면 Asynchronous Programming in Dart 시리즈의 다음 비디오로 넘어가도 돼요. 스트림(streams)에 관한 내용인데, 값이나 오류를 제공할 수 있다는 점에서 future와 아주 비슷해요. 다만 future는 결과를 하나 주고 멈추는 반면, 스트림은 계속해서 흘러가죠.
새 탭에서 YouTube 재생: "Dart Streams - Flutter in Focus"
이 글이 바탕으로 한 비디오를 만든 Andrew Brogdon에게 큰 감사를 전합니다.
더 알아보기
- Dart 비동기 프로그래밍: Isolates와 event loops — 비동기 작업의 기초를 다룬 시리즈 첫 번째 글.
- Dart 비동기 프로그래밍: Streams — future와 비슷하지만 계속 흐르는 스트림에 대한 다음 글.
- futures, async, await codelab — 실행 가능한 예시로 배우기.