앱이 "된다"와 "부드럽다"는 다르다
앱 개발을 처음 시작하면 가장 큰 목표는 "일단 동작하게 만들기"다. 버튼을 누르면 화면이 바뀌고, 서버에서 데이터를 받아오고, 로그인한 사용자만 노트를 볼 수 있으면 꽤 멀리 온 것처럼 느껴진다. 실제로도 그렇다. 여기까지 왔다면 이미 앱 개발의 큰 산을 넘은 셈이다.
하지만 사용자는 코드가 잘 동작하는지보다 손끝에서 앱이 어떻게 느껴지는지를 먼저 판단한다. 화면이 0.5초 늦게 열리거나, 리스트를 스크롤할 때 중간중간 걸리거나, 이미지를 불러오는 동안 화면이 깜빡이면 사용자는 "이 앱은 뭔가 불안하다"고 느낀다. 기능은 맞지만 경험은 틀린 것이다.
Flutter 앱의 성능 최적화는 마법 주문을 외우는 일이 아니다. const를 많이 붙이면 빨라진다거나, setState를 쓰면 무조건 느려진다는 식으로 접근하면 오히려 코드가 이상해진다. 좋은 최적화는 언제나 측정에서 시작한다. 어디가 느린지 확인하고, 원인을 좁히고, 하나씩 고친 뒤 다시 측정한다.
이 장에서는 Flutter DevTools를 중심으로 느린 화면을 찾고, 불필요한 rebuild를 줄이고, 리스트와 이미지처럼 사용자가 바로 체감하는 영역을 개선하는 방법을 배운다. 목표는 "이론상 빠른 코드"가 아니라 실제 기기에서 부드럽게 움직이는 앱이다.

