Wednesday, March 20, 2024

Flutter 비동기 처리 마스터하기: async, Isolate, Stream 완전 정복

Google이 개발한 오픈소스 UI 툴킷인 Flutter는 단일 코드베이스로 모바일, 웹, 데스크톱용 네이티브 앱을 아름답게 만들 수 있게 해줍니다. 하지만 아름다운 UI는 성공의 절반에 불과합니다. 진정으로 뛰어난 사용자 경험을 제공하려면 앱이 응답성이 뛰어나고, 부드럽고, 빨라야 합니다. 바로 이 지점에서 Flutter의 강력한 비동기 프로그래밍 기능이 빛을 발합니다.

이 가이드에서는 Dart와 Flutter의 동시성 처리 3대 핵심 요소인 async/await(지연이 있는 작업을 처리하기 위해), Isolate(진정한 병렬 처리를 위해), 그리고 Stream(시간의 흐름에 따른 데이터 시퀀스를 관리하기 위해)을 깊이 있게 탐구합니다. 이 개념들을 이해하는 것은 고성능 애플리케이션을 개발하고 UI의 '버벅임'(Jank) 현상을 제거하는 열쇠입니다.

핵심 과제: UI 스레드 보호하기

앱의 사용자 인터페이스를 1초에 60번에서 120번씩 화면을 그리는 단 한 명의 전담 작업자라고 상상해 보세요. 만약 이 작업자에게 인터넷에서 데이터를 가져오거나 복잡한 계산을 하는 등 시간이 오래 걸리는 작업을 시키면, 그동안 화면을 그리는 일을 할 수 없게 됩니다. 그 결과는? 멈춰버리고 응답하지 않는 앱입니다. 비동기 프로그래밍은 바로 이런 무거운 작업을 다른 작업자에게 위임하여 UI 스레드(메인 스레드)가 본연의 임무에 집중할 수 있도록 하는 전략입니다.

1. `async`와 `await`: 기다림이 필요한 작업을 위해

가장 흔한 비동기 작업은 프로그램이 네트워크나 데이터베이스 같은 외부 리소스를 기다려야 하는 I/O(입출력) 바운드 작업입니다. `async`와 `await` 키워드는 이러한 상황을 깔끔하고 가독성 높은 코드로 처리할 수 있는 방법을 제공합니다.

카페에서 커피를 주문하는 것에 비유할 수 있습니다. 당신은 주문을 하고(`await` 호출) 영수증(`Future` 객체)을 받습니다. 바리스타를 멍하니 쳐다보며 기다리는 대신, 자유롭게 휴대폰을 보거나 친구와 대화할 수 있습니다(UI 스레드는 멈추지 않습니다). 그리고 당신의 이름이 불리면 커피가 준비된 것이고(`Future` 완료), 다음 행동으로 넘어갈 수 있습니다.

주요 개념:

  • `Future`: 미래의 특정 시점에 사용 가능해질 '값' 또는 '오류'를 나타내는 객체입니다.
  • `async`: 함수를 비동기로 만드는 키워드입니다. 이 키워드가 붙은 함수는 반환값을 암묵적으로 `Future`로 감쌉니다.
  • `await`: `Future`가 완료될 때까지 `async` 함수의 실행을 일시 중지하는 키워드입니다. `async` 함수 안에서만 사용할 수 있습니다.

실용적인 예제: 사용자 데이터 가져오기

단순히 기다리는 것 대신, API에서 사용자 데이터를 가져오는 실제와 같은 네트워크 요청을 시뮬레이션해 보겠습니다. 여기서는 `Future`의 결과에 따라 UI를 구성하는 일반적인 Flutter 패턴인 `FutureBuilder` 위젯을 사용합니다.


import 'dart:convert';
import 'package:http/http.dart' as http;

