Flutter 스크롤 버벅임 잡기: RepaintBoundary와 Raster 스레드 최적화 실전

최근 핀테크 프로젝트의 대시보드 리뉴얼 작업을 진행하면서 예상치 못한 성능 이슈에 직면했습니다. 디자이너가 요구한 복잡한 그라데이션과 그림자 효과가 포함된 리스트를 구현했는데, 고사양 아이폰에서는 부드럽게 작동하던 스크롤이 보급형 안드로이드 기기(갤럭시 A 시리즈 등)에서는 프레임 드랍(Jank) 현상을 일으켰습니다. 60fps 방어가 깨지면서 UI/UX 경험이 심각하게 저하되었고, 사용자는 "앱이 무겁다"는 피드백을 즉각적으로 보내왔습니다. 이 글에서는 단순히 `const`를 붙이는 수준의 최적화가 아니라, flutter 엔진의 렌더링 파이프라인을 분석하고 Raster 스레드의 부하를 줄여 성능을 극적으로 개선한 과정을 공유합니다.

Flutter 렌더링 파이프라인 분석과 병목 지점

문제 상황은 구체적이었습니다. Flutter 3.19 버전, Dart 3.3 환경에서 `ListView.builder`를 사용하여 약 100여 개의 금융 상품 카드를 렌더링하는 화면이었습니다. 각 카드는 복잡한 `CustomPaint`와 `BoxShadow`를 포함하고 있었습니다. DevTools의 Performance Overlay를 켜보니, UI 스레드는 5ms 이내로 여유가 있었지만, Raster(GPU) 스레드가 프레임당 25ms 이상 치솟는 스파이크가 발생하고 있었습니다. 이는 명백히 그리기(Paint) 단계에서의 병목이었습니다.

많은 개발자들이 플러터의 성능 최적화를 논할 때 위젯 트리의 재빌드(Rebuild) 횟수를 줄이는 것에만 집중하곤 합니다. 물론 불필요한 `setState` 호출을 줄이는 것은 중요합니다. 하지만 우리의 케이스처럼 UI 스레드가 아닌 Raster 스레드가 병목인 상황에서는 빌드 최적화만으로는 한계가 명확합니다. 렌더링 파이프라인은 크게 'Build -> Layout -> Paint -> Composite' 단계를 거치는데, 복잡한 그래픽 연산은 Paint 단계에서 CPU와 GPU 자원을 집중적으로 소모하기 때문입니다.

Critical Issue: UI 스레드가 널널해도 Raster 스레드가 과부하 걸리면 화면은 끊깁니다. 특히 `BackdropFilter`나 `Opacity` 위젯은 레이어 합성 비용을 급격히 증가시키는 주범입니다.

우리는 초기 분석에서 이 점을 간과했습니다. 단순히 Provider의 `Selector`를 더 세밀하게 적용하여 리빌드 범위를 좁혔으나, 스크롤 시 발생하는 렌더링 비용은 여전히 동일했습니다. 이는 화면의 아주 작은 영역만 변경되어도 부모 위젯을 포함한 전체 레이어를 다시 그려야(Repaint) 하는 플러터의 기본 동작 방식 때문입니다.

실패한 접근: 무조건적인 const 사용과 캐싱

가장 먼저 시도했던 방법은 모든 위젯 생성자에 `const`를 붙이고, 무거운 이미지 리소스를 `precacheImage`로 미리 로드하는 것이었습니다. 이 방법은 메모리 사용량을 안정화시키고 GC(Garbage Collection) 빈도를 줄이는 데는 효과가 있었으나, 스크롤 시 발생하는 Raster 스레드의 연산량을 줄이지는 못했습니다. 왜냐하면, `const` 위젯이라 할지라도 화면상에서의 위치가 변경(스크롤)되면, 렌더링 엔진은 해당 픽셀을 다시 계산해서 GPU에 텍스처를 업로드해야 하기 때문입니다. 즉, "생성 비용"은 줄였지만 "그리기 비용"은 그대로였던 것입니다.

해결책: RepaintBoundary를 이용한 레이어 분리

문제 해결의 열쇠는 `RepaintBoundary` 위젯에 있었습니다. 이 위젯은 하위 트리를 별도의 디스플레이 리스트(Display List)로 분리하여, 부모 위젯이 다시 그려질 때 자식 위젯까지 강제로 다시 그려지는 것을 방지합니다. 즉, 렌더링 캐시(Raster Cache)를 생성하여 GPU가 이미 그려진 텍스처를 재활용하게 만드는 것입니다.

다음은 최적화 전과 후의 코드 비교입니다.

