Monday, July 10, 2023

Flutter 성능 최적화의 핵심: 비동기 처리와 Isolate 심층 분석

현대의 모바일 애플리케이션 환경에서 사용자 경험은 성공의 가장 중요한 척도 중 하나입니다. 사용자는 부드러운 스크롤, 즉각적인 반응, 그리고 끊김 없는 애니메이션을 기대합니다. 이러한 기대를 충족시키지 못하는 앱은 '버벅거린다(janky)'는 평가를 받으며 쉽게 외면당합니다. Flutter가 자랑하는 초당 60프레임(또는 그 이상)의 렌더링 성능을 유지하기 위해서는 UI 스레드를 절대로 막아서는 안 됩니다. 하지만 네트워크 요청, 대용량 파일 처리, 복잡한 데이터 파싱 등 시간이 오래 걸리는 작업들은 필연적으로 발생합니다. 그렇다면 어떻게 이 두 가지 상충되는 요구사항을 조화시킬 수 있을까요? 해답은 바로 동시성(Concurrency) 프로그래밍에 있습니다. Flutter와 Dart는 이를 위해 강력하고 직관적인 두 가지 핵심 도구, 즉 비동기 프로그래밍(Asynchronous Programming)Isolate를 제공합니다. 이 글에서는 이 두 가지 개념의 원리를 깊이 파고들어, 언제 그리고 어떻게 사용해야 하는지에 대한 명확한 기준과 실용적인 예제를 통해 여러분의 Flutter 앱 성능을 한 단계 끌어올리는 방법을 제시합니다.

1장: 모든 것의 시작, Dart의 이벤트 루프와 비동기 처리

Flutter의 비동기 모델을 제대로 이해하려면 먼저 그 기반이 되는 Dart의 실행 모델을 알아야 합니다. Dart는 자바스크립트처럼 단일 스레드(Single-threaded) 기반 언어입니다. 즉, 한 번에 하나의 코드만 실행할 수 있다는 의미입니다. 그렇다면 어떻게 동시에 여러 작업을 처리하는 것처럼 보일 수 있을까요? 그 비밀은 바로 이벤트 루프(Event Loop)에 있습니다.

1.1. 이벤트 루프의 작동 원리

Dart 애플리케이션이 시작되면, 코드를 실행할 메인 Isolate(스레드와 유사하지만 메모리를 공유하지 않는 독립된 실행 단위)가 생성되고, 이 Isolate는 자신만의 이벤트 루프를 가집니다. 이벤트 루프는 두 개의 큐(Queue), 즉 마이크로태스크 큐(Microtask Queue)이벤트 큐(Event Queue)를 관리하며 끊임없이 작업을 확인하고 처리합니다.

  • 이벤트 큐 (Event Queue): 외부에서 발생하는 모든 이벤트를 처리합니다. 예를 들어, 사용자의 터치 입력, 파일 I/O, 네트워크 응답, 타이머 등이 여기에 해당합니다. Dart의 Future 작업은 대부분 이 이벤트 큐를 통해 처리됩니다.
  • 마이크로태스크 큐 (Microtask Queue): 이벤트 큐보다 높은 우선순위를 가집니다. 이 큐는 현재 실행 중인 작업이 완료된 직후, 다른 이벤트를 처리하기 전에 즉시 실행해야 하는 매우 짧은 비동기 작업을 위해 존재합니다. 예를 들어, Future.microtask()를 사용하여 작업을 추가할 수 있습니다.

이벤트 루프의 처리 순서는 다음과 같습니다.

  1. 마이크로태스크 큐에 있는 모든 작업을 순서대로 실행하여 큐를 비웁니다.
  2. 마이크로태스크 큐가 비어있다면, 이벤트 큐에서 첫 번째 작업을 가져와 실행합니다.
  3. 이 과정(1, 2번)을 무한히 반복합니다.

이 모델의 핵심은 어떤 작업도 이벤트 루프를 막아서는(blocking) 안 된다는 것입니다. 만약 하나의 작업이 너무 오래 걸리면(예: 500ms), 이벤트 루프는 다른 어떤 이벤트(사용자 입력, 화면 그리기 등)도 처리할 수 없게 되어 앱이 그대로 멈춰버립니다. 이것이 바로 비동기 프로그래밍이 필수적인 이유입니다.

