Wednesday, July 12, 2023

Flutter 스크롤 성능 최적화: ShrinkWrap과 Slivers의 올바른 사용법

Flutter 애플리케이션을 개발하다 보면 동적인 콘텐츠를 스크롤 가능한 영역에 표시해야 하는 경우가 빈번하게 발생합니다. 이때 가장 흔하게 마주치는 문제 중 하나는 스크롤 위젯이 자신의 크기를 결정하지 못해 발생하는 레이아웃 오류, 특히 'Vertical viewport was given unbounded height'와 같은 예외 상황입니다. 이 문제를 해결하기 위해 많은 개발자들이 shrinkWrap: true라는 속성을 사용하곤 합니다. 이는 간단하고 빠른 해결책처럼 보이지만, 그 이면에는 심각한 성능 저하의 위험이 도사리고 있습니다.

한편, Flutter는 이러한 복잡하고 동적인 스크롤 UI를 효율적이고 유연하게 구축하기 위해 'Sliver'라는 강력한 아키텍처를 제공합니다. Slivers는 단순한 목록을 넘어, 앱 바가 스크롤에 따라 사라지거나, 여러 종류의 스크롤 위젯(리스트, 그리드 등)이 하나의 스크롤 효과 안에서 자연스럽게 연결되는 등 정교한 사용자 경험을 구현할 수 있게 해줍니다.

이 글에서는 즉각적인 문제 해결을 위한 shrinkWrap의 올바른 사용 사례와 그 한계를 명확히 짚어보고, Flutter의 스크롤 철학의 핵심인 Slivers의 개념과 활용법을 깊이 있게 탐구합니다. 언제 shrinkWrap을 사용해야 하고, 언제 더 나아가 Slivers를 도입해야 하는지에 대한 명확한 기준을 제시하여, 여러분이 더 높은 성능과 뛰어난 사용자 경험을 갖춘 애플리케이션을 만들 수 있도록 돕겠습니다.

ShrinkWrap: 편리함의 이면과 성능의 함정

shrinkWrapListView, GridView, CustomScrollView와 같은 스크롤 가능한 위젯들이 가지는 boolean 타입의 속성입니다. 이 속성의 본질적인 역할은 스크롤 위젯의 크기 계산 방식을 바꾸는 것입니다.

기본적으로 스크롤 위젯(shrinkWrap: false)은 부모 위젯으로부터 허용된 최대 크기를 모두 차지하려는 경향이 있습니다. 예를 들어, 세로 스크롤 ListView는 화면의 세로 방향으로 가능한 한 많은 공간을 차지하려고 합니다. 하지만 Column과 같이 자식의 크기에 따라 자신의 크기를 결정하는 위젯 내부에 이런 ListView를 배치하면 충돌이 발생합니다. Column은 자식들이 얼마나 큰지 알아야 자신의 높이를 정할 수 있는데, ListView는 "최대한 크게"라고만 답하니, 무한한 높이를 요구하는 상황이 되어버립니다. 이것이 바로 'unbounded height' 오류의 원인입니다.

shrinkWrap: true로 설정하면, 스크롤 위젯은 "최대한 크게"가 아니라 "내부 콘텐츠를 모두 담을 수 있을 만큼만"의 크기를 차지하도록 동작 방식이 바뀝니다. 즉, 모든 자식 위젯의 크기를 계산하여 그 합만큼의 크기를 가지게 됩니다. 이로써 ColumnListView의 정확한 높이를 알 수 있게 되고 레이아웃 오류가 해결됩니다.

가장 흔한 사용 사례: Column 내부의 ListView

다음은 shrinkWrap이 필요한 전형적인 시나리오입니다. 정적인 위젯들(예: 제목 텍스트)과 동적인 리스트를 하나의 세로 레이아웃에 함께 표시하고 싶을 때입니다.


// 오류가 발생하는 코드 예시
Scaffold(
  appBar: AppBar(title: Text('ShrinkWrap Example')),
  body: Column(
    children: [
      Text('리스트 제목', style: TextStyle(fontSize: 24)),
      // 오류 발생! ListView가 무한한 높이를 요구함.
      ListView.builder(
        itemCount: 50,
        itemBuilder: (context, index) {
          return ListTile(title: Text('Item $index'));
        },
      ),
    ],
  ),
);

위 코드를 실행하면 즉시 렌더링 오류를 마주하게 됩니다. 이 문제를 해결하기 위해 ListViewExpanded로 감싸는 방법도 있지만, 이는 리스트가 남은 공간을 모두 채우게 만들어 Column의 다른 위젯들이 밀려나는 결과를 낳을 수 있습니다. 우리가 원하는 것은 리스트가 자신의 콘텐츠만큼의 공간만 차지하는 것이므로, shrinkWrap이 적절한 해결책이 됩니다.


