
Column 안에 ListView 넣을 때 에러 해결 (Unbounded Height)
Column 안에 ListView를 넣으면 왜 화면이 하얗게 변하거나 에러가 날까요? '무한한 높이'의 역설을 이해하고, Expanded와 Slivers로 우아하게 해결하는 방법을 정리해봤습니다.

Column 안에 ListView를 넣으면 왜 화면이 하얗게 변하거나 에러가 날까요? '무한한 높이'의 역설을 이해하고, Expanded와 Slivers로 우아하게 해결하는 방법을 정리해봤습니다.
로그인 화면을 만들었는데 키보드가 올라오니 노란 줄무늬 에러가 뜹니다. resizeToAvoidBottomInset부터 스크롤 뷰, 그리고 채팅 앱을 위한 reverse 팁까지, 키보드 대응의 모든 것을 정리해봤습니다.

안드로이드는 오는데 iOS는 조용합니다. 혹은 앱이 켜져 있을 때만 옵니다. Background/Terminated 상태 처리, APNs 인증서, 그리고 Notification Channel 설정까지 완벽하게 해결합니다.

안드로이드는 Xcode보다 낫다고요? Gradle 지옥에 빠져보면 그 말이 쏙 들어갈 겁니다. minSdkVersion 충돌, Multidex 에러, Namespace 변경(Gradle 8.0), JDK 버전 문제, 그리고 의존성 트리 분석까지 완벽하게 해결해 봅니다.

