서론: 왜 비동기 프로그래밍이 Flutter의 심장인가?
오늘날 우리가 사용하는 대부분의 모바일 애플리케이션은 네트워크 통신, 파일 입출력, 데이터베이스 접근 등 즉시 완료되지 않는 작업을 수행합니다. 만약 사용자가 버튼을 눌러 인터넷에서 이미지를 다운로드하는 동안 애플리케이션 전체가 멈춰버린다면 어떨까요? 사용자는 앱이 고장 났다고 생각하고 즉시 이탈할 것입니다. 이처럼 사용자 경험(UX)은 애플리케이션의 성공에 지대한 영향을 미치며, 부드럽고 반응성 좋은 UI는 훌륭한 UX의 핵심 요소입니다.
Flutter는 Dart 언어를 기반으로 하며, Dart는 싱글 스레드(Single-threaded) 환경에서 코드를 실행합니다. 이는 한 번에 하나의 작업만 처리할 수 있다는 의미입니다. 이러한 환경에서 시간이 오래 걸리는 작업을 동기적(synchronous)으로 처리하면, 해당 작업이 끝날 때까지 UI 렌더링을 포함한 다른 모든 작업이 차단됩니다. 이것이 바로 'UI 버벅임' 또는 '앱 멈춤' 현상의 주된 원인입니다.
이 문제를 해결하기 위해 등장한 패러다임이 바로 비동기 프로그래밍(Asynchronous Programming)입니다. 비동기 프로그래밍은 시간이 오래 걸리는 작업을 백그라운드 스레드나 별도의 로직에 위임하고, 그 작업이 완료될 때까지 기다리지 않고 즉시 다음 코드를 실행하여 프로그램의 흐름을 유지하는 방식입니다. 작업이 완료되면 그 결과를 받아 후속 처리를 진행합니다. 이를 통해 Flutter 앱은 네트워크 요청과 같은 무거운 작업을 처리하면서도 사용자의 터치 이벤트에 즉각적으로 반응하고 부드러운 애니메이션을 유지할 수 있습니다.
Dart 언어는 이러한 비동기 처리를 위해 강력하고 직관적인 도구들을 제공하며, 그 중심에는 Future
객체가 있습니다. Future
는 '미래의 어느 시점에 완료될 작업'을 표현하는 객체입니다. 더 나아가, 때로는 작업이 동기적으로 즉시 완료될 수도 있고, 비동기적으로 나중에 완료될 수도 있는 유연한 상황을 처리해야 할 때가 있습니다. 바로 이때 FutureOr
라는 특별한 타입이 빛을 발합니다.
본 글에서는 Flutter 개발자라면 반드시 깊이 이해해야 할 Future
와 FutureOr
에 대해 심층적으로 탐구합니다. 단순히 문법을 나열하는 것을 넘어, Dart의 이벤트 루프 모델과 같은 근본적인 동작 원리부터 시작하여, 다양한 API 사용법, 실전 예제, 그리고 견고한 비동기 코드를 작성하기 위한 최상의 패턴까지 모든 것을 다룰 것입니다. 이 글을 통해 여러분은 Flutter의 비동기 처리 메커니즘을 완벽하게 이해하고, 어떤 복잡한 시나리오에서도 자신감 있게 비동기 코드를 설계하고 구현할 수 있는 능력을 갖추게 될 것입니다.
1. Dart의 동시성 모델: 이벤트 루프와 큐의 이해
Future
의 동작 방식을 제대로 이해하려면, 먼저 Dart가 코드를 어떻게 실행하고 관리하는지에 대한 근본적인 이해가 필요합니다. 앞서 언급했듯이 Dart는 싱글 스레드 기반이지만, '이벤트 루프(Event Loop)'라는 메커니즘을 통해 동시성(concurrency)을 달성합니다.
1.1. 싱글 스레드와 이벤트 기반 모델
싱글 스레드라는 것은 말 그대로 실행 흐름이 단 하나라는 뜻입니다. 이는 코드의 복잡성을 줄여주고 동기화 문제(예: race condition, deadlock)로부터 개발자를 해방시켜준다는 장점이 있습니다. 하지만 동시에 하나의 작업만 처리할 수 있다는 명백한 한계도 존재합니다.
Dart는 이 한계를 극복하기 위해 이벤트 기반 모델을 채택했습니다. 모든 작업은 '이벤트'로 취급되며, 이러한 이벤트들은 큐(Queue)에 차례대로 쌓입니다. '이벤트 루프'는 이 큐를 끊임없이 확인하며, 처리할 이벤트가 있으면 가져와서 실행하고, 큐가 비어 있으면 다음 이벤트가 들어올 때까지 대기합니다. 여기서 말하는 이벤트란 사용자의 터치, 타이머 만료, 네트워크 응답 수신 등 다양한 비동기 작업을 포함합니다.
Dart의 이벤트 처리 시스템에는 두 종류의 큐가 존재합니다.
- 이벤트 큐 (Event Queue): 외부에서 발생하는 대부분의 이벤트가 여기에 들어갑니다. I/O 작업, 타이머, 그리기 이벤트, 사용자 입력 등이 해당됩니다.
- 마이크로태스크 큐 (Microtask Queue): 이벤트 큐보다 높은 우선순위를 갖는 특별한 큐입니다. 주로 Dart 코드 내부에서 발생하는 매우 짧은 비동기 작업을 처리하기 위해 사용됩니다. 예를 들어,
Future.microtask()
를 통해 작업을 이 큐에 추가할 수 있습니다.
1.2. 마이크로태스크 큐와 이벤트 큐의 상호작용
이벤트 루프의 처리 순서는 매우 중요하며, 다음과 같은 규칙을 따릅니다.
- 마이크로태스크 큐를 먼저 확인하고, 큐가 완전히 빌 때까지 모든 작업을 순차적으로 실행합니다.
- 마이크로태스크 큐가 비워지면, 이벤트 큐에서 가장 오래된 이벤트 하나를 가져와 실행합니다.
- 하나의 이벤트를 처리한 후, 다시 1번으로 돌아가 마이크로태스크 큐를 확인합니다. 이 과정을 무한히 반복합니다.
이러한 우선순위 때문에 마이크로태스크 큐에 작업을 추가하는 것은 신중해야 합니다. 만약 마이크로태스크가 계속해서 새로운 마이크로태스크를 추가하는 악순환이 발생하면, 이벤트 루프는 이벤트 큐에 있는 작업(예: UI 렌더링, 사용자 입력)을 처리할 기회를 얻지 못해 애플리케이션이 멈추게 될 수 있습니다.
import 'dart:async';
void main() {
print('main start');
// 이벤트 큐에 작업을 추가합니다.
Future(() => print('Event Queue 1 (Future)'));
Future(() => print('Event Queue 2 (Future)'));
// 마이크로태스크 큐에 작업을 추가합니다.
scheduleMicrotask(() => print('Microtask Queue 1'));
scheduleMicrotask(() => print('Microtask Queue 2'));
print('main end');
}
위 코드의 실행 결과는 어떻게 될까요? 이벤트 루프의 동작 원리를 생각해보면 예측할 수 있습니다.
main start main end Microtask Queue 1 Microtask Queue 2 Event Queue 1 (Future) Event Queue 2 (Future)
main
함수 내의 동기적인 코드(print
문)가 먼저 모두 실행됩니다. 그 과정에서 Future
와 scheduleMicrotask
는 각각 이벤트 큐와 마이크로태스크 큐에 작업을 등록합니다. main
함수가 종료된 후, 이벤트 루프는 마이크로태스크 큐를 먼저 확인하고, 그 안에 있는 모든 작업을 실행합니다. 마이크로태스크 큐가 비워진 후에야 비로소 이벤트 큐의 작업을 하나씩 처리하기 시작합니다. Future
는 바로 이 이벤트 큐를 활용하여 비동기 작업을 스케줄링하는 핵심 도구입니다.
2. Future: 미래와의 약속, 그 본질을 파헤치다
이제 Dart의 동시성 모델을 이해했으니, 비동기 프로그래밍의 주인공인 Future
에 대해 본격적으로 알아볼 시간입니다. Future<T>
는 이름 그대로 '미래에 T 타입의 값을 반환할 것'이라는 약속(promise)입니다. 이 약속은 당장은 결과값이 없지만, 언젠가는 성공적으로 값을 반환하거나(completed with value), 혹은 실패하여 오류를 반환할(completed with error) 것입니다.
2.1. Future의 세 가지 상태
모든 Future
객체는 자신의 생명주기 동안 다음 세 가지 상태 중 하나를 가집니다.
- 미완료 (Uncompleted):
Future
가 생성되고 아직 작업이 완료되지 않은 상태입니다. 비동기 작업이 현재 진행 중임을 의미합니다. - 값으로 완료 (Completed with a value): 비동기 작업이 성공적으로 끝나고 결과값을 반환한 상태입니다.
Future<String>
이라면 문자열 값을,Future<int>
라면 정수 값을 가지게 됩니다. - 오류로 완료 (Completed with an error): 비동기 작업 도중 예외가 발생하여 실패한 상태입니다. 이 경우
Future
는 결과값 대신 오류(Error) 객체와 스택 트레이스(Stack Trace)를 가집니다.
중요한 점은, Future
는 한번 완료되면 (값으로든 오류로든) 그 상태와 결과가 절대 변하지 않는다는 것입니다. 이는 비동기 작업의 결과를 안정적으로 다룰 수 있게 해주는 중요한 특징입니다.
2.2. Future 생성하기: 다양한 접근법
Future
를 생성하는 방법은 여러 가지가 있습니다. 상황에 맞는 적절한 방법을 사용하는 것이 중요합니다.
1. async 함수 사용 (가장 일반적인 방법)
함수 본문 앞에 async
키워드를 붙이면, 해당 함수는 자동으로 Future
를 반환하게 됩니다. 함수가 값을 return
하면 Future
는 그 값으로 완료되고, 예외를 throw
하면 오류로 완료됩니다.
// 2초 후에 성공적으로 문자열을 반환하는 Future를 생성
Future<String> fetchUserData() async {
// 실제로는 네트워크 통신 등을 수행
await Future.delayed(const Duration(seconds: 2));
return 'John Doe';
}
// 1초 후에 예외를 발생시키는 Future를 생성
Future<String> fetchUserDataWithError() async {
await Future.delayed(const Duration(seconds: 1));
throw Exception('Failed to fetch user data');
}
2. Future() 생성자 사용
Future
생성자에 실행할 함수를 전달하여 직접 Future
를 만들 수 있습니다. 이 함수는 이벤트 큐에 등록되어 나중에 실행됩니다.
Future<int> calculateComplexValue() {
return Future(() {
// 시간이 오래 걸리는 동기적인 계산
int result = 0;
for (int i = 0; i < 1000000000; i++) {
result += i;
}
return result;
});
}
3. Future.delayed()
지정된 시간만큼 기다린 후에 특정 작업을 수행하거나 값을 반환하는 Future
를 생성합니다. 테스트나 애니메이션 지연 등에 유용하게 사용됩니다.
// 3초 후에 실행
Future.delayed(const Duration(seconds: 3), () {
print('3 seconds have passed.');
});
4. Future.value() / Future.error()
이미 완료된 상태의 Future
를 생성해야 할 때 사용합니다. API가 Future
를 반환해야 하지만, 특정 조건에서는 동기적으로 값을 즉시 반환할 수 있을 때 유용합니다. (이 패턴은 FutureOr
와 깊은 관련이 있습니다.)
Future<String> getCachedData() {
// 캐시에 데이터가 있다고 가정
if (_cache.containsKey('data')) {
// 이미 값이 있으므로 즉시 완료된 Future를 반환
return Future.value(_cache['data']);
}
// 캐시에 데이터가 없으면 오류를 담은 Future를 반환
return Future.error(Exception('Data not found in cache'));
}
2.3. Future 결과 소비하기 (1): 콜백 기반의 then()
Future
가 생성되었다면, 이제 그 결과를 어떻게 사용할지 결정해야 합니다. 가장 전통적인 방법은 then()
메서드를 사용하는 것입니다. then()
은 Future
가 성공적으로 완료되었을 때 실행될 콜백 함수를 등록하는 역할을 합니다.
void main() {
print('Fetching user data...');
fetchUserData().then((String userData) {
// Future가 성공적으로 완료되면 이 블록이 실행됨
print('Success: $userData');
});
print('This line executes immediately.');
}
// 실행 결과:
// Fetching user data...
// This line executes immediately.
// (2초 후)
// Success: John Doe
then()
은 비동기 작업의 흐름을 체인(chain) 형태로 연결하는 데 매우 강력합니다.
오류 처리: catchError()
비동기 작업은 언제든 실패할 수 있으므로 오류 처리는 필수적입니다. catchError()
메서드를 사용하여 Future
가 오류로 완료되었을 때 실행될 콜백을 등록할 수 있습니다.
fetchUserDataWithError().then((userData) {
print('Success: $userData');
}).catchError((error) {
// Future가 오류로 완료되면 이 블록이 실행됨
print('Error occurred: $error');
});
최종 정리: whenComplete()
작업의 성공 여부와 관계없이 항상 마지막에 실행되어야 하는 코드가 있다면(예: 로딩 인디케이터 숨기기, 리소스 해제), whenComplete()
를 사용합니다.
showLoadingIndicator();
fetchUserData()
.then((userData) => updateUI(userData))
.catchError((error) => showError(error))
.whenComplete(() => hideLoadingIndicator()); // 성공하든 실패하든 항상 호출
이러한 콜백 기반 방식은 강력하지만, 로직이 복잡해지면 콜백 함수가 중첩되어 '콜백 지옥(Callback Hell)'을 만들 수 있고 코드의 가독성이 떨어지는 단점이 있습니다.
2.4. Future 결과 소비하기 (2): 동기식 코드처럼, async/await
Dart는 콜백 지옥 문제를 해결하고 비동기 코드를 동기 코드처럼 간결하게 작성할 수 있도록 async
와 await
키워드를 제공합니다. 이는 내부적으로 then()
을 사용하는 것과 동일하게 동작하지만, 훨씬 더 직관적이고 읽기 쉬운 코드를 만들어줍니다.
- async: 함수 시그니처에 붙여 해당 함수가 비동기 함수이며, 내부에서
await
를 사용할 수 있음을 알립니다.async
함수는 항상Future
를 반환합니다. - await:
Future
앞에 붙여 사용하며, 해당Future
가 완료될 때까지 함수의 실행을 '일시 중지'시킵니다.Future
가 성공적으로 완료되면 그 결과값을 반환하고, 오류로 완료되면 예외를 던집니다.
then()
을 사용했던 예제를 async/await
로 다시 작성해 보겠습니다.
Future<void> printUserData() async {
print('Fetching user data...');
try {
// fetchUserData()가 완료될 때까지 기다렸다가 결과를 userData에 할당
String userData = await fetchUserData();
print('Success: $userData');
// 또 다른 비동기 작업
String userDetails = await fetchUserDetails(userData);
print('Details: $userDetails');
} catch (error) {
// await하는 Future에서 예외가 발생하면 catch 블록이 실행됨
print('Error occurred: $error');
} finally {
// 성공/실패 여부와 관계없이 실행
print('Operation finished.');
}
}
// main 함수에서 호출
void main() async {
await printUserData();
print('All user operations are done.');
}
코드가 위에서 아래로 순차적으로 실행되는 것처럼 보여 훨씬 이해하기 쉽습니다. 오류 처리는 익숙한 try-catch-finally
구문을 그대로 사용할 수 있어 매우 편리합니다. 대부분의 현대 Dart/Flutter 개발에서는 특별한 이유가 없는 한 then()
보다 async/await
를 사용하는 것이 권장됩니다.
2.5. 고급 Future API: 여러 비동기 작업 다루기
때로는 여러 개의 비동기 작업을 동시에 처리해야 할 필요가 있습니다. Dart는 이를 위한 유용한 정적 메서드들을 제공합니다.
Future.wait()
여러 개의 Future
를 리스트로 받아, 모든 Future
가 성공적으로 완료될 때까지 기다립니다. 모든 작업이 완료되면 각 Future
의 결과값을 담은 리스트를 반환하는 새로운 Future
를 반환합니다. 만약 전달된 Future
중 하나라도 오류로 완료되면, Future.wait()
전체가 즉시 해당 오류로 완료됩니다.
사용 사례: 페이지 로딩 시 필요한 사용자 정보, 친구 목록, 최신 게시글을 각각 다른 API를 통해 동시에 요청하고, 모든 데이터가 준비되었을 때 UI를 표시하고 싶을 때 사용합니다.
Future<void> loadDashboard() async {
try {
print('Loading dashboard data...');
// 세 개의 비동기 작업을 동시에 시작
List<dynamic> results = await Future.wait([
fetchUserProfile(), // Future<Profile> 반환
fetchFriendList(), // Future<List<Friend>> 반환
fetchLatestPosts(), // Future<List<Post>> 반환
]);
// results[0]는 Profile, results[1]은 List<Friend>, ...
Profile userProfile = results[0];
List<Friend> friends = results[1];
List<Post> posts = results[2];
buildDashboardUI(userProfile, friends, posts);
print('Dashboard loaded successfully!');
} catch (e) {
print('Failed to load dashboard: $e');
showErrorUI();
}
}
Future.any()
여러 개의 Future
중 가장 먼저 완료되는 것의 결과를 반환합니다. 어떤 Future
가 먼저 완료될지(성공이든 실패든) 상관없이, 첫 번째 완료 결과가 Future.any()
의 결과가 됩니다.
사용 사례: 여러 미러 서버 중 가장 응답이 빠른 서버에서 데이터를 다운로드받고 싶을 때 유용합니다.
Future.forEach()
리스트의 각 요소에 대해 비동기 함수를 순차적으로 실행합니다. 이전 요소에 대한 작업이 완료되어야 다음 요소에 대한 작업을 시작합니다.
3. FutureOr: 동기와 비동기의 경계를 허무는 유연함
지금까지 우리는 모든 비동기 작업이 Future
를 반환한다고 가정했습니다. 하지만 실제 개발에서는 어떤 함수가 때로는 값을 즉시 반환(동기)하고, 때로는 나중에 값을 반환(비동기)해야 하는 상황이 종종 발생합니다. 이런 유연성이 필요한 API를 설계할 때, FutureOr<T>
가 강력한 해결책을 제시합니다.
FutureOr<T>
는 이름에서 알 수 있듯이, Future<T>
타입이거나 혹은 T
타입일 수 있음을 나타내는 특별한 타입입니다.
3.1. FutureOr가 필요한 순간: 캐싱 로직
FutureOr
의 필요성을 가장 잘 보여주는 대표적인 예는 데이터 캐싱(caching) 로직입니다.
사용자 프로필 데이터를 가져오는 함수를 만든다고 상상해봅시다. 이상적인 시나리오는 다음과 같습니다.
- 메모리 캐시(빠른 저장소)에 사용자 프로필 데이터가 있는지 확인합니다.
- 만약 캐시에 데이터가 있다면, 네트워크 요청 없이 즉시 해당 데이터를 동기적으로 반환합니다.
- 만약 캐시에 데이터가 없다면, 네트워크 API를 호출하여 데이터를 가져옵니다. 이 작업은 시간이 걸리므로 비동기적으로 처리하고, 결과를
Future
로 감싸서 반환합니다.
이 함수는 두 가지 다른 반환 방식을 가집니다. 동기적인 Profile
객체 또는 비동기적인 Future<Profile>
. 이 함수의 반환 타입을 어떻게 정의해야 할까요?
dynamic
이나 Object
를 사용할 수도 있지만, 이는 타입 안전성을 해치고 사용하는 쪽에서 불필요한 타입 캐스팅과 검사를 유발합니다. 바로 이 지점에서 FutureOr<Profile>
이 등장합니다.
import 'dart:async';
class UserProfile {
final String name;
UserProfile(this.name);
}
class ProfileRepository {
// 간단한 메모리 내 캐시
final Map<String, UserProfile> _cache = {};
// 네트워크 지연을 시뮬레이션
Future<UserProfile> _fetchFromNetwork(String userId) async {
await Future.delayed(const Duration(seconds: 2));
print('Fetching from network for $userId...');
return UserProfile('Network User ($userId)');
}
// FutureOr를 사용하여 유연한 API를 제공
FutureOr<UserProfile> getUserProfile(String userId) {
if (_cache.containsKey(userId)) {
// 캐시 히트: 동기적으로 UserProfile 객체를 반환
print('Cache hit for $userId. Returning synchronously.');
return _cache[userId]!;
} else {
// 캐시 미스: 비동기적으로 네트워크에서 데이터를 가져오는 Future를 반환
print('Cache miss for $userId. Fetching asynchronously.');
return _fetchFromNetwork(userId).then((profile) {
// 가져온 데이터는 다음을 위해 캐시에 저장
_cache[userId] = profile;
return profile;
});
}
}
}
이렇게 FutureOr
를 사용함으로써 getUserProfile
함수는 내부 구현(캐시 히트/미스)에 따라 반환 방식이 달라지더라도 일관되고 타입-안전한 시그니처를 유지할 수 있습니다.
3.2. FutureOr의 정체: 유니언 타입
FutureOr<T>
는 클래스가 아닙니다. 직접 new FutureOr()
와 같이 인스턴스화할 수 없습니다. 이것은 Dart 타입 시스템의 특별한 기능으로, 두 개 이상의 타입을 허용하는 '유니언 타입(Union Type)'의 한 형태입니다. 컴파일러와 런타임은 FutureOr<T>
타입의 변수에 T
타입의 값이나 Future<T>
타입의 값을 할당하는 것을 허용합니다.
이러한 유연성은 라이브러리나 프레임워크를 설계할 때 매우 유용합니다. 사용자가 동기적인 콜백 함수를 전달할 수도 있고, 비동기적인 콜백 함수를 전달할 수도 있는 API를 만들 때 FutureOr
를 매개변수 타입으로 활용할 수 있습니다.
3.3. FutureOr 다루기: 우아한 처리 기법
FutureOr<T>
타입의 값을 받았다면, 이것이 실제 T
인지 Future<T>
인지 어떻게 알고 처리해야 할까요?
방법 1: 타입 체크 (is)
가장 기본적인 방법은 is
키워드를 사용하여 타입을 직접 확인하는 것입니다.
void processProfile(FutureOr<UserProfile> profileOrFuture) {
if (profileOrFuture is UserProfile) {
// 동기적으로 값이 넘어온 경우
print('Processing profile synchronously: ${profileOrFuture.name}');
} else if (profileOrFuture is Future<UserProfile>) {
// 비동기적으로 Future가 넘어온 경우
profileOrFuture.then((profile) {
print('Processing profile asynchronously: ${profile.name}');
});
}
}
이 방법은 명시적이지만, 코드가 다소 장황해질 수 있습니다.
방법 2: await 사용 (가장 권장되는 방법)
가장 우아하고 간결한 방법은 await
키워드를 사용하는 것입니다. await
는 마법처럼 두 가지 경우를 모두 처리해줍니다.
await
의 대상이Future<T>
이면,Future
가 완료될 때까지 기다린 후 결과값T
를 반환합니다.await
의 대상이 이미T
타입의 값이면, 기다리지 않고 즉시 그 값T
를 그대로 반환합니다.
이러한 특성 덕분에, FutureOr<T>
를 다루는 코드는 매우 단순해집니다.
Future<void> main() async {
final repository = ProfileRepository();
print('--- First call (will be async) ---');
// getUserProfile이 Future<UserProfile>을 반환하더라도 await가 처리
UserProfile profile1 = await repository.getUserProfile('user123');
print('Got profile: ${profile1.name}\n');
print('--- Second call (will be sync from cache) ---');
// getUserProfile이 UserProfile을 직접 반환하더라도 await가 처리
UserProfile profile2 = await repository.getUserProfile('user123');
print('Got profile: ${profile2.name}');
}
위 main
함수는 repository.getUserProfile
이 내부적으로 동기적으로 동작하는지 비동기적으로 동작하는지 전혀 신경 쓸 필요가 없습니다. await
키워드 하나로 모든 상황이 깔끔하게 처리됩니다. 이것이 FutureOr
를 소비하는 가장 이상적인 방법입니다.
4. 실전 Flutter: Future와 FutureOr를 활용한 UI 구축
이론적인 내용을 모두 학습했으니, 이제 배운 지식을 실제 Flutter 애플리케이션에 적용해 보겠습니다. 비동기 데이터를 가져와 사용자에게 보여주는 것은 Flutter 앱 개발의 가장 흔한 시나리오 중 하나입니다.
4.1. 비동기 UI의 교과서: FutureBuilder 위젯
Flutter는 Future
의 상태 변화에 따라 UI를 자동으로 다시 그려주는 매우 편리한 FutureBuilder
위젯을 제공합니다. 개발자는 로딩 중, 데이터 수신 완료, 오류 발생 등 각 상태에 맞는 위젯만 정의해주면 됩니다.
FutureBuilder
의 주요 속성은 다음과 같습니다.
- future: 관찰할
Future
객체를 전달합니다. 이Future
의 상태가 변경될 때마다builder
가 다시 호출됩니다. - builder:
BuildContext
와AsyncSnapshot
을 인자로 받는 함수입니다.AsyncSnapshot
은Future
의 현재 상태와 데이터를 담고 있는 객체입니다. 이 함수는 UI를 구성하는 위젯을 반환해야 합니다.
AsyncSnapshot
객체를 통해 Future
의 현재 상태를 알 수 있습니다.
snapshot.connectionState
: 연결 상태를 나타내는 열거형(ConnectionState.none
,ConnectionState.waiting
,ConnectionState.active
,ConnectionState.done
). 주로waiting
(로딩 중)과done
(완료) 상태를 확인합니다.snapshot.hasData
:Future
가 값으로 완료되었는지 여부.snapshot.data
:Future
가 성공적으로 반환한 데이터.snapshot.hasError
:Future
가 오류로 완료되었는지 여부.snapshot.error
:Future
가 반환한 오류 객체.
다음은 네트워크에서 명언을 가져와 화면에 표시하는 간단한 예제입니다.
import 'package:flutter/material.dart';
import 'dart:convert';
import 'package:http/http.dart' as http;
// API로부터 데이터를 가져오는 비동기 함수
Future<String> fetchQuote() async {
final response = await http.get(Uri.parse('https://api.quotable.io/random'));
if (response.statusCode == 200) {
// 2초 지연을 추가하여 로딩 상태를 확실히 확인
await Future.delayed(const Duration(seconds: 2));
return jsonDecode(response.body)['content'];
} else {
throw Exception('Failed to load quote');
}
}
class QuoteScreen extends StatefulWidget {
const QuoteScreen({Key? key}) : super(key: key);
@override
_QuoteScreenState createState() => _QuoteScreenState();
}
class _QuoteScreenState extends State<QuoteScreen> {
late Future<String> _quoteFuture;
@override
void initState() {
super.initState();
_quoteFuture = fetchQuote();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Quote of the Day')),
body: Center(
child: FutureBuilder<String>(
future: _quoteFuture, // 이 Future의 상태를 감시
builder: (context, snapshot) {
// 1. 로딩 중 상태 처리
if (snapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
}
// 2. 오류 발생 상태 처리
if (snapshot.hasError) {
return Text(
'Error: ${snapshot.error}',
style: const TextStyle(color: Colors.red),
);
}
// 3. 데이터 수신 성공 상태 처리
if (snapshot.hasData) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
'"${snapshot.data}"',
style: Theme.of(context).textTheme.headline5,
textAlign: TextAlign.center,
),
);
}
// 4. 그 외의 경우 (데이터가 없는 초기 상태 등)
return const Text('No quote available.');
},
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
// 버튼을 눌러 새로운 명언을 가져오도록 상태 갱신
setState(() {
_quoteFuture = fetchQuote();
});
},
child: const Icon(Icons.refresh),
),
);
}
}
중요: Future
객체는 build
메서드 안에서 직접 생성하면 안 됩니다. build
메서드는 화면이 다시 그려질 때마다 호출되므로, 여기에 fetchQuote()
를 직접 넣으면 불필요한 API 호출이 계속 발생합니다. initState
에서 한 번만 생성하여 상태 변수(_quoteFuture
)에 저장하고, FutureBuilder
는 이 변수를 참조하도록 해야 합니다.
4.2. 데이터 계층 설계: 캐싱을 적용한 Repository 패턴
더 복잡한 애플리케이션에서는 UI 로직과 데이터 로직을 분리하는 것이 중요합니다. Repository 패턴은 데이터 소스(네트워크, 데이터베이스, 캐시 등)에 대한 접근을 추상화하는 좋은 방법입니다. 여기에 FutureOr
를 적용하여 캐싱 로직을 효율적으로 구현해 보겠습니다.
// 데이터 모델
class Post {
final int id;
final String title;
final String body;
Post({required this.id, required this.title, required this.body});
factory Post.fromJson(Map<String, dynamic> json) {
return Post(
id: json['id'],
title: json['title'],
body: json['body'],
);
}
}
// Repository 클래스
class PostRepository {
final Map<int, Post> _postCache = {};
Future<Post> _fetchPostFromApi(int postId) async {
print('Fetching post $postId from API...');
final response = await http.get(Uri.parse('https://jsonplaceholder.typicode.com/posts/$postId'));
if (response.statusCode == 200) {
return Post.fromJson(jsonDecode(response.body));
} else {
throw Exception('Failed to load post');
}
}
// FutureOr를 사용하여 동기/비동기 반환을 모두 처리
FutureOr<Post> getPost(int postId) {
if (_postCache.containsKey(postId)) {
print('Cache hit for post $postId!');
return _postCache[postId]!; // 동기 반환
} else {
print('Cache miss for post $postId.');
// 비동기 반환
return _fetchPostFromApi(postId).then((post) {
_postCache[postId] = post; // 캐시에 저장
return post;
});
}
}
}
// ViewModel 또는 BLoC 등에서 이 Repository를 사용
class PostViewModel {
final _repository = PostRepository();
Future<Post> loadPost(int postId) async {
// ViewModel은 Repository의 내부 캐싱 로직을 알 필요가 없음
// 그냥 await만 하면 동기든 비동기든 알아서 처리됨
return await _repository.getPost(postId);
}
}
// UI (StatefulWidget)
class PostDetailsScreen extends StatefulWidget {
final int postId;
const PostDetailsScreen({Key? key, required this.postId}) : super(key: key);
@override
_PostDetailsScreenState createState() => _PostDetailsScreenState();
}
class _PostDetailsScreenState extends State<PostDetailsScreen> {
final _viewModel = PostViewModel();
late Future<Post> _postFuture;
@override
void initState() {
super.initState();
_postFuture = _viewModel.loadPost(widget.postId);
}
@override
Widget build(BuildContext context) {
// FutureBuilder를 사용하여 UI를 구성 (위의 QuoteScreen 예제와 유사)
return FutureBuilder<Post>(
future: _postFuture,
builder: (context, snapshot) {
// ... 로딩, 에러, 데이터 상태에 따라 UI 표시 ...
if (snapshot.hasData) {
return Text(snapshot.data!.title);
}
// ...
return const CircularProgressIndicator();
},
);
}
}
이 구조를 통해 UI(PostDetailsScreen
)는 데이터가 캐시에서 오는지 네트워크에서 오는지 전혀 신경 쓰지 않고, 오직 ViewModel
을 통해 데이터를 요청하고 FutureBuilder
로 그 결과를 그리기만 하면 됩니다. 데이터 로직의 복잡성은 Repository
내부에 완전히 캡슐화되어 코드의 유지보수성과 테스트 용이성이 크게 향상됩니다.
4.3. 예외 처리: 견고한 애플리케이션의 초석
비동기 작업은 다양한 이유로 실패할 수 있습니다. 인터넷 연결이 끊기거나, 서버가 다운되거나, API 응답 형식이 바뀌는 등 예측 불가능한 상황에 대비해야 합니다. 견고한 앱은 이러한 예외 상황을 우아하게 처리하고 사용자에게 적절한 피드백을 제공해야 합니다.
FutureBuilder
의 snapshot.hasError
와 snapshot.error
를 활용하여 사용자 친화적인 오류 메시지를 표시하는 것이 중요합니다.
// FutureBuilder의 builder 함수 내부
if (snapshot.hasError) {
// 오류의 종류에 따라 다른 메시지를 보여줄 수 있음
final error = snapshot.error;
String errorMessage = 'An unknown error occurred.';
if (error is SocketException) {
errorMessage = 'No Internet connection. Please check your network.';
} else if (error is TimeoutException) {
errorMessage = 'The request timed out. Please try again.';
} else if (error is Exception) {
// 서버에서 보낸 특정 에러 메시지를 표시할 수도 있음
errorMessage = error.toString().replaceFirst('Exception: ', '');
}
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, color: Colors.red, size: 60),
const SizedBox(height: 20),
Text(errorMessage, textAlign: TextAlign.center),
const SizedBox(height: 20),
ElevatedButton(
onPressed: () {
// 재시도 로직
setState(() {
_postFuture = _viewModel.loadPost(widget.postId);
});
},
child: const Text('Retry'),
),
],
);
}
오류 화면에 '재시도' 버튼을 제공하여 사용자가 다시 작업을 시도할 수 있게 하는 것은 매우 좋은 사용자 경험을 제공합니다.
결론: 비동기 코드를 자신감 있게 작성하기
이 글을 통해 우리는 Flutter 비동기 프로그래밍의 핵심인 Future
와 FutureOr
에 대해 깊이 있게 탐구했습니다. Dart의 이벤트 루프라는 기본 원리부터 시작하여, Future
의 생명주기와 다양한 사용법, 그리고 동기와 비동기의 경계를 허무는 FutureOr
의 유연성까지 살펴보았습니다.
핵심 내용을 다시 한번 정리해 보겠습니다.
- 비동기 프로그래밍은 선택이 아닌 필수입니다. Flutter의 싱글 스레드 환경에서 반응성 좋은 UI를 유지하려면 반드시 비동기 처리를 이해하고 사용해야 합니다.
Future
는 미래의 결과에 대한 약속입니다. 이 약속은 성공적으로 값을 반환하거나, 혹은 오류를 반환하며 완료됩니다.async/await
는 비동기 코드를 작성하는 현대적이고 직관적인 방법입니다. 가독성과 유지보수성을 위해 콜백 기반의then()
보다async/await
사용을 적극 권장합니다.FutureOr
는 API 설계의 유연성을 더해줍니다. 특히 캐싱과 같이 동기적/비동기적 반환이 모두 가능한 상황에서 타입 안전성을 유지하며 간결한 코드를 작성할 수 있게 해줍니다.await
키워드는FutureOr
를 소비하는 가장 우아한 방법입니다.FutureBuilder
는 비동기 데이터를 Flutter UI에 연결하는 강력한 다리입니다. 로딩, 데이터, 오류 상태를 명확하게 분리하여 처리함으로써 사용자 경험을 크게 향상시킬 수 있습니다.
Future
와 FutureOr
를 마스터하는 것은 단순히 Dart의 문법을 배우는 것을 넘어, 효율적이고 안정적이며 사용자 친화적인 애플리케이션을 구축하는 능력의 기반이 됩니다. 이제 여러분은 어떤 비동기 시나리오를 마주하더라도, 그 동작 원리를 이해하고 최적의 해결책을 설계할 수 있는 단단한 기초를 다졌습니다.
여기서 멈추지 마십시오. 비동기 처리의 또 다른 한 축인 Stream
(시간에 따른 연속적인 비동기 이벤트의 흐름)에 대해 학습한다면, Flutter의 비동기 세계를 더욱 완벽하게 정복할 수 있을 것입니다. 자신감을 가지고 여러분의 다음 Flutter 프로젝트에 오늘 배운 지식을 마음껏 적용해 보시길 바랍니다.
0 개의 댓글:
Post a Comment