Flutter 앱 성능 최적화: async와 Isolate로 UI 끊김(Jank) 영구 제거하기

최근 금융 데이터 시각화 프로젝트를 진행하던 중, 치명적인 성능 이슈에 직면했습니다. 대용량 JSON 데이터(약 15MB)를 파싱하여 차트에 렌더링하는 순간, 화면이 약 2초간 멈추는 현상이 발생했습니다. 로그캣(Logcat)에는 개발자들이 가장 두려워하는 경고 메시지가 출력되었습니다. UI thread blocked for 2100ms! Skipped 120 frames! 최신형 디바이스에서도 스크롤이 뚝뚝 끊기는 소위 'Jank' 현상은 사용자 경험(UX)을 망치는 주범입니다. 많은 개발자들이 flutter의 비동기 처리를 위해 단순히 await 키워드만 붙이면 해결된다고 믿지만, 이것이 바로 성능 병목의 시작점입니다.

Dart의 단일 스레드 모델과 async의 함정

이 문제를 근본적으로 해결하려면 Dart 언어의 실행 모델인 '이벤트 루프(Event Loop)'를 깊이 이해해야 합니다. Java나 C#과 달리 Dart는 기본적으로 Single Thread로 동작합니다. 즉, 한 번에 하나의 작업만 CPU를 점유할 수 있습니다.

우리가 흔히 사용하는 async / await 패턴은 '병렬(Parallel)' 처리가 아닙니다. 이는 단순히 I/O 작업(네트워크 요청, 파일 읽기 등)이 완료될 때까지 기다리는 동안, 이벤트 루프가 다른 급한 작업(UI 렌더링 등)을 처리할 수 있도록 양보(Yield)하는 '동시성(Concurrency)' 개념에 가깝습니다. 하지만 JSON 파싱, 이미지 리사이징, 암호화 같은 CPU 집약적(CPU-bound) 작업은 I/O 대기가 발생하지 않습니다. 따라서 async 함수 내부라 할지라도, 무거운 연산이 시작되면 메인 스레드(UI 스레드)는 그 연산이 끝날 때까지 꼼짝없이 붙잡혀 있게 됩니다.

Critical Misconception: Future를 사용한다고 해서 새로운 스레드가 생성되는 것이 아닙니다. 무거운 for 루프를 async 함수에 넣어도 여전히 메인 스레드를 차단(Block)합니다.

실패 사례: Future.delayed의 배신

처음에는 단순히 렌더링 순서를 미루면 해결될 것이라 생각하고 Future.delayed를 사용하여 파싱 로직을 감싸보았습니다. 이는 초보적인 접근 방식이었습니다.

// ❌ 잘못된 접근: 여전히 UI 렉을 유발함
Future<void> loadData() async {
  // UI 렌더링을 위해 잠깐 양보하는 척하지만...
  await Future.delayed(Duration.milliseconds(100));
  
  // 이 라인이 실행되는 순간 메인 스레드는 다시 멈춥니다.
  // 15MB JSON 디코딩은 CPU를 100% 점유합니다.
  var data = jsonDecode(hugeJsonString); 
  updateState(data);
}

위 코드는 실행 시점을 아주 잠깐 뒤로 미룰 뿐, jsonDecode가 실행되는 순간 메인 스레드를 점유하여 애니메이션을 멈추게 합니다. 이것이 바로 우리가 isolate를 도입해야 하는 이유입니다.

해결책: Isolate를 이용한 진정한 병렬 처리

Dart에서 멀티 스레딩을 구현하는 유일한 방법은 Isolate를 사용하는 것입니다. Isolate는 메모리를 공유하지 않는 독립적인 실행 공간입니다. Java의 스레드와 달리 메모리 경합(Race Condition)이 발생하지 않는다는 장점이 있지만, 데이터 전달 시 메시지 패싱(Message Passing) 방식을 사용해야 합니다.

과거에는 Isolate.spawn을 사용하여 포트(Port)를 직접 관리하고 양방향 통신을 구현해야 했기에 코드가 매우 복잡했습니다. 하지만 Dart 2.19 버전부터 도입된 Isolate.run을 사용하면 보일러플레이트 코드 없이 깔끔하게 백그라운드 작업을 처리할 수 있습니다. 다음은 실제 프로덕션 환경에서 UI 끊김을 완벽하게 제거한 코드입니다.

// ✅ Optimized: Isolate.run을 사용한 병렬 처리
import 'dart:async';
import 'dart:convert';
import 'dart:isolate';