// shrinkWrap으로 문제를 해결한 코드
Scaffold(
  appBar: AppBar(title: Text('ShrinkWrap Example')),
  body: SingleChildScrollView( // 전체를 스크롤 가능하게 만듦
    child: Column(
      children: [
        Text('리스트 제목', style: TextStyle(fontSize: 24)),
        ListView.builder(
          shrinkWrap: true, // ListView가 자신의 콘텐츠 크기만큼만 차지하도록 함
          physics: NeverScrollableScrollPhysics(), // ListView 자체의 스크롤은 비활성화
          itemCount: 50,
          itemBuilder: (context, index) {
            return ListTile(title: Text('Item $index'));
          },
        ),
        // 다른 위젯들...
        Container(height: 100, color: Colors.blue),
      ],
    ),
  ),
);

위 코드에서는 shrinkWrap: true와 함께 physics: NeverScrollableScrollPhysics()를 사용했습니다. 이는 중첩된 스크롤 문제를 방지하기 위함입니다. Column 전체를 SingleChildScrollView가 감싸고 있으므로, 내부 ListView가 자체적으로 스크롤되는 것을 막고 모든 스크롤 제어를 부모인 SingleChildScrollView에 위임하는 것이 자연스러운 사용자 경험을 제공합니다.

성능 저하의 원인: 지연 로딩(Lazy Loading)의 상실

shrinkWrap이 편리한 해결책임은 분명하지만, 그 대가는 결코 가볍지 않습니다. 바로 성능입니다. ListView.builder와 같은 위젯의 가장 큰 장점은 '지연 로딩' 또는 '가상화(virtualization)'입니다. 이는 화면에 실제로 보이는 항목들만 렌더링하고, 스크롤되어 보이지 않는 항목들은 메모리에서 제거하거나 생성하지 않음으로써 수천, 수만 개의 아이템도 효율적으로 처리할 수 있게 해줍니다.

하지만 shrinkWrap: true를 사용하는 순간, 이 모든 장점이 사라집니다. 위젯의 전체 크기를 계산하기 위해 Flutter 프레임워크는 리스트에 있는 모든 자식 위젯을 미리 빌드하고 레이아웃을 측정해야 합니다. 아이템이 10개라면 문제가 되지 않겠지만, 100개, 1,000개가 되면 애플리케이션의 시작이 눈에 띄게 느려지고, 스크롤 시 버벅거림(jank)이 발생할 수 있습니다. 이는 특히 저사양 기기에서 치명적일 수 있습니다.

따라서 shrinkWrap은 다음과 같은 제한적인 상황에서만 사용하는 것이 좋습니다.

  • 리스트의 아이템 개수가 적고, 예측 가능할 때 (예: 10~20개 내외)
  • 화면에 한 번에 모든 아이템을 표시해야 하는 디자인적 요구사항이 있을 때
  • 성능보다 빠른 개발 속도가 훨씬 더 중요하고, 해당 화면의 사용 빈도가 낮을 때

이 외의 대부분의 경우, 특히 아이템 개수가 많거나 무한 스크롤이 필요한 경우에는 Slivers를 사용하는 것이 아키텍처 관점에서 올바른 접근 방식입니다.

Slivers: Flutter 스크롤 아키텍처의 정수

Slivers는 Flutter의 스크롤 시스템을 지탱하는 저수준(low-level)의 핵심 개념입니다. 우리가 일반적으로 사용하는 ListView, GridView 같은 위젯들도 내부적으로는 Slivers를 사용하여 구현되어 있습니다. Sliver의 가장 큰 특징은 '뷰포트(viewport)의 일부를 지연해서 렌더링하는 조각'이라는 점입니다. 이들은 CustomScrollView라는 부모 안에서 조립되어 하나의 유기적인 스크롤 경험을 만들어냅니다.

Slivers를 사용한다는 것은, 단순히 위젯을 배치하는 것을 넘어 스크롤 동작 자체를 디자인하는 것과 같습니다. 이를 통해 shrinkWrap으로는 불가능했던 다양하고 복잡한 스크롤 효과를 성능 저하 없이 구현할 수 있습니다.

Sliver 아키텍처의 구성 요소

  1. CustomScrollView: 모든 Sliver들의 '컨테이너' 역할을 하는 위젯입니다. 자체적으로 스크롤 방향, 컨트롤러, 물리 효과 등을 관리하며, slivers라는 속성을 통해 여러 Sliver 위젯들을 자식으로 가집니다.
  2. Sliver 위젯들: CustomScrollView 내부에 배치되는 개별 스크롤 조각들입니다. SliverList, SliverGrid, SliverAppBar 등 다양한 종류가 있으며, 각각 특정 목적에 맞게 설계되었습니다.

