플러터 앱이 첫 실행에서만 버벅인다면: 쉐이더 컴파일 재밍(Shader Compilation Jank) 완전 정복

디버그 모드에서는 60fps로 매끄럽게 돌아가던 애니메이션이, 릴리즈 빌드를 스토어에 올리자마자 신규 기기에서 “뚝뚝 끊긴다”는 리포트가 들어옵니다. 재현하려고 개발 폰에서 다시 열어보면 멀쩡합니다. QA 팀이 준 로그를 봐도 딱히 예외는 없습니다. const 위젯을 다 붙이고, ListView.builder로 바꾸고, RepaintBoundary까지 넣어봐도 증상은 그대로입니다. 이 패턴에 정확히 들어맞는다면 범인은 위젯 트리가 아니라 쉐이더 컴파일 재밍(shader compilation jank) 일 가능성이 높습니다.
이 글은 “const 위젯 쓰세요”, “빌드 메서드를 가볍게 하세요” 수준의 일반론을 넘어, 왜 이 문제가 디버그 모드에서는 절대 보이지 않고 실제 기기의 프로파일·릴리즈 빌드에서만 드러나는지, DevTools에서 정확히 무엇을 봐야 하는지, 그리고 흔히 알려진 SkSL 워밍업 절차가 최신 Flutter에서는 실제로 어떻게 달라졌는지를 소스 코드와 공식 문서를 직접 대조해 정리한 실전 기록입니다.
핵심 요약
- 쉐이더 컴파일 재밍은 GPU가 처음 보는 draw 연산에 대해 기기별로, 런타임에 쉐이더/파이프라인을 새로 만들어야 해서 생기는 스톨입니다. 한 번 컴파일하면 그 이후로는 재발하지 않습니다.
- 디버그 모드는 JIT 기반이라 이 문제가 애초에 다른 방식으로 감춰지고, 프로파일·릴리즈 모드는 에뮬레이터/시뮬레이터에서 아예 비활성화되어 있어 반드시 실제 기기에서 확인해야 합니다.
- DevTools Performance 뷰의 Frame chart에서 진한 빨간색(dark red)으로 표시되는 프레임이 쉐이더 컴파일 스파이크의 시그니처입니다.
flutter run --profile --cache-sksl+--bundle-sksl-path로 이어지는 고전적인 SkSL 워밍업 절차는 Flutter 3.32(2025년 5월 스테이블)부터 CLI에서 완전히 삭제되었습니다. 지금 이 커맨드를 그대로 따라 하면 에러가 납니다.- Impeller가 기본 렌더러가 되면서 이 문제의 상당 부분이 구조적으로 사라졌지만, 커스텀 프래그먼트 쉐이더나 구형 안드로이드 기기의 폴백 경로 등 여전히 남아 있는 함정이 있습니다.
Jank의 정의와 프레임 예산이 실전에서 의미하는 것
Flutter 공식 문서는 jank를 이렇게 정의합니다: “프레임이 평소보다 훨씬 오래 걸려 드롭되면 애니메이션이 뚝뚝 끊겨 보인다. 예를 들어 어떤 프레임이 평소보다 10배 오래 걸리면 그 프레임은 드롭될 가능성이 높고, 그 결과 애니메이션이 삐걱거려 보인다.” 핵심은 “가끔 오래 걸리는 프레임 하나”가 체감 성능 전체를 깎아 먹는다는 점입니다. 평균 프레임 타임이 아니라 최악의 프레임(worst frame) 이 진짜 지표입니다.
- 60Hz 디스플레이: 공식 문서 기준 각 프레임은 “약 16ms” 안에 렌더링을 마쳐야 jank가 나지 않습니다.
- 120Hz 디스플레이: Flutter는 지원 기기에서 120fps도 목표로 하며, 산술적으로 프레임 예산은 약 8.3ms로 줄어듭니다(1000ms ÷ 120). 예산이 절반 이하로 줄어드는 만큼, 같은 쉐이더 컴파일 스톨이라도 120Hz 기기에서 체감 재밍은 더 심하게 느껴집니다.
이 예산은 두 개의 스레드에 걸쳐 소비됩니다.
- UI 스레드: Dart VM에서 앱 코드와 Flutter 프레임워크 코드를 실행합니다. 위젯 빌드, 레이아웃, 페인트 커맨드를 담은 레이어 트리를 만들어 raster 스레드로 넘깁니다. 공식 문서는 “이 스레드를 막지 말라”고 명시적으로 경고합니다.
- Raster 스레드: 레이어 트리를 받아 실제로 GPU에 그리는 스레드입니다. Skia와 Impeller가 이 스레드에서 동작합니다. 이 스레드는 직접 건드릴 수 없고, 여기가 느리다면 그건 결국 Dart 코드가 시킨 일(무엇을 그리라고 했는지) 때문입니다. 참고로 이 스레드는 예전에는 “GPU 스레드”라고 불렸고, 지금도
--trace-skia플래그의 도움말 문구에 “raster thread (formerly known as the GPU thread)“라는 표현이 그대로 남아 있어 이름이 바뀐 이력을 확인할 수 있습니다.
쉐이더 컴파일 재밍은 거의 항상 Raster 스레드에서 발생합니다. UI 스레드 그래프가 멀쩡한데 GPU/Raster 그래프에만 큰 빨간 막대가 튀는 패턴이 바로 이 문제의 지문입니다.
왜 디버그 모드에서는 재현되지 않는가
여기가 이 이슈가 가장 많이 오해받는 지점입니다. Flutter의 빌드 모드는 단순한 최적화 스위치가 아니라 완전히 다른 실행 경로입니다.
- 디버그 모드: “빠른 개발·실행 주기에 맞춰 컴파일되며, 실행 속도·바이너리 크기·배포에는 최적화되어 있지 않다”고 공식 문서가 명시합니다. assert가 켜져 있고, 소스 레벨 디버깅을 위해 DevTools가 붙을 수 있습니다.
- 프로파일 모드: “일부 디버깅 능력을 유지하되 성능 프로파일링에 충분한 수준”이라고 정의되며, 모바일 기준으로는 릴리즈 모드와 거의 동일합니다(트레이싱과 일부 서비스 익스텐션만 추가로 켜짐). 그리고 결정적으로: “프로파일 모드는 에뮬레이터·시뮬레이터에서는 비활성화된다. 그 환경의 동작이 실제 성능을 대표하지 않기 때문이다.”
- 릴리즈 모드: assert 제거, 디버깅 정보 제거, “빠른 시작·빠른 실행·작은 패키지 크기에 최적화”됩니다.
즉 디버그 모드는 애초에 “실행 속도”를 최적화 목표로 삼지 않기 때문에, 성능 이슈 자체가 다른 양상으로 나타나거나 아예 드러나지 않습니다. 게다가 쉐이더 컴파일은 근본적으로 기기의 구체적인 GPU 하드웨어·드라이버에 종속됩니다. Flutter 프레임워크의 ShaderWarmUp 클래스 문서 주석은 이를 이렇게 설명합니다:
“이 워밍업은 각 기기별로 개별 실행되어야 한다. 쉐이더 컴파일은 그 기기가 가진 특정 GPU 하드웨어와 드라이버에 의존하기 때문이다. 엔진은 기기에 독립적이므로 Flutter 엔진 컴파일 시점에 미리 계산해둘 수 없다.”
같은 문서는 쉐이더 하나를 컴파일하는 데 걸리는 시간을 구체적으로 명시합니다: “컴파일은 느릴 수 있다(20ms~200ms).” 화면 하나에 처음 보는 그라디언트, 블러, 그림자, 커스텀 ShapeBorder 조합이 대여섯 개만 몰려도 산술적으로 100~1000ms대의 raster 스파이크가 나올 수 있다는 뜻입니다(어디까지나 문서에 명시된 셰이더당 컴파일 비용을 단순히 곱한 이론적 범위입니다). 60Hz 기준 16ms짜리 프레임 예산 수십 개를 한 번에 잡아먹는 셈이니, 사용자 눈에는 “화면이 잠깐 멈췄다 재생되는” 것처럼 보입니다.
그리고 이 컴파일은 기기별로, 그 기기에서 해당 그리기 연산을 처음 만나는 순간에만 발생합니다. 개발자의 손에 익은 테스트 기기는 이미 몇 번이고 그 화면을 열어봤기 때문에 캐시가 데워져 있을 수 있지만(단, 이 캐시는 앱 재설치·업데이트·OS 캐시 클리어 시 날아갑니다), 방금 스토어에서 앱을 내려받은 신규 기기는 모든 쉐이더를 처음부터 다시 만나는 “차가운” 상태입니다. “신규 기기에서만 나타난다”는 리포트가 실은 재현성 문제가 아니라 정확히 예측 가능한 구조적 결과인 이유가 여기에 있습니다.
DevTools Performance 뷰에서 진짜 원인 찾아내기
1단계: 반드시 프로파일 모드 + 실기기로 실행
flutter run --profile
에뮬레이터·시뮬레이터에서는 프로파일 모드 자체가 비활성화되어 있으므로, 실제 기기(가능하면 문제를 보고한 것과 비슷한 저사양·신규 기기)에 연결해야 합니다.
2단계: Frame chart에서 다크레드 프레임 찾기
DevTools의 Performance 탭을 열면 프레임마다 UI 스레드 막대와 Raster 스레드 막대가 한 쌍으로 표시됩니다. 공식 문서는 이렇게 설명합니다: “쉐이더 컴파일을 수행하는 프레임은 진한 빨간색(dark red)으로 표시된다.” 이 색상이 바로 셰이더 컴파일 재밍의 시각적 지문입니다. 일반적인 jank(빌드/레이아웃 과부하)는 UI 스레드 막대가 먼저 빨갛게 튀는 반면, 쉐이더 컴파일 재밍은 Raster 스레드 막대만 단독으로 수백 ms 튀어 오르는 패턴을 보입니다.
3단계: Timeline events에서 콜스택 확인
의심되는 프레임을 클릭해 Timeline events 탭으로 들어가면, ShaderWarmUp 클래스 문서가 안내하는 것과 같은 패턴을 찾을 수 있습니다: 애니메이션 도중에 길게 늘어진 GrGLProgramBuilder::finalize 호출이 보이고, 그 부모 호출로 FillRectOp, CircularRRectOp 같은 XyzOp 형태의 draw 연산 이름이 붙어 있다면, 그 draw 연산이 새 쉐이더 컴파일을 유발했다는 뜻입니다. (이 트레이스 이름들은 Skia 백엔드 기준이며, Impeller에서는 다른 이름의 파이프라인 생성 이벤트로 나타납니다.)
4단계: Enhance tracing로 해상도 높이기
Performance 탭의 “Enhance tracing” 드롭다운에는 세 가지 옵션이 있습니다.
- Track Widget Builds: 타임라인에
build()메서드 이벤트와 위젯 이름을 표시 - Track Layouts: 렌더 오브젝트의 layout 이벤트 표시
- Track Paints: 렌더 오브젝트의 paint 이벤트 표시
단, 공식 문서가 경고하듯 “이 옵션들을 켜면 프레임 타임 자체가 영향을 받을 수 있다.” 원인을 좁힐 때만 켜고, 실제 수치를 잴 때는 꺼야 합니다. 같은 탭의 “More debugging options”에서 Clip / Opacity / Physical Shape 레이어를 개별적으로 꺼보면, 과도한 클리핑이나 그림자 효과가 새 파이프라인을 유발하는지도 분리해서 확인할 수 있습니다.
5단계: --trace-skia와 --purge-persistent-cache로 재현성 확보
flutter run --profile --trace-skia
--trace-skia는 “raster 스레드(옛 이름 GPU 스레드) 디버깅에 유용”하다고 도움말에 명시되어 있으며, 오버헤드가 크기 때문에 기본적으로는 꺼져 있습니다. 그리고 실전에서 가장 중요한 플래그는 따로 있습니다.
flutter run --profile --purge-persistent-cache
이 플래그는 현재 stable Flutter에도 그대로 남아 있으며, 도움말에 정확히 이렇게 적혀 있습니다: “기존의 모든 영구 캐시를 삭제한다. 이를 통해 보통 앱을 처음 실행할 때만 발생하는 쉐이더 컴파일 재밍을 재현하거나, 컴파일 재밍 수정(예: 쉐이더 워밍업)을 신뢰성 있게 테스트할 수 있다.” 즉 개발자의 손때 묻은 테스트 기기에서도 “신규 기기 첫 실행” 상태를 강제로 재현할 수 있는 공식 수단입니다. QA나 회귀 테스트에서 이 플래그 없이 반복 실행만 하면 캐시가 데워진 상태만 보게 되어 문제를 영영 놓칠 수 있습니다.
SkSL 워밍업 캐시: 고전적인 해법, 그리고 실전에서 걸리는 함정
인터넷에 돌아다니는 대부분의 튜토리얼은 여기서 다음 절차를 안내합니다.
# 1. 프로파일 모드로 실행하며 실제로 마주치는 쉐이더를 기록
flutter run --profile --cache-sksl
# 2. 앱을 여기저기 조작해 애니메이션·전환을 최대한 많이 트리거한 뒤 종료
# → build/ 아래에 .sksl.json 캐시 파일이 생성됨
# 3. 그 캐시를 릴리즈 빌드에 번들링
flutter build apk --release --bundle-sksl-path flutter_01.sksl.json
flutter build appbundle --release --bundle-sksl-path flutter_01.sksl.json
flutter build ios --release --bundle-sksl-path flutter_01.sksl.json
이 절차는 “GPU가 신규 기기에서 처음 보는 쉐이더를 런타임에 컴파일하는” Skia 시절의 구조적 문제를 우회하기 위한 것이었습니다. 앱 실행 중 실제로 쓰인 쉐이더 조합을 미리 캡처해서, 빌드에 캐시로 넣어두면 앱이 시작될 때 한꺼번에 준비시켜 애니메이션 도중 스톨을 없애는 방식입니다.
여기서 실전 함정이 있습니다. 위 커맨드를 최신 Flutter에서 그대로 실행하면 동작하지 않습니다. 실제로 GitHub 이슈(flutter/flutter#171585)에는 Flutter 3.32 이상에서 --bundle-sksl-path를 쓰려던 개발자가 Could not find an option named '--bundle-sksl-path' 에러를 받은 사례가 그대로 남아 있습니다. Flutter 저장소 커밋 이력을 직접 확인해보면 원인이 명확합니다. 2025년 2월 10일 머지된 커밋(33a4c95de07, “remove SkSL bundling and dump skp on compilation”)은 flutter run, flutter build apk/appbundle/ios, flutter drive에서 SkSL 번들링 관련 옵션을 전부 제거했고, 커밋 메시지는 이렇게 설명합니다.
“SkSL 프리컴파일은 애초에 iOS에서만 의미가 있었다. 다른 플랫폼에서는 Skia가 타겟 아키텍처별로 쉐이더를 생성하기 때문에 다른 기기에서는 무효할 수 있어 사용을 권장하지 않았다. 그런데 이제 iOS에서는 Skia를 아예 쓸 수 없게 되었다.”
이 변경은 Flutter 3.32.0(2025년 5월 스테이블)부터 포함되어 있습니다. 실제로 로컬에 설치한 Flutter 3.44 SDK로 flutter run --help -v, flutter build apk --help -v, flutter build web --help -v를 전부 확인해봐도 cache-sksl, bundle-sksl-path 문자열은 단 한 곳에도 남아 있지 않습니다. 즉 “SkSL 워밍업 캐시를 만들어 번들링하라”는 조언 자체가, 최신 Flutter 기준으로는 더 이상 실행 가능한 절차가 아닙니다. 공식 문서(docs.flutter.dev/perf/rendering-performance)의 최신 버전도 셰이더 컴파일 재밍에 대한 모바일 조언을 “Flutter의 기본 렌더러인 Impeller를 쓰고 있는지 확인하라”는 한 문장으로 대체했습니다.
그럼 지금 이 문제를 만나면 무엇을 해야 하는가
- 우선 Impeller를 그대로 두세요.
--no-enable-impeller로 끄지 않는 한, 최신 Flutter는 이미 Impeller가 기본값입니다. 아래 절에서 설명하듯 Impeller는 SkSL 캐시가 하던 일을 구조적으로 대신 해줍니다. ShaderWarmUpAPI는 프레임워크에 여전히 남아 있습니다.PaintingBinding.shaderWarmUp에 커스텀ShaderWarmUp서브클래스를runApp호출 전에 등록하면, 앱 시작 시점에 오프스크린 캔버스에 대표적인 draw 연산을 미리 그려 컴파일 비용을 시작 지연으로 옮길 수 있습니다. 다만 이 API의 문서 자체가GrGLProgramBuilder,--trace-skia같은 Skia 시절 개념을 기준으로 쓰여 있어, Impeller 경로에서는 예전만큼 결정적인 효과를 보장하지 않습니다.- 레거시 프로젝트라면 옛 절차가 여전히 유효합니다. Flutter 3.32 이전 버전에 고정되어 있거나, 안드로이드에서 명시적으로
--no-enable-impeller를 켜 구형 Skia/OpenGL 경로를 쓰는 프로젝트라면--cache-sksl/--bundle-sksl-path조합은 그 버전 안에서는 그대로 동작합니다. 다만 마이그레이션 계획 없이 이 경로에 계속 의존하는 것은 권장하지 않습니다.
Impeller 도입 이후 달라진 점과 여전히 남은 한계
Impeller는 셰이더 컴파일 재밍을 “고치는” 것이 아니라 문제가 발생하는 시점 자체를 옮겨버리는 접근입니다. 공식 문서가 밝히는 설계 목표 네 가지는 다음과 같습니다.
- 예측 가능성(Predictable): 컴파일·캐싱 결정을 실행 전에 미리 확정해둔다
- 계측 가능성(Instrumentable): 그래픽 리소스에 라벨을 붙여 프로파일링·캡처가 쉽다
- 이식성(Portable): 쉐이더를 한 번 작성해 Metal/Vulkan/GLES 등 백엔드별 포맷으로 변환한다
- 현대적·동시성(Modern & concurrent): 최신 그래픽 API를 활용하고 작업을 여러 스레드에 분산한다
플랫폼별 현재 상태는 다음과 같습니다(공식 문서 docs.flutter.dev/perf/impeller 기준).
| 플랫폼 | 현황 |
|---|---|
| iOS | Impeller가 유일하게 지원되는 렌더러. Skia로 되돌릴 수 없음 |
| Android | API 29 이상에서 기본 활성화(Flutter 3.27부터). API 29 미만이거나 Vulkan 미지원 기기는 Impeller의 레거시 OpenGL 백엔드로 자동 폴백 |
| macOS | --enable-impeller 플래그로 옵트인 상태이며, 향후 릴리즈에서 옵트아웃 자체가 제거될 예정 |
| Web | 아직 Skia(CanvasKit) 사용. 향후 Impeller 채택 가능성 언급 |
흥미로운 사실 하나: 정작 Flutter 자체 CLI 도움말에는 낡은 문구가 남아 있습니다. flutter run --help -v로 --enable-impeller 설명을 보면 지금도 “Impeller is the default renderer on iOS. On Android, Impeller is available but not the default”라고 나옵니다. flutter_tools 소스의 addEnableImpellerFlag 함수를 git blame으로 추적해보면 이 문구는 2023년 3월, 즉 Impeller가 막 도입되던 시점에 작성된 뒤 단 한 번도 갱신되지 않았습니다. 공식 문서와 릴리즈 노트가 실제 기본값 변경 사실을 정확히 반영하고 있으므로, CLI 도움말의 이 문구는 실제 동작이 아니라 방치된 설명 문자열로 봐야 합니다. 이런 사소한 불일치조차 실전에서는 “지금 내 프로젝트에 Impeller가 켜져 있는 게 맞나?“를 헷갈리게 만드는 원인이 됩니다.
Impeller가 재밍을 없애는 진짜 메커니즘
핵심은 “쉐이더 대부분을 엔진 빌드 시점에 오프라인으로 미리 컴파일해둔다”는 점입니다. 여기서 한 걸음 더 들어가 보면, Impeller 엔진 소스(shell/common/switch_defs.h, common/settings.h)에는 impeller-lazy-shader-mode라는 내부 스위치가 있고, 주석은 다음과 같이 설명합니다.
“Impeller 백엔드에 필요한 모든 PSO(Pipeline State Object)의 초기화를 지연시킬지 여부. 기본값은 false.”
즉 기본값(false)에서 Impeller는 필요한 PSO를 전부 앱 시작 시점에 즉시(eager) 초기화합니다. 예전에 개발자가 수동으로 ShaderWarmUp을 작성해 “컴파일 비용을 애니메이션 도중이 아니라 시작 시점으로 옮기던” 작업을, Impeller는 엔진 차원에서 기본으로 대신 해주는 셈입니다. 안드로이드 AndroidManifest.xml에 io.flutter.embedding.android.ImpellerLazyShaderInitialization 메타데이터를 true로 주면 이 초기화를 지연시켜 콜드 스타트를 조금 더 빠르게 만들 수 있지만, 그 대가로 첫 사용 시점에 예전 SkSL 시절과 비슷한 스톨이 다시 나타날 수 있습니다. “임펠러라서 무조건 재밍이 없다”가 아니라 트레이드오프의 기본값이 바뀐 것이라는 이해가 정확합니다.
그래도 남아 있는 한계
- 커스텀 프래그먼트 쉐이더:
pubspec.yaml의shaders:로 등록한.frag애셋은impellerc가 빌드 시점에 백엔드별 포맷으로 오프라인 컴파일합니다. 다만 흔치 않은 블렌드 모드,BackdropFilter,PlatformView합성 경로 조합은 사전 컴파일된 파이프라인 집합 밖에 있을 수 있어 드물게 첫 사용 시 스톨이 남습니다. Flutter 팀은 이런 회귀를[Impeller]접두사를 붙여 이슈로 제보해달라고 안내합니다. - Vulkan 미지원 구형 안드로이드: Impeller의 OpenGL 폴백 경로는 Vulkan 경로만큼 최적화되어 있지 않습니다.
- 웹: CanvasKit 기반이라 여전히 Skia 시절의 특성이 남아 있습니다.
프로파일링 전후 데이터로 개선을 검증하기
수정이 실제로 효과가 있었는지는 감이 아니라 숫자로 확인해야 합니다. 재현 가능한 시나리오를 하나 정합니다(예: 앱 실행 → 그라디언트·그림자가 많은 상세 화면으로 진입 → 리스트 스크롤 3회). 이 시나리오를 아래 두 조건으로 각각 실행하고 DevTools Performance 탭의 프레임 리스트를 비교합니다.
- 수정 전, 캐시 삭제 상태:
flutter run --profile --purge-persistent-cache로 실행해 “신규 기기 첫 실행”을 재현 - 수정 후, 동일 조건: Impeller 확인/워밍업 조치를 적용한 뒤 다시
--purge-persistent-cache로 실행
비교할 때 보는 것은 평균이 아니라 최악의 프레임(worst frame) raster 시간입니다. 개선 전에는 특정 프레임이 예산(16ms 또는 8.3ms)을 몇 배씩 초과하며 다크레드로 표시되고, 개선 후에는 같은 시나리오에서 그 스파이크가 사라지고 모든 프레임이 예산 안에 들어오는지를 Frame chart에서 육안으로, Timeline events에서 콜스택 단위로 확인합니다. CI에 성능 회귀 테스트를 붙이고 싶다면 flutter drive로 동일 시나리오를 자동화하고, 실행 시마다 --purge-persistent-cache를 강제해 “따뜻해진 캐시” 때문에 회귀를 놓치는 일을 방지하는 것이 핵심입니다.
체크리스트
- 문제를 실제 기기의 프로파일 또는 릴리즈 빌드에서만 재현 시도했는가 (에뮬레이터·디버그 모드에서는 애초에 안 보임)
- DevTools Performance 탭에서 Raster 스레드의 다크레드 프레임을 확인했는가
-
--purge-persistent-cache로 “신규 기기 첫 실행” 상태를 강제 재현해 테스트했는가 - 현재 Flutter 버전에서 Impeller가 실제로 켜져 있는지 확인했는가 (
--no-enable-impeller로 끄지 않았는지) -
--cache-sksl/--bundle-sksl-path는 Flutter 3.32 이상에서는 제거되었다는 점을 인지하고 있는가 - 커스텀
.frag쉐이더나 특이한 블렌드 모드/BackdropFilter를 쓰는 화면이 있다면 별도로 의심하고 있는가 - 수정 전후를 최악의 프레임 raster 시간 기준으로 비교했는가