1.2. 미래의 결과값: Future 깊이 이해하기

Future는 Dart 비동기 프로그래밍의 핵심 구성 요소입니다. 이는 '미래의 어느 시점에 완료될 하나의 작업'을 나타내는 객체입니다. 이 작업은 성공적으로 완료되어 값을 반환할 수도 있고, 오류와 함께 실패할 수도 있습니다.

Future는 세 가지 상태를 가집니다:

  • 미완료 (Uncompleted): 비동기 작업이 아직 진행 중인 상태입니다.
  • 값으로 완료 (Completed with a value): 작업이 성공적으로 완료되었고, 결과값을 가지고 있는 상태입니다.
  • 오류로 완료 (Completed with an error): 작업 수행 중 오류가 발생하여 실패한 상태입니다.

Future가 완료되었을 때 특정 코드를 실행하기 위해 콜백(callback) 함수를 등록할 수 있습니다.


// 네트워크에서 사용자 데이터를 가져오는 가상 함수
Future<String> fetchUserData() {
  return Future.delayed(Duration(seconds: 2), () => 'John Doe');
  // 2초 후에 'John Doe'라는 문자열로 완료되는 Future를 반환합니다.
}

void main() {
  print('사용자 데이터 로딩 시작...');
  fetchUserData().then((data) {
    // Future가 성공적으로 완료되면 이 코드가 실행됩니다.
    print('수신된 데이터: $data');
  }).catchError((error) {
    // Future가 오류로 완료되면 이 코드가 실행됩니다.
    print('오류 발생: $error');
  }).whenComplete(() {
    // 성공하든 실패하든, Future가 완료되면 항상 실행됩니다.
    print('데이터 로딩 완료.');
  });
  print('메인 함수 종료. 하지만 프로그램은 Future가 완료될 때까지 종료되지 않습니다.');
}

1.3. 동기 코드처럼 비동기 다루기: async와 await

.then()을 사용한 콜백 체이닝은 코드가 길어지면 소위 '콜백 지옥(callback hell)'을 만들어 가독성을 떨어뜨릴 수 있습니다. Dart는 이를 해결하기 위해 asyncawait라는 아름다운 문법적 설탕(syntactic sugar)을 제공합니다.

  • async: 함수 선언부에 async 키워드를 붙이면, 해당 함수는 비동기 함수가 되며 항상 Future를 반환합니다. 함수 내에서 await 키워드를 사용할 수 있게 됩니다.
  • await: Future 객체 앞에 await 키워드를 사용하면, 해당 Future가 완료될 때까지 함수의 실행을 '일시 중지'합니다. 중요한 점은, 이것이 이벤트 루프 전체를 멈추는 것이 아니라, 오직 해당 async 함수 내의 실행 흐름만 멈춘다는 것입니다. 그동안 이벤트 루프는 다른 이벤트를 자유롭게 처리할 수 있습니다. Future가 완료되면, await는 결과값을 반환하고(성공 시) 또는 예외를 던집니다(실패 시).

앞선 예제를 async/await로 다시 작성해 보겠습니다.


Future<String> fetchUserData() {
  return Future.delayed(Duration(seconds: 2), () => 'John Doe');
}

// 오류 발생을 시뮬레이션하는 함수
Future<String> fetchUserDataWithError() {
  return Future.delayed(Duration(seconds: 2), () => throw Exception('네트워크 연결 실패'));
}

// main 함수를 async로 선언해야 await를 사용할 수 있습니다.
void main() async {
  print('사용자 데이터 로딩 시작...');
  try {
    // fetchUserData()가 완료될 때까지 여기서 기다립니다.
    final data = await fetchUserData(); 
    print('수신된 데이터: $data');

    print('\n오류 상황 테스트 시작...');
    final errorData = await fetchUserDataWithError();
    print('이 줄은 실행되지 않습니다.');

  } catch (error) {
    // await하는 Future가 오류로 완료되면 catch 블록이 실행됩니다.
    print('오류 발생: $error');
  } finally {
    // try-catch와 함께 finally를 사용하여 완료 시점을 처리할 수 있습니다.
    print('\n데이터 로딩 절차 완료.');
  }
}