Slivers는 기존의 박스 레이아웃 모델(자신이 가로, 세로 크기를 가지는)과 다릅니다. Sliver는 스크롤 축을 따라 뷰포트와의 상호작용을 통해 자신의 레이아웃을 동적으로 결정합니다. 이 덕분에 화면에 보이지 않는 부분은 렌더링하지 않는 '지연 로딩'이 자연스럽게 가능해집니다.

주요 Sliver 위젯들과 활용법

Slivers의 진정한 힘은 다양한 종류의 Sliver들을 조합하여 복잡한 화면을 구성할 때 드러납니다. 앞서 ColumnListView로 해결하려 했던 문제를 Slivers를 사용해 다시 구현해 보겠습니다.


Scaffold(
  body: CustomScrollView(
    slivers: <Widget>[
      // 1. 동적으로 크기가 변하는 앱 바
      SliverAppBar(
        title: Text('Slivers Example'),
        floating: true, // 스크롤을 내리면 바로 나타남
        pinned: true,   // 상단에 고정됨
        expandedHeight: 200.0,
        flexibleSpace: FlexibleSpaceBar(
          background: Image.network(
            'https://via.placeholder.com/500x200',
            fit: BoxFit.cover,
          ),
        ),
      ),
      // 2. 리스트 위에 고정된 헤더 (Box 위젯을 Sliver로 변환)
      SliverToBoxAdapter(
        child: Padding(
          padding: const EdgeInsets.all(16.0),
          child: Text('그리드 섹션', style: TextStyle(fontSize: 24)),
        ),
      ),
      // 3. 그리드 뷰
      SliverGrid(
        gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
          crossAxisCount: 3,
          mainAxisSpacing: 10.0,
          crossAxisSpacing: 10.0,
          childAspectRatio: 1.0,
        ),
        delegate: SliverChildBuilderDelegate(
          (BuildContext context, int index) {
            return Container(
              color: Colors.teal[100 * (index % 9)],
              child: Center(child: Text('Grid Item $index')),
            );
          },
          childCount: 9,
        ),
      ),
      // 4. 또 다른 헤더
      SliverToBoxAdapter(
        child: Padding(
          padding: const EdgeInsets.all(16.0),
          child: Text('리스트 섹션', style: TextStyle(fontSize: 24)),
        ),
      ),
      // 5. 리스트 뷰
      SliverList(
        delegate: SliverChildBuilderDelegate(
          (BuildContext context, int index) {
            return ListTile(
              leading: CircleAvatar(child: Text('${index + 1}')),
              title: Text('List Item ${index + 1}'),
              subtitle: Text('This is a subtitle'),
            );
          },
          childCount: 50,
        ),
      ),
    ],
  ),
);

위 예제는 Slivers의 강력함을 잘 보여줍니다. 하나의 CustomScrollView 안에서 다음의 요소들이 완벽하게 통합된 스크롤 경험을 제공합니다.

  • SliverAppBar: 스크롤 시 자연스럽게 축소되고 상단에 고정되는 앱 바를 만듭니다. shrinkWrap 방식으로는 구현하기 매우 까다로운 기능입니다.
  • SliverToBoxAdapter: Column에서 사용했던 일반 Text 위젯과 같은, 스크롤 기능이 없는 일반 위젯(Box Protocol 위젯)을 Sliver 리스트에 포함시켜주는 다리 역할을 합니다. 이를 통해 정적인 콘텐츠와 동적인 리스트를 쉽게 결합할 수 있습니다.
  • SliverGridSliverList: 각각 그리드와 리스트 레이아웃을 담당합니다. 이들은 모두 지연 로딩을 지원하므로, 내부에 수백, 수천 개의 아이템이 있어도 성능에 영향을 주지 않습니다.

이 모든 요소들이 별개의 스크롤 영역을 가지는 것이 아니라, 하나의 연속적인 스크롤 공간 내에서 움직입니다. 이는 shrinkWrap과 중첩 스크롤 뷰로 흉내 내기 어려운, 매우 부드럽고 직관적인 사용자 경험을 제공합니다.

고급 Sliver: SliverPersistentHeader

Sliver의 유연성은 여기서 그치지 않습니다. SliverPersistentHeader를 사용하면 스크롤 위치에 따라 크기나 모양이 변하면서도 화면 일부에 고정되는 헤더를 만들 수 있습니다. 탭 바(Tab Bar)가 스크롤되어 앱 바 아래에 붙는 효과가 대표적인 예입니다.

이를 구현하려면 SliverPersistentHeaderDelegate를 상속받는 커스텀 클래스를 만들어야 하지만, 그 결과물은 매우 인상적입니다.


