광고
~ / posts / developer
💻 개발자

플러터 성능 판단표, const·키·RepaintBoundary 언제 쓰나

플러터 앱이 스크롤이나 애니메이션에서 끊기면 "const 를 다 붙이라", "RepaintBoundary 를 감싸라", "키를 줘라" 같은 조언이 한꺼번에 쏟아지죠. 결론부터 말하면 먼저 실기기 프로파일 모드에서 느린 쪽이 UI 스레드인지 래스터 스레드인지 가르고, UI 쪽이면 setState 범위·긴 목록·AnimatedBuilder 자식부터, 래스터 쪽이면 Opacity·saveLayer·클리핑부터 보는 게 순서예요. RepaintBoundary 와 키는 증상이 확인된 자리에만 넣는 도구예요.

세 트리의 내부 구조는 안다고 보고, 이 글은 고르는 데 필요한 숫자와 판단 기준만 다뤄요. 프레임 예산, 직접 센 재빌드·페인트 횟수, 키별 State 행방, 확인 순서 차례예요.

광고

먼저 숫자 하나, 주사율별 프레임 예산

버벅임(jank)은 다음 화면 갱신 전까지 프레임을 못 만들어 건너뛰는 현상이라, 기준은 늘 한 프레임에 쓸 수 있는 시간이에요. 플러터 성능 모범 사례 문서는 빌드(UI 스레드)와 렌더(래스터 스레드)가 따로 돌아 60Hz 에서 각각 16ms 를 쓸 수 있다고 하면서도, 지연이 중요하면 빌드 8ms·렌더 8ms, 합쳐 16ms 안에 끝내라고 권해요. 120Hz 기기에서는 합계 8ms 미만을 목표로 하라고 적어요.

화면 주사율별 한 프레임 시간 예산
프레임 전체빌드·렌더 절반씩
0510152060Hz · 프레임 전체 16.7ms60Hz · 빌드·렌더 절반씩 8.3ms60Hz90Hz · 프레임 전체 11.1ms90Hz · 빌드·렌더 절반씩 5.6ms90Hz120Hz · 프레임 전체 8.3ms120Hz · 빌드·렌더 절반씩 4.2ms120Hz

120Hz 에서는 UI 스레드가 4ms 남짓 안에 build·layout·paint 를 끝내야 여유가 있어요

단위: ms · 자료: 1000ms ÷ 주사율로 직접 계산. 절반 배분은 Flutter 성능 모범 사례 문서의 60Hz 권장(빌드 8ms·렌더 8ms)을 같은 비율로 적용
표로 보기
구분프레임 전체빌드·렌더 절반씩
60Hz16.78.3
90Hz11.15.6
120Hz8.34.2

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. 잎 위젯에서 setState0번다시 빌드되는 건 숫자를 그리는 잎 하나뿐이에요

공식 가이드도 setState 는 실제로 바뀌는 부분을 감싸는 가장 작은 서브트리에서 부르라고 하고, 재빌드는 이전 프레임과 같은 자식 인스턴스를 만나면 멈춘다고 적어요. UI 조각을 Widget _buildX() 메서드보다 StatelessWidget 으로 만들라는 권고도 같은 이유예요.

B 가 가능하려면 자식이 컴파일 시점 상수여야 해요. 매번 달라지는 값(Item(i) 의 i 처럼)을 받는 위젯은 const 로 만들 수 없으니, 그때는 C 처럼 상태를 아래로 내리는 쪽이 답이에요.

애니메이션 한 번에 생기는 숨은 비용

애니메이션은 매 프레임 같은 일을 반복하니 작은 낭비가 초당 수십 배로 불어요. 두 경우를 60프레임 동안 세 봤어요.

첫째는 AnimatedBuilder 의 builder 안에 애니메이션과 상관없는 자식 50개를 넣은 경우예요. 공식 가이드의 함정 목록에 그대로 있는 실수로, 바뀌지 않는 부분은 child 인자로 한 번만 만들어 넘기라고 해요.