보시다시피, 코드가 마치 동기적으로 순서대로 실행되는 것처럼 보여 훨씬 직관적이고 가독성이 높습니다. 오류 처리도 기존의 try-catch 구문을 그대로 사용할 수 있어 편리합니다.

1.4. UI와 비동기 데이터 결합: FutureBuilder

Flutter에서 비동기 작업의 결과를 UI에 표시하는 것은 매우 흔한 패턴입니다. FutureBuilder 위젯은 이 과정을 매우 우아하게 처리해 줍니다. FutureBuilderFuture를 인자로 받아, 해당 Future의 상태 변화에 따라 UI를 다시 빌드합니다.

FutureBuilderbuilder 콜백은 AsyncSnapshot 객체를 제공하는데, 이 객체는 Future의 현재 상태에 대한 모든 정보를 담고 있습니다.

  • snapshot.connectionState: Future의 현재 연결 상태를 나타냅니다. (ConnectionState.none, ConnectionState.waiting, ConnectionState.active, ConnectionState.done)
  • snapshot.hasData: Future가 값으로 완료되었는지 여부를 나타냅니다.
  • snapshot.data: Future가 반환한 데이터입니다.
  • snapshot.hasError: Future가 오류로 완료되었는지 여부를 나타냅니다.
  • snapshot.error: Future가 반환한 오류 객체입니다.

import 'package:flutter/material.dart';
import 'dart:math';

// 가상의 API 호출 함수
Future<String> fetchWeatherForecast() async {
  await Future.delayed(Duration(seconds: 3));
  if (Random().nextBool()) {
    return "오늘 날씨는 맑음, 최고 기온 25도";
  } else {
    throw Exception("일시적인 서버 오류 발생");
  }
}

class WeatherScreen extends StatefulWidget {
  @override
  _WeatherScreenState createState() => _WeatherScreenState();
}

class _WeatherScreenState extends State<WeatherScreen> {
  late Future<String> _weatherFuture;

  @override
  void initState() {
    super.initState();
    _weatherFuture = fetchWeatherForecast();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text("오늘의 날씨 정보")),
      body: Center(
        child: FutureBuilder<String>(
          future: _weatherFuture,
          builder: (context, snapshot) {
            // 1. 로딩 중 상태 처리
            if (snapshot.connectionState == ConnectionState.waiting) {
              return CircularProgressIndicator();
            }
            
            // 2. 에러 발생 상태 처리
            if (snapshot.hasError) {
              return Text(
                "날씨 정보를 불러오는 데 실패했습니다: ${snapshot.error}",
                style: TextStyle(color: Colors.red),
              );
            }

            // 3. 데이터 수신 성공 상태 처리
            if (snapshot.hasData) {
              return Text(
                snapshot.data!,
                style: TextStyle(fontSize: 24),
                textAlign: TextAlign.center,
              );
            }
            
            // 4. 그 외의 경우 (보통 로딩 전 초기 상태)
            return Text("날씨 정보를 기다리는 중...");
          },
        ),
      ),
    );
  }
}

이처럼 FutureBuilder를 사용하면 로딩 상태, 성공 상태, 실패 상태에 따른 UI를 선언적으로 명확하게 분리하여 관리할 수 있습니다. initState에서 Future를 초기화하는 이유는 build 메서드가 여러 번 호출될 수 있기 때문에, 매번 API를 호출하는 것을 방지하기 위함입니다.

2장: 진정한 병렬 처리, Isolate의 세계

async/await는 I/O 작업(네트워크, 파일 읽기/쓰기 등)을 처리하는 데 매우 효과적입니다. 이러한 작업들은 대부분 CPU가 아닌 외부 자원이 응답하기를 '기다리는' 시간이기 때문에, 그동안 이벤트 루프는 다른 UI 렌더링이나 사용자 입력 같은 작업을 처리할 수 있습니다. 하지만 만약 작업 자체가 CPU를 계속해서 사용하는 CPU-bound 작업이라면 어떨까요?