// SliverPersistentHeader를 사용한 탭 바 예시 (Delegate 클래스 생략)
CustomScrollView(
  slivers: [
    SliverAppBar(
      title: Text('Persistent Header'),
      pinned: true,
    ),
    SliverPersistentHeader(
      delegate: _MySliverPersistentHeaderDelegate(
        // 여기에 탭 바 위젯을 전달
        TabBar(
          tabs: [Tab(text: 'Tab 1'), Tab(text: 'Tab 2')],
        ),
      ),
      pinned: true, // 앱 바 아래에 고정
    ),
    // 탭 뷰 콘텐츠 (예: SliverList)
    SliverFillRemaining(
      child: TabBarView(
        children: [
          Center(child: Text('Content for Tab 1')),
          Center(child: Text('Content for Tab 2')),
        ],
      ),
    ),
  ],
)

전략적 선택: 언제 무엇을 사용할 것인가?

이제 shrinkWrap과 Slivers의 특징과 장단점을 모두 이해했으니, 어떤 상황에서 어떤 기술을 선택해야 하는지에 대한 명확한 기준을 세울 수 있습니다. 이는 "어느 것이 더 좋은가?"의 문제가 아니라 "현재 상황에 어느 것이 더 적합한가?"의 문제입니다.

고려 사항 shrinkWrap: true Slivers (CustomScrollView)
주요 사용 사례 Column, Row 등 크기가 제한되지 않은 공간 내부에 작은 리스트를 배치할 때 다양한 종류의 스크롤 위젯(리스트, 그리드, 앱 바)을 하나의 스크롤로 통합할 때
성능 (아이템 개수) 나쁨. 아이템이 많아질수록 성능이 급격히 저하됨 (모든 아이템을 한 번에 렌더링) 우수. 아이템 개수에 거의 영향을 받지 않음 (지연 로딩 지원)
구현 복잡도 낮음. 속성 하나만 추가하면 됨 중간. CustomScrollView와 여러 Sliver 위젯에 대한 이해가 필요함
기능 및 유연성 제한적. 단순 리스트 표시에 국한됨 매우 높음. 사라지는 앱 바, 고정 헤더 등 정교한 커스텀 스크롤 효과 구현 가능

결론: 현명한 스크롤 구현을 위한 최종 제언

Flutter에서 동적 스크롤 UI를 구현할 때, shrinkWrap은 유혹적인 지름길처럼 보일 수 있습니다. 실제로 아이템의 개수가 매우 적고(20개 미만) 고정되어 있으며, 빠른 구현이 필요할 때는 합리적인 선택이 될 수 있습니다. 하지만 이것이 습관적인 해결책이 되어서는 안 됩니다.

대부분의 실무 애플리케이션에서는 데이터의 양이 유동적이며, 성능은 사용자 경험과 직결되는 핵심 요소입니다. 따라서 다음과 같은 마음가짐을 갖는 것이 중요합니다.

  1. Slivers를 기본으로 생각하라: 스크롤 가능한 콘텐츠와 다른 위젯을 함께 배치해야 할 때, 가장 먼저 CustomScrollView와 Slivers를 떠올리세요. 이는 Flutter가 의도한 가장 효율적이고 확장 가능한 방식입니다.
  2. shrinkWrap은 의심하며 사용하라: shrinkWrap: true를 코드에 추가하는 순간, "이 리스트의 아이템 개수가 정말로 항상 적다고 보장할 수 있는가?"라고 스스로에게 질문해야 합니다. 미래에 데이터가 늘어날 가능성이 조금이라도 있다면, 지금 Slivers로 전환하는 것이 장기적으로는 시간을 아끼는 길입니다.
  3. 성능을 측정하라: 개발 중인 앱의 성능이 의심될 때는 Flutter DevTools의 'Performance' 탭을 활용하여 UI 렌더링 성능을 직접 확인하세요. shrinkWrap으로 인한 프레임 드랍을 눈으로 확인하면 Slivers의 중요성을 더욱 체감하게 될 것입니다.

shrinkWrap은 특정 문제를 해결하는 유용한 도구이지만, Slivers는 Flutter 스크롤의 가능성을 최대로 이끌어내는 강력한 아키텍처입니다. Slivers를 자유자재로 다룰 수 있게 되면, 여러분은 단순한 목록 표시를 넘어 사용자들을 매료시키는 동적이고 아름다운 스크롤 경험을 창조할 수 있는 개발자로 거듭날 것입니다. 지금 바로 여러분의 프로젝트에 Slivers를 적용하여 그 성능과 유연성을 직접 경험해 보시기 바랍니다.


0 개의 댓글:

Post a Comment