// API로부터 사용자 프로필을 비동기적으로 가져오는 함수
Future<String> fetchUserData() async {
  // 'await'는 네트워크 호출이 완료될 때까지 여기서 실행을 일시 중지합니다.
  final response = await http.get(Uri.parse('https://jsonplaceholder.typicode.com/users/1'));

  if (response.statusCode == 200) {
    // 서버가 200 OK 응답을 반환하면, JSON을 파싱합니다.
    String userName = jsonDecode(response.body)['name'];
    return '환영합니다, $userName 님!';
  } else {
    // 서버가 200 OK 응답을 반환하지 않으면, 예외를 발생시킵니다.
    throw Exception('사용자 데이터를 불러오는 데 실패했습니다.');
  }
}

// Flutter 위젯 내 사용 예시:
// Widget build(BuildContext context) {
//   return FutureBuilder<String>(
//     future: fetchUserData(), // 관찰할 Future 객체
//     builder: (context, snapshot) {
//       if (snapshot.connectionState == ConnectionState.waiting) {
//         return CircularProgressIndicator(); // 대기 중에는 로딩 인디케이터 표시
//       } else if (snapshot.hasError) {
//         return Text('오류: ${snapshot.error}'); // 오류 메시지 표시
//       } else if (snapshot.hasData) {
//         return Text(snapshot.data!); // 데이터가 도착하면 표시
//       } else {
//         return Text('데이터 없음');
//       }
//     },
//   );
// }

2. `Isolate`: CPU를 많이 사용하는 무거운 작업을 위해

만약 작업이 I/O를 기다리는 것이 아니라, 큰 이미지를 처리하거나 거대한 JSON 파일을 파싱하는 것처럼 CPU 성능을 많이 요구하는 작업이라면 어떨까요? 이런 작업을 메인 UI 스레드에서 실행하면 심각한 멈춤 현상을 유발할 것입니다. 바로 이럴 때 `Isolate`가 필요합니다.

Isolate는 Dart의 동시성 모델입니다. 메모리를 공유하여 경쟁 상태나 교착 상태 같은 복잡한 문제를 일으킬 수 있는 기존의 스레드와 달리, 각 Isolate는 자신만의 메모리 힙을 가지며 상태를 전혀 공유하지 않습니다. 이들은 완벽하게 '격리(isolate)'되어 있으며, 오직 메시지를 주고받는 방식으로만 통신합니다. 이는 매우 시끄럽고 힘든 작업을 처리하기 위해 방음 시설이 완비된 별도의 작업실에 있는 전문가를 고용하고, 당신은 평화롭게 자신의 일을 계속하는 것과 같습니다.

더 쉬운 방법: `compute` 함수

`Isolate.spawn`을 사용해 수동으로 Isolate를 관리할 수도 있지만, Flutter는 `compute`라는 훨씬 간단한 헬퍼 함수를 제공합니다. 이 함수는 지정된 함수를 새로운 Isolate에서 실행하고, 인자를 전달하며, 그 결과를 `Future`로 반환해 줍니다.

실용적인 예제: 거대한 JSON 파싱하기

API로부터 매우 큰 JSON 문자열을 받았다고 가정해 봅시다. 이를 디코딩하는 작업은 UI 버벅임을 유발할 만큼 느릴 수 있습니다. 이 작업을 `compute`를 사용해 다른 Isolate로 옮길 수 있습니다.


import 'dart:convert';
import 'package:flutter/foundation.dart';

// 이 함수가 별도의 Isolate에서 실행됩니다.
// 반드시 최상위 함수이거나 static 메서드여야 합니다.
List<Photo> parsePhotos(String responseBody) {
  final parsed = jsonDecode(responseBody).cast<Map<String, dynamic>>();
  return parsed.map<Photo>((json) => Photo.fromJson(json)).toList();
}

// 간단한 데이터 클래스
class Photo {
  final int id;
  final String title;
  Photo({required this.id, required this.title});

  factory Photo.fromJson(Map<String, dynamic> json) {
    return Photo(id: json['id'], title: json['title']);
  }
}

