구글의 플러터(Flutter)는 단일 코드베이스로 여러 플랫폼에서 미려하고 빠른 네이티브 애플리케이션을 구축할 수 있는 혁신적인 UI 툴킷입니다. 개발자들은 플러터의 풍부한 위젯 라이브러리와 선언적 UI 구조 덕분에 시각적으로 매력적인 인터페이스를 손쉽게 구현할 수 있습니다. 하지만 사용자의 눈을 사로잡는 화려한 디자인은 훌륭한 앱 경험의 시작일 뿐입니다. 진정한 만족감은 앱이 사용자의 모든 터치와 스와이프에 즉각적으로 반응하고, 물 흐르듯 부드러운 애니메이션을 보여주며, 데이터를 기다리는 동안에도 멈춤 없이 상호작용이 가능할 때 완성됩니다. 바로 이 지점에서 플러터의 심장과도 같은 비동기 프로그래밍 모델이 그 진가를 발휘합니다.
많은 개발자들이 async/await, Isolate, Stream이라는 단어에 익숙하지만, 이들을 단순히 '느린 작업을 처리하는 방법' 정도로만 이해하는 경우가 많습니다. 본 글에서는 이러한 표면적인 이해를 넘어, 각 요소가 Dart의 이벤트 기반 아키텍처 내에서 어떻게 동작하는지, 그리고 이들을 언제, 왜, 어떻게 사용해야 하는지에 대한 깊이 있는 통찰을 제공하고자 합니다. 이는 단순히 고성능 앱을 만드는 기술을 넘어, 사용자 경험의 질을 근본적으로 향상시키는 철학에 대한 이야기입니다. UI '버벅임(Jank)' 현상을 제거하고 사용자가 사랑에 빠질 수밖에 없는 앱을 만드는 여정을 지금부터 함께 시작하겠습니다.
근본적인 도전: 단 하나의 UI 스레드를 지켜라
플러터 애플리케이션의 심장부에는 '이벤트 루프(Event Loop)'를 실행하는 단 하나의 메인 스레드, 즉 UI 스레드가 존재합니다. 이 스레드를 60Hz 디스플레이에서는 1초에 60번, 120Hz 디스플레이에서는 120번씩 화면을 새로 그려야 하는 극도로 성실하지만 한 번에 한 가지 일밖에 못 하는 장인에 비유해 봅시다. 이 장인에게 주어진 시간은 각 프레임당 약 16.6밀리초(ms) 또는 8.3밀리초에 불과합니다. 이 짧은 시간 안에 레이아웃 계산, 페인팅, 사용자 입력 처리 등 모든 UI 관련 작업을 마쳐야 합니다.
만약 이 중요한 장인에게 시간이 오래 걸리는 작업을 시킨다면 어떻게 될까요? 예를 들어, 네트워크를 통해 수 메가바이트(MB)의 이미지를 다운로드하거나, 복잡한 JSON 데이터를 파싱하거나, 암호화 알고리즘을 실행하는 등의 작업을 명령했다고 상상해 보십시오. 장인은 그 작업이 끝날 때까지 화면을 그리는 본연의 임무를 완전히 멈출 것입니다. 그 결과는 참혹합니다. 사용자가 보기에는 앱이 그대로 '얼어붙은' 상태, 즉 '버벅임'이 발생하며, 어떤 터치에도 반응하지 않는 최악의 사용자 경험으로 이어집니다. 비동기 프로그래밍은 바로 이러한 재앙을 막기 위한 핵심 전략입니다. 시간이 많이 소요되는 무거운 작업을 다른 작업자(또는 다른 시간)에게 위임함으로써, 우리의 소중한 UI 스레드 장인이 오로지 화면을 그리고 사용자 상호작용에 응답하는 본질적인 임무에만 집중할 수 있도록 환경을 조성해주는 것입니다.
1. `async`와 `await`: 비동기 코드의 문법적 설탕, 그 이상의 의미
가장 흔하게 마주치는 비동기 시나리오는 프로그램이 외부 리소스의 응답을 수동적으로 '기다려야' 하는 I/O(Input/Output) 바운드 작업입니다. 네트워크 API 호출, 데이터베이스 쿼리, 파일 읽기/쓰기 등이 대표적인 예입니다. Dart 언어는 `async`와 `await`라는 키워드를 통해 이러한 상황을 마치 동기 코드처럼 순차적이고 깔끔하게 작성할 수 있도록 지원합니다. 하지만 이 키워드들은 단순한 편의 기능을 넘어, Dart의 이벤트 루프와 깊이 연관되어 동작합니다.
원문에서 언급한 카페 비유는 매우 훌륭합니다. 주문(`await` 호출)을 하고 받은 영수증(`Future` 객체)은 '언젠가 커피가 준비될 것'이라는 약속입니다. 중요한 것은 영수증을 받고 바리ста 앞에서 멍하니 기다리는 것이 아니라, 그 시간 동안 다른 일(UI 렌더링, 다른 이벤트 처리)을 할 수 있다는 점입니다. `await`는 스레드를 차단하는 것이 아니라, 해당 `async` 함수의 실행을 '일시 중지'하고 제어권을 이벤트 루프에 돌려줍니다. 이벤트 루프는 그동안 다른 이벤트를 처리하다가, 약속된 `Future`가 완료되면(커피가 준비되면) 중지되었던 `async` 함수의 다음 코드부터 실행을 재개합니다.
핵심 개념 깊이 보기:
Future: 비동기 작업의 결과를 담는 상자입니다. 처음에는 '미완료(uncompleted)' 상태이며, 내부에 값이 없습니다. 작업이 성공적으로 끝나면 '완료(completed with a value)' 상태가 되어 값을 담게 되고, 실패하면 '완료(completed with an error)' 상태가 되어 오류 정보를 담습니다.async: 함수 선언부에 이 키워드를 붙이면, 해당 함수는 이제 비동기 함수가 됩니다. 이 함수의 반환 값은 명시적으로 `Future`를 반환하지 않더라도 자동으로 `Future`로 감싸집니다. 예를 들어, `async int myFunction()`은 실제로는 `Future`를 반환합니다. await: 이 키워드는 `async` 함수 내에서만 사용할 수 있으며, `Future` 객체 앞에 위치합니다. `await`를 만나면 Dart는 해당 `Future`가 완료될 때까지 함수의 실행을 잠시 멈추고, CPU 제어권을 다른 작업을 처리할 수 있도록 이벤트 루프에 양보합니다. `Future`가 완료되면, 멈췄던 바로 그 지점부터 실행을 이어갑니다.
이벤트 루프와 마이크로태스크 큐
이 동작을 더 정확히 이해하려면 Dart의 이벤트 처리 모델을 알아야 합니다. Dart는 두 개의 큐(Queue)를 가집니다: **이벤트 큐(Event Queue)**와 **마이크로태스크 큐(Microtask Queue)**입니다.
- 이벤트 큐: 외부 이벤트(I/O, 타이머, 사용자 입력 등)와 관련된 코드들이 들어가는 곳입니다.
- 마이크로태스크 큐: 주로 `Future.then()` 콜백이나 `await` 이후의 코드처럼, 현재 작업에 바로 이어지는 짧은 비동기 코드들이 들어갑니다. 마이크로태스크 큐는 이벤트 큐보다 우선순위가 높아서, 큐에 항목이 있다면 이벤트 루프는 이벤트 큐를 확인하기 전에 마이크로태스크 큐를 먼저 모두 비웁니다.
`await`를 사용한 코드가 `Future`가 완료된 후 다시 실행될 때, 그 המשך(continuation) 코드는 마이크로태스크 큐에 등록됩니다. 이 덕분에 다른 중요한 이벤트들(예: 다음 프레임 렌더링)을 방해하지 않으면서도 비교적 신속하게 작업의 후처리를 이어갈 수 있습니다.
실용적인 예제: 사용자 데이터 로딩 UI 심화
단순히 데이터를 가져오는 것을 넘어, 로딩, 에러, 데이터 없음, 데이터 성공 등 다양한 UI 상태를 처리하는 견고한 예제를 살펴봅시다. `FutureBuilder`는 이런 패턴을 구현하는 데 매우 유용하지만, 상태 관리 솔루션(Provider, Riverpod, BLoC 등)과 결합하면 더욱 강력하고 테스트 가능한 코드를 작성할 수 있습니다.
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:flutter/material.dart';
// 데이터 모델 정의
class UserProfile {
final int id;
final String name;
final String email;
UserProfile({required this.id, required this.name, required this.email});
factory UserProfile.fromJson(Map<String, dynamic> json) {
return UserProfile(
id: json['id'],
name: json['name'],
email: json['email'],
);
}
}
// API 서비스를 담당하는 클래스
class ApiService {
Future<UserProfile> fetchUserProfile(int userId) async {
// 2초 지연을 시뮬레이션하여 네트워크 지연을 체감할 수 있게 함
await Future.delayed(const Duration(seconds: 2));
try {
final response = await http
.get(Uri.parse('https://jsonplaceholder.typicode.com/users/$userId'));
if (response.statusCode == 200) {
return UserProfile.fromJson(jsonDecode(response.body));
} else if (response.statusCode == 404) {
throw Exception('사용자를 찾을 수 없습니다.');
} else {
throw Exception('서버 오류가 발생했습니다: ${response.statusCode}');
}
} on TimeoutException catch (_) {
throw Exception('요청 시간이 초과되었습니다. 네트워크 연결을 확인하세요.');
} catch (e) {
// 이미 Exception 객체인 경우 그대로 다시 던지고, 아닌 경우 새로 만듦
throw Exception('데이터를 불러오는 중 알 수 없는 오류 발생: $e');
}
}
}
// 이 함수를 호출하는 Flutter 위젯
class UserProfileWidget extends StatefulWidget {
final ApiService apiService;
const UserProfileWidget({Key? key, required this.apiService}) : super(key: key);
@override
_UserProfileWidgetState createState() => _UserProfileWidgetState();
}
class _UserProfileWidgetState extends State<UserProfileWidget> {
late Future<UserProfile> _userProfileFuture;
@override
void initState() {
super.initState();
// initState에서 Future를 한 번만 호출하는 것이 중요.
// build 메서드에서 호출하면 setState 등으로 리빌드될 때마다 API가 호출됨.
_userProfileFuture = widget.apiService.fetchUserProfile(1);
}
@override
Widget build(BuildContext context) {
return Center(
child: FutureBuilder<UserProfile>(
future: _userProfileFuture,
builder: (context, snapshot) {
// 1. 연결 상태 확인 (로딩 중)
if (snapshot.connectionState == ConnectionState.waiting) {
return const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('사용자 정보를 가져오는 중...'),
],
);
}
// 2. 에러 상태 확인
else if (snapshot.hasError) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, color: Colors.red, size: 48),
SizedBox(height: 16),
Text('오류: ${snapshot.error}', textAlign: TextAlign.center),
SizedBox(height: 16),
ElevatedButton(
onPressed: () {
setState(() {
// 재시도 로직
_userProfileFuture = widget.apiService.fetchUserProfile(1);
});
},
child: const Text('다시 시도'),
)
],
);
}
// 3. 데이터 성공 상태 확인
else if (snapshot.hasData) {
final user = snapshot.data!;
return Card(
elevation: 4,
margin: const EdgeInsets.all(16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(user.name, style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 8),
Text(user.email),
const SizedBox(height: 4),
Text('ID: ${user.id}'),
],
),
),
);
}
// 4. 데이터가 없는 예외적인 상태
else {
return const Text('표시할 데이터가 없습니다.');
}
},
),
);
}
}
이 예제는 FutureBuilder를 사용하여 비동기 작업의 전체 생명주기(대기, 오류, 성공)에 따라 다른 UI를 보여주는 견고한 패턴을 제시합니다. 특히 `initState`에서 `Future`를 초기화하여 불필요한 API 호출을 방지하고, 오류 발생 시 사용자에게 명확한 피드백과 함께 재시도 옵션을 제공하는 것이 중요합니다.
2. `Isolate`: 고립된 메모리 공간에서의 진정한 병렬 처리
만약 처리해야 할 작업이 I/O를 기다리는 것이 아니라, CPU 자체의 연산 능력을 집중적으로 요구하는 CPU 바운드 작업이라면 어떨까요? 예를 들어, 고해상도 이미지에 복잡한 필터를 적용하거나, 수십만 개의 항목이 있는 리스트를 정렬/필터링하거나, 거대한 JSON 문자열을 파싱하여 복잡한 객체 그래프로 변환하는 작업들이 여기에 해당합니다. 이러한 작업을 `async/await`만으로 처리하려고 하면, 해당 코드가 실행되는 동안 UI 스레드는 여전히 차단되어 버벅임을 유발합니다. `async/await`는 기다리는 동안 다른 일을 할 수 있게 해줄 뿐, 계산 자체를 다른 곳에서 대신해주지는 않기 때문입니다.
이때 필요한 것이 바로 `Isolate`입니다. Isolate는 Dart가 동시성(Concurrency)을 달성하는 방법으로, 전통적인 스레드(Thread)와는 근본적으로 다른 모델을 가집니다. 가장 큰 차이점은 **메모리를 공유하지 않는다(No Shared Memory)**는 점입니다.
기존의 스레드 모델에서는 여러 스레드가 동일한 메모리 공간에 접근할 수 있습니다. 이는 데이터 공유가 쉽다는 장점이 있지만, 두 개 이상의 스레드가 동시에 같은 데이터에 접근하여 수정하려고 할 때 발생하는 '경쟁 상태(Race Condition)'나, 서로가 점유한 리소스를 기다리며 무한 대기하는 '교착 상태(Deadlock)'와 같은 치명적이고 디버깅하기 어려운 문제들을 야기합니다. Isolate는 이러한 문제들을 원천적으로 차단합니다. 각 Isolate는 자신만의 독립적인 메모리 힙(Heap)과 이벤트 루프를 가집니다. 마치 별도의 프로그램처럼 완전히 '격리(isolated)'되어 실행됩니다.
Traditional Threads Dart Isolates
+---------------------------+ +---------------------------+
| Shared Memory | | Main Isolate Memory |
| +-------+ +-------+ | +---------------------------+
| | Data |<--|ThreadA| | | UI & Event Loop |
| | (Lock?)| +-------+ | +---------------------------+
| | |<--|ThreadB| | ^ |
| +-------+ +-------+ | | v (Messages via Ports)
| | +---------------------------+
| (Potential for races, | | Background Isolate Mem |
| deadlocks, complexity) | +---------------------------+
+---------------------------+ | Heavy Computation |
+---------------------------+
그렇다면 Isolate들은 어떻게 서로 통신할까요? 바로 '포트(Ports)'를 통한 메시지 패싱(Message Passing) 방식을 사용합니다. 한 Isolate가 다른 Isolate에게 데이터를 보내고 싶으면, 데이터의 복사본을 만들어 포트를 통해 메시지로 전달합니다. 데이터를 직접 공유하는 것이 아니라, 안전하게 복사하여 전달하므로 동시성 문제가 발생하지 않습니다. 이는 방음 시설이 완비된 작업실에 있는 전문가에게 서류를 전달하여 작업을 지시하고, 작업이 끝나면 결과 보고서를 받는 것과 같습니다. 당신은 전문가가 일하는 동안 소음이나 방해 없이 당신의 일을 계속할 수 있습니다.
더 쉬운 접근법: `compute` 함수
`Isolate.spawn`과 `ReceivePort`, `SendPort`를 사용하여 수동으로 Isolate를 생성하고 통신 채널을 설정하는 것은 꽤 번거로운 작업입니다. 다행히도 Flutter 프레임워크는 이러한 복잡성을 추상화한 `compute`라는 매우 편리한 헬퍼 함수를 제공합니다. `compute` 함수는 다음과 같은 일을 대신 처리해줍니다.
- 새로운 Isolate를 생성합니다.
- 지정한 함수를 그 Isolate에서 실행하도록 지시합니다.
- 함수에 필요한 인자를 메시지로 전달합니다.
- 함수의 실행이 끝나면, 그 결과값을 다시 원래의 Isolate로 메시지로 받아옵니다.
- 새로 생성했던 Isolate를 정리(kill)합니다.
- 최종 결과값을 `Future`로 감싸서 반환합니다.
개발자 입장에서는 그저 백그라운드에서 실행하고 싶은 함수와 인자만 `compute`에 넘겨주면, 그 결과가 `Future`로 돌아오므로 `await` 키워드를 사용하여 손쉽게 결과를 받아볼 수 있습니다.
실용적인 예제: 거대한 JSON 데이터 안전하게 파싱하기
API로부터 수만 개의 사진 정보를 담은 거대한 JSON 배열을 수신했다고 가정해 봅시다. `jsonDecode` 함수 자체는 매우 빠르지만, 이처럼 큰 데이터에 대해서는 UI 스레드에서 실행하기에 부담스러울 수 있습니다. `compute`를 사용하면 이 파싱 작업을 다른 Isolate로 안전하게 위임할 수 있습니다.
import 'dart:convert';
import 'package:flutter/foundation.dart'; // compute 함수를 위해 필요
import 'package:http/http.dart' as http;
// 이 함수는 반드시 최상위 함수(top-level function)이거나
// static 메서드여야 Isolate로 전달될 수 있습니다.
// Isolate는 클래스의 인스턴스 메서드와 그 컨텍스트(this)를 공유할 수 없기 때문입니다.
List<Photo> _parsePhotos(String responseBody) {
// 이 코드는 이제 백그라운드 Isolate에서 실행됩니다.
final parsed = jsonDecode(responseBody).cast<Map<String, dynamic>>();
return parsed.map<Photo>((json) => Photo.fromJson(json)).toList();
}
// 간단한 사진 데이터 모델
class Photo {
final int albumId;
final int id;
final String title;
final String url;
final String thumbnailUrl;
Photo({
required this.albumId,
required this.id,
required this.title,
required this.url,
required this.thumbnailUrl,
});
factory Photo.fromJson(Map<String, dynamic> json) {
return Photo(
albumId: json['albumId'],
id: json['id'],
title: json['title'],
url: json['url'],
thumbnailUrl: json['thumbnailUrl'],
);
}
}
// 메인 코드(UI 스레드)에서 호출하는 함수
class PhotoRepository {
Future<List<Photo>> fetchPhotos() async {
final response = await http.get(Uri.parse('https://jsonplaceholder.typicode.com/photos'));
if (response.statusCode == 200) {
// `compute` 함수를 호출하여 _parsePhotos 함수를 백그라운드 Isolate에서 실행.
// response.body라는 큰 문자열이 다른 Isolate로 복사되어 전달됩니다.
// 파싱 작업이 UI를 멈추게 하는 것을 완벽하게 방지합니다.
return compute(_parsePhotos, response.body);
} else {
throw Exception('사진 데이터를 불러오는 데 실패했습니다.');
}
}
}
// 이 Repository를 사용하는 Flutter 위젯에서는
// PhotoRepository().fetchPhotos()를 호출하고 FutureBuilder 등으로 처리하면 됩니다.
주의할 점은 `compute`에 전달하는 함수는 반드시 최상위 함수이거나 `static` 메서드여야 한다는 것입니다. 이는 Isolate가 메모리를 공유하지 않기 때문에, 특정 클래스 인스턴스에 종속된 메서드(와 그 인스턴스의 상태)를 다른 Isolate로 보낼 수 없기 때문입니다.
3. `Stream`: 시간에 따라 흐르는 비동기 이벤트의 강
`Future`가 단 한 번의 비동기 결과(성공 또는 실패)를 나타내는 것과 달리, `Stream`은 시간에 따라 발생하는 0개 이상의 비동기 이벤트 시퀀스를 나타냅니다. `Future`가 택배 배송이라면, `Stream`은 신문 구독이나 유튜브 라이브 스트리밍과 같습니다. 한 번 연결을 설정해두면 데이터가 계속해서 도착할 수 있습니다.
Stream은 본질적으로 비동기적인 이터러블(iterable)입니다. `for` 루프를 통해 동기적으로 리스트의 항목을 하나씩 꺼내보는 것처럼, `await for` 루프를 사용하면 스트림에서 데이터가 발생할 때마다 비동기적으로 하나씩 처리할 수 있습니다. Stream은 특히 다음과 같은 상황에서 매우 강력합니다.
- 실시간 데이터 업데이트: Firebase Realtime Database나 Firestore의 변경 사항을 구독하거나, 웹소켓(WebSocket)을 통해 서버로부터 지속적인 푸시 메시지를 받을 때.
- 사용자 입력 처리: 사용자가 텍스트 필드에 입력하는 내용을 실시간으로 감지하고, 자동 완성 추천이나 유효성 검사를 수행할 때.
- 반복적인 이벤트: `Timer.periodic`처럼 일정 간격으로 이벤트를 발생시켜야 할 때.
- 파일 I/O: 대용량 파일을 작은 덩어리(chunk)로 나누어 점진적으로 읽거나 쓸 때.
`StreamBuilder`를 이용한 실시간 UI 업데이트
플러터에서는 `StreamBuilder` 위젯을 사용하여 Stream의 변화에 따라 UI를 자동으로 다시 그릴 수 있습니다. `StreamBuilder`는 주어진 Stream을 구독(listen)하고 있다가, 새로운 데이터가 도착하거나(`onData`), 오류가 발생하거나(`onError`), Stream이 닫히면(`onDone`) builder 함수를 다시 호출하여 UI를 업데이트합니다.
실용적인 예제: 실시간 검색 창 구현
사용자 입력에 즉각적으로 반응하는 검색 기능을 만들어 봅시다. 여기서는 `StreamController`를 사용하여 사용자의 입력 이벤트를 프로그래매틱하게 Stream으로 변환하고, 이를 처리하여 검색 결과를 보여주는 예제입니다.
import 'dart:async';
import 'package:flutter/material.dart';
// 가짜 데이터 소스
const List<String> allItems = [
'Apple', 'Banana', 'Cherry', 'Date', 'Elderberry',
'Fig', 'Grape', 'Honeydew', 'Kiwi', 'Lemon',
];
class RealtimeSearchWidget extends StatefulWidget {
const RealtimeSearchWidget({Key? key}) : super(key: key);
@override
_RealtimeSearchWidgetState createState() => _RealtimeSearchWidgetState();
}
class _RealtimeSearchWidgetState extends State<RealtimeSearchWidget> {
// 1. StreamController 생성: 외부에서 이벤트를 스트림에 추가할 수 있게 해줌.
final _searchController = StreamController<String>();
// 검색 로직 (실제 앱에서는 API 호출 등이 될 수 있음)
Stream<List<String>> _performSearch(String query) async* {
// 쿼리가 비어있으면 빈 리스트를 반환
if (query.isEmpty) {
yield [];
return;
}
// 실제 네트워크 요청 등을 시뮬레이션하기 위한 지연
await Future.delayed(const Duration(milliseconds: 500));
final results = allItems
.where((item) => item.toLowerCase().contains(query.toLowerCase()))
.toList();
// 검색 결과를 stream으로 방출(yield)
yield results;
}
@override
void dispose() {
// 2. 중요: StreamController는 반드시 dispose에서 닫아주어야 메모리 누수를 막을 수 있음.
_searchController.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
TextField(
decoration: const InputDecoration(
labelText: '과일 검색',
border: OutlineInputBorder(),
),
// 3. 텍스트 필드의 내용이 변경될 때마다 StreamController에 새로운 값을 추가.
onChanged: (query) {
_searchController.add(query);
},
),
const SizedBox(height: 16),
Expanded(
// 4. StreamBuilder가 searchController의 stream을 구독.
child: StreamBuilder<String>(
stream: _searchController.stream,
builder: (context, snapshot) {
final query = snapshot.data ?? '';
// 5. 입력이 바뀔 때마다 검색 수행 스트림을 새로 생성하여 구독
return StreamBuilder<List<String>>(
stream: _performSearch(query),
builder: (context, searchSnapshot) {
if (searchSnapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (searchSnapshot.hasError) {
return Center(child: Text('오류: ${searchSnapshot.error}'));
}
final results = searchSnapshot.data ?? [];
if (results.isEmpty && query.isNotEmpty) {
return const Center(child: Text('검색 결과가 없습니다.'));
}
return ListView.builder(
itemCount: results.length,
itemBuilder: (context, index) {
return ListTile(title: Text(results[index]));
},
);
},
);
},
),
),
],
),
);
}
}
이 예제는 중첩된 `StreamBuilder`를 사용합니다. 바깥쪽 `StreamBuilder`는 사용자 입력을 감지하고, 안쪽 `StreamBuilder`는 그 입력을 기반으로 실제 검색을 수행하고 결과를 표시합니다. 실제 애플리케이션에서는 `rxdart` 패키지의 `debounceTime` 연산자를 스트림에 추가하여, 사용자가 타이핑을 멈춘 후에만 검색을 실행하도록 최적화할 수도 있습니다.
결론: 언제 무엇을 사용해야 하는가? 종합적인 의사결정 가이드
지금까지 플러터의 세 가지 핵심 비동기 도구를 깊이 있게 탐구했습니다. 이들을 마스터하고 적재적소에 사용하는 것은 버벅임 없는 고성능 애플리케이션을 구축하는 데 필수적입니다. 다음은 주어진 상황에 가장 적합한 도구를 선택하는 데 도움이 되는 구체적인 의사결정 가이드입니다.
| 도구 | 핵심 특징 | 주요 사용 사례 | 주의할 점 |
|---|---|---|---|
async/await와 Future |
단일 비동기 결과를 처리. UI 스레드를 차단하지 않고 '기다림'. 주로 I/O 바운드 작업에 적합. |
|
CPU를 많이 사용하는 계산 작업을 직접 실행하면 UI 스레드가 차단됩니다. 이것은 '기다리는' 기술이지, '대신 계산하는' 기술이 아닙니다. |
Isolate (주로 compute 사용) |
별도의 메모리 공간에서 코드를 병렬로 실행. 진정한 멀티코어 활용. CPU 바운드 작업에 필수적. |
|
Isolate 생성 및 데이터 통신(복사)에 오버헤드가 있습니다. 매우 짧고 간단한 계산에는 오히려 성능이 저하될 수 있습니다. 전달되는 함수는 최상위 또는 static이어야 합니다. |
Stream |
시간에 따라 발생하는 0개 이상의 연속적인 비동기 이벤트를 처리. 반응형 프로그래밍의 핵심. |
|
Stream 구독은 수동으로 취소하거나, StreamController는 반드시 닫아주어야 메모리 누수를 방지할 수 있습니다. (StreamBuilder는 이를 자동으로 처리해줍니다.) |
궁극적으로, 플러터에서의 비동기 프로그래밍은 단순히 앱의 성능을 개선하는 것을 넘어, 사용자에게 끊김 없고 직관적인 경험을 선사하기 위한 근본적인 도구입니다. UI 스레드를 성역처럼 보호하고, 무거운 작업은 적절한 배경으로 위임하며, 흐르는 데이터는 스트림으로 자연스럽게 처리하는 습관을 들인다면, 당신의 애플리케이션은 기술적으로 뛰어날 뿐만 아니라 사용자의 일상에 기분 좋은 경험을 더하는 존재가 될 것입니다.
Post a Comment