- Published on
Dart로 Flutter 성능 최적화하는 실전 패턴
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
Flutter 성능 최적화는 흔히 const 나 RepaintBoundary 같은 위젯 레벨 팁으로 시작하지만, 실제 병목은 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) 실전 체크리스트: 적용 순서
마지막으로, 현업에서 바로 적용할 수 있는 우선순위 체크리스트입니다.
flutter run --profile로 재현하고 DevTools에서 스파이크 구간 캡처- CPU Hot path가 Dart라면
- 변환 체인(
where/map/toList) 과도 여부 확인 - 루프 내 할당(문자열 결합, 정규식 생성, 임시 객체) 제거
- 변환 체인(
- 무거운 작업은 isolate로 이동
- JSON 파싱, 정렬, 대량 필터링, 암복호화, 압축
- isolate가 애매하면 작업을 청크로 쪼개고 이벤트 루프에 양보
- 비동기는
- 독립 작업은
Future.wait - 대량 작업은 동시성 제한
- 독립 작업은
- 상태 업데이트는 변경 범위를 줄이고 캐시를 도입
Flutter 성능 최적화는 결국 “프레임 예산 안에 끝내기”의 게임입니다. Dart 코드에서 할당과 CPU 시간을 줄이고, 메인 isolate를 가볍게 유지하는 습관을 들이면 위젯 트리 최적화보다 더 큰 효과를 보는 경우가 많습니다.
추가로, 사용자 체감 성능을 측정하고 Long Task를 분해하는 관점은 Flutter에서도 매우 유효하니, 필요하다면 Chrome INP 급등 원인 - Long Task 분해 실전 도 함께 참고해 보세요.