플러터 앱이 스크롤이나 애니메이션에서 끊기면 "const 를 다 붙이라", "RepaintBoundary 를 감싸라", "키를 줘라" 같은 조언이 한꺼번에 쏟아지죠. 결론부터 말하면 먼저 실기기 프로파일 모드에서 느린 쪽이 UI 스레드인지 래스터 스레드인지 가르고, UI 쪽이면 setState 범위·긴 목록·AnimatedBuilder 자식부터, 래스터 쪽이면 Opacity·saveLayer·클리핑부터 보는 게 순서예요. RepaintBoundary 와 키는 증상이 확인된 자리에만 넣는 도구예요.
세 트리의 내부 구조는 안다고 보고, 이 글은 고르는 데 필요한 숫자와 판단 기준만 다뤄요. 프레임 예산, 직접 센 재빌드·페인트 횟수, 키별 State 행방, 확인 순서 차례예요.
먼저 숫자 하나, 주사율별 프레임 예산
버벅임(jank)은 다음 화면 갱신 전까지 프레임을 못 만들어 건너뛰는 현상이라, 기준은 늘 한 프레임에 쓸 수 있는 시간이에요. 플러터 성능 모범 사례 문서는 빌드(UI 스레드)와 렌더(래스터 스레드)가 따로 돌아 60Hz 에서 각각 16ms 를 쓸 수 있다고 하면서도, 지연이 중요하면 빌드 8ms·렌더 8ms, 합쳐 16ms 안에 끝내라고 권해요. 120Hz 기기에서는 합계 8ms 미만을 목표로 하라고 적어요.
60Hz 에서 3ms 걸리던 재빌드는 예산의 18%지만 120Hz 에서는 36%예요. 고주사율 기기에서만 끊긴다는 제보가 오면 이 비율부터 떠올리세요.
재는 조건도 중요해요. UI 성능 측정 안내는 성능 디버깅을 거의 전부 실제 기기의 프로파일 모드에서 하라고 해요. 디버그 모드는 비싼 assert 와 JIT 때문에 숫자가 왜곡되고, 빌드 모드 문서에 따르면 프로파일 모드는 에뮬레이터에서 아예 꺼져 있어요. 사용자가 쓸 법한 가장 느린 기기로 재요.
setState 위치와 const, 재빌드 횟수를 직접 세 보면
위젯 테스트로 build() 가 몇 번 불리는지 세 봤어요. 환경은 Flutter 3.38.7(Dart 3.10.7), Apple M1, flutter test 예요. 횟수는 환경과 무관하게 같고, 시간은 디버그 테스트 환경이라 상대 비교로만 봐 주세요. 글 쓰는 시점 최신 안정판은 Flutter SDK 릴리스 목록 기준 3.47.8(2026-10-23)이에요.
int itemBuilds = 0;
class Item extends StatelessWidget {
const Item(this.i, {super.key});
final int i;
@override
Widget build(BuildContext context) {
itemBuilds++; // 몇 번 불리는지만 센다
return SizedBox(height: 20, child: Text('item $i'));
}
}
// A: 부모 State 에 카운터가 있고 Item(i) 100개를 매번 새로 만든다
// B: A 와 같지만 자식을 const Item(0) 으로 만든다
// C: 카운터를 잎 위젯 LeafCounter 로 내리고 부모는 StatelessWidget
// 각 경우 setState 10번 + pump 10번 뒤 itemBuilds / 10 을 출력
| 구성 | 버튼 1번에 Item.build 호출 | 왜 그런가 |
|---|---|---|
A. 부모에서 setState, 자식 Item(i) | 100번 | 부모가 새 인스턴스를 만들어 넘기니 자식 엘리먼트가 매번 갱신돼요 |
B. 부모에서 setState, 자식 const Item(0) | 0번 | 같은 인스턴스라 그 아래로 내려가지 않아요 |
| C. 잎 위젯에서 setState | 0번 | 다시 빌드되는 건 숫자를 그리는 잎 하나뿐이에요 |
공식 가이드도 setState 는 실제로 바뀌는 부분을 감싸는 가장 작은 서브트리에서 부르라고 하고, 재빌드는 이전 프레임과 같은 자식 인스턴스를 만나면 멈춘다고 적어요. UI 조각을 Widget _buildX() 메서드보다 StatelessWidget 으로 만들라는 권고도 같은 이유예요.
B 가 가능하려면 자식이 컴파일 시점 상수여야 해요. 매번 달라지는 값(Item(i) 의 i 처럼)을 받는 위젯은 const 로 만들 수 없으니, 그때는 C 처럼 상태를 아래로 내리는 쪽이 답이에요.
애니메이션 한 번에 생기는 숨은 비용
애니메이션은 매 프레임 같은 일을 반복하니 작은 낭비가 초당 수십 배로 불어요. 두 경우를 60프레임 동안 세 봤어요.
첫째는 AnimatedBuilder 의 builder 안에 애니메이션과 상관없는 자식 50개를 넣은 경우예요. 공식 가이드의 함정 목록에 그대로 있는 실수로, 바뀌지 않는 부분은 child 인자로 한 번만 만들어 넘기라고 해요.
둘째는 CustomPaint 로 그리는 회전 표시기 옆에 300×300 정적 그림을 둔 경우예요. RepaintBoundary API 문서는 같은 레이어를 쓰는 렌더오브젝트 하나가 다시 그려져야 하면 가장 가까운 경계 아래 전체가 같이 다시 그려진다고 설명해요.
투명도도 같은 맥락이에요. Opacity API 문서는 0과 1 사이 값에서 중간 버퍼를 써서 비싸다며 애니메이션에는 AnimatedOpacity·FadeTransition 을, 이미지·색 하나라면 그 자체에 투명도를 주라고 해요.
키 고르는 판단표, 순서를 바꾸면 State 가 어디로 가나
키는 성능보다 State 가 맞는 항목에 붙어 있느냐의 문제예요. 항목 100개짜리 목록에서 각 항목 State 가 처음 만들어질 때 받은 id 를 기억하게 하고, 목록을 뒤집거나 첫 항목을 지운 뒤 "기억한 id 와 지금 위젯의 id 가 다른 State" 를 셌어요.
| 키 | 순서 뒤집기: 엉뚱한 State | 첫 항목 삭제: 엉뚱한 State | 그냥 재빌드할 때 새로 만든 State |
|---|---|---|---|
| 없음 | 100개 | 99개 | 0개 |
ValueKey(index) | 100개 | 99개 | 0개 |
ValueKey(item.id) | 0개 | 0개 | 0개 |
build() 안 UniqueKey() | 0개 (대신 100개 새로 만듦) | 0개 (99개 새로 만듦) | 99개 |
읽을 점은 세 가지예요.
- 키가 없으면 State 는 위치를 따라가요. 뒤집어도 State 를 하나도 버리지 않고 100개 전부 엉뚱한 항목에 붙었어요. 체크박스·입력 중인 글자·펼침 상태가 다른 줄로 옮겨 가는 버그가 이거예요. 첫 항목을 지우면 맨 끝 State 하나가 버려지고 나머지 99개가 한 칸씩 밀려요.
ValueKey(index)는 키가 없는 것과 똑같았어요. 인덱스는 위치와 같은 정보라서 짝을 바꿔 주지 못해요.build()안에서 만든UniqueKey()는 상태를 지키는 게 아니라 매번 버려요. 아무것도 안 바뀐 재빌드에서도 State 99개를 버리고 새로 만들었어요. 입력 포커스가 날아가고 애니메이션이 처음부터 다시 도는 원인이 돼요.
GlobalKey 는 부모를 옮겨도 State 를 살리고 currentState 로 꺼내 쓸 수 있지만 비싸요. GlobalKey API 문서는 옮길 때 State 와 모든 자손의 deactivate 를 부르고 InheritedWidget 에 기대는 위젯을 다시 빌드한다고 적어요. FormState 접근처럼 꼭 필요한 곳 아니면 일반 키로 충분해요.
긴 목록에서는 키만으로 State 를 지킬 수 없다는 것도 알아 두세요. ListView API 문서는 화면 밖으로 스크롤된 자식의 엘리먼트·State·렌더오브젝트를 파괴한다고 적어요. 체크 여부 같은 값은 위젯 State 가 아니라 목록 데이터(모델)에 두는 게 안전해요.
긴 목록, Column 과 ListView.builder 차이
항목 1만 개를 SingleChildScrollView + Column 으로 그리면 첫 화면에 1만 개를 전부 빌드해요. ListView.builder 는 보이는 만큼만 만들어요. 800×600 테스트 화면, 항목 높이 20에서 세 보니 이랬어요.
| 방식 | 첫 화면 Item.build 호출 | 화면에 보이는 항목 |
|---|---|---|
| Column 항목 1만 개 | 10,000번 | 30개 |
| ListView.builder 1만 개 (itemExtent 20) | 43번 | 30개 |
43 은 보이는 30개(600 ÷ 20)에 화면 아래 캐시 영역 250픽셀(RenderAbstractViewport.defaultCacheExtent)의 13개를 더한 값이에요. 캐시 크기를 정하는 cacheExtent 인자는 v3.41 이후 폐기 예정이라, 최신판에서는 scrollCacheExtent 를 써요. 첫 프레임 시간도 재 봤어요. 다섯 번 잰 중앙값이에요.
Column 은 항목이 10배가 되자 시간도 약 10배(616 → 6,553ms)가 됐고, builder 는 1,000개든 1만 개든 49~64ms 사이였어요. 공식 가이드도 화면에 안 보이는 자식이 대부분이면 Column()·ListView() 처럼 자식 리스트를 통째로 받는 생성자를 피하라고 해요.
itemExtent 나 prototypeItem 을 주면 더 좋아요. ListView 문서는 스크롤 위치가 크게 바뀔 때 자식 크기를 미리 알면 일을 줄일 수 있어 더 효율적이라고 적어요. 반대로 격자 칸을 "가장 큰 칸에 맞추는" 식의 배치는 모든 자식에게 원하는 크기를 묻는 intrinsic 패스를 한 번 더 돌게 해요. 공식 가이드는 칸 크기를 미리 정하거나 기준 칸 하나를 정해 두라고 해요.
상황별 설정 판단표
지금까지 본 도구를 "언제 쓰고, 무엇을 잃고, 어떻게 확인하나"로 모았어요.
| 설정 | 이럴 때 | 대가 | 효과 확인 |
|---|---|---|---|
const 생성자 | 인자가 상수인 위젯 | 거의 없음 | Track widget builds 에서 빠지는지 |
| setState 를 잎으로 내리기 | 화면 일부만 자주 바뀜 | 위젯 클래스가 늘어남 | 재빌드 횟수 |
AnimatedBuilder 의 child | 애니메이션과 무관한 자식이 있음 | 없음 | 애니메이션 중 재빌드 횟수 |
ValueKey(item.id) | 순서 변경·삽입·삭제가 있는 상태 있는 목록 | 거의 없음 | 뒤섞은 뒤 상태가 따라가는지 |
GlobalKey | 부모 이동 중 State 유지, 외부 접근 | 이동 때 deactivate·재빌드 | 꼭 필요한 곳만 |
ListView.builder + itemExtent | 화면보다 긴 목록 | 화면 밖 State 는 사라짐 | 첫 프레임 시간 |
RepaintBoundary | 자주 다시 그리는 영역 옆에 정적 영역 | 레이어가 하나 늘어남 | repaint rainbow |
FadeTransition·AnimatedOpacity | 투명도 애니메이션 | 없음 | 래스터 시간, saveLayer 이벤트 |
RepaintBoundary 는 많을수록 좋은 게 아니에요. 경계마다 레이어가 생기고, API 문서는 효과가 나는 조건을 "서브트리가 주변과 다른 시점에 다시 그려질 때"로 한정해요. Drawer 는 스스로 경계를 넣고, ListView 는 addRepaintBoundaries 기본값이 true 라 항목을 또 감쌀 필요가 없어요.
버벅임 원인 찾는 순서, 체크리스트
- 조건 맞추기: 가장 느린 지원 기기에서
flutter run --profile로 재요. - 느린 스레드 가르기: DevTools Performance 화면의 프레임 차트는 프레임마다 UI 와 Raster 막대를 따로 보여 줘요. UI 측정 안내에 따르면 UI 쪽이 붉으면 Dart 코드가 비싼 것이고, 래스터 쪽이 붉으면 장면이 그리기에 복잡한 거예요. 둘 다면 UI 부터예요.
- UI 스레드가 길면: Track widget builds 로 한 프레임에 무엇이 다시 빌드되는지 봐요. 넓으면 setState 를 내리거나 const·child 단락을 만들고, 긴 목록은 builder 로, Track layouts 로 intrinsic 이벤트를 찾아요. build() 안의 정렬·파싱도 이때 걸러요.
- 래스터 스레드가 길면: 성능 가이드는
saveLayer()가 화면 밖 버퍼를 잡아 비싸고,ShaderMask·ColorFilter같은 위젯이 대신 부를 수 있다고 해요. 타임라인의 saveLayer 이벤트를 찾고, 애니메이션 중 Opacity·클리핑을 걷어 내요. 클리핑은 기본값이Clip.none이라 직접 켠 곳만 보면 돼요. - 다시 그리는 범위 보기:
debugRepaintRainbowEnabled = true를 켜고, 정적인데 같이 색이 바뀌는 영역 경계에만 RepaintBoundary 를 넣어요.
첫 실행 때만 끊기는 증상은 예전에는 셰이더 컴파일 탓인 경우가 많았어요. Impeller 문서에 따르면 3.27 부터 iOS 와 Android API 29 이상의 기본 렌더러가 Impeller 이고, 셰이더를 엔진 빌드 때 미리 컴파일해 실행 중에 컴파일하지 않아요. macOS 는 3.47 부터 기본값이에요. Vulkan 을 못 쓰는 낮은 버전 안드로이드는 OpenGL 렌더러로 돌아가니 따로 재 봐야 해요.
새 화면 만들기 전에 따질 것
다음 다섯 가지를 설계 때 적어 두면 판단표 대부분이 정해져요.
- 목표 주사율과 가장 느린 기기: 120Hz 면 UI 스레드 예산이 4ms 남짓이에요.
- 자주 바뀌는 값이 어디 있나: 타이머·진행률처럼 자주 바뀌는 값은 작은 위젯이 들게 나눠요. 상태 관리 패키지를 써도 구독 범위를 좁히는 원리는 같아요.
- 목록 길이와 항목 높이: 한 화면을 넘을 수 있으면 builder, 높이가 같으면
itemExtent를 처음부터 정해요. - 항목에 상태가 있나, 순서가 바뀌나: 둘 다 '예'면 서버 id 로
ValueKey를 주고, 체크 여부 같은 값은 모델에 둬요. - 화면에서 계속 움직이는 영역: 로딩 표시기·차트처럼 혼자 계속 그리는 영역을 표시해 두고, 실측에서 옆이 같이 칠해지면 경계를 넣어요.
자주 묻는 질문
const 를 모든 위젯에 붙이면 성능이 확실히 좋아지나요?
붙일 수 있는 곳에 붙이는 건 손해가 없어요. 다만 인자가 매번 바뀌는 위젯은 const 가 안 되니, 더 넓게 듣는 방법은 setState 위치를 낮추는 거예요. 직접 센 예에서는 두 방법 모두 자식 재빌드를 100번에서 0번으로 줄였어요.
목록에서 항목을 지웠더니 다른 항목의 체크 표시가 남아요.
키가 없거나 ValueKey(index) 일 때 State 가 위치를 따라가 한 칸씩 밀린 거예요. ValueKey(item.id) 를 주면 해결되고, 스크롤로 State 가 사라질 수도 있으니 체크 여부는 목록 데이터에 두는 게 근본 해결이에요.
RepaintBoundary 는 어디에 넣어야 하나요?
계속 다시 그려지는 영역과 정적인 영역이 같은 레이어를 쓸 때 그 사이에 넣어요. debugRepaintRainbowEnabled 로 같이 칠해지는 걸 확인한 뒤 넣고, ListView 항목처럼 이미 경계가 있는 곳은 감싸지 않아요.
디버그 모드에서 끊기는데 출시해도 괜찮을까요?
디버그 모드 숫자로는 판단할 수 없어요. 공식 문서는 디버그 모드 결과가 오해를 부른다며 실기기 프로파일 모드에서 재라고 해요. 거기서도 예산을 넘을 때 원인을 찾으면 돼요.
setState 를 한 이벤트 안에서 여러 번 부르면 그만큼 느려지나요?
setState 는 다시 빌드할 것으로 표시만 하고, 빌드는 다음 프레임에 한 번 해요. 문제는 호출 횟수보다 호출한 State 가 트리 위쪽이라 재빌드 범위가 넓은 경우예요.
정리
- 기준은 프레임 예산이에요. 60Hz 16.7ms, 120Hz 8.3ms이고, 지연이 중요하면 그 절반씩을 UI·래스터에 나눠 잡아요.
- 재는 조건(실기기·프로파일 모드) → 느린 스레드 → 원인 순서로 좁혀요. UI 쪽은 setState 범위·const·AnimatedBuilder child·builder 목록, 래스터 쪽은 saveLayer·Opacity·클리핑이 먼저예요.
- 키는 상태 있는 목록의 정합성 문제예요. 순서가 바뀌는 목록엔
ValueKey(item.id), 인덱스 키와 build 안의UniqueKey()는 쓰지 않아요. - RepaintBoundary 는 repaint rainbow 로 같이 칠해지는 정적 영역을 확인한 자리에만 넣어요.
더 깊이 읽기
이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 플러터 렌더링 파이프라인 원리 — 위젯·엘리먼트·렌더오브젝트에서 도식과 코드로 단계별로 풀었어요.