Published on

Dart로 Flutter 성능 최적화하는 실전 패턴

Authors

Flutter 성능 최적화는 흔히 constRepaintBoundary 같은 위젯 레벨 팁으로 시작하지만, 실제 병목은 Dart 코드에서 더 자주 발생합니다. 특히 리스트/맵 변환이 잦은 화면, JSON 파싱이 많은 피드, 이미지/네트워크 후처리, 대량 상태 변경이 있는 UI는 Dart 레이어에서 프레임 드랍(jank)을 만들기 쉽습니다.

이 글은 Dart 관점에서 Flutter 성능을 끌어올리는 방법을 다룹니다. 목표는 두 가지입니다.

  • UI 스레드(메인 isolate)에서 “긴 작업”을 줄여 프레임 시간을 안정화
  • 불필요한 할당(Allocation)과 GC 압박을 줄여 스파이크를 제거

웹 성능에서 INP가 Long Task에 의해 나빠지듯, Flutter도 한 프레임 안에 끝나지 않는 작업이 누적되면 체감 성능이 급격히 악화됩니다. Long Task를 쪼개는 사고방식은 Flutter에서도 그대로 통합니다. 관련 관점은 Chrome INP 급등 원인 - Long Task 분해 실전 글과도 연결됩니다.

1) 먼저 측정: DevTools에서 “무엇이 느린지” 확정하기

최적화는 측정 없이는 방향을 잃습니다. Flutter DevTools에서 최소한 아래 3가지를 확인하세요.

  • Performance 탭: 프레임 차트에서 UI/Raster 스파이크 확인
  • CPU Profiler: 어느 함수가 시간을 쓰는지(특히 JSON, 정렬, 필터링)
  • Memory 탭: 할당이 폭증하는 구간, GC 빈도, retained size

프로파일링은 반드시 profile 모드로 하세요.

flutter run --profile

화면이 버벅이는 타이밍에 DevTools에서 CPU 프로파일을 캡처하고, 상위 Hot path가 Dart 코드인지(가공/파싱/정렬) 아니면 빌드/페인트인지부터 나눕니다. 이 글은 Dart 코드가 원인인 케이스를 집중 공략합니다.

2) “할당 줄이기”가 곧 성능: 불필요한 객체 생성 제거

Dart VM은 GC가 빠른 편이지만, UI 프레임 안에서 할당이 폭증하면 GC 타이밍이 프레임 경계와 겹치며 jank로 이어질 수 있습니다.

2-1) 리스트 변환 체인 최소화

다음 패턴은 보기엔 깔끔하지만 중간 리스트를 여러 번 만들 수 있습니다.

final items = raw
    .where((e) => e.enabled)
    .map((e) => e.toViewModel())
    .toList();

데이터가 크거나 자주 호출되면, 단일 패스로 줄이는 게 유리합니다.

List<ViewModel> buildItems(List<Entity> raw) {
  final result = <ViewModel>[];
  for (final e in raw) {
    if (!e.enabled) continue;
    result.add(e.toViewModel());
  }
  return result;
}

추가로, 결과 크기를 대략 알 수 있으면 List.filled 또는 result.length 예측이 어려워도, 최소한 result를 재사용하는 방식(캐시)도 고려할 수 있습니다. 단, 재사용은 버그(이전 값 잔존) 위험이 있으니 “불변 모델”로 유지할 수 있을 때만 권장합니다.

2-2) 문자열 결합은 StringBuffer

루프에서 + 로 문자열을 누적하면 중간 문자열이 계속 생길 수 있습니다.

String buildLog(List<String> parts) {
  var s = '';
  for (final p in parts) {
    s += p;
  }
  return s;
}
String buildLog(List<String> parts) {
  final sb = StringBuffer();
  for (final p in parts) {
    sb.write(p);
  }
  return sb.toString();
}

2-3) 정규식/포맷터는 캐시

정규식 컴파일이나 포맷터 생성이 빈번하면 비용이 누적됩니다.

final _emailRe = RegExp(r'^[^@]+@[^@]+\.[^@]+$');

bool isValidEmail(String s) => _emailRe.hasMatch(s);

3) UI 스레드에서 무거운 작업을 하지 않기: isolate와 compute