예를 들어, 고화질 이미지에 복잡한 필터를 적용하거나, 거대한 JSON 파일을 파싱하거나, 암호화 알고리즘을 실행하는 등의 작업은 CPU를 100% 사용하며 수백 밀리초 이상 걸릴 수 있습니다. 이런 작업을 메인 Isolate에서 실행하면, async/await를 사용하더라도 이벤트 루프 자체가 계산 작업에 붙잡혀 다른 어떤 일도 할 수 없게 됩니다. 결과는? 앱이 완전히 멈추고 사용자는 최악의 경험을 하게 됩니다.

이러한 문제를 해결하기 위해 Dart는 Isolate라는 강력한 무기를 제공합니다.

2.1. Isolate란 무엇인가? 스레드와의 차이점

Isolate는 "격리된"이라는 이름에서 알 수 있듯이, 다른 Isolate와 메모리를 공유하지 않는 독립적인 실행 환경입니다. 각 Isolate는 자신만의 메모리 힙(memory heap)과 이벤트 루프를 가집니다. 이는 마치 별개의 작은 프로그램이 동시에 실행되는 것과 같습니다.

전통적인 멀티스레딩(multi-threading) 모델과 Isolate의 가장 큰 차이점은 메모리 공유 여부입니다.

  • 전통적인 스레드: 여러 스레드가 같은 메모리 공간을 공유합니다. 이로 인해 여러 스레드가 동시에 같은 데이터에 접근하려 할 때 발생하는 경쟁 상태(race condition)나 교착 상태(deadlock) 같은 복잡하고 어려운 문제들이 발생할 수 있습니다. 이를 해결하기 위해 뮤텍스(mutex), 세마포어(semaphore) 같은 동기화 메커니즘이 필요하며, 이는 프로그래밍을 매우 어렵게 만듭니다.
  • Dart의 Isolate: 메모리를 전혀 공유하지 않습니다 (Shared-nothing concurrency). 이 아키텍처는 공유된 상태로 인해 발생하는 수많은 버그를 원천적으로 차단합니다. Isolate들은 서로 직접적인 접근이 불가능하며, 오직 메시지 패싱(message passing)을 통해서만 통신할 수 있습니다. 이는 마치 서로 다른 컴퓨터가 네트워크를 통해 데이터를 주고받는 것과 유사합니다.

이러한 설계 덕분에 Dart/Flutter 개발자는 복잡한 동기화 문제없이 안전하게 병렬 프로그래밍을 할 수 있습니다.

2.2. Isolate 간의 소통: SendPort와 ReceivePort

메모리를 공유하지 않는 Isolate들이 어떻게 데이터를 주고받을까요? 바로 SendPortReceivePort를 통해서입니다.

  • ReceivePort: 메시지를 수신할 수 있는 '우체통'을 생성합니다. 이 우체통은 메시지를 들을 수 있는 Stream을 제공합니다.
  • SendPort: ReceivePort에 메시지를 보낼 수 있는 '편지 발송 수단'입니다. ReceivePort를 생성하면 그에 해당하는 SendPort를 얻을 수 있습니다.

Isolate 통신의 기본 흐름은 다음과 같습니다.

  1. 메인 Isolate에서 ReceivePort를 생성합니다.
  2. 새로운 Isolate를 생성(Isolate.spawn())하면서, 메인 Isolate의 ReceivePort에서 얻은 SendPort를 인자로 전달합니다.
  3. 새로운 Isolate는 자신의 작업을 수행한 후, 전달받은 SendPort를 통해 결과 메시지를 메인 Isolate로 보냅니다.
  4. 메인 Isolate는 자신의 ReceivePort를 통해 메시지가 도착하기를 기다리다가, 메시지를 수신하면 다음 작업을 처리합니다.

다음은 두 Isolate가 통신하는 기본적인 예제입니다.


import 'dart:isolate';

// 새로 생성될 Isolate에서 실행될 함수
// 이 함수는 반드시 최상위 함수이거나 static 함수여야 합니다.
void heavyTask(SendPort sendPort) {
  print('[Background Isolate] 무거운 작업 시작...');
  int total = 0;
  for (int i = 0; i < 1000000000; i++) {
    total += i;
  }
  print('[Background Isolate] 무거운 작업 완료. 결과 전송...');
  
  // 계산 결과를 메인 Isolate로 보냅니다.
  sendPort.send(total);
}