둘째는 CustomPaint 로 그리는 회전 표시기 옆에 300×300 정적 그림을 둔 경우예요. RepaintBoundary API 문서는 같은 레이어를 쓰는 렌더오브젝트 하나가 다시 그려져야 하면 가장 가까운 경계 아래 전체가 같이 다시 그려진다고 설명해요.

1초(60프레임) 애니메이션 동안 생긴 불필요한 작업 횟수
builder 안에 자식 50개 (build)builder 안에 자식 50개 (build) · 3,000회3,000회child 인자로 뺌 (build)child 인자로 뺌 (build) · 0회0회경계 없이 옆 그림 (paint)경계 없이 옆 그림 (paint) · 60회60회RepaintBoundary 둠 (paint)RepaintBoundary 둠 (paint) · 0회0회

50개 × 60프레임 = 3,000번이에요. 120Hz 라면 같은 1초에 6,000번이 돼요

단위: 회 · 자료: Flutter 3.38.7 위젯 테스트로 build()·paint() 호출을 직접 셈 (60프레임, 16ms 간격)
표로 보기
구분횟수
builder 안에 자식 50개 (build)3,000
child 인자로 뺌 (build)0
경계 없이 옆 그림 (paint)60
RepaintBoundary 둠 (paint)0

투명도도 같은 맥락이에요. 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개

읽을 점은 세 가지예요.

순서 변경 여부·부모 이동·고유 id·같은 객체 여부를 차례로 물어 키 없음·GlobalKey·ValueKey(item.id)·ObjectKey(item) 중 하나를 고르는 순서도
대부분의 목록은 세 번째 칸, ValueKey(item.id) 에서 끝나요

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 과 ListView.builder (중앙값)
ColumnListView.builder
02,0004,0006,0008,000항목 1000개 · Column 616ms항목 1000개 · ListView.builder 64ms항목 1000개항목 10000개 · Column 6,553ms항목 10000개 · ListView.builder 49ms항목 10000개

Column 은 항목 수에 비례해 늘었고, builder 는 항목 수와 거의 무관했어요

단위: ms · 자료: Flutter 3.38.7 flutter test(디버그 JIT)·Apple M1 에서 pumpWidget 5회 측정한 중앙값. 절대값은 실기기 프로파일 모드와 다르고 비율만 참고
표로 보기
구분ColumnListView.builder
항목 1000개61664
항목 10000개6,55349

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 라 항목을 또 감쌀 필요가 없어요.

버벅임 원인 찾는 순서, 체크리스트

재는 조건, 느린 스레드 구분, UI 스레드 원인, 래스터 스레드 원인, 다시 그리는 범위를 다섯 칸으로 나눈 플러터 버벅임 점검표
왼쪽 위부터 차례로, 고친 뒤엔 같은 조건으로 다시 재요
  1. 조건 맞추기: 가장 느린 지원 기기에서 flutter run --profile 로 재요.
  2. 느린 스레드 가르기: DevTools Performance 화면의 프레임 차트는 프레임마다 UI 와 Raster 막대를 따로 보여 줘요. UI 측정 안내에 따르면 UI 쪽이 붉으면 Dart 코드가 비싼 것이고, 래스터 쪽이 붉으면 장면이 그리기에 복잡한 거예요. 둘 다면 UI 부터예요.
  3. UI 스레드가 길면: Track widget builds 로 한 프레임에 무엇이 다시 빌드되는지 봐요. 넓으면 setState 를 내리거나 const·child 단락을 만들고, 긴 목록은 builder 로, Track layouts 로 intrinsic 이벤트를 찾아요. build() 안의 정렬·파싱도 이때 걸러요.
  4. 래스터 스레드가 길면: 성능 가이드는 saveLayer() 가 화면 밖 버퍼를 잡아 비싸고, ShaderMask·ColorFilter 같은 위젯이 대신 부를 수 있다고 해요. 타임라인의 saveLayer 이벤트를 찾고, 애니메이션 중 Opacity·클리핑을 걷어 내요. 클리핑은 기본값이 Clip.none 이라 직접 켠 곳만 보면 돼요.
  5. 다시 그리는 범위 보기: debugRepaintRainbowEnabled = true 를 켜고, 정적인데 같이 색이 바뀌는 영역 경계에만 RepaintBoundary 를 넣어요.

