최신 애플리케이션 개발에서 비동기 프로그래밍은 더 이상 선택이 아닌 필수입니다. 사용자는 네트워크 요청, 데이터베이스 조회, 파일 시스템 접근과 같은 작업이 완료될 때까지 애플리케이션이 멈춰있는 '랙' 현상을 용납하지 않습니다. 즉, 오래 걸리는 작업을 백그라운드에서 처리하고, 그 결과를 사용자 인터페이스(UI)에 자연스럽게 반영하는 능력이 현대 앱의 핵심 경쟁력입니다. 플러터(Flutter)는 이러한 비동기 처리를 우아하게 다루기 위한 강력한 도구인 FutureBuilder를 제공합니다.
FutureBuilder는 이름에서 알 수 있듯이, 미래의 어느 시점에 완료될 작업(Future)의 상태 변화를 감지하고, 그 상태에 따라 UI를 동적으로 그려주는 선언적 위젯입니다. 단순히 작업이 끝났을 때 데이터를 보여주는 것을 넘어, 작업이 진행 중인 로딩 상태, 작업이 실패한 에러 상태까지 모두 손쉽게 처리할 수 있게 해줍니다. 이를 통해 개발자는 setState를 수동으로 호출하며 복잡한 상태 분기 로직을 관리해야 하는 부담에서 벗어나, 훨씬 더 깔끔하고 직관적인 코드를 작성할 수 있습니다.
이 글에서는 FutureBuilder의 기본적인 개념과 작동 원리부터 시작하여, 실제 애플리케이션에서 마주칠 수 있는 일반적인 함정과 이를 해결하는 모범 사례, 그리고 API 연동과 같은 실전 예제까지 심도 있게 다룰 것입니다. FutureBuilder를 올바르게 이해하고 활용한다면, 여러분의 플러터 애플리케이션은 한층 더 견고하고 사용자 친화적인 모습으로 거듭날 것입니다.
FutureBuilder의 핵심 구성 요소와 생명주기
FutureBuilder의 강력함을 제대로 활용하기 위해서는 내부 동작 방식을 이해하는 것이 중요합니다. FutureBuilder는 단 두 개의 핵심 속성, future와 builder를 통해 마법을 부립니다. 그리고 이 과정에서 `AsyncSnapshot`이라는 객체가 핵심적인 역할을 수행합니다.
주요 속성(Properties)
future: FutureBuilder가 수신 대기할Future객체를 지정합니다. 이Future는 네트워크 요청, 데이터베이스 쿼리 등 비동기적으로 데이터를 반환하는 모든 작업이 될 수 있습니다. FutureBuilder는 이future의 상태가 변경될 때마다 자신의builder를 다시 호출하여 UI를 갱신합니다.builder: UI를 실제로 그리는 역할을 하는 콜백 함수입니다.(BuildContext context, AsyncSnapshot<T> snapshot)형태의 매개변수를 받으며, 반드시 위젯을 반환해야 합니다.builder함수는future의 상태가 바뀔 때마다 실행되므로, 이 함수 내에서snapshot의 상태를 확인하고 그에 맞는 위젯을 반환하는 로직을 작성하게 됩니다.
핵심 데이터 객체: AsyncSnapshot
builder 함수가 받는 AsyncSnapshot 객체는 future의 현재 상태에 대한 모든 정보를 담고 있는 스냅샷입니다. 이 객체의 속성을 통해 우리는 비동기 작업의 진행 상황을 정확히 파악할 수 있습니다.
-
connectionState:future와의 연결 상태를 나타내는 가장 중요한 속성입니다.ConnectionState열거형(enum) 값을 가지며, 각 상태는 다음과 같은 의미를 지닙니다.ConnectionState.none:future가 아직 설정되지 않은 초기 상태입니다 (예:future속성이 null일 때).ConnectionState.waiting: 비동기 작업이 현재 진행 중인 상태입니다. 이 상태일 때 보통 로딩 인디케이터(CircularProgressIndicator)를 표시합니다.ConnectionState.active: 데이터 스트림(Stream)에서 활성 상태를 의미하지만, 하나의 값만 반환하는Future에서는 일반적으로 사용되지 않습니다. (StreamBuilder에서 주로 사용됩니다.)ConnectionState.done: 비동기 작업이 완료된 상태입니다. 작업이 성공적으로 데이터를 반환했거나, 오류와 함께 종료된 경우 모두 이 상태가 됩니다. 따라서 이 상태에서는hasData나hasError를 추가로 확인해야 합니다.
data:future가 성공적으로 완료되었을 때 반환된 데이터입니다. 데이터가 아직 도착하지 않았거나 오류가 발생한 경우 null이 될 수 있습니다. 타입은Future<T>의 제네릭 타입T와 일치합니다.error:future가 실패했을 때 발생한 오류 객체입니다. 오류가 발생하지 않았다면 null입니다.hasData:data가 null이 아닌 값을 가지고 있는지 여부를 나타내는 편리한 bool 속성입니다.hasError:error객체가 존재하는지 여부를 나타내는 bool 속성입니다.
이러한 구성 요소들을 조합하여 FutureBuilder는 다음과 같은 생명주기에 따라 동작합니다.
1. FutureBuilder가 위젯 트리에 처음 추가되면, future 속성에 제공된 Future 객체의 실행을 구독합니다.
2. Future가 실행을 시작하면, connectionState는 ConnectionState.waiting이 되고, FutureBuilder는 builder 함수를 호출하여 로딩 UI를 그립니다.
3. Future가 성공적으로 완료되어 데이터를 반환하면, connectionState는 ConnectionState.done이 되고, snapshot.data에 결과값이 채워집니다. FutureBuilder는 다시 builder를 호출하여 성공 UI를 그립니다.
4. Future가 실패하여 오류를 던지면, connectionState는 ConnectionState.done이 되고, snapshot.error에 오류 객체가 채워집니다. FutureBuilder는 builder를 호출하여 에러 UI를 그립니다.
기본 사용법과 권장 패턴
이론을 알았으니, 이제 실제 코드를 통해 FutureBuilder를 사용하는 방법을 살펴보겠습니다. 가장 기본적인 예제는 일정 시간 후에 문자열을 반환하는 비동기 함수를 만들고, 이를 FutureBuilder로 화면에 표시하는 것입니다.
1단계: 비동기 함수(Future) 정의
먼저, Future을 반환하는 간단한 비동기 함수를 작성합니다. 이 함수는 2초 동안 지연된 후, "데이터 로딩 완료!"라는 문자열을 반환합니다. 실제 앱에서는 이 부분이 API 호출이나 데이터베이스 조회 로직이 될 것입니다.
Future<String> fetchDelayedData() async {
// 2초 동안 대기하여 비동기 작업을 시뮬레이션합니다.
await Future.delayed(const Duration(seconds: 2));
// 성공적으로 데이터를 반환합니다.
// 실패 케이스를 테스트하려면 아래 주석을 해제하세요.
// throw Exception('데이터 로딩에 실패했습니다.');
return '데이터 로딩 완료!';
}
2단계: FutureBuilder 위젯 구현
이제 위에서 정의한 fetchDelayedData 함수를 FutureBuilder와 연결하여 UI를 구성합니다. 여기서 핵심은 builder 함수 내에서 snapshot의 connectionState를 확인하고 각 상태에 맞는 위젯을 반환하는 것입니다.
아래는 ConnectionState를 기반으로 분기하는 가장 안정적이고 권장되는 패턴입니다.
import 'package:flutter/material.dart';
class BasicFutureBuilderScreen extends StatelessWidget {
const BasicFutureBuilderScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('FutureBuilder 기본 예제'),
),
body: Center(
child: FutureBuilder<String>(
// 주의: 이 위치에 future 함수를 직접 호출하는 것은
// 특정 상황에서 문제를 일으킬 수 있습니다. (다음 섹션에서 설명)
future: fetchDelayedData(),
builder: (BuildContext context, AsyncSnapshot<String> snapshot) {
// 1. 가장 먼저 연결 상태를 확인합니다.
if (snapshot.connectionState == ConnectionState.waiting) {
// 데이터 로딩 중일 때
return const CircularProgressIndicator();
}
// 2. 연결이 완료된 후, 에러 유무를 확인합니다.
else if (snapshot.hasError) {
// 에러가 발생했을 때
return Text('에러 발생: ${snapshot.error}');
}
// 3. 에러가 없고, 데이터가 존재할 때 (가장 이상적인 상황)
else if (snapshot.hasData) {
// 성공적으로 데이터를 받았을 때
final String data = snapshot.data!;
return Text(
data,
style: const TextStyle(fontSize: 24),
);
}
// 4. 데이터도 에러도 없는 경우 (예: future가 null)
else {
return const Text('데이터 없음');
}
},
),
),
);
}
}
// 비동기 함수 (위에서 정의한 것과 동일)
Future<String> fetchDelayedData() async {
await Future.delayed(const Duration(seconds: 2));
return '데이터 로딩 완료!';
}
위 코드에서 builder 내부의 분기 순서는 매우 중요합니다. 항상 ConnectionState.waiting을 먼저 확인하여 로딩 상태를 처리하고, 그 후에 작업이 완료되었을(ConnectionState.done) 때를 가정하여 hasError와 hasData를 순차적으로 확인하는 것이 논리적으로 안전합니다. 이렇게 구성하면 앱이 실행될 때 2초 동안 로딩 인디케이터가 표시된 후, "데이터 로딩 완료!"라는 텍스트가 화면에 나타나게 됩니다.
가장 흔한 함정: `build` 메서드 내 Future 생성
FutureBuilder를 처음 사용하는 개발자들이 가장 흔하게 저지르는 실수는 build 메서드 내에서 Future를 반환하는 함수를 직접 호출하는 것입니다. 위의 기본 예제 코드도 사실 이 문제를 잠재적으로 포함하고 있습니다.
// ...
child: FutureBuilder<String>(
future: fetchDelayedData(), // <-- 이 부분이 문제의 소지가 있습니다.
builder: (context, snapshot) {
// ...
},
)
//...
무엇이 문제인가?
플러터에서 build 메서드는 위젯이 화면에 그려져야 할 때마다 호출됩니다. 이는 단순히 처음 화면이 로드될 때뿐만 아니라, 화면 회전, 키보드 등장, 부모 위젯의 상태 변경 등 다양한 이유로 매우 빈번하게 발생할 수 있습니다. 만약 future 속성에 fetchDelayedData()와 같이 함수 호출 자체를 넣어두면, build 메서드가 실행될 때마다 이 함수가 새롭게 호출됩니다. 그 결과, 비동기 작업이 계속해서 다시 시작되어 로딩 인디케이터만 무한히 깜빡이거나, 의도치 않은 네트워크 요청이 반복적으로 발생하는 심각한 문제를 야기합니다.
올바른 해결책: Future 객체의 생명주기 관리
이 문제를 해결하는 핵심은 Future 객체를 단 한 번만 생성하고, build 메서드가 여러 번 호출되더라도 동일한 Future 객체를 참조하도록 하는 것입니다. 이를 위한 가장 표준적인 방법은 StatefulWidget을 사용하는 것입니다.
`StatefulWidget`과 `initState` 활용하기
StatefulWidget의 State 객체는 위젯이 리빌드되어도 상태를 유지합니다. State의 생명주기 메서드 중 initState는 해당 위젯이 트리에 삽입될 때 단 한 번만 호출되므로, 비동기 작업을 시작하기에 가장 이상적인 장소입니다.
아래는 이 패턴을 적용한 올바른 코드입니다.
import 'package:flutter/material.dart';
class CorrectFutureBuilderScreen extends StatefulWidget {
const CorrectFutureBuilderScreen({Key? key}) : super(key: key);
@override
_CorrectFutureBuilderScreenState createState() => _CorrectFutureBuilderScreenState();
}
class _CorrectFutureBuilderScreenState extends State<CorrectFutureBuilderScreen> {
// 1. Future 객체를 State의 멤버 변수로 선언합니다.
late final Future<String> _myFuture;
@override
void initState() {
super.initState();
// 2. initState에서 Future를 딱 한 번만 생성하여 변수에 할당합니다.
_myFuture = fetchDelayedData();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('올바른 FutureBuilder 사용법'),
),
body: Center(
child: FutureBuilder<String>(
// 3. build 메서드에서는 미리 생성해 둔 Future 변수를 참조하기만 합니다.
future: _myFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
} else if (snapshot.hasError) {
return Text('에러 발생: ${snapshot.error}');
} else if (snapshot.hasData) {
return Text(
snapshot.data!,
style: const TextStyle(fontSize: 24),
);
} else {
return const Text('데이터 없음');
}
},
),
),
);
}
}
// 비동기 함수
Future<String> fetchDelayedData() async {
print('fetchDelayedData 함수 호출됨!'); // 호출 시점을 확인하기 위한 로그
await Future.delayed(const Duration(seconds: 2));
return '데이터 로딩 완료!';
}
위 코드처럼 수정하면, 화면 회전과 같은 이유로 build 메서드가 아무리 많이 호출되어도 fetchDelayedData 함수는 최초 한 번만 실행됩니다. FutureBuilder는 이미 진행 중이거나 완료된 _myFuture의 최신 상태를 계속해서 참조하므로, 데이터가 로딩된 후에는 그 상태가 안정적으로 유지됩니다.
이 패턴은 FutureBuilder를 사용할 때 반드시 기억해야 할 가장 중요한 원칙입니다. 복잡한 애플리케이션에서는 Riverpod, Provider, BLoC와 같은 상태 관리 라이브러리를 통해 Future의 생명주기를 관리하는 것이 더 나은 아키텍처가 될 수 있습니다.
실전 활용: API 연동 및 데이터 모델링
이제 실제 애플리케이션과 유사한 시나리오, 즉 외부 API를 호출하여 JSON 데이터를 가져와 화면에 목록 형태로 표시하는 예제를 만들어 보겠습니다. 이 과정에서 단순히 데이터를 가져오는 것을 넘어, 가져온 데이터를 Dart 객체로 변환(모델링)하는 좋은 습관도 함께 다룹니다.
1단계: 의존성 추가
HTTP 통신을 위해 `http` 패키지가 필요합니다. `pubspec.yaml` 파일에 다음과 같이 추가하고 패키지를 설치해주세요.
dependencies:
flutter:
sdk: flutter
http: ^1.1.0 # 최신 버전으로 사용하는 것을 권장합니다.
2단계: 데이터 모델 클래스 작성
API로부터 받는 JSON 데이터의 구조에 맞춰 Dart 클래스를 만드는 것은 매우 중요합니다. 이는 코드의 안정성을 높이고, 오타로 인한 오류를 방지하며, 자동 완성의 이점을 누릴 수 있게 해줍니다. 이번 예제에서는 JSONPlaceholder의 `/posts` 엔드포인트를 사용하겠습니다. 응답 데이터의 형태는 다음과 같습니다.
[
{
"userId": 1,
"id": 1,
"title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
"body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto"
},
...
]
이 구조에 맞는 `Post` 모델 클래스를 작성합니다.
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'] as int,
id: json['id'] as int,
title: json['title'] as String,
body: json['body'] as String,
);
}
}
fromJson 팩토리 생성자를 만들어두면, Map 형태의 JSON 데이터를 손쉽게 Post 객체 인스턴스로 변환할 수 있습니다.
3단계: API 호출 서비스 함수 작성
API를 호출하고 응답을 파싱하여 Future<List<Post>>를 반환하는 함수를 작성합니다. 이 로직을 별도의 함수나 클래스로 분리하면 재사용성이 높아집니다.
import 'dart:convert';
import 'package:http/http.dart' as http;
// ... Post 클래스 정의 ...
Future<List<Post>> fetchPosts() async {
final response = await http.get(Uri.parse('https://jsonplaceholder.typicode.com/posts'));
if (response.statusCode == 200) {
// 응답 본문(JSON 문자열)을 Dart 리스트로 디코딩
final List<dynamic> body = json.decode(response.body);
// 리스트의 각 아이템(Map)을 Post 객체로 변환
final List<Post> posts = body.map((dynamic item) => Post.fromJson(item)).toList();
return posts;
} else {
// 응답이 성공적이지 않으면 에러를 던집니다.
throw Exception('포스트 목록을 불러오는데 실패했습니다.');
}
}
4단계: FutureBuilder와 ListView.builder로 UI 구성
이제 모든 준비가 끝났습니다. 앞서 배운 'StatefulWidget과 initState' 패턴을 사용하여 `fetchPosts` 함수를 호출하고, FutureBuilder를 통해 결과를 `ListView.builder`로 화면에 렌더링합니다.
import 'package:flutter/material.dart';
// http, model, service 함수들을 import 합니다.
class PostListScreen extends StatefulWidget {
const PostListScreen({Key? key}) : super(key: key);
@override
_PostListScreenState createState() => _PostListScreenState();
}
class _PostListScreenState extends State<PostListScreen> {
late final Future<List<Post>> _postsFuture;
@override
void initState() {
super.initState();
_postsFuture = fetchPosts();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('API 연동 예제'),
),
body: FutureBuilder<List<Post>>(
future: _postsFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
} else if (snapshot.hasError) {
return Center(child: Text('에러: ${snapshot.error}'));
} else if (snapshot.hasData) {
final List<Post> posts = snapshot.data!;
// 데이터가 성공적으로 로드되면 ListView를 표시
return ListView.builder(
itemCount: posts.length,
itemBuilder: (context, index) {
final Post post = posts[index];
return Card(
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
child: ListTile(
leading: CircleAvatar(child: Text(post.id.toString())),
title: Text(post.title),
subtitle: Text(
post.body,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
);
},
);
} else {
return const Center(child: Text('데이터가 없습니다.'));
}
},
),
);
}
}
이 코드는 견고한 비동기 UI 처리의 완벽한 예시입니다. 로딩 중, 에러 발생, 데이터 성공의 세 가지 상태를 명확하게 처리하며, `ListView.builder`를 사용하여 긴 목록도 효율적으로 렌더링합니다. 또한, `Post` 모델 클래스를 사용하여 타입 안정성을 확보하고 코드 가독성을 높였습니다.
FutureBuilder 심화 기법과 고려사항
FutureBuilder의 기본 사용법에 익숙해졌다면, 이제 몇 가지 심화 기법을 통해 더욱 다양한 상황에 유연하게 대처할 수 있습니다.
`initialData` 속성 활용하기
initialData 속성을 사용하면 future가 완료되기 전에 표시할 초기 데이터를 제공할 수 있습니다. 이는 다음과 같은 상황에서 유용합니다.
- 캐시된 데이터가 있어 로딩 중에도 이전 데이터를 잠시 보여주고 싶을 때
future가 매우 빨리 완료될 것으로 예상되어 로딩 인디케이터가 잠시 깜빡이는(flickering) 현상을 방지하고 싶을 때
initialData가 제공되면 FutureBuilder는 future가 완료되기 전까지 snapshot.data에 initialData 값을 담아 builder를 호출합니다. 따라서 connectionState가 waiting일 때도 데이터를 화면에 표시할 수 있습니다.
FutureBuilder<int>(
future: _getCounterValue(),
initialData: 0, // 카운터의 초기값으로 0을 설정
builder: (context, snapshot) {
// future가 완료되지 않았어도 snapshot.data는 0을 가짐
return Text('현재 카운트: ${snapshot.data}');
},
)
새로고침 기능 구현하기
사용자가 데이터를 새로고침하기를 원할 때, 예를 들어 화면을 아래로 당겨서 새로고침(Pull-to-refresh)하거나 새로고침 버튼을 누를 때, 새로운 Future를 실행해야 합니다. FutureBuilder 자체에는 새로고침 기능이 내장되어 있지 않으므로, 개발자가 직접 구현해야 합니다.
핵심은 setState를 호출하여 State의 Future 변수에 새로운 Future 인스턴스를 할당하는 것입니다. setState가 호출되면 위젯이 리빌드되고, FutureBuilder는 새로운 future를 감지하여 비동기 작업을 다시 시작합니다.
// ... _PostListScreenState 내부에 ...
void _refreshPosts() {
setState(() {
// _postsFuture에 새로운 Future를 할당하여 FutureBuilder가 재실행되도록 함
_postsFuture = fetchPosts();
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('API 연동 예제'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _refreshPosts, // 새로고침 버튼
),
],
),
body: RefreshIndicator( // Pull-to-refresh 기능 추가
onRefresh: () async {
// onRefresh 콜백은 Future를 반환해야 함
// 기존 Future가 완료될 때까지 기다리도록 구현할 수도 있지만,
// UI 갱신을 위해 새로운 Future를 할당하는 것이 일반적임
setState(() {
_postsFuture = fetchPosts();
});
await _postsFuture; // 새로 할당된 Future가 끝날 때까지 대기
},
child: FutureBuilder<List<Post>>(
future: _postsFuture,
builder: (context, snapshot) {
// ... 기존 builder 로직 ...
},
),
),
);
}
위 예제에서는 `AppBar`에 새로고침 버튼을 추가하고, `RefreshIndicator` 위젯으로 전체 `FutureBuilder`를 감싸 두 가지 방식의 새로고침을 모두 구현했습니다. 두 방식 모두 `setState`를 통해 _postsFuture` 변수를 갱신하는 동일한 원리로 동작합니다.
결론
FutureBuilder는 플러터에서 비동기 데이터를 UI에 반영하는 가장 기본적이면서도 강력한 도구입니다. 비동기 작업의 '로딩 중', '성공', '실패' 상태를 선언적으로 처리함으로써, 복잡한 상태 관리 로직을 크게 단순화하고 코드의 가독성과 유지보수성을 향상시킵니다.
이 글을 통해 우리는 다음의 핵심 사항들을 배웠습니다.
- FutureBuilder는
future와builder속성을 통해 동작하며,AsyncSnapshot으로 비동기 작업의 상태를 전달합니다. - 가장 흔하지만 치명적인 실수는
build메서드 내에서Future를 생성하는 것이며, 이는StatefulWidget의initState를 사용하여 해결해야 합니다. - 실제 앱 개발에서는 API로부터 받은 JSON을 Dart 모델 클래스로 변환하여 타입 안정성을 확보하는 것이 중요합니다.
initialData를 사용해 초기 UI를 제공하거나,setState를 이용해 사용자가 직접 데이터를 새로고침하는 기능을 구현할 수 있습니다.
FutureBuilder의 원리를 정확히 이해하고 올바른 패턴을 적용한다면, 어떤 비동기 작업이라도 매끄럽고 안정적으로 처리하는 반응형 UI를 구축할 수 있을 것입니다. 이는 결국 사용자에게 더 나은 경험을 제공하고, 개발자에게는 더 즐거운 개발 경험을 선사할 것입니다.
0 개의 댓글:
Post a Comment