void main() async {
  print('[Main Isolate] 프로그램 시작');
  
  // 1. 메인 Isolate에 ReceivePort를 생성합니다.
  final receivePort = ReceivePort();

  // 2. 새로운 Isolate를 생성하고, 메인 Isolate의 SendPort를 전달합니다.
  // Isolate.spawn(실행할 함수, 함수에 전달할 인자);
  final newIsolate = await Isolate.spawn(heavyTask, receivePort.sendPort);
  
  print('[Main Isolate] 새로운 Isolate가 생성되었습니다. UI는 여전히 반응합니다.');
  
  // 3. ReceivePort를 통해 데이터가 들어오기를 기다립니다.
  // receivePort는 Stream이므로, .first를 사용해 첫 번째 메시지를 기다릴 수 있습니다.
  final result = await receivePort.first;
  
  print('[Main Isolate] 백그라운드 Isolate로부터 결과 수신: $result');

  // 4. Isolate 정리
  receivePort.close();
  newIsolate.kill();
  
  print('[Main Isolate] 프로그램 종료');
}

2.3. 더 간편한 방법: compute 함수

Isolate.spawnSendPort/ReceivePort를 직접 다루는 것은 유연하지만, 단순히 어떤 함수를 백그라운드에서 실행하고 결과만 받아오고 싶은 경우에는 다소 번거로울 수 있습니다. Flutter는 이러한 일반적인 사용 사례를 위해 compute라는 매우 편리한 고수준 API를 제공합니다.

compute 함수는 다음과 같은 일을 자동으로 처리해 줍니다.

  1. 새로운 Isolate를 생성합니다.
  2. 전달된 함수를 해당 Isolate에서 실행합니다. (함수에 인자도 전달합니다)
  3. 함수가 결과를 반환하면, 그 결과를 메인 Isolate로 다시 전달합니다.
  4. 작업이 완료되면 생성했던 Isolate를 자동으로 종료합니다.

compute 함수는 내부적으로 Isolate.spawn과 포트를 사용하지만, 이 모든 복잡한 과정을 추상화하여 개발자는 단 한 줄의 코드로 병렬 작업을 수행할 수 있습니다.


import 'package:flutter/foundation.dart'; // compute 함수를 사용하기 위해 필요

// Isolate에서 실행될 함수.
// compute에 사용될 함수는 반드시 최상위(top-level) 함수이거나 static 메서드여야 합니다.
int heavyCalculation(int value) {
  print('[Background Isolate] 계산 시작: $value');
  int total = 0;
  for (int i = 0; i < value * 200000000; i++) {
    total += i;
  }
  print('[Background Isolate] 계산 완료');
  return total;
}

void main() async {
  print('[Main Isolate] 프로그램 시작');
  
  // compute(실행할 함수, 함수에 전달할 인자)
  // compute는 결과값을 담은 Future를 반환합니다.
  final result = await compute(heavyCalculation, 5);
  
  print('[Main Isolate] 결과 수신: $result');
  print('[Main Isolate] 프로그램 종료');
}

보시다시피 코드가 훨씬 간결하고 명확해졌습니다. 대부분의 CPU-bound 작업은 compute 함수만으로 충분히 해결할 수 있습니다.

3장: 전략적 선택: 언제 무엇을 사용해야 하는가?

이제 우리는 Flutter의 두 가지 동시성 도구인 async/awaitIsolate에 대해 알아보았습니다. 그렇다면 실제 프로젝트에서 어떤 상황에 어떤 도구를 선택해야 할까요? 핵심은 처리하려는 작업의 종류, 즉 I/O-bound 작업CPU-bound 작업을 구분하는 데 있습니다.