Flutter에서 UI는 메인 isolate에서 돌아갑니다. 여기서 JSON 파싱, 압축 해제, 대량 정렬, 이미지 메타 처리 같은 CPU 바운드 작업을 하면 프레임이 밀립니다.

3-1) compute 로 간단히 오프로딩

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

List<Item> _parseItems(String body) {
  final decoded = jsonDecode(body) as List<dynamic>;
  return decoded
      .cast<Map<String, dynamic>>()
      .map(Item.fromJson)
      .toList();
}

Future<List<Item>> fetchAndParseItems(String body) {
  return compute(_parseItems, body);
}

주의할 점

  • compute 는 메시지 패싱 비용이 있습니다. 작은 데이터에 남발하면 오히려 느려질 수 있습니다.
  • 전달/반환 값은 isolate 간 전송 가능한 타입이어야 합니다.

3-2) “큰 작업을 쪼개기”: 프레임을 살리는 협력적 분할

isolate를 쓰기 애매한 중간급 작업이라면, 한 번에 끝내지 말고 이벤트 루프에 양보하면서 나눠 처리하는 것도 방법입니다.

Future<void> processInChunks(List<int> data) async {
  const chunkSize = 2000;
  for (var i = 0; i < data.length; i += chunkSize) {
    final end = (i + chunkSize < data.length) ? i + chunkSize : data.length;
    final chunk = data.sublist(i, end);

    // CPU 작업 처리
    _heavyWork(chunk);

    // 다음 프레임에 기회 양보
    await Future<void>.delayed(Duration.zero);
  }
}

void _heavyWork(List<int> chunk) {
  // ...
}

이 패턴은 “Long Task를 분해한다”는 관점과 동일합니다. 웹에서 INP를 깎는 방식과 사고가 같다는 점에서, 위에서 언급한 Chrome INP 급등 원인 - Long Task 분해 실전 과 함께 보면 이해가 빠릅니다.

4) 비동기 최적화: await 남발과 동시성 제어

Flutter 코드에서 성능을 갉아먹는 흔한 실수는 다음입니다.

  • 독립적인 네트워크/디스크 작업을 순차 await 로 묶어 전체 시간을 늘림
  • 반대로 무제한 동시 요청으로 디코딩/상태 업데이트가 폭주

4-1) 독립 작업은 Future.wait

Future<UserBundle> loadBundle() async {
  final results = await Future.wait([
    api.fetchProfile(),
    api.fetchSettings(),
    api.fetchNotifications(),
  ]);

  return UserBundle(
    profile: results[0] as Profile,
    settings: results[1] as Settings,
    notifications: results[2] as List<NotificationItem>,
  );
}

4-2) 동시성 제한(스로틀링)로 폭주 방지

이미지 메타 추출, 파일 해시 계산, 대량 API 호출을 한 번에 던지면 메모리와 CPU가 동시에 튀면서 프레임이 흔들립니다. 간단한 세마포어로 동시성을 제한할 수 있습니다.

class Semaphore {
  Semaphore(this._max);
  final int _max;
  int _current = 0;
  final List<Completer<void>> _waiters = [];

  Future<void> acquire() {
    if (_current < _max) {
      _current++;
      return Future.value();
    }
    final c = Completer<void>();
    _waiters.add(c);
    return c.future;
  }

  void release() {
    if (_waiters.isNotEmpty) {
      final c = _waiters.removeAt(0);
      c.complete();
      return;
    }
    _current--;
  }
}

Future<T> withPermit<T>(Semaphore s, Future<T> Function() fn) async {
  await s.acquire();
  try {
    return await fn();
  } finally {
    s.release();
  }
}

사용 예시

final sem = Semaphore(4);

Future<List<Result>> runJobs(List<Job> jobs) async {
  final futures = jobs.map((j) {
    return withPermit(sem, () => j.run());
  }).toList();
  return Future.wait(futures);
}

5) 상태 업데이트 비용 줄이기: “변경 범위”를 최소화

Dart 코드가 빠르더라도, 상태 변경이 너무 잦으면 빌드가 과도해져 성능이 떨어집니다. 핵심은 “필요한 부분만” 업데이트하는 것입니다.

  • 큰 모델을 통째로 교체하기보다, UI에 필요한 슬라이스만 노출
  • 빈번히 변하는 값은 ValueNotifier 같은 가벼운 도구로 분리
  • 리스트 전체를 새로 만들기보다 변경된 아이템만 갱신

