effidevFlutter · Cloudflare 엣지 · 클라우드 비용 최적화

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

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

디버그 모드에서는 60fps로 매끄럽게 돌아가던 애니메이션이, 릴리즈 빌드를 스토어에 올리자마자 신규 기기에서 “뚝뚝 끊긴다”는 리포트가 들어옵니다. 재현하려고 개발 폰에서 다시 열어보면 멀쩡합니다. QA 팀이 준 로그를 봐도 딱히 예외는 없습니다. const 위젯을 다 붙이고, ListView.builder로 바꾸고, RepaintBoundary까지 넣어봐도 증상은 그대로입니다. 이 패턴에 정확히 들어맞는다면 범인은 위젯 트리가 아니라 쉐이더 컴파일 재밍(shader compilation jank) 일 가능성이 높습니다.

이 글은 “const 위젯 쓰세요”, “빌드 메서드를 가볍게 하세요” 수준의 일반론을 넘어, 왜 이 문제가 디버그 모드에서는 절대 보이지 않고 실제 기기의 프로파일·릴리즈 빌드에서만 드러나는지, DevTools에서 정확히 무엇을 봐야 하는지, 그리고 흔히 알려진 SkSL 워밍업 절차가 최신 Flutter에서는 실제로 어떻게 달라졌는지를 소스 코드와 공식 문서를 직접 대조해 정리한 실전 기록입니다.

핵심 요약

Jank의 정의와 프레임 예산이 실전에서 의미하는 것

Flutter 공식 문서는 jank를 이렇게 정의합니다: “프레임이 평소보다 훨씬 오래 걸려 드롭되면 애니메이션이 뚝뚝 끊겨 보인다. 예를 들어 어떤 프레임이 평소보다 10배 오래 걸리면 그 프레임은 드롭될 가능성이 높고, 그 결과 애니메이션이 삐걱거려 보인다.” 핵심은 “가끔 오래 걸리는 프레임 하나”가 체감 성능 전체를 깎아 먹는다는 점입니다. 평균 프레임 타임이 아니라 최악의 프레임(worst frame) 이 진짜 지표입니다.

이 예산은 두 개의 스레드에 걸쳐 소비됩니다.

쉐이더 컴파일 재밍은 거의 항상 Raster 스레드에서 발생합니다. UI 스레드 그래프가 멀쩡한데 GPU/Raster 그래프에만 큰 빨간 막대가 튀는 패턴이 바로 이 문제의 지문입니다.

왜 디버그 모드에서는 재현되지 않는가

여기가 이 이슈가 가장 많이 오해받는 지점입니다. Flutter의 빌드 모드는 단순한 최적화 스위치가 아니라 완전히 다른 실행 경로입니다.

즉 디버그 모드는 애초에 “실행 속도”를 최적화 목표로 삼지 않기 때문에, 성능 이슈 자체가 다른 양상으로 나타나거나 아예 드러나지 않습니다. 게다가 쉐이더 컴파일은 근본적으로 기기의 구체적인 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” 드롭다운에는 세 가지 옵션이 있습니다.

단, 공식 문서가 경고하듯 “이 옵션들을 켜면 프레임 타임 자체가 영향을 받을 수 있다.” 원인을 좁힐 때만 켜고, 실제 수치를 잴 때는 꺼야 합니다. 같은 탭의 “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 도입 이후 달라진 점과 여전히 남은 한계

Impeller는 셰이더 컴파일 재밍을 “고치는” 것이 아니라 문제가 발생하는 시점 자체를 옮겨버리는 접근입니다. 공식 문서가 밝히는 설계 목표 네 가지는 다음과 같습니다.

플랫폼별 현재 상태는 다음과 같습니다(공식 문서 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.xmlio.flutter.embedding.android.ImpellerLazyShaderInitialization 메타데이터를 true로 주면 이 초기화를 지연시켜 콜드 스타트를 조금 더 빠르게 만들 수 있지만, 그 대가로 첫 사용 시점에 예전 SkSL 시절과 비슷한 스톨이 다시 나타날 수 있습니다. “임펠러라서 무조건 재밍이 없다”가 아니라 트레이드오프의 기본값이 바뀐 것이라는 이해가 정확합니다.

그래도 남아 있는 한계

프로파일링 전후 데이터로 개선을 검증하기

수정이 실제로 효과가 있었는지는 감이 아니라 숫자로 확인해야 합니다. 재현 가능한 시나리오를 하나 정합니다(예: 앱 실행 → 그라디언트·그림자가 많은 상세 화면으로 진입 → 리스트 스크롤 3회). 이 시나리오를 아래 두 조건으로 각각 실행하고 DevTools Performance 탭의 프레임 리스트를 비교합니다.

  1. 수정 전, 캐시 삭제 상태: flutter run --profile --purge-persistent-cache로 실행해 “신규 기기 첫 실행”을 재현
  2. 수정 후, 동일 조건: Impeller 확인/워밍업 조치를 적용한 뒤 다시 --purge-persistent-cache로 실행

비교할 때 보는 것은 평균이 아니라 최악의 프레임(worst frame) raster 시간입니다. 개선 전에는 특정 프레임이 예산(16ms 또는 8.3ms)을 몇 배씩 초과하며 다크레드로 표시되고, 개선 후에는 같은 시나리오에서 그 스파이크가 사라지고 모든 프레임이 예산 안에 들어오는지를 Frame chart에서 육안으로, Timeline events에서 콜스택 단위로 확인합니다. CI에 성능 회귀 테스트를 붙이고 싶다면 flutter drive로 동일 시나리오를 자동화하고, 실행 시마다 --purge-persistent-cache를 강제해 “따뜻해진 캐시” 때문에 회귀를 놓치는 일을 방지하는 것이 핵심입니다.

체크리스트