class DataRepository {
  // CPU 집약적인 작업을 별도의 Isolate로 위임
  Future<List<StockData>> parseHeavyJson(String jsonString) async {
    try {
      // Isolate.run은 새로운 Isolate를 생성하고, 작업을 수행한 뒤,
      // 결과만 메인 스레드로 반환하고 즉시 종료됩니다.
      final result = await Isolate.run(() {
        // 이 블록 내부는 메인 스레드와 완전히 분리된 메모리 공간입니다.
        // 여기서 아무리 무거운 작업을 해도 UI는 멈추지 않습니다.
        
        final List<dynamic> decoded = jsonDecode(jsonString);
        return decoded.map((e) => StockData.fromJson(e)).toList();
      });
      
      return result;
    } catch (e) {
      // Isolate 내부 에러도 메인 스레드에서 catch 가능
      throw Exception('Data Parsing Failed: $e');
    }
  }
}

위 코드의 핵심은 Isolate.run입니다. 이 함수는 작업을 수행하는 동안 메인 Isolate(UI 스레드)와는 전혀 다른 메모리 힙을 사용합니다. 따라서 jsonDecode가 수백 킬로바이트의 객체를 생성하더라도 메인 스레드의 GC(가비지 컬렉터)에 영향을 주지 않으며, 프레임 드랍(Frame Drop)이 발생하지 않습니다.

성능 벤치마크 및 검증

실제 갤럭시 S21 디바이스(Android 13)에서 50,000개의 객체가 포함된 JSON 리스트를 파싱하는 테스트를 수행했습니다. 결과는 극적인 차이를 보여줍니다.

방법 (Method) UI 차단 시간 (Block Time) 평균 FPS (스크롤 중) 사용자 체감
단순 async/await 1,850ms 0 FPS (화면 멈춤) 앱이 죽은 것처럼 보임
Compute / Isolate 4ms (Overhead) 58~60 FPS 매우 부드러움

단순 async 방식은 약 1.8초간 UI를 완전히 얼려버렸지만, Isolate 방식은 스레드 생성 오버헤드인 약 4ms를 제외하고는 메인 스레드에 아무런 부하를 주지 않았습니다. 스크롤 애니메이션이 유지되면서 백그라운드에서 데이터 로딩이 완료되는 쾌적한 UX를 달성할 수 있었습니다.

Dart 공식 문서: Isolate 클래스 상세 보기

주의사항 및 Edge Cases

Isolate가 만능 해결책은 아닙니다. flutter 개발 시 다음의 경우를 주의해야 합니다.

  1. 데이터 복사 비용 (Copy Cost): Isolate 간에 데이터를 주고받을 때, 기본적으로 데이터는 '복사'됩니다. 만약 100MB 크기의 이미지를 Isolate로 보내고 다시 받는다면, 메모리 복사 비용 자체가 성능 저하를 일으킬 수 있습니다. (다행히 최신 Dart 버전에서는 Isolate.exit을 통해 결과값을 복사 없이 포인터만 이동시키는 최적화가 적용되었습니다.)
  2. 플랫폼 채널 제약: Isolate 내부에서는 플러그인(Platform Channel)을 직접 호출하는 데 제약이 있을 수 있습니다. 예를 들어, shared_preferences 같은 로컬 DB 접근은 메인 Isolate에서만 가능한 경우가 많습니다.
  3. 초기화 비용: Isolate를 생성(Spawn)하는 데는 메모리와 시간이 소모됩니다. 아주 가벼운 연산(예: 간단한 수학 계산)을 위해 Isolate를 띄우는 것은 배보다 배꼽이 더 큰 상황을 초래합니다.
Best Practice: 파일 I/O나 네트워크 요청 자체는 Dart의 비동기 시스템이 효율적으로 처리하므로 async만으로 충분합니다. Isolate는 파싱, 압축, 이미지 필터링과 같이 'CPU 계산'이 많이 필요한 작업에만 선별적으로 사용하세요.

결론

Flutter의 선언형 UI는 아름답지만, 그 뒷단의 로직이 메인 스레드를 막아서는 안 됩니다. asyncawait는 비동기 처리의 문법적 설탕일 뿐, 병렬 처리를 보장하지 않습니다. 앱의 반응성을 극대화하려면 CPU 집약적인 작업을 식별하고, 과감하게 isolate로 격리시켜야 합니다. 오늘 소개한 Isolate.run 패턴을 적용하여 사용자의 손끝에 즉각 반응하는 고성능 애플리케이션을 구축해 보시기 바랍니다.

Post a Comment