최적화의 첫 단계는 추측하지 않는 것
성능 문제가 생기면 가장 먼저 하고 싶은 일은 코드를 고치는 것이다. 하지만 "아마 이 위젯이 무거울 거야", "이미지 때문일 거야", "상태관리가 문제일 거야"라고 추측하면서 고치기 시작하면 시간을 많이 쓰고도 결과가 나아지지 않을 수 있다.
Flutter는 성능 분석을 위해 DevTools의 Performance view를 제공한다. 이 화면에서는 프레임이 얼마나 걸렸는지, UI thread와 raster thread 중 어느 쪽이 느렸는지, 특정 프레임에서 어떤 build/layout/paint 작업이 실행되었는지 확인할 수 있다. Flutter는 보통 60fps 환경에서 약 16ms 안에 한 프레임을 그려야 하고, 이 시간을 넘으면 사용자는 끊김, 즉 jank를 느낄 수 있다. 120Hz 기기에서는 그 예산이 약 8ms 수준으로 더 짧아진다.1
성능을 볼 때는 Debug 모드가 아니라 Profile 모드를 사용해야 한다. Debug 모드는 개발 편의를 위한 검사와 디버깅 정보가 많이 들어 있어 실제 앱 성능을 대표하지 않는다. 성능을 확인할 때는 가능한 한 실제 기기에서 다음 명령으로 실행하자.
flutter run --profile앱이 실행되면 IDE의 "Open DevTools" 버튼을 누르거나 터미널에 표시되는 DevTools 주소를 브라우저에서 열 수 있다. 그다음 Performance 탭으로 이동해 기록을 시작하고, 문제가 되는 동작을 직접 수행한다. 예를 들어 개인 지식 노트 앱에서 노트 목록을 빠르게 스크롤하거나, 검색어를 연속으로 입력하거나, 이미지가 많은 노트 상세 화면을 열어본다.
성능 분석은 보통 다음 순서로 진행하면 좋다.
실제 기기에서 Profile 모드로 앱을 실행한다.
DevTools Performance view에서 기록을 시작한다.
느리다고 느껴지는 동작을 10~15초 정도 반복한다.
빨간색으로 표시된 janky frame을 선택한다.
UI thread와 raster thread 중 어디가 튀는지 확인한다.
하나의 원인을 고친 뒤 같은 동작을 다시 측정한다.
중요한 것은 한 번에 여러 가지를 고치지 않는 것이다. const를 붙이고, 이미지 크기를 줄이고, 상태 구조를 바꾸고, 리스트를 페이지네이션으로 바꾸는 작업을 한꺼번에 하면 무엇이 효과가 있었는지 알 수 없다. 성능 최적화는 수술에 가깝다. 증상을 보고, 위치를 찾고, 필요한 만큼만 절개해야 한다.
Flutter DevTools Performance view 읽기
DevTools의 Performance view에서 가장 먼저 볼 곳은 Flutter frames chart다. 이 차트는 각 프레임을 막대로 보여준다. 막대가 프레임 예산을 넘으면 빨간색으로 표시되며, 이 프레임이 바로 사용자가 끊김을 느낄 가능성이 높은 지점이다.1
각 프레임은 크게 두 종류의 작업으로 나눌 수 있다. 하나는 UI thread 작업이고, 다른 하나는 raster thread 작업이다.
UI thread는 Dart 코드가 실행되는 곳이다. 우리가 작성한 build() 메서드, 상태 변경, 레이아웃 계산, 위젯 트리 구성 같은 일이 여기서 일어난다. UI thread 시간이 튄다면 보통 너무 많은 위젯이 rebuild되었거나, build() 안에서 무거운 계산을 했거나, 복잡한 레이아웃 계산이 반복되는 상황을 의심할 수 있다.
Raster thread는 UI thread가 만든 그림 명령을 실제 픽셀로 그리는 쪽에 가깝다. 이미지 디코딩, 그림자, clip, blur, shader, 복잡한 paint 작업 등이 이쪽 비용을 키울 수 있다. Raster thread 시간이 튄다면 "위젯이 너무 많이 rebuild된다"보다 "그리는 일이 너무 비싸다"에 가깝다.
간단히 정리하면 이렇게 볼 수 있다.
| 관찰되는 현상 | 먼저 의심할 곳 | 자주 나오는 원인 |
|---|---|---|
| UI thread 시간이 높다 | build/layout | 큰 위젯 트리 rebuild, 무거운 계산, 복잡한 레이아웃 |
| Raster thread 시간이 높다 | paint/raster | 큰 이미지, 그림자, blur, clip, shader, 과도한 레이어 |
| 스크롤할 때만 끊긴다 | list/image/state | 긴 리스트 전체 build, 이미지 로딩, 항목 전체 rebuild |
| 검색 입력 때마다 느리다 | state/computation | 입력마다 전체 화면 rebuild, 필터링을 build에서 반복 |
| 화면 전환 첫 순간만 끊긴다 | shader/image/startup | 첫 렌더링 비용, 이미지 준비 부족, 초기화 작업 과다 |
느린 프레임을 클릭하면 Frame analysis 탭에서 해당 프레임에 대한 힌트를 볼 수 있다. Timeline events 탭에서는 더 자세한 이벤트 흐름을 확인할 수 있다. 필요하다면 Track Widget Builds, Track Layouts, Track Paints 같은 옵션을 켜고 다시 재현한다. 이 옵션들은 build, layout, paint 이벤트를 타임라인에 더 자세히 표시해 주므로 "어떤 위젯이 자꾸 만들어지는지"를 추적할 때 유용하다.1
다만 이 추적 옵션 자체도 비용이 있으므로 항상 켜두지는 말자. 성능 분석 중에만 켜고, 측정 목적이 끝나면 끄는 것이 좋다.
UI thread가 느릴 때: rebuild부터 의심하자
Flutter 앱에서 가장 자주 만나고, 가장 고치기 쉬운 성능 문제는 불필요한 rebuild다. Flutter의 선언형 UI에서는 상태가 바뀌면 UI를 다시 설명한다. 이 자체는 정상이다. 문제는 작은 값 하나가 바뀌었는데 화면 전체가 다시 설명될 때 생긴다.
예를 들어 노트 상세 화면에 좋아요 버튼이 있다고 해보자. 좋아요 개수만 바뀌는데 본문, 이미지, 태그 목록, 댓글 입력창까지 모두 rebuild된다면 낭비가 크다. 앱이 작을 때는 티가 나지 않지만 화면이 복잡해질수록 이런 낭비가 jank로 나타난다.
공식 성능 문서에서도 build() 메서드에서 반복적이고 비용이 큰 작업을 피하고, 큰 위젯 하나에 모든 UI를 몰아넣기보다 변화 단위에 맞게 위젯을 나누라고 권장한다. 또한 가능한 곳에 const 생성자를 사용하면 Flutter가 rebuild 작업을 줄이는 데 도움이 된다.2
나쁜 예부터 보자.
class NoteDetailScreen extends ConsumerWidget {
const NoteDetailScreen({super.key, required this.noteId});
final String noteId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(noteDetailProvider(noteId));
return Scaffold(
appBar: AppBar(title: Text(state.note.title)),
body: Column(
children: [
NoteHeroImage(url: state.note.imageUrl),
NoteBody(content: state.note.content),
TagList(tags: state.note.tags),
LikeButton(count: state.likeCount),
],
),
);
}
}이 코드는 읽기 쉽지만, likeCount만 바뀌어도 NoteDetailScreen 전체가 provider를 다시 watch하므로 큰 영역이 rebuild될 수 있다. 개선 방향은 "화면 전체가 상태를 구독하지 말고, 필요한 위젯이 필요한 값만 구독하게 만들기"다.
class NoteDetailScreen extends StatelessWidget {
const NoteDetailScreen({super.key, required this.noteId});
final String noteId;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: NoteDetailAppBar(noteId: noteId),
body: Column(
children: [
NoteHeroImageSection(noteId: noteId),
NoteBodySection(noteId: noteId),
NoteTagSection(noteId: noteId),
LikeSection(noteId: noteId),
],
),
);
}
}
class LikeSection extends ConsumerWidget {
const LikeSection({super.key, required this.noteId});
final String noteId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final likeCount = ref.watch(
noteDetailProvider(noteId).select((state) => state.likeCount),
);
return LikeButton(count: likeCount);
}
}여기서 핵심은 select다. Riverpod의 select는 상태 객체 전체가 아니라 특정 값만 구독하게 해준다. 위 코드에서는 likeCount가 바뀔 때 LikeSection만 rebuild되고, 본문이나 이미지 영역은 영향을 덜 받는다.
Provider를 사용한다면 Selector나 context.select를 사용할 수 있고, Bloc을 사용한다면 BlocSelector나 buildWhen을 사용할 수 있다. 상태관리 패키지는 달라도 원리는 같다. 바뀐 데이터와 rebuild되는 UI의 범위를 최대한 가깝게 맞춘다.
build 메서드는 가볍고 순수하게 유지한다
build()는 생각보다 자주 호출된다. 부모가 rebuild될 때, 화면 크기가 바뀔 때, 키보드가 올라올 때, Theme이나 MediaQuery 같은 상위 정보가 바뀔 때도 다시 호출될 수 있다. 따라서 build() 안에 무거운 작업을 넣으면 "가끔 실행되는 코드"가 아니라 "언제든 반복 실행될 수 있는 코드"가 된다.
피해야 할 대표적인 작업은 다음과 같다.
build()안에서 HTTP 요청을 시작하는 코드큰 리스트를 매번 정렬하거나 필터링하는 코드
이미지나 파일을 동기적으로 읽는 코드
복잡한 문자열 조합을 반복하는 코드
상태를 변경하거나
setState()를 호출하는 코드
예를 들어 검색 결과를 화면에 보여준다고 해보자.
@override
Widget build(BuildContext context) {
final results = notes
.where((note) => note.title.contains(query))
.toList()
..sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
return NoteList(notes: results);
}노트가 20개라면 괜찮을 수 있다. 하지만 노트가 3,000개이고, 검색어를 입력할 때마다 전체 화면이 rebuild된다면 문제가 된다. 이런 계산은 상태가 바뀌는 시점에 한 번 수행하거나, provider/view model 쪽에서 파생 상태로 관리하는 편이 좋다.
final filteredNotesProvider = Provider<List<Note>>((ref) {
final notes = ref.watch(notesProvider);
final query = ref.watch(searchQueryProvider);
final results = notes
.where((note) => note.title.contains(query))
.toList()
..sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
return results;
});이렇게 하면 UI는 결과를 표시하는 일에 집중하고, 데이터 가공은 상태 계층이 맡는다. 아키텍처 장에서 나눈 presentation/application/domain/data 계층이 여기서 힘을 발휘한다. 구조가 정리된 앱은 성능 문제도 고치기 쉽다.
const는 작은 습관이지만 효과가 크다
const는 Flutter 성능 최적화에서 가장 유명한 도구다. 그러나 const를 "붙이면 무조건 빨라지는 장식"으로 생각하면 곤란하다. const의 본질은 변하지 않는 위젯 인스턴스를 재사용할 수 있게 만드는 것이다.
class EmptyNoteView extends StatelessWidget {
const EmptyNoteView({super.key});
@override
Widget build(BuildContext context) {
return const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.note_add_outlined, size: 48),
SizedBox(height: 12),
Text('아직 작성한 노트가 없습니다.'),
],
),
);
}
}이 위젯은 외부 상태에 따라 바뀌지 않는다. 이런 정적 UI는 const로 만드는 것이 자연스럽다. EdgeInsets, Duration, Text, Icon, SizedBox처럼 값이 고정된 객체도 가능한 한 const로 선언하자.
다만 모든 것을 const로 만들 수는 없다. 사용자 이름, API 응답, 현재 선택된 탭처럼 실행 중에 바뀌는 값은 const가 될 수 없다. 좋은 기준은 간단하다. "이 값이 앱 실행 중에도 바뀌지 않는가?" 그렇다면 const 후보에 올린다.
analysis_options.yaml에 Flutter 권장 lint를 적용하면 const를 붙일 수 있는 위치를 IDE가 알려준다.
include: package:flutter_lints/flutter.yaml자동 수정 기능을 사용하면 놓친 const를 빠르게 추가할 수 있다. 이것만으로도 작은 앱에서는 rebuild 비용이 눈에 띄게 줄어드는 경우가 많다.
RepaintBoundary는 페인트 비용을 자르는 칼이다
rebuild와 repaint는 다르다. rebuild는 위젯 트리를 다시 설명하는 일이고, repaint는 실제로 다시 그리는 일이다. RepaintBoundary는 rebuild를 막는 도구가 아니라 repaint 범위를 분리하는 도구다.
예를 들어 대시보드 화면에 복잡한 차트가 있고, 그 옆에 1초마다 바뀌는 타이머가 있다고 하자. 타이머 때문에 화면 전체가 자주 다시 그려진다면 차트까지 계속 repaint될 수 있다. 이럴 때 차트를 RepaintBoundary로 감싸면 페인트 비용을 격리하는 데 도움이 된다.
class NoteStatsCard extends StatelessWidget {
const NoteStatsCard({super.key, required this.data});
final NoteStatsData data;
@override
Widget build(BuildContext context) {
return RepaintBoundary(
child: CustomPaint(
painter: NoteStatsPainter(data),
size: const Size(double.infinity, 180),
),
);
}
}하지만 RepaintBoundary를 아무 데나 많이 넣으면 오히려 손해가 될 수 있다. 별도의 레이어를 만들기 때문에 메모리와 합성 비용이 추가된다. 복잡한 차트, 지도, 애니메이션, 자주 바뀌는 작은 영역처럼 페인트 격리 효과가 분명한 곳에만 사용하자.
CustomPainter를 직접 만든다면 shouldRepaint도 중요하다.
class NoteStatsPainter extends CustomPainter {
const NoteStatsPainter(this.data);
final NoteStatsData data;
@override
void paint(Canvas canvas, Size size) {
// 차트 그리기
}
@override
bool shouldRepaint(covariant NoteStatsPainter oldDelegate) {
return oldDelegate.data != data;
}
}shouldRepaint가 항상 true를 반환하면 데이터가 같아도 계속 다시 그린다. "정말 다시 그려야 하는가?"를 비교하는 습관이 중요하다.
Raster thread가 느릴 때: 이미지, 그림자, clip, shader를 본다
DevTools에서 raster thread 시간이 튄다면 그림을 그리는 쪽이 부담을 느끼고 있다는 뜻이다. 이때는 rebuild를 줄여도 효과가 제한적일 수 있다. 원인은 대개 시각 효과에 있다.
Flutter의 공식 성능 문서에서는 saveLayer()처럼 오프스크린 버퍼를 사용하는 작업을 신중하게 사용하라고 설명한다. saveLayer()는 특정 효과를 위해 화면 밖 임시 버퍼에 먼저 그린 뒤 다시 합성하는 방식인데, 모바일 GPU에서는 이런 작업이 부담이 될 수 있다. 일부 ShaderMask, ColorFilter, 투명도가 섞인 효과, 복잡한 clip과 그림자도 상황에 따라 비용이 커질 수 있다.2
Raster 비용을 줄일 때는 다음 질문을 던져보자.
큰 이미지가 작은 영역에 표시되고 있지 않은가?
리스트 항목마다 강한 그림자와 둥근 clip을 반복하고 있지 않은가?
blur나 opacity가 넓은 영역에 적용되어 있지 않은가?
같은 화면에 레이어가 너무 많이 쌓여 있지 않은가?
첫 화면 전환에서 shader compilation으로 인한 끊김이 나타나지 않는가?
Material 3 UI를 만들다 보면 둥근 카드, 그림자, 이미지, 애니메이션을 자연스럽게 많이 사용하게 된다. 이 요소들은 앱을 예쁘게 만들지만, 과하면 스크롤 성능을 떨어뜨린다. 디자인은 풍성하게 하되, 반복되는 리스트 항목에서는 효과를 절제하는 편이 좋다. 카드 하나는 예쁘지만, 카드 100개가 동시에 스크롤되면 이야기가 달라진다.
긴 리스트는 lazy build가 기본이다
사용자가 가장 자주 체감하는 성능 문제는 스크롤이다. 노트 목록, 검색 결과, 알림 목록, 댓글 목록처럼 긴 데이터는 반드시 lazy build 방식으로 만들어야 한다.
작은 목록에서는 다음 코드도 문제없이 동작한다.
ListView(
children: notes.map((note) => NoteTile(note: note)).toList(),
)하지만 노트가 수백 개, 수천 개가 되면 이 방식은 위험하다. 화면에 보이지 않는 항목까지 한꺼번에 위젯으로 만들기 때문이다. 긴 목록에서는 ListView.builder를 사용하자.
ListView.builder(
itemCount: notes.length,
itemBuilder: (context, index) {
final note = notes[index];
return NoteTile(
key: ValueKey(note.id),
note: note,
);
},
)ValueKey(note.id)는 항목의 정체성을 Flutter에 알려준다. 특히 항목 삭제, 삽입, 정렬이 있는 리스트에서 key는 상태가 엉뚱한 항목에 붙는 문제를 줄이고, 변경된 항목을 더 정확히 처리하는 데 도움을 준다.
모든 항목의 높이가 같다면 itemExtent를 지정하는 것도 좋은 최적화다.
ListView.builder(
itemCount: notes.length,
itemExtent: 88,
itemBuilder: (context, index) {
return NoteTile(note: notes[index]);
},
)itemExtent를 지정하면 Flutter가 각 항목의 높이를 매번 측정하지 않아도 된다. 항목 높이가 고정된 알림 목록, 설정 목록, 간단한 노트 목록에서는 큰 도움이 된다. 항목마다 높이가 다르지만 대표 높이를 줄 수 있다면 prototypeItem을 고려할 수 있다.
반대로 긴 리스트에서 shrinkWrap: true를 남용하면 lazy build의 장점을 잃을 수 있다. shrinkWrap은 리스트가 자신의 전체 높이를 계산해야 하는 상황을 만들 수 있기 때문이다. 짧은 리스트를 다른 스크롤 안에 넣는 특수한 경우가 아니라면, 긴 목록에서는 피하는 편이 좋다.
데이터 자체가 많다면 페이지네이션도 필요하다. 10,000개의 노트를 한 번에 받아와 메모리에 올리는 대신 20개나 50개씩 나눠 불러오자. 사용자는 전체 데이터가 메모리에 있는지보다 스크롤이 끊기지 않는지를 더 중요하게 느낀다.
이미지 최적화는 체감 성능의 핵심이다
이미지는 앱을 풍성하게 만들지만 성능 비용도 크다. 네트워크 지연, 디코딩 비용, 메모리 사용량, 캐시 정책이 모두 얽혀 있다. 특히 사용자가 올린 원본 이미지를 그대로 보여주는 앱은 쉽게 느려진다. 스마트폰 사진 한 장은 4000px이 넘을 수 있지만, 리스트 썸네일은 보통 80~200px이면 충분하다.
이미지 최적화의 첫 원칙은 "표시 크기에 맞는 이미지를 가져오기"다. 서버에서 썸네일 URL을 제공할 수 있다면 원본 대신 썸네일을 사용하자. Firebase Storage를 사용한다면 업로드 후 Cloud Functions로 썸네일을 생성하는 구조를 고려할 수 있다.
Flutter에서 이미지를 표시할 때는 메모리 디코딩 크기도 제한할 수 있다.
Image.network(
note.thumbnailUrl,
width: 96,
height: 96,
fit: BoxFit.cover,
cacheWidth: 192,
cacheHeight: 192,
)여기서 width와 height는 화면에 표시되는 크기이고, cacheWidth와 cacheHeight는 디코딩 캐시에 사용할 이미지 크기 힌트다. 고해상도 이미지를 작은 썸네일로 보여줄 때 메모리 사용량을 줄이는 데 도움이 된다.
네트워크 이미지는 캐싱도 중요하다. cached_network_image 같은 패키지를 사용하면 이미 내려받은 이미지를 다시 요청하지 않고 재사용할 수 있으며, placeholder와 error UI를 함께 제공할 수 있다.
CachedNetworkImage(
imageUrl: note.thumbnailUrl,
width: 96,
height: 96,
fit: BoxFit.cover,
placeholder: (context, url) => const ColoredBox(
color: Color(0xFFECEFF1),
),
errorWidget: (context, url, error) => const Icon(Icons.broken_image),
)이미지가 중요한 화면에서는 프리캐싱도 사용할 수 있다. 예를 들어 노트 상세 화면으로 들어가기 직전에 대표 이미지를 미리 준비하면 첫 진입 때 깜빡임이 줄어든다.
Future<void> precacheNoteImage(
BuildContext context,
String imageUrl,
) async {
await precacheImage(NetworkImage(imageUrl), context);
}물론 프리캐싱도 남용하면 메모리를 많이 쓴다. 사용자가 곧 볼 가능성이 높은 이미지에만 제한적으로 적용하자.
CPU Profiler로 무거운 Dart 코드를 찾기
Performance view에서 UI thread가 느린데 build/layout/paint만으로 원인이 명확하지 않을 때는 CPU Profiler를 사용한다. CPU Profiler는 어떤 함수가 시간을 많이 쓰는지 보여준다. 특히 다음 작업은 UI isolate를 막기 쉽다.
큰 JSON 문자열 파싱
긴 리스트 정렬과 그룹화
대량 문자열 검색
이미지나 파일 관련 동기 처리
암호화, 압축, 해시 계산 같은 CPU 중심 작업
예를 들어 REST API에서 받은 큰 JSON을 화면 진입 직후 바로 파싱한다고 해보자. 데이터가 작을 때는 괜찮지만, 크기가 커지면 화면이 멈춘 것처럼 보일 수 있다. 이럴 때는 compute나 별도 isolate를 사용해 무거운 작업을 UI isolate 밖으로 옮기는 방법을 고려한다.
import 'package:flutter/foundation.dart';
Future<List<NoteDto>> parseNotesInBackground(String jsonString) {
return compute(parseNotes, jsonString);
}
List<NoteDto> parseNotes(String jsonString) {
// JSON decode 후 DTO 리스트로 변환
// 이 함수는 top-level 함수이거나 static 함수여야 한다.
return NoteDto.parseList(jsonString);
}다만 isolate는 공짜가 아니다. 데이터를 주고받는 비용이 있기 때문에 작은 작업까지 모두 isolate로 보내면 오히려 복잡도만 늘어난다. "한 프레임 안에 끝나기 어려운 무거운 작업"에 사용할 때 효과가 크다.
직접 타임라인 이벤트를 남겨 원인을 좁힐 수도 있다. dart:developer의 Timeline API를 사용하면 DevTools Timeline에서 내가 표시한 구간을 볼 수 있다.1
import 'dart:developer';
Future<void> loadAndPrepareNotes() async {
Timeline.startSync('loadAndPrepareNotes');
try {
final notes = await repository.fetchNotes();
await cache.saveAll(notes);
} finally {
Timeline.finishSync();
}
}이런 표식은 복잡한 화면에서 매우 유용하다. "느린 것 같다"가 아니라 "노트 로딩 후 캐시 저장 구간에서 120ms를 썼다"처럼 말할 수 있게 된다.
Memory view로 누수와 과한 메모리 사용 찾기
성능 문제는 프레임 드롭만이 아니다. 메모리가 계속 증가하면 앱이 점점 느려지거나 결국 종료될 수 있다. 특히 화면을 여러 번 열고 닫았는데 메모리가 줄지 않는다면 리소스가 해제되지 않았을 가능성이 있다.
Flutter에서 자주 dispose해야 하는 객체는 다음과 같다.
AnimationControllerTextEditingControllerScrollControllerFocusNodeStreamSubscriptionTimer직접 만든 controller나 listener 객체
예를 들어 검색 화면에서 TextEditingController와 FocusNode를 사용한다면 다음처럼 정리해야 한다.
class SearchScreen extends StatefulWidget {
const SearchScreen({super.key});
@override
State<SearchScreen> createState() => _SearchScreenState();
}
class _SearchScreenState extends State<SearchScreen> {
final _controller = TextEditingController();
final _focusNode = FocusNode();
@override
void dispose() {
_controller.dispose();
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return TextField(
controller: _controller,
focusNode: _focusNode,
);
}
}AnimationController도 마찬가지다.
class PulseIcon extends StatefulWidget {
const PulseIcon({super.key});
@override
State<PulseIcon> createState() => _PulseIconState();
}
class _PulseIconState extends State<PulseIcon>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 800),
)..repeat(reverse: true);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: _controller,
child: const Icon(Icons.notifications_active),
);
}
}Memory view에서는 화면을 열기 전 스냅샷을 찍고, 화면을 여러 번 열고 닫은 뒤 다시 스냅샷을 비교해볼 수 있다. 특정 controller나 화면 객체 수가 계속 증가한다면 dispose 누락, subscription 해제 누락, 전역 캐시에 객체를 계속 쌓는 문제를 의심해야 한다.
이미지 메모리도 주의해야 한다. 고해상도 이미지를 많이 보여주는 화면은 메모리를 빠르게 잡아먹는다. 썸네일 사용, 캐시 크기 제한, 페이지네이션, 화면에 필요한 이미지만 유지하는 전략이 중요하다.
Network view로 과도한 API 호출을 잡기
앱이 느린 이유가 렌더링이 아니라 네트워크일 수도 있다. 사용자는 로딩 스피너가 길게 보이면 "앱이 느리다"고 느낀다. DevTools의 Network view를 사용하면 어떤 요청이 언제 발생했고, 얼마나 오래 걸렸고, 응답 크기가 어느 정도인지 확인할 수 있다.
다음과 같은 상황은 자주 발생한다.
@override
Widget build(BuildContext context) {
final future = repository.fetchNotes();
return FutureBuilder(
future: future,
builder: (context, snapshot) {
// ...
},
);
}이 코드는 build()가 다시 호출될 때마다 새로운 future가 만들어질 수 있다. 그 결과 같은 API를 반복 호출할 위험이 있다. Future는 initState에서 한 번 만들거나, Riverpod의 FutureProvider처럼 캐싱과 생명주기를 관리해주는 구조를 사용하는 편이 좋다.
class NotesScreen extends StatefulWidget {
const NotesScreen({super.key});
@override
State<NotesScreen> createState() => _NotesScreenState();
}
class _NotesScreenState extends State<NotesScreen> {
late final Future<List<Note>> _notesFuture;
@override
void initState() {
super.initState();
_notesFuture = repository.fetchNotes();
}
@override
Widget build(BuildContext context) {
return FutureBuilder<List<Note>>(
future: _notesFuture,
builder: (context, snapshot) {
// ...
},
);
}
}Network view에서 같은 URL이 짧은 시간에 여러 번 호출된다면 build 위치, provider 생명주기, 화면 전환 시 refresh 로직을 확인하자. 성능 최적화는 프레임만 보는 일이 아니다. 불필요한 API 호출을 줄이는 것도 배터리, 데이터 사용량, 서버 비용, 사용자 경험을 모두 개선한다.
앱 시작 속도도 성능이다
앱 시작 속도는 사용자가 처음 만나는 성능이다. 첫 화면이 뜨기 전에 너무 많은 일을 하면 앱이 무겁게 느껴진다. Firebase 초기화, 로컬 DB 열기, 원격 설정 가져오기, 사용자 세션 확인, 캐시 정리, 폰트 로딩, 이미지 프리로드를 모두 시작 시점에 몰아넣으면 첫 진입이 느려진다.
시작 속도를 개선할 때는 "첫 화면에 반드시 필요한 일"과 "나중에 해도 되는 일"을 구분하자. 예를 들어 로그인 여부 확인은 필요할 수 있지만, 모든 노트의 태그 통계 계산은 첫 프레임 이후로 미룰 수 있다.
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
runApp(const ProviderScope(child: App()));
}main()에서 반드시 필요한 초기화만 끝낸 뒤 앱을 띄우고, 나머지는 앱 내부에서 비동기로 진행하는 편이 좋다. 초기 화면에서는 skeleton UI나 간결한 loading UI를 보여주고, 데이터가 준비되는 대로 자연스럽게 채운다.
앱 시작 성능을 볼 때도 Debug 모드가 아니라 Profile 또는 Release 빌드에 가까운 환경에서 확인해야 한다. 개발 중 로그가 많거나 디버그 도구가 붙어 있으면 실제 사용자 환경과 다르게 보일 수 있다.
최적화 우선순위 정하기
성능 문제를 모두 고치려 하면 끝이 없다. 좋은 개발자는 모든 것을 최적화하는 사람이 아니라, 사용자에게 가장 크게 보이는 문제부터 고치는 사람이다.
개인 지식 노트 앱을 예로 들면 우선순위는 대략 이렇게 잡을 수 있다.
노트 목록 스크롤이 끊기지 않는가?
검색 입력에 즉시 반응하는가?
노트 상세 화면이 자연스럽게 열리는가?
이미지가 깜빡이거나 늦게 나타나지 않는가?
화면을 여러 번 오가도 메모리가 계속 증가하지 않는가?
앱 첫 실행 시간이 지나치게 길지 않은가?
이 순서는 앱 성격에 따라 달라진다. 지도 앱이라면 pan/zoom 성능이 가장 중요하고, 채팅 앱이라면 메시지 리스트와 키보드 반응성이 중요하다. 중요한 것은 사용자가 가장 자주 하는 행동을 기준으로 삼는 것이다.
성능 목표를 숫자로 정해두면 팀 작업에서도 도움이 된다. 예를 들어 "노트 목록 빠른 스크롤 중 janky frame이 눈에 띄게 없어야 한다", "검색 입력 후 100ms 안에 로컬 결과를 갱신한다", "같은 화면을 10번 열고 닫아도 관련 controller 객체가 누적되지 않는다"처럼 기준을 만들 수 있다.
실전 디버깅 시나리오: 노트 목록이 버벅인다
이제 하나의 문제를 끝까지 따라가 보자. 개인 지식 노트 앱에서 노트 목록을 스크롤할 때 중간중간 끊긴다고 가정한다.
먼저 Profile 모드로 실행한다.
flutter run --profileDevTools Performance view에서 기록을 시작하고 노트 목록을 빠르게 스크롤한다. Flutter frames chart에 빨간 프레임이 보인다. 프레임을 선택했더니 UI thread 시간이 높다. Track Widget Builds를 켜고 다시 기록하니 검색창, 상단 사용자 정보, 하단 버튼까지 스크롤 중 반복해서 rebuild되고 있다.
원인을 보니 화면 최상단에서 전체 notesProvider를 watch하고 있었고, 리스트 항목의 읽음 상태가 바뀔 때마다 화면 전체가 rebuild되고 있었다. 이때 해결은 리스트 전체를 갈아엎는 것이 아니라 구독 범위를 줄이는 것이다.
class NoteTile extends ConsumerWidget {
const NoteTile({super.key, required this.noteId});
final String noteId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final note = ref.watch(
notesProvider.select(
(notes) => notes.firstWhere((note) => note.id == noteId),
),
);
return ListTile(
title: Text(note.title),
subtitle: Text(note.preview),
trailing: note.isPinned
? const Icon(Icons.push_pin, size: 18)
: null,
);
}
}그다음 리스트는 id 목록만 가지고 항목별로 필요한 데이터를 구독하게 바꾼다.
class NoteList extends ConsumerWidget {
const NoteList({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final noteIds = ref.watch(noteIdsProvider);
return ListView.builder(
itemCount: noteIds.length,
itemExtent: 88,
itemBuilder: (context, index) {
final noteId = noteIds[index];
return NoteTile(
key: ValueKey(noteId),
noteId: noteId,
);
},
);
}
}다시 측정한다. UI thread 시간이 줄었지만 여전히 몇몇 프레임에서 raster thread가 튄다. 이번에는 이미지가 원인이다. 각 노트 항목에 원본 이미지를 그대로 표시하고 있었다. 썸네일 URL을 사용하고, 표시 크기와 디코딩 크기를 제한한다.
Image.network(
note.thumbnailUrl,
width: 56,
height: 56,
fit: BoxFit.cover,
cacheWidth: 112,
cacheHeight: 112,
)다시 측정하니 스크롤이 안정된다. 이 과정에서 중요한 것은 "처음부터 이미지가 문제라고 단정하지 않았다"는 점이다. 먼저 UI thread 문제를 고치고, 그다음 raster thread 문제를 고쳤다. 측정이 방향을 알려준 것이다.
성능 체크리스트
기능을 완성한 뒤 출시 전에는 다음 항목을 확인하자. 모든 항목을 기계적으로 적용하라는 뜻은 아니다. 문제가 생겼을 때 어디부터 볼지 알려주는 지도라고 생각하면 된다.
Profile 모드와 실제 기기에서 주요 화면을 측정했는가?
DevTools Performance view에서 janky frame을 확인했는가?
UI thread와 raster thread 중 병목이 어디인지 구분했는가?
큰
build()메서드를 변화 단위에 맞게 작은 위젯으로 나눴는가?정적인 위젯과 값에
const를 적용했는가?Provider/Riverpod/Bloc에서 필요한 상태 조각만 구독하는가?
긴 목록에
ListView.builder,GridView.builder,SliverList를 사용하는가?고정 높이 리스트에
itemExtent또는prototypeItem을 고려했는가?긴 리스트에서
shrinkWrap: true를 남용하지 않았는가?이미지 표시 크기와 원본/디코딩 크기가 지나치게 차이 나지 않는가?
placeholder와 error UI로 이미지 로딩 경험을 다듬었는가?
반복 API 호출이 Network view에 나타나지 않는가?
controller, subscription, timer를
dispose()에서 정리했는가?앱 시작 시 반드시 필요한 초기화만 먼저 수행하는가?
부드러운 앱은 사용자를 설득한다
성능 최적화는 마지막에 억지로 붙이는 장식이 아니다. 좋은 구조, 적절한 상태관리, 가벼운 UI, 신중한 이미지 처리, 측정하는 습관이 합쳐진 결과다. 이 장에서 배운 DevTools 사용법은 앞으로 앱을 만들 때 계속 사용할 도구다.
Flutter는 기본적으로 빠른 프레임워크다. 하지만 빠른 도구를 쓴다고 자동으로 빠른 앱이 만들어지지는 않는다. 긴 리스트에 모든 항목을 한꺼번에 만들고, 원본 이미지를 그대로 보여주고, 작은 상태 변화에 화면 전체를 rebuild하면 어떤 프레임워크도 힘들다.
반대로 원인을 측정하고, UI thread와 raster thread를 구분하고, 실제로 바뀐 부분만 rebuild되도록 만들면 앱은 눈에 띄게 달라진다. 사용자는 내부 구조를 알지 못하지만 손끝은 안다. 스크롤이 부드럽고, 화면 전환이 자연스럽고, 입력이 즉시 반응하는 앱은 그 자체로 신뢰를 준다.
다음 장에서는 성능만큼 중요한 또 하나의 축인 보안과 안정성을 다룬다. 빠른 앱도 사용자 데이터를 안전하게 지키지 못하면 좋은 앱이 될 수 없다.
참고
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.