첫 실행 때만 끊기는 증상은 예전에는 셰이더 컴파일 탓인 경우가 많았어요. Impeller 문서에 따르면 3.27 부터 iOS 와 Android API 29 이상의 기본 렌더러가 Impeller 이고, 셰이더를 엔진 빌드 때 미리 컴파일해 실행 중에 컴파일하지 않아요. macOS 는 3.47 부터 기본값이에요. Vulkan 을 못 쓰는 낮은 버전 안드로이드는 OpenGL 렌더러로 돌아가니 따로 재 봐야 해요.

새 화면 만들기 전에 따질 것

다음 다섯 가지를 설계 때 적어 두면 판단표 대부분이 정해져요.

광고
광고

자주 묻는 질문

const 를 모든 위젯에 붙이면 성능이 확실히 좋아지나요?

붙일 수 있는 곳에 붙이는 건 손해가 없어요. 다만 인자가 매번 바뀌는 위젯은 const 가 안 되니, 더 넓게 듣는 방법은 setState 위치를 낮추는 거예요. 직접 센 예에서는 두 방법 모두 자식 재빌드를 100번에서 0번으로 줄였어요.

목록에서 항목을 지웠더니 다른 항목의 체크 표시가 남아요.

키가 없거나 ValueKey(index) 일 때 State 가 위치를 따라가 한 칸씩 밀린 거예요. ValueKey(item.id) 를 주면 해결되고, 스크롤로 State 가 사라질 수도 있으니 체크 여부는 목록 데이터에 두는 게 근본 해결이에요.

RepaintBoundary 는 어디에 넣어야 하나요?

계속 다시 그려지는 영역과 정적인 영역이 같은 레이어를 쓸 때 그 사이에 넣어요. debugRepaintRainbowEnabled 로 같이 칠해지는 걸 확인한 뒤 넣고, ListView 항목처럼 이미 경계가 있는 곳은 감싸지 않아요.

디버그 모드에서 끊기는데 출시해도 괜찮을까요?

디버그 모드 숫자로는 판단할 수 없어요. 공식 문서는 디버그 모드 결과가 오해를 부른다며 실기기 프로파일 모드에서 재라고 해요. 거기서도 예산을 넘을 때 원인을 찾으면 돼요.

setState 를 한 이벤트 안에서 여러 번 부르면 그만큼 느려지나요?

setState 는 다시 빌드할 것으로 표시만 하고, 빌드는 다음 프레임에 한 번 해요. 문제는 호출 횟수보다 호출한 State 가 트리 위쪽이라 재빌드 범위가 넓은 경우예요.

정리

더 깊이 읽기

이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 플러터 렌더링 파이프라인 원리 — 위젯·엘리먼트·렌더오브젝트에서 도식과 코드로 단계별로 풀었어요.

참고한 자료

#플러터 성능 최적화#Flutter 버벅임 해결#플러터 키 ValueKey#RepaintBoundary 사용법#setState 재빌드 줄이기#ListView.builder 성능#플러터 프레임 예산#Flutter DevTools 성능
← 이전 글이분 탐색 언제 쓰나, bisect·lower_bound 판단표와 경계 버그 체크리스트다음 글 →지식 증류 방식 고르기, 시퀀스·로짓·온폴리시 KD 비용과 체크리스트
광고

더 둘러보기

💻 개발자 다른 도구

🔥 많이 찾는 도구

🔮 재미로 한 번 더

📝 읽어 볼 만한 글

글 전체 보기 →