서론: 우리 모두가 마주했던 그 절망의 순간
Flutter 개발 여정을 시작한 분이라면, 혹은 이미 숙련된 개발자라 할지라도, 누구나 한 번쯤은 검은 화면 위 노란색 줄무늬와 함께 나타나는 'RenderFlex overflowed' 또는 'unbounded height' 에러 메시지를 마주하며 깊은 절망에 빠져본 경험이 있을 것입니다. 특히 UI 레이아웃을 구성할 때 가장 기본적이면서도 핵심적인 위젯인 Column
을 사용하다 보면 이 문제는 어김없이 우리를 찾아옵니다.
상단에는 프로필 이미지와 사용자 이름, 중간에는 스크롤이 가능한 긴 소개글, 그리고 하단에는 고정된 버튼들. 상상 속에서는 완벽하게 동작하는 이 아름다운 UI가 막상 코드로 구현되는 순간, 시뮬레이터는 무한한 공간으로 확장하려는 위젯들의 아우성으로 가득 차며 차가운 에러 메시지를 뿜어냅니다. 많은 개발자들이 이 문제의 원인을 Column
이나 Flexible
위젯의 잘못된 사용 탓으로 돌리곤 하지만, 진정한 원인은 더 깊은 곳, 바로 'Flutter의 레이아웃 렌더링 원리'에 대한 이해 부족에 있습니다.
이 글에서는 단순히 '이 코드를 복사해서 붙여넣으세요' 식의 임시방편적인 해결책을 제시하는 것을 넘어, 왜 Column
과 SingleChildScrollView
가 함께 사용될 때 충돌하는지, Flutter의 레이아웃 제약 조건(Constraints)은 어떻게 작동하는지 근본적인 원리를 파헤쳐 볼 것입니다. 이 원리를 이해한다면, 당신은 더 이상 예측 불가능한 레이아웃 에러 앞에서 좌절하지 않고, 어떤 복잡한 UI 요구사항에도 자신감 있게 대처할 수 있는 진정한 Flutter 레이아웃 마스터로 거듭날 수 있을 것입니다.
1. 문제의 근원: 제약 없는 공간(Unbounded Constraints)의 비극
Flutter의 레이아웃 시스템은 매우 단순하지만 강력한 규칙에 따라 작동합니다: "제약 조건은 위젯 트리 아래로(down), 크기는 위젯 트리 위로(up), 부모는 자식의 위치를 결정한다." 이 규칙을 이해하는 것이 모든 레이아웃 문제 해결의 시작입니다.
여기서 핵심은 '제약 조건(Constraints)'입니다. 부모 위젯은 자식 위젯에게 "너는 최소 이만큼, 최대 이만큼의 너비와 높이를 가질 수 있어"라는 제약 조건을 전달합니다. 자식 위젯은 이 제약 조건 내에서 자신의 크기를 결정하고, 그 결과를 다시 부모에게 알립니다.
이제 이 관점에서 Column
과 SingleChildScrollView
를 분석해 보겠습니다.
1.1. Column의 본성: "주어진 공간을 모두 차지하겠다!"
Column
위젯은 기본적으로 수직 방향으로 자식들을 배치합니다. 이때 mainAxisSize
속성의 기본값은 MainAxisSize.max
입니다. 이는 "부모가 허락하는 한, 수직 방향으로 가능한 모든 공간을 차지하겠다"는 의미입니다.
만약 Column
이 Scaffold
의 body
처럼 화면 전체라는 명확한 높이(Bounded Height)를 가진 부모 아래에 있다면 아무런 문제가 없습니다. Column
은 화면의 높이만큼 자신을 확장하고 그 안에서 자식들을 배치합니다.
하지만 만약 Column
의 부모가 "네가 원하는 만큼 커져도 좋아"라고 말하는, 즉 수직으로 무한한 공간(Unbounded Height)을 제공하는 위젯이라면 어떻게 될까요? Column
은 그 말 그대로 무한한 높이를 가지려고 시도하며, 이는 Flutter 렌더링 엔진을 혼란에 빠뜨립니다.
1.2. SingleChildScrollView의 딜레마: "얼마나 커질 수 있는지 알려줘!"
SingleChildScrollView
의 역할은 자신의 자식 위젯이 자신의 화면(뷰포트) 크기보다 클 경우 스크롤 기능을 제공하는 것입니다. 이 역할을 제대로 수행하기 위해 SingleChildScrollView
는 두 가지를 알아야 합니다.
- 자신의 크기 (뷰포트의 크기): 스크롤이 보이는 영역의 크기입니다.
- 자식의 크기: 전체 스크롤 가능한 콘텐츠의 크기입니다.
문제는 SingleChildScrollView
가 자신의 자식에게 제약 조건을 전달하는 방식에 있습니다. SingleChildScrollView
는 자식에게 "너비는 내 너비에 맞춰야 하지만, 높이는 네가 원하는 만큼 무한히 커져도 괜찮아"라는 제약 조건을 전달합니다. 스크롤이 가능해야 하므로, 자식의 높이에 제한을 두지 않는 것은 당연한 동작입니다.
1.3. 비극의 시작: Column과 SingleChildScrollView의 만남
이제 최악의 시나리오를 조합해 봅시다. Column
안에 SingleChildScrollView
를 넣는 경우입니다.
// 🚨 흔히 발생하는 에러 상황
Column(
children: [
Text('타이틀'),
// 이 SingleChildScrollView는 부모인 Column으로부터
// "네가 원하는 만큼 커져도 좋아" 라는 제약을 받는다.
// 하지만 SingleChildScrollView는 자신의 높이가 정해져야만
// 스크롤 영역을 계산할 수 있다. -> 충돌 발생!
SingleChildScrollView(
child: Column(
children: List.generate(50, (index) => Text('아이템 $index')),
),
),
Text('하단 텍스트'),
],
)
이 코드에서 무슨 일이 일어날까요?
- 최상위
Column
은 자신의 자식들을 배치하려고 합니다. - 첫 번째
Text
는 자신의 공간을 차지합니다. - 문제의
SingleChildScrollView
차례입니다.Column
은SingleChildScrollView
에게 "네가 차지할 수 있는 높이에는 제한이 없어"라고 말합니다. 왜냐하면Column
자신도 부모로부터 받은 공간 내에서 유연하게 크기를 조절하려고 하기 때문입니다. SingleChildScrollView
는 혼란에 빠집니다. "부모가 내 높이를 정해주지 않았네? 그럼 내 자식에게 물어봐야 하나? 아니, 내 역할은 자식이 아무리 커도 스크롤되게 하는 건데... 내 자신의 높이를 모르니 뷰포트를 그릴 수가 없어!"
이러한 딜레마가 바로 Vertical viewport was given unbounded height.
라는 유명한 에러 메시지의 실체입니다. 뷰포트(SingleChildScrollView
)가 무한한 높이 제약을 받았다는 뜻이죠.
특히 Column
의 자식으로 Flexible
이나 Expanded
를 사용했을 때 이 문제는 더욱 명확하게 드러납니다. Expanded
는 남은 공간을 '모두' 차지하라는 뜻인데, Column
자체가 무한한 높이를 가지려고 하는 상황에서는 '남은 공간'의 크기를 계산할 수 없기 때문입니다. 이것이 RenderFlex children have non-zero flex but incoming height constraints are unbounded.
에러의 원인입니다.
2. 해결책 완전 정복: 상황별 최적의 솔루션
원인을 명확히 이해했으니, 이제 해결은 간단합니다. 핵심은 단 하나, 어떤 방법으로든 SingleChildScrollView
에게 유한한(Bounded) 높이를 알려주는 것입니다. 상황에 따라 다양한 해결책을 적용할 수 있습니다.
솔루션 1: `Expanded` - 남은 공간을 모두 채워라 (가장 일반적인 해결책)
가장 흔하고 이상적인 시나리오는 '화면의 특정 부분(헤더, 푸터 등)을 제외한 나머지 공간을 스크롤 영역으로 만들고 싶을 때'입니다. 이럴 때는 `Expanded` 위젯이 완벽한 해결책입니다.
원리: Expanded
는 자신이 속한 `Flex` 위젯(Column
, Row
)의 주축(main axis) 방향으로 남은 공간을 모두 차지하도록 강제합니다. 중요한 것은 `Expanded`가 제대로 동작하려면 그 부모인 `Column` 자체가 유한한 높이를 가지고 있어야 한다는 점입니다. 일반적으로 `Scaffold`의 `body`에 직접配置된 `Column`은 화면 전체라는 유한한 높이를 가지므로, 이 조건이 충족됩니다.
잘못된 예시 (에러 발생):
Scaffold(
appBar: AppBar(title: Text('에러 발생 예시')),
body: Column(
children: [
Text('헤더 영역', style: TextStyle(fontSize: 24)),
// Column이 SingleChildScrollView에게 무한 높이를 허용하므로 에러 발생
SingleChildScrollView(
child: Column(
children: List.generate(50, (index) => ListTile(title: Text('아이템 $index'))),
),
),
Text('푸터 영역', style: TextStyle(fontSize: 24)),
],
),
);
올바른 예시 (`Expanded` 사용):
Scaffold(
appBar: AppBar(title: Text('Expanded 해결책')),
body: Column(
children: [
// 1. 고정된 위젯 (헤더)
Container(
height: 100,
color: Colors.blue[100],
alignment: Alignment.center,
child: Text('헤더 영역', style: TextStyle(fontSize: 24)),
),
// 2. 남은 모든 공간을 차지할 스크롤 영역
// Expanded가 SingleChildScrollView에게 유한한 높이를 강제한다.
Expanded(
child: SingleChildScrollView(
child: Column(
children: List.generate(50, (index) => ListTile(title: Text('아이템 $index'))),
),
),
),
// 3. 고정된 위젯 (푸터)
Container(
height: 80,
color: Colors.green[100],
alignment: Alignment.center,
child: Text('푸터 영역', style: TextStyle(fontSize: 24)),
),
],
),
);
위 코드에서 `Expanded`는 `Scaffold`의 `body`가 가진 전체 높이에서 헤더(100px)와 푸터(80px)의 높이를 뺀 '나머지 모든 공간'을 차지합니다. 이렇게 명확하고 유한한 높이 제약을 받은 `SingleChildScrollView`는 자신의 뷰포트 크기를 정확히 인지하고 정상적으로 스크롤 기능을 수행할 수 있게 됩니다.
솔루션 2: `SizedBox` 또는 `Container` - 명시적인 높이 지정
스크롤 영역이 남은 공간을 모두 채우는 것이 아니라, 특정 고정된 크기를 가져야 할 때가 있습니다. 예를 들어, 카드 UI 안에 200픽셀 높이의 스크롤 목록을 넣는 경우입니다.
원리: `SizedBox`나 `Container` 위젯으로 `SingleChildScrollView`를 감싸고 `height` 속성을 직접 지정해주는 가장 직관적이고 간단한 방법입니다. 이는 `SingleChildScrollView`에게 "너의 높이는 정확히 200픽셀이야" 라고 명시적으로 알려주는 것과 같습니다.
예시 코드:
Card(
margin: EdgeInsets.all(16.0),
child: Column(
// Column의 크기가 자식들에 의해 결정되도록 설정
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: Icon(Icons.album),
title: Text('앨범 제목'),
subtitle: Text('아티스트 이름'),
),
// 스크롤 뷰에 명시적인 높이를 부여
SizedBox(
height: 200, // 이 부분이 핵심!
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Text('매우 긴 앨범 소개글... ' * 100),
),
),
),
ButtonBar(
children: [
TextButton(onPressed: () {}, child: Text('듣기')),
TextButton(onPressed: () {}, child: Text('저장')),
],
),
],
),
)
이 방법은 매우 간단하지만, 고정된 값을 사용하기 때문에 다양한 화면 크기에 대응하기 어려울 수 있습니다. 이럴 때는 `MediaQuery`를 활용하여 화면 크기에 비례하는 높이를 지정할 수 있습니다.
// 화면 높이의 30%를 스크롤 영역으로 사용
SizedBox(
height: MediaQuery.of(context).size.height * 0.3,
child: SingleChildScrollView(
// ...
),
)
솔루션 3: `ConstrainedBox` - 유연한 높이 제약
`SizedBox`보다 더 유연한 제약을 주고 싶을 때 `ConstrainedBox`가 유용합니다. 최소 높이와 최대 높이를 지정하여 콘텐츠의 양에 따라 스크롤 뷰의 크기가 동적으로 변하도록 만들 수 있습니다.
원리: `ConstrainedBox`는 자식에게 `BoxConstraints`라는 제약 조건을 전달합니다. 이를 통해 "너의 높이는 최소 100픽셀, 최대 300픽셀이어야 해"와 같은 복합적인 규칙을 설정할 수 있습니다.
예시 코드:
// 콘텐츠가 적으면 그만큼만 커지고, 많아지면 최대 300까지만 커지고 스크롤됨
ConstrainedBox(
constraints: BoxConstraints(
minHeight: 100.0,
maxHeight: 300.0,
),
child: SingleChildScrollView(
child: Text(
'내용이 짧으면 이 텍스트만큼의 높이만 차지합니다.\n' * (isExpanded ? 20 : 2),
),
),
)
이 방법은 동적인 콘텐츠 길이에 따라 UI가 자연스럽게 반응해야 할 때 매우 효과적입니다.
솔루션 4: `shrinkWrap: true`와 `MainAxisSize.min` - 자식의 크기에 맞춰라
이 솔루션은 문제의 관점을 약간 바꿉니다. `SingleChildScrollView`가 부모인 `Column` 안에 있는 것이 아니라, Column
자체가 스크롤 가능한 콘텐츠인 경우에 주로 사용됩니다. 예를 들어, `ListView` 안에 `Column`을 넣는 경우입니다.
원리:
- `shrinkWrap: true` (`ListView`, `SingleChildScrollView` 등): 이 속성은 스크롤 뷰에게 "부모가 주는 공간을 다 차지하지 말고, 네 자식 콘텐츠가 필요한 만큼만 네 크기를 줄여라" 라고 지시합니다.
- `mainAxisSize: MainAxisSize.min` (`Column`): 이 속성은 `Column`에게 "부모가 허락하는 공간을 다 차지하지 말고, 네 자식들의 높이의 합만큼만 네 크기를 줄여라" 라고 지시합니다.
이 둘은 보통 함께 사용됩니다. ListView
안에 들어간 `Column`이 `MainAxisSize.max`(기본값)를 유지하면, `ListView`라는 무한한 높이를 제공하는 부모 안에서 `Column` 역시 무한한 높이를 가지려고 시도하여 에러가 발생합니다. 따라서 `Column`의 크기를 자식들에게 맞추도록 `MainAxisSize.min`으로 설정해야 합니다.
예시 코드 (잘못된 사용):
// ListView 안에 일반 Column을 사용하면 에러 발생
ListView(
children: [
Text("ListView의 아이템 1"),
Column( // MainAxisSize.max가 기본값이므로 무한 높이를 차지하려 함
children: [
Text("Column 내부 아이템 A"),
Text("Column 내부 아이템 B"),
],
),
Text("ListView의 아이템 3"),
],
);
예시 코드 (올바른 사용):
// ListView 안에 MainAxisSize.min을 적용한 Column 사용
ListView(
children: [
Text("ListView의 아이템 1"),
Column(
// 자식들의 크기만큼만 차지하도록 설정
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, // 정렬을 위해 추가
children: [
Text("Column 내부 아이템 A", style: TextStyle(fontWeight: FontWeight.bold)),
Text("Column 내부 아이템 B"),
FlutterLogo(),
],
),
Text("ListView의 아이템 3"),
],
);
이 방법은 스크롤 가능한 목록 내부에 부분적으로 `Column` 구조가 필요할 때 유용합니다.
솔루션 5: `IntrinsicHeight` - 최후의 수단 (성능 주의)
가끔은 `Column`의 자식들이 자신의 내재된(intrinsic) 크기에 따라 서로의 높이를 맞추어야 하는 복잡한 경우가 있습니다. 예를 들어, 두 개의 `Container`를 나란히 놓고, 둘 중 더 큰 `Container`의 높이에 다른 하나가 맞춰지도록 하고 싶을 때입니다.
원리: `IntrinsicHeight`는 자식 위젯 트리를 두 번 통과(two-pass)하는, 비용이 비싼 레이아웃을 수행합니다. 첫 번째 패스에서는 자식들이 가질 수 있는 '이상적인' 또는 '내재된' 높이를 계산만 하고 실제로 렌더링하지 않습니다. 두 번째 패스에서 이 계산된 높이를 기반으로 실제 제약 조건을 설정하고 자식들을 렌더링합니다.
경고: `IntrinsicHeight`는 매우 유용할 수 있지만, 레이아웃 계산을 두 번 수행하기 때문에 성능에 심각한 영향을 줄 수 있습니다. 특히 깊고 복잡한 위젯 트리나 스크롤 목록 안에서 사용하는 것은 피해야 합니다.
예시 코드:
// IntrinsicHeight를 사용하여 Row 안의 Column들이 높이를 맞추게 함
IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Column(
children: [
FlutterLogo(size: 50),
Text('짧은 텍스트'),
],
),
),
VerticalDivider(width: 20, thickness: 1, color: Colors.grey),
Expanded(
child: Column(
children: [
FlutterLogo(size: 80),
Text('여기는 조금 더 긴 텍스트가 들어갑니다. 높이가 자동으로 맞춰집니다.'),
],
),
),
],
),
)
이 위젯은 `Column`과 `SingleChildScrollView` 문제의 직접적인 해결책이라기보다는, 복잡한 `Column` 레이아웃 자체를 해결하는 방법 중 하나이며, 정말 다른 방법이 없을 때 최후의 수단으로 고려해야 합니다.
결론: 제약 조건을 지배하는 자가 레이아웃을 지배한다
Flutter에서 `Column`과 `SingleChildScrollView`를 함께 사용할 때 발생하는 무한 높이 관련 에러는 버그가 아니라, Flutter의 일관된 렌더링 규칙이 만들어내는 자연스러운 결과입니다. 이 문제의 핵심은 "스크롤이 가능한 위젯은 반드시 유한한(Bounded) 높이 제약을 받아야 한다"는 대원칙에 있습니다.
우리는 이 문제를 해결하기 위한 다양한 무기를 살펴보았습니다.
Expanded
: 남은 공간을 모두 채울 때 사용하는 최고의 선택.SizedBox
/Container
: 고정된 크기를 명시적으로 부여할 때.ConstrainedBox
: 최소/최대 크기로 유연한 제약을 걸 때.shrinkWrap
/MainAxisSize.min
: 스크롤 뷰 내부에서 위젯이 자신의 콘텐츠 크기에 맞게 줄어들어야 할 때.IntrinsicHeight
: 성능 저하를 감수하고 복잡한 높이 계산이 필요할 때 사용하는 최후의 수단.
이제 당신은 단순히 에러를 회피하는 개발자가 아닙니다. "왜" 이 에러가 발생하는지 근본 원리를 이해하고, 주어진 UI 요구사항과 맥락에 따라 가장 효율적이고 적절한 해결책을 선택할 수 있는 한 단계 높은 수준의 개발자로 성장했습니다. Flutter의 "제약 조건은 아래로, 크기는 위로"라는 황금률을 항상 기억하십시오. 이 원칙만 마음에 새긴다면, 앞으로 마주할 그 어떤 복잡한 레이아웃 문제도 자신감 있게 해결해 나갈 수 있을 것입니다.
0 개의 댓글:
Post a Comment