Debug에선 잘 되는데 Release에서만 죽나요? 범인은 '난독화'입니다. R8의 원리, Mapping 파일 분석, 그리고 Reflection을 사용하는 라이브러리를 지켜내는 방법(@Keep)을 정리해봤습니다.

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의 레이아웃 세계관에서는 가장 어려운 철학적 난제 중 하나입니다.
이 에러의 핵심은 "높이(Height)를 누가 정하느냐"의 싸움입니다.
Column은 세로 방향으로 자식들을 배치합니다. 기본적으로 자식들에게 "너네 크기(Height) 얼마나 필요해? 다 말해봐. 내가 맞춰줄게."라고 묻습니다. (MainAxisSize.max이지만, 자식의 크기를 존중함)ListView는 스크롤 가능한 위젯입니다. 스크롤이 된다는 건 콘텐츠가 화면보다 길다는 뜻이죠. 이론적으로 ListView의 높이는 무한(Infinite)입니다.Column: "야 ListView, 너 높이 몇이야? 내가 그려줄게."ListView: "저 무한대인데요?"Column: "..." (무한대를 그릴 수는 없음)Unbounded height 에러 발생. Flutter는 무한한 높이를 가진 위젯을 그릴 수 없습니다.ListView에게 "무한대라고 우기지 말고, Column이 쓰고 남은 공간만 써"라고 제약을 주는 것입니다.
Column(
children: [
Text("나의 할 일 목록"), // 고정된 크기 (예: 50px)
Expanded( // 남은 공간을 다 차지함
child: ListView.builder(
itemCount: 100,
itemBuilder: ...,
),
),
],
)
이렇게 하면 Expanded가 화면의 남은 높이(전체 높이 - 50px)를 계산해서 ListView에게 딱 정해진 높이(Bounded Height)를 강제로 할당합니다.
ListView는 이제 "아, 나는 화면 끝까지만 그려지는구나"라고 깨닫고 정상적으로 스크롤을 만듭니다.
대부분의 경우 이게 정답입니다.
특정 높이만 주고 싶다면 SizedBox나 Container로 감싸면 됩니다.
Column(
children: [
Text("헤더"),
SizedBox(
height: 200, // 높이를 200으로 고정
child: ListView(...),
),
Text("푸터"),
],
)
하지만 이건 반응형 디자인(Responsive Design)에 좋지 않습니다. 태블릿에서는 너무 작아 보이거나, 작은 폰에서는 화면을 넘칠 수 있습니다.
제일 많이 오용되는 속성입니다.
ListView(
shrinkWrap: true, // ⚠️ 주의!
physics: NeverScrollableScrollPhysics(),
...
)
shrinkWrap: true는 ListView에게 "너 무한대라고 하지 말고, 자식들 크기 딱 맞춰서 줄어들어"라고 말합니다.
얼핏 좋아 보이지만, 치명적인 단점이 있습니다.
Lazy Loading이 동작하지 않습니다.
아이템이 100개면 100개를, 1,000개면 1,000개를 한 번에 다 렌더링해서 높이를 계산해야 합니다.
리스트가 길어질수록 뚝뚝 끊기는(Jank) 현상을 경험하게 될 것입니다. 웬만하면 쓰지 마세요.
만약 요구사항이 더 복잡하다면? "헤더도 같이 스크롤되어야 해요." "리스트가 올라갈 때 헤더가 자연스럽게 사라지거나 작아져야 해요(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,
),
),
],
)
Sliver는 "조각"이라는 뜻입니다. 화면을 조각조각 나눠서 사용자가 보고 있는 부분(Viewport)만 렌더링하는 고성능 스크롤 시스템입니다.
Column + ListView 조합은 스크롤 영역이 분리되지만(헤더는 고정, 리스트만 스크롤), CustomScrollView는 전체가 하나의 스크롤로 동작합니다. UX적으로 훨씬 자연스럽습니다.
Expanded 말고 다른 방법은 없을까요?
Flexible이나 LimitedBox도 대안이 될 수 있습니다.
LimitedBox:
"평소에는 무한대여도 되지만, 만약 부모가 무한대(Unbounded)를 주면, 너는 딱 300px까지만 커져라"라고 제한을 거는 위젯입니다.
shrinkWrap 대신 성능 손해 없이 "작을 땐 작게, 클 땐 제한된 크기까지" 동작하게 할 수 있는 유용한 위젯입니다.
Nested Scroll Views (중첩 스크롤 멸망전):
제발 SingleChildScrollView 안에 ListView를 넣고 shrinkWrap: true를 쓰지 마세요.
이건 "무한한 종이 위에 무한한 종이를 펴는 행위"입니다. Flutter 엔진이 비명을 지릅니다.
스크롤 안에 스크롤이 필요하다면, 반드시 CustomScrollView + Slivers 조합으로 가야 합니다.
탭 바(TabBar)가 있고, 각 탭 안에 리스트가 또 있는 구조(예: 인스타그램 프로필 페이지)는 어떻게 할까요? 전체 스크롤도 되면서, 내부 탭의 리스트도 스크롤되어야 합니다.
이때 NestedScrollView가 등장합니다.
NestedScrollView(
headerSliverBuilder: (context, _) => [
SliverAppBar(title: Text("프로필"), pinned: true),
],
body: TabBarView(
children: [
ListView(...), // 탭 1
ListView(...), // 탭 2
],
),
)
이건 Flutter 레이아웃의 끝판왕 레벨입니다. 하지만 원리는 같습니다. "누가 스크롤 제어권을 가질 것인가?"를 명확히 해주는 겁니다.
리스트에서 스크롤을 내린 상태에서 탭을 이동했다가 돌아오면, 스크롤이 맨 위로 초기화되는 현상이 있습니다. 사용자는 짜증을 냅니다. "아니, 나 어디까지 봤는지 까먹었잖아!"
이럴 때는 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를 쓸 때 거의 필수적인 테크닉입니다.
헤더는 고정되고 리스트만 스크롤?
-> Column + Expanded + ListView (가장 추천)
헤더도 리스트랑 같이 스크롤?
-> ListView에 Header를 첫 번째 아이템으로 넣거나,
-> CustomScrollView + SliverToBoxAdapter + SliverList (Best)
아이템이 10개 미만으로 아주 적음?
-> Column + SingleChildScrollView. 굳이 ListView 안 써도 됨.
-> 혹은 shrinkWrap: true (최후의 수단)
탭이 있고 복잡한 스크롤?
-> NestedScrollView
"Unbounded height" 에러는 여러분을 괴롭히려는 게 아닙니다. "무한대의 우주를 유한한 스마트폰 화면에 어떻게 구겨 넣을지 결정해달라"는 Flutter의 호소입니다.
Expanded라는 상자에 잘 담아주기만 하면 됩니다.