예를 들어 스크롤 중에 매 프레임마다 전체 리스트를 재계산하면, Dart 연산과 빌드가 동시에 늘어납니다. 이때는 계산 결과를 캐시하고, 입력이 바뀔 때만 재계산하도록 구조를 바꾸는 편이 효과가 큽니다.

class FilterCache {
  String? _lastQuery;
  List<Item>? _lastResult;

  List<Item> filter(List<Item> items, String query) {
    if (_lastQuery == query && _lastResult != null) {
      return _lastResult!;
    }
    _lastQuery = query;
    _lastResult = _doFilter(items, query);
    return _lastResult!;
  }

  List<Item> _doFilter(List<Item> items, String query) {
    final q = query.trim().toLowerCase();
    if (q.isEmpty) return items;

    final out = <Item>[];
    for (final it in items) {
      if (it.titleLower.contains(q)) out.add(it);
    }
    return out;
  }
}

캐시는 메모리를 사용하므로, 화면 생명주기와 함께 폐기되도록 범위를 잘 잡는 것이 중요합니다.

6) 컬렉션/알고리즘 관점: O(n log n) 을 피하는 설계

성능 문제는 “코드 몇 줄”보다 알고리즘에서 결정되는 경우가 많습니다.

  • 매 입력마다 전체 정렬을 다시 하는가
  • 검색을 선형 탐색으로 하는가
  • 중복 제거를 매번 리스트로 하는가

6-1) 중복 제거는 Set 으로

List<String> uniqueIds(List<String> ids) {
  final seen = <String>{};
  final out = <String>[];
  for (final id in ids) {
    if (seen.add(id)) out.add(id);
  }
  return out;
}

6-2) 검색이 잦다면 인덱스(Map) 유지

class ItemIndex {
  ItemIndex(List<Item> items)
      : byId = {for (final it in items) it.id: it};

  final Map<String, Item> byId;

  Item? find(String id) => byId[id];
}

리스트에서 매번 firstWhere 를 돌리는 구조를 인덱스로 바꾸면, 체감 성능이 크게 바뀝니다.

7) GC 스파이크 줄이기: 큰 버퍼와 바이트 배열 다루기

이미지/파일/암호화/압축을 다루면 Uint8List 같은 바이트 배열이 자주 등장합니다. 여기서 흔한 문제는 “복사”입니다.

  • sublist 가 실제로 복사를 유발하는지 확인
  • 가능한 경우 view를 쓰거나, 한 번 만든 버퍼를 재사용

예를 들어 네트워크 응답을 받아 여러 번 변환하면, 그만큼 메모리 피크와 GC 비용이 커집니다. 변환 단계를 줄이고, 필요한 포맷으로 한 번만 바꾸는 것이 좋습니다.

8) 실전 체크리스트: 적용 순서

마지막으로, 현업에서 바로 적용할 수 있는 우선순위 체크리스트입니다.

  1. flutter run --profile 로 재현하고 DevTools에서 스파이크 구간 캡처
  2. CPU Hot path가 Dart라면
    • 변환 체인(where/map/toList) 과도 여부 확인
    • 루프 내 할당(문자열 결합, 정규식 생성, 임시 객체) 제거
  3. 무거운 작업은 isolate로 이동
    • JSON 파싱, 정렬, 대량 필터링, 암복호화, 압축
  4. isolate가 애매하면 작업을 청크로 쪼개고 이벤트 루프에 양보
  5. 비동기는
    • 독립 작업은 Future.wait
    • 대량 작업은 동시성 제한
  6. 상태 업데이트는 변경 범위를 줄이고 캐시를 도입

Flutter 성능 최적화는 결국 “프레임 예산 안에 끝내기”의 게임입니다. Dart 코드에서 할당과 CPU 시간을 줄이고, 메인 isolate를 가볍게 유지하는 습관을 들이면 위젯 트리 최적화보다 더 큰 효과를 보는 경우가 많습니다.

추가로, 사용자 체감 성능을 측정하고 Long Task를 분해하는 관점은 Flutter에서도 매우 유효하니, 필요하다면 Chrome INP 급등 원인 - Long Task 분해 실전 도 함께 참고해 보세요.