// Bad Practice: 스크롤 시 매번 전체가 다시 그려짐
class ProductCard extends StatelessWidget {
  final Product product;
  
  const ProductCard({super.key, required this.product});

  @override
  Widget build(BuildContext context) {
    // 복잡한 그라데이션과 쉐도우가 포함된 컨테이너
    return Container(
      decoration: BoxDecoration(
        boxShadow: [BoxShadow(blurRadius: 10, color: Colors.black12)],
        gradient: LinearGradient(colors: [Colors.blue, Colors.purple]),
      ),
      child: Text(product.name),
    );
  }
}

// Optimized Practice: 레이어 분리로 다시 그리기 방지
class OptimizedProductCard extends StatelessWidget {
  final Product product;

  const OptimizedProductCard({super.key, required this.product});

  @override
  Widget build(BuildContext context) {
    // RepaintBoundary로 감싸서 별도의 레이어로 격리
    return RepaintBoundary(
      child: Container(
        decoration: BoxDecoration(
          boxShadow: [BoxShadow(blurRadius: 10, color: Colors.black12)],
          gradient: LinearGradient(colors: [Colors.blue, Colors.purple]),
        ),
        child: Text(product.name),
      ),
    );
  }
}

위 코드에서 `RepaintBoundary`를 추가하는 것만으로 flutter 엔진은 `ProductCard`를 별도의 레이어로 인식합니다. 스크롤이 발생할 때 엔진은 카드의 내용을 다시 그리는(Rasterizing) 대신, 이미 그려진 텍스처의 위치(Offset)만 이동시킵니다. 이는 CPU/GPU 연산량을 획기적으로 줄여줍니다.

지표 (Galaxy A52 기준) 최적화 전 최적화 후 (RepaintBoundary)
Raster 시간 (평균) 18.4ms 4.2ms
UI 시간 (평균) 3.8ms 3.9ms
FPS (스크롤 중) 42 fps 59 fps

벤치마크 결과를 보면 Raster 스레드의 소요 시간이 1/4 수준으로 감소했음을 알 수 있습니다. 특히 주목할 점은 UI 스레드 시간은 거의 변화가 없다는 것입니다. 이는 우리가 수정한 것이 Dart 코드 레벨의 논리가 아니라, Skia(또는 Impeller) 엔진 레벨의 렌더링 동작임을 시사합니다. 결과적으로 저사양 기기에서도 60fps를 안정적으로 방어하며 부드러운 UI/UX를 제공할 수 있게 되었습니다.

Check Official Performance Docs

주의사항: RepaintBoundary 남용의 부작용

하지만 `RepaintBoundary`는 만병통치약이 아닙니다. 이 위젯을 사용하면 각 레이어를 별도의 텍스처 메모리로 보관해야 하므로, 앱의 전반적인 메모리 사용량이 증가합니다. 만약 리스트 아이템이 매우 많거나(수천 개), 각 아이템의 크기가 매우 큰 경우, 오히려 과도한 메모리 점유로 인해 OOM(Out of Memory) 크래시가 발생할 수 있습니다.

따라서 단순한 텍스트나 배경색만 있는 가벼운 위젯에는 적용하지 않는 것이 좋습니다. 복잡한 패스(Path) 그리기가 있거나, 블러(Blur), 그림자(Shadow) 같이 픽셀 파이프라인 비용이 비싼 위젯에만 선별적으로 적용해야 합니다. 또한, `ListView` 내부가 아닌 일반적인 정적 화면에서는 굳이 분리할 필요가 없습니다. Dart 언어의 효율성을 믿고 엔진이 자동으로 처리하게 두는 것이 대부분의 경우 낫습니다.

Best Practice: DevTools에서 'Flash Repaint Rainbow' 옵션을 켜고, 스크롤 시 불필요하게 다시 그려지는 영역이 있는지 시각적으로 확인한 후 `RepaintBoundary`를 적용하세요.

결론

Flutter는 강력한 크로스플랫폼 도구이지만, 네이티브 수준의 UI/UX를 달성하기 위해서는 렌더링 엔진에 대한 이해가 필수적입니다. 이번 트러블슈팅을 통해 우리는 단순히 코드를 작성하는 것을 넘어, 하드웨어 리소스를 어떻게 효율적으로 사용할 것인가에 대한 중요성을 재확인했습니다. 프레임 드랍이 발생할 때는 무작정 로직을 의심하기보다, DevTools를 통해 병목 구간(UI vs Raster)을 정확히 파악하고, `RepaintBoundary`와 같은 도구를 적재적소에 활용하여 성능을 튜닝하는 엔지니어링 접근이 필요합니다.

Post a Comment