// 메인 코드에서 호출하는 방법
Future<List<Photo>> fetchAndParsePhotos() async {
  final response = await http.get(Uri.parse('https://jsonplaceholder.typicode.com/photos'));
  
  // 'compute' 함수를 사용해 'parsePhotos'를 별도의 Isolate에서 실행합니다.
  // 이를 통해 거대한 JSON 응답을 파싱하는 동안 UI가 멈추는 것을 방지합니다.
  return compute(parsePhotos, response.body);
}

3. `Stream`: 연속적인 비동기 이벤트를 위해

`Future`가 단 하나의 값을 반환하는 반면, `Stream`은 시간의 흐름에 따라 일련의 값(또는 오류)을 전달합니다. `Future`를 일회성 택배 배송으로, `Stream`을 정기 구독 서비스나 컨베이어 벨트로 생각할 수 있습니다.

Stream은 다음과 같이 여러 번 발생할 수 있는 이벤트를 처리하는 데 완벽합니다.

  • 사용자 입력 이벤트 (예: 텍스트 필드 변경)
  • 서버로부터의 실시간 데이터 (예: 웹소켓, Firebase)
  • 파일 I/O 청크 (분할된 데이터)
  • 타이머와 같은 반복적인 이벤트

실용적인 예제: 실시간 시계

1초마다 현재 시간을 방출하는 Stream을 만들 수 있습니다. Flutter에서는 `StreamBuilder` 위젯이 Stream을 구독하고, 새로운 값이 전달될 때마다 UI를 다시 빌드하는 데 가장 적합한 도구입니다.


import 'dart:async';

// 1초마다 현재 시간을 생성하여 전달하는 Stream을 반환하는 함수
Stream<String> timedCounter() {
  return Stream.periodic(Duration(seconds: 1), (i) {
    return DateTime.now().toIso8601String();
  });
}

// Flutter 위젯 내 사용 예시:
// Widget build(BuildContext context) {
//   return StreamBuilder<String>(
//     stream: timedCounter(), // 구독할 Stream
//     builder: (context, snapshot) {
//       if (snapshot.connectionState == ConnectionState.waiting) {
//         return Text("시계 초기화 중...");
//       } else if (snapshot.hasError) {
//         return Text("오류: ${snapshot.error}");
//       } else if (snapshot.hasData) {
//         // Stream이 새로운 값을 전달할 때마다 Text 위젯을 다시 빌드
//         return Text("현재 시간: ${snapshot.data}", style: TextStyle(fontSize: 24));
//       } else {
//         return Text("시간 데이터 없음.");
//       }
//     },
//   );
// }

결론: 무엇을 언제 사용해야 할까?

이 세 가지 개념을 마스터하면 응답성이 뛰어나고 성능이 좋은 Flutter 애플리케이션을 만들 수 있습니다. 작업에 적합한 도구를 선택하는 데 도움이 되는 간단한 가이드는 다음과 같습니다.

  • `async`/`await`와 `Future`: 네트워크 요청이나 데이터베이스 접근과 같이 결과를 기다려야 하는 일회성 비동기 작업(주로 I/O 바운드 작업)에 사용합니다.
  • `Isolate` (`compute`를 통해): 이미지 처리나 대규모 데이터 구조 파싱과 같이 UI 스레드를 멈추게 할 수 있는, 단시간에 끝나는 CPU 집약적 계산에 사용합니다.
  • `Stream`: 백엔드의 실시간 데이터, 지속적인 사용자 입력, 타이머 이벤트 등 시간의 흐름에 따라 발생하는 일련의 비동기 이벤트를 처리할 때 사용합니다.

이러한 패턴들을 효과적으로 적용하면 앱의 성능을 향상시킬 뿐만 아니라, 여러분이 제공하는 사용자 경험의 질을 한 차원 높일 수 있을 것입니다.


0 개의 댓글:

Post a Comment