3.1. I/O-bound 작업 vs CPU-bound 작업

  • I/O-bound (입출력 위주) 작업: 작업의 대부분 시간을 외부 시스템(네트워크, 디스크, 데이터베이스 등)의 응답을 기다리는 데 사용하는 경우입니다. 이 시간 동안 CPU는 거의 유휴 상태입니다.
    • 예시: API 호출, 파일 다운로드/업로드, 데이터베이스 쿼리, SharedPreferences 읽기/쓰기.
    • 해결책: Futureasync/await. 이벤트 루프는 I/O 작업이 완료되기를 기다리는 동안 다른 작업을 자유롭게 처리할 수 있으므로, 별도의 Isolate를 생성하는 오버헤드 없이 효율적으로 동시성을 달성할 수 있습니다.
  • CPU-bound (CPU 위주) 작업: 작업 시간의 대부분을 CPU가 복잡한 계산을 수행하는 데 사용하는 경우입니다. 이 작업은 메인 Isolate에서 실행되면 이벤트 루프를 점유하여 UI를 멈추게 만듭니다.
    • 예시: 대용량 JSON 파싱 및 디코딩, 이미지 처리(필터 적용, 리사이징), 복잡한 알고리즘(암호화, 정렬, 데이터 분석), 방대한 리스트의 필터링 및 변환.
    • 해결책: Isolate (주로 compute 함수 사용). 작업을 별도의 Isolate로 옮겨 메인 Isolate의 이벤트 루프가 UI 렌더링과 사용자 상호작용에만 집중할 수 있도록 해야 합니다.

3.2. 의사결정 플로우차트

새로운 비동기 작업을 추가해야 할 때, 다음의 간단한 질문을 통해 올바른 도구를 선택할 수 있습니다.

1. 이 작업이 UI 렌더링 프레임(약 16ms)보다 훨씬 오래 걸릴 가능성이 있는가?
   |
   +-- 아니오 -> 그냥 동기적으로 실행하세요.
   |
   +-- 예 -> 다음 질문으로

2. 작업이 오래 걸리는 이유가 주로 외부 응답을 '기다리기' 때문인가 (네트워크, 디스크 등)?
   |
   +-- 예 (I/O-bound) -> `Future`와 `async/await`를 사용하세요.
   |
   +-- 아니오 (CPU-bound) -> 다음 질문으로

3. 작업이 순수한 '계산' 때문에 오래 걸리는가 (복잡한 연산, 파싱 등)?
   |
   +-- 예 (CPU-bound) -> `Isolate`를 사용하세요. (가급적 `compute` 함수를 우선 고려)

3.3. 실제 시나리오별 적용 사례

시나리오 1: 소셜 미디어 피드 로딩

  • 작업 내용: 서버 API를 호출하여 최신 게시물 목록(JSON 데이터)을 받아와 화면에 표시한다.
  • 분석: 주된 병목 지점은 서버가 응답할 때까지 기다리는 네트워크 지연 시간입니다. 이는 전형적인 I/O-bound 작업입니다. JSON 파싱은 데이터가 매우 크지 않은 이상 일반적으로 빠릅니다.
  • 적절한 도구: async/await를 사용한 http 요청. UI는 FutureBuilder로 구성합니다.

Future<List<Post>> fetchPosts() async {
  final response = await http.get(Uri.parse('https://api.example.com/posts'));
  if (response.statusCode == 200) {
    // JSON 파싱은 이 단계에서 동기적으로 일어납니다.
    // 대부분의 경우 이것만으로도 충분히 빠릅니다.
    List<dynamic> data = json.decode(response.body);
    return data.map((json) => Post.fromJson(json)).toList();
  } else {
    throw Exception('Failed to load posts');
  }
}

시나리오 2: 갤러리에서 선택한 이미지에 세피아 필터 적용하기

  • 작업 내용: 사용자가 선택한 고해상도 이미지의 모든 픽셀 값을 읽어 세피아 톤으로 변환하는 연산을 수행한다.
  • 분석: 수백만 개 픽셀의 R, G, B 값을 각각 계산해야 합니다. 이는 외부 자원을 기다리는 것이 아니라 순수하게 CPU를 사용하는 CPU-bound 작업입니다. 메인 Isolate에서 실행하면 앱이 즉시 멈춥니다.
  • 적절한 도구: compute 함수를 사용하여 이미지 처리 로직을 백그라운드 Isolate에서 실행합니다.

import 'package:image/image.dart' as img; // 'image' 패키지 활용
import 'package:flutter/foundation.dart';

