Column 안에 ListView 넣을 때 에러 해결 (Unbounded Height)
1. "화면이 하얗게 질렸습니다."
Flutter 개발자가 겪는 공포의 순간 2위는 (1위는 빌드 에러), 앱을 실행했는데 화면이 아무것도 안 나오는 하얀색(White Screen of Death)이거나, 빨간 화면에 "Vertical viewport was given unbounded height"라는 에러가 뜰 때입니다.
코드는 아주 단순했습니다.
Column(
children: [
Text("나의 할 일 목록"), // 헤더
ListView.builder( // 리스트
itemCount: 100,
itemBuilder: (context, index) => Text("할 일 $index"),
),
],
)
"아니, 제목 밑에 리스트 나오는 게 그렇게 어려운 일인가요?" 네, Flutter의 레이아웃 세계관에서는 가장 어려운 철학적 난제 중 하나입니다.
2. 원리 이해 - 무한과 무한의 충돌
이 에러의 핵심은 "높이(Height)를 누가 정하느냐"의 싸움입니다.
- Column의 본능:
Column은 세로 방향으로 자식들을 배치합니다. 기본적으로 자식들에게 "너네 크기(Height) 얼마나 필요해? 다 말해봐. 내가 맞춰줄게."라고 묻습니다. (MainAxisSize.max이지만, 자식의 크기를 존중함) - ListView의 본능:
ListView는 스크롤 가능한 위젯입니다. 스크롤이 된다는 건 콘텐츠가 화면보다 길다는 뜻이죠. 이론적으로ListView의 높이는 무한(Infinite)입니다. - 충돌:
Column: "야 ListView, 너 높이 몇이야? 내가 그려줄게."ListView: "저 무한대인데요?"Column: "..." (무한대를 그릴 수는 없음)- 결과:
Unbounded height에러 발생. Flutter는 무한한 높이를 가진 위젯을 그릴 수 없습니다.
3. 해결책 1 - Expanded (가장 쉬운 정답)
ListView에게 "무한대라고 우기지 말고, Column이 쓰고 남은 공간만 써"라고 제약을 주는 것입니다.
Column(
children: [
Text("나의 할 일 목록"), // 고정된 크기 (예: 50px)
Expanded( // 남은 공간을 다 차지함
child: ListView.builder(
itemCount: 100,
itemBuilder: ...,
),
),
],
)
이렇게 하면 Expanded가 화면의 남은 높이(전체 높이 - 50px)를 계산해서 ListView에게 딱 정해진 높이(Bounded Height)를 강제로 할당합니다.
ListView는 이제 "아, 나는 화면 끝까지만 그려지는구나"라고 깨닫고 정상적으로 스크롤을 만듭니다.
대부분의 경우 이게 정답입니다.
4. 해결책 2 - SizedBox (고정 크기)
특정 높이만 주고 싶다면 SizedBox나 Container로 감싸면 됩니다.
Column(
children: [
Text("헤더"),
SizedBox(
height: 200, // 높이를 200으로 고정
child: ListView(...),
),
Text("푸터"),
],
)
하지만 이건 반응형 디자인(Responsive Design)에 좋지 않습니다. 태블릿에서는 너무 작아 보이거나, 작은 폰에서는 화면을 넘칠 수 있습니다.
5. 해결책 3 - shrinkWrap (성능의 덫)
제일 많이 오용되는 속성입니다.
ListView(
shrinkWrap: true, // ⚠️ 주의!
physics: NeverScrollableScrollPhysics(),
...
)
shrinkWrap: true는 ListView에게 "너 무한대라고 하지 말고, 자식들 크기 딱 맞춰서 줄어들어"라고 말합니다.
얼핏 좋아 보이지만, 치명적인 단점이 있습니다.
Lazy Loading이 동작하지 않습니다.
아이템이 100개면 100개를, 1,000개면 1,000개를 한 번에 다 렌더링해서 높이를 계산해야 합니다.
리스트가 길어질수록 뚝뚝 끊기는(Jank) 현상을 경험하게 될 것입니다. 웬만하면 쓰지 마세요.
6. 해결책 4 - CustomScrollView + Slivers (고수들의 방법)
만약 요구사항이 더 복잡하다면? "헤더도 같이 스크롤되어야 해요." "리스트가 올라갈 때 헤더가 자연스럽게 사라지거나 작아져야 해요(SliverAppBar)."
이때는 Column 안에 ListView를 넣는 구조 자체를 버려야 합니다.
대신 CustomScrollView 안에 모든 것을 Sliver 형태로 넣어야 합니다.
CustomScrollView(
slivers: [
// 1. 일반 위젯(Text 등)을 Sliver 세계로 가져오기
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(16.0),
child: Text("나의 할 일 목록", style: TextStyle(fontSize: 24)),
),
),
// 2. Sliver 전용 리스트
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => ListTile(title: Text("할 일 $index")),
childCount: 100,
),
),
],
)
왜 Slivers인가?
Sliver는 "조각"이라는 뜻입니다. 화면을 조각조각 나눠서 사용자가 보고 있는 부분(Viewport)만 렌더링하는 고성능 스크롤 시스템입니다.
Column + ListView 조합은 스크롤 영역이 분리되지만(헤더는 고정, 리스트만 스크롤), CustomScrollView는 전체가 하나의 스크롤로 동작합니다. UX적으로 훨씬 자연스럽습니다.
LimitedBox, Flexible, and Space 한 걸음 더
Expanded 말고 다른 방법은 없을까요?
Flexible이나 LimitedBox도 대안이 될 수 있습니다.
LimitedBox:
"평소에는 무한대여도 되지만, 만약 부모가 무한대(Unbounded)를 주면, 너는 딱 300px까지만 커져라"라고 제한을 거는 위젯입니다.
shrinkWrap 대신 성능 손해 없이 "작을 땐 작게, 클 땐 제한된 크기까지" 동작하게 할 수 있는 유용한 위젯입니다.
Nested Scroll Views (중첩 스크롤 멸망전):
제발 SingleChildScrollView 안에 ListView를 넣고 shrinkWrap: true를 쓰지 마세요.
이건 "무한한 종이 위에 무한한 종이를 펴는 행위"입니다. Flutter 엔진이 비명을 지릅니다.
스크롤 안에 스크롤이 필요하다면, 반드시 CustomScrollView + Slivers 조합으로 가야 합니다.
NestedScrollView (스크롤 중첩의 끝판왕) 뜯어보기
탭 바(TabBar)가 있고, 각 탭 안에 리스트가 또 있는 구조(예: 인스타그램 프로필 페이지)는 어떻게 할까요? 전체 스크롤도 되면서, 내부 탭의 리스트도 스크롤되어야 합니다.
이때 NestedScrollView가 등장합니다.
NestedScrollView(
headerSliverBuilder: (context, _) => [
SliverAppBar(title: Text("프로필"), pinned: true),
],
body: TabBarView(
children: [
ListView(...), // 탭 1
ListView(...), // 탭 2
],
),
)
이건 Flutter 레이아웃의 끝판왕 레벨입니다. 하지만 원리는 같습니다. "누가 스크롤 제어권을 가질 것인가?"를 명확히 해주는 겁니다.
9. Pro Tip: Preserving Scroll State (AutomaticKeepAliveClientMixin)
리스트에서 스크롤을 내린 상태에서 탭을 이동했다가 돌아오면, 스크롤이 맨 위로 초기화되는 현상이 있습니다. 사용자는 짜증을 냅니다. "아니, 나 어디까지 봤는지 까먹었잖아!"
이럴 때는 AutomaticKeepAliveClientMixin을 사용해야 합니다.
class MyListPage extends StatefulWidget { ... }
class _MyListPageState extends State<MyListPage> with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true; // 이거 하나면 상태 유지 끝!
@override
Widget build(BuildContext context) {
super.build(context); // 꼭 호출해야 함
return ListView(...);
}
}
이 믹스인을 사용하면 Flutter가 해당 위젯이 화면 밖으로 나가도 메모리에서 삭제하지 않고 보존해줍니다.
NestedScrollView나 TabBarView를 쓸 때 거의 필수적인 테크닉입니다.
핵심 용어 정리
- Viewport: 화면에서 실제로 보여지는 창문(Window). 스크롤 뷰는 이 Viewport를 통해 거대한 콘텐츠의 일부만 보여줍니다.
- Offset: 스크롤 위치. 0.0이면 맨 위, 100.0이면 100픽셀만큼 내려온 상태.
- Extent: Sliver의 크기. 세로 스크롤일 땐 높이, 가로 스크롤일 땐 너비를 의미합니다.
- Overscroll: iOS에서 스크롤을 끝까지 당겼을 때 튕기는 효과(Bouncing). Android는 물결 효과(Clamping or Stretching).
11. 요약 - 상황별 선택 가이드
-
헤더는 고정되고 리스트만 스크롤? ->
Column+Expanded+ListView(가장 추천) -
헤더도 리스트랑 같이 스크롤? ->
ListView에Header를 첫 번째 아이템으로 넣거나, ->CustomScrollView+SliverToBoxAdapter+SliverList(Best) -
아이템이 10개 미만으로 아주 적음? ->
Column+SingleChildScrollView. 굳이ListView안 써도 됨. -> 혹은shrinkWrap: true(최후의 수단) -
탭이 있고 복잡한 스크롤? ->
NestedScrollView
"Unbounded height" 에러는 여러분을 괴롭히려는 게 아닙니다. "무한대의 우주를 유한한 스마트폰 화면에 어떻게 구겨 넣을지 결정해달라"는 Flutter의 호소입니다.
Expanded라는 상자에 잘 담아주기만 하면 됩니다.