Flutter 애플리케이션 개발 초기 단계에서 가장 빈번하게 마주치는 레이아웃 오류는 Vertical viewport was given unbounded height입니다. 이는 Column과 같이 세로 확장이 무제한으로 허용되는 부모 위젯 내부에, 스크롤 가능한 ListView를 배치할 때 발생합니다. 뷰포트(Viewport) 시스템은 자식 위젯의 크기를 확정해야 렌더링을 수행할 수 있는데, 두 위젯 모두 높이를 무한대로 확장하려 하기 때문에 제약 조건(Constraints) 충돌이 일어나는 것입니다.
많은 개발자가 이를 해결하기 위해 shrinkWrap: true 속성을 사용합니다. 이 속성은 스크롤 뷰가 자신의 콘텐츠 크기만큼만 영역을 차지하도록 강제하여 레이아웃 오류를 즉시 해결합니다. 그러나 이는 프로덕션 레벨의 애플리케이션, 특히 데이터가 동적으로 늘어나는 환경에서는 심각한 성능 병목(Bottleneck)을 초래하는 안티 패턴(Anti-pattern)이 될 수 있습니다.
본 포스트에서는 shrinkWrap이 렌더링 파이프라인에 미치는 영향을 분석하고, 이를 대체할 수 있는 Sliver 아키텍처의 설계 원칙과 실무 적용 방법을 다룹니다.
1. shrinkWrap 사용 시의 성능 트레이드오프
Flutter의 ListView나 GridView는 기본적으로 지연 로딩(Lazy Loading) 또는 가상화(Virtualization) 메커니즘을 사용합니다. 이는 전체 아이템이 1,000개라 하더라도, 실제 화면(Viewport)에 노출되는 10여 개의 아이템에 대해서만 RenderObject를 생성하고 메모리에 올리는 방식입니다. 이 덕분에 리스트의 길이가 아무리 길어져도 초기 로딩 속도와 스크롤 성능이 일정하게 유지됩니다.
그러나 shrinkWrap: true를 설정하면 이 메커니즘이 무력화됩니다. 해당 속성이 활성화되면 Flutter 엔진은 스크롤 뷰의 전체 크기를 계산하기 위해, 내부에 포함된 모든 자식 위젯을 즉시 빌드하고 레이아웃을 측정(Measure)합니다.
shrinkWrap: true가 설정된 ListView에 100개의 아이템이 있다면, 화면 진입 시점(Build Phase)에 100번의 위젯 빌드와 레이아웃 연산이 동기적으로 실행됩니다. 이는 UI 스레드를 차단(Block)하여 프레임 드랍(Frame Drop)의 직접적인 원인이 됩니다.
따라서 shrinkWrap은 다음과 같은 매우 제한적인 상황에서만 사용해야 합니다.
- 설정 메뉴와 같이 아이템 개수가 고정적이고 매우 적을 때 (보통 20개 미만).
- 화면 전체가 아닌 다이얼로그(Dialog) 내부 등 제한된 영역에서 리스트를 보여줄 때.
- 중첩 스크롤 이슈가 없으며, 데이터가 로컬에 한정될 때.
2. Sliver 아키텍처와 RenderProtocol
복잡한 스크롤 인터페이스를 성능 저하 없이 구현하기 위해서는 Flutter의 Sliver 프로토콜을 이해해야 합니다. Flutter의 렌더링 객체는 크게 RenderBox와 RenderSliver로 구분됩니다.
- RenderBox: 카르테시안 좌표계(x, y)를 기반으로 고정된 크기(Size)를 가집니다. 일반적인
Container,Row,Column등이 이에 해당합니다. - RenderSliver: 뷰포트 내에서의 스크롤 위치와 크기(Geometry)를 기반으로 동작합니다. 부모 뷰포트로부터
SliverConstraints를 받고, 자신이 차지할 공간인SliverGeometry를 반환합니다.
CustomScrollView는 여러 개의 RenderSliver를 하나의 스크롤 영역으로 통합하여 관리합니다. 이 구조를 사용하면, 화면 상단에는 이미지가 있고 하단에는 무한 스크롤 리스트가 있는 UI를 구성할 때도, 각 섹션이 뷰포트에 진입하는 시점에만 렌더링 리소스를 사용하게 됩니다.
3. 구현 전략: CustomScrollView 전환
기존의 Column + shrinkWrap 구조를 CustomScrollView + Sliver 구조로 리팩토링하는 과정입니다. 이 패턴은 스크롤 성능을 O(N)에서 O(1)(화면에 보이는 아이템 수 비례)로 최적화합니다.
Refactoring: Before & After
아래는 성능 문제를 유발하는 전형적인 코드 패턴입니다.
// [Anti-Pattern] Column 내부에 shrinkWrap 사용
// 리스트 아이템이 많아질수록 초기 렌더링 비용이 급증함
Widget buildLegacyStructure() {
return SingleChildScrollView(
child: Column(
children: [
Container(height: 200, color: Colors.blue), // 헤더
ListView.builder(
shrinkWrap: true, // 성능 저하의 주범
physics: NeverScrollableScrollPhysics(), // 스크롤 이벤트 충돌 방지
itemCount: 1000,
itemBuilder: (c, i) => ListTile(title: Text('Item $i')),
),
],
),
);
}
다음은 CustomScrollView를 사용하여 동일한 UI를 구현하되, 지연 로딩을 완벽하게 지원하는 코드입니다.
// [Best Practice] CustomScrollView와 Slivers 사용
// 화면에 보이는 요소만 렌더링하므로 아이템이 1만 개라도 성능 저하 없음
Widget buildOptimizedStructure() {
return Scaffold(
body: CustomScrollView(
slivers: <Widget>[
// 1. 일반 위젯(RenderBox)을 Sliver 프로토콜로 변환
SliverToBoxAdapter(
child: Container(
height: 200,
color: Colors.blue,
child: Center(child: Text('Header Area')),
),
),
// 2. 고정된 헤더 (Sticky Header) 구현 가능
SliverPersistentHeader(
pinned: true,
delegate: _MyHeaderDelegate(), // 별도 Delegate 구현 필요
),
// 3. 지연 로딩이 적용된 리스트
SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
// 뷰포트에 진입할 때만 호출됨
return ListTile(
title: Text('Optimized Item $index'),
);
},
childCount: 1000,
),
),
],
),
);
}
SliverToBoxAdapter는 RenderBox 기반의 일반 위젯을 RenderSliver 환경에서 사용할 수 있도록 래핑(Wrapping)하는 브리지 역할을 수행합니다. 단, 이 내부의 자식이 너무 크면 성능 이점이 희석될 수 있으므로 주의해야 합니다.
4. Sliver 위젯 선택 가이드
다양한 Sliver 위젯들은 각기 다른 렌더링 특성과 용도를 가집니다. 상황에 맞는 적절한 위젯 선택이 중요합니다.
| 위젯 명 | 주요 역할 | 사용 시나리오 |
|---|---|---|
| SliverList | 선형 리스트 렌더링 | 가장 일반적인 세로/가로 목록 표시. |
| SliverGrid | 2D 격자 배치 | 앨범, 상품 목록 등 격자형 레이아웃. |
| SliverAppBar | 동적 헤더 제어 | 스크롤 시 상단바 축소/확장/고정 효과. |
| SliverFillRemaining | 잔여 공간 채움 | 콘텐츠가 적을 때 화면 하단까지 배경을 채우거나 중앙 정렬 시 사용. |
고급 최적화: SliverPersistentHeader
단순한 리스트 표시를 넘어, 스크롤 중에 특정 섹션 헤더가 상단에 고정되어야 한다면 SliverPersistentHeader가 필수적입니다. 이는 shrinkWrap 구조에서는 구현하기 매우 까다로운 UX 패턴입니다. maxExtent와 minExtent를 정의하여 스크롤 위치에 따라 헤더의 크기를 자연스럽게 변화시킬 수 있습니다.
class MyHeaderDelegate extends SliverPersistentHeaderDelegate {
@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
// shrinkOffset을 이용하여 애니메이션 효과 구현 가능
return Container(
color: Colors.white,
alignment: Alignment.center,
child: Text("Sticky Header"),
);
}
@override
double get maxExtent => 100.0; // 펼쳐졌을 때 높이
@override
double get minExtent => 60.0; // 고정되었을 때 높이
@override
bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) {
return false; // 불필요한 리빌드 방지
}
}
결론
Flutter 개발에서 shrinkWrap: true는 프로토타이핑 단계의 빠른 구현을 위한 도구일 뿐, 확장 가능한 아키텍처를 위한 솔루션이 아닙니다. 애플리케이션의 복잡도가 증가하고 데이터 양이 늘어날수록, 뷰포트 기반의 지연 로딩을 지원하는 CustomScrollView와 Sliver 패턴 도입은 선택이 아닌 필수입니다.
초기 학습 곡선이 다소 존재하지만, Sliver 아키텍처를 적용함으로써 얻을 수 있는 메모리 효율성과 60fps(혹은 120fps)의 부드러운 스크롤 경험은 그 비용을 충분히 상쇄합니다. 현재 프로젝트에서 shrinkWrap을 남용하고 있다면, 성능 프로파일링 도구를 통해 렌더링 비용을 확인하고 리팩토링을 고려해보시길 권장합니다.
Post a Comment