// 이 함수는 백그라운드 Isolate에서 실행됩니다.
// 이미지 데이터(byte array)를 받아 처리 후 다시 byte array를 반환합니다.
Uint8List applySepiaFilter(Uint8List imageData) {
  img.Image? image = img.decodeImage(imageData);
  if (image != null) {
    img.sepia(image);
    return Uint8List.fromList(img.encodeJpg(image));
  }
  return imageData;
}

// UI 코드 내에서...
Future<void> onApplyFilterButtonPressed() async {
  setState(() { _isProcessing = true; });

  // UI 스레드를 막지 않고 필터 적용
  final filteredImageData = await compute(applySepiaFilter, originalImageData);
  
  setState(() {
    _displayImage = MemoryImage(filteredImageData);
    _isProcessing = false;
  });
}

시나리오 3: 대용량 데이터 파일(수십 MB) 동기화

  • 작업 내용: 서버에서 대용량 압축 파일(.zip)을 다운로드하고, 압축을 해제한 후, 내부의 거대한 JSON 파일을 파싱하여 로컬 데이터베이스에 저장한다.
  • 분석: 이 작업은 I/O-bound와 CPU-bound가 혼합되어 있습니다.
    1. 파일 다운로드: I/O-bound
    2. 압축 해제: 파일 시스템 접근(I/O)과 압축 해제 알고리즘(CPU)이 혼합되어 있지만, 규모가 크면 CPU-bound 성격이 강해질 수 있습니다.
    3. JSON 파싱: 수십 MB 크기의 JSON이라면 명백한 CPU-bound 작업입니다.
    4. 데이터베이스 저장: I/O-bound
  • 적절한 도구: 각 단계를 적절한 도구로 조합합니다.

Future<void> syncLargeData() async {
  try {
    // 1. 파일 다운로드 (I/O-bound) -> async/await
    final zipFile = await downloadFile('https://api.example.com/large_data.zip');
    
    // 2. 압축 해제 및 파싱 (CPU-bound) -> compute
    final List<MyData> processedData = await compute(unzipAndParse, zipFile.path);

    // 3. 데이터베이스 저장 (I/O-bound) -> async/await
    await saveToDatabase(processedData);

    print("동기화 완료!");
  } catch (e) {
    print("동기화 실패: $e");
  }
}

// 백그라운드에서 실행될 함수
List<MyData> unzipAndParse(String filePath) {
  // 1. 압축 해제 로직 ...
  final bytes = unzip(filePath);
  // 2. JSON 파싱 로직 ...
  final jsonString = utf8.decode(bytes);
  final List<dynamic> jsonData = json.decode(jsonString);
  // 3. 객체 변환 ...
  return jsonData.map((item) => MyData.fromJson(item)).toList();
}

결론: 유연한 UI를 위한 필수 역량

Flutter에서 부드럽고 반응성 좋은 사용자 경험을 제공하는 것은 선택이 아닌 필수입니다. 이를 위해 개발자는 시간이 소요되는 작업을 UI 스레드로부터 분리하는 방법을 반드시 숙지해야 합니다. Dart가 제공하는 async/awaitIsolate는 각기 다른 목적을 가진 강력한 동시성 도구입니다.

핵심은 작업의 본질을 파악하는 것입니다. 네트워크 통신이나 파일 입출력처럼 주로 '기다리는' I/O-bound 작업에는 async/await를 사용하여 이벤트 루프의 효율을 극대화해야 합니다. 반면, 복잡한 계산이나 대용량 데이터 처리처럼 CPU를 집중적으로 사용하는 CPU-bound 작업에는 Isolate(주로 compute 함수)를 사용하여 진정한 병렬 처리를 구현하고 메인 스레드를 자유롭게 해주어야 합니다.

이 두 가지 도구를 올바르게 이해하고 상황에 맞게 전략적으로 사용하는 능력은 단순한 기술을 넘어, 사용자가 사랑하는 고품질 Flutter 앱을 만드는 핵심 역량이 될 것입니다. 이제 여러분의 코드베이스를 점검하고, UI를 멈추게 하는 잠재적인 병목 지점을 찾아내어 적절한 비동기 처리와 Isolate를 적용해 보시기 바랍니다.


0 개의 댓글:

Post a Comment