Flutter로 앱을 개발하다 보면 반드시 마주치는 UI 챌린지가 있습니다. 바로 '하단 버튼'의 배치 문제입니다. 단순해 보이지만, 이 문제는 많은 개발자들을 괴롭히는 까다로운 요구사항을 품고 있습니다. 예를 들어, 회원가입 폼이나 약관 동의 페이지를 생각해 봅시다.
- 시나리오 A: 콘텐츠가 짧아서 화면에 다 들어오는 경우, '다음' 또는 '동의' 버튼은 화면 맨 아래에 고정되어야 합니다. 사용자에게 다음 행동을 명확히 제시하고, 화면의 비어있는 공간을 효율적으로 활용하여 안정적인 레이아웃을 제공하기 위함입니다.
- 시나리오 B: 표시할 콘텐츠(약관 내용, 입력 폼 등)가 매우 길어서 스크롤이 필요한 경우, 이 버튼은 콘텐츠의 가장 마지막 부분, 즉 스크롤의 끝에 위치해야 합니다. 만약 이 경우에도 버튼이 화면 하단에 고정되어 있다면, 스크롤되는 콘텐츠를 가리게 되어 최악의 사용자 경험을 제공하게 됩니다.
이 두 가지 시나리오를 모두 만족시키는 UI를 어떻게 구현할 수 있을까요? 아래 이미지는 우리가 해결해야 할 문제를 시각적으로 명확하게 보여줍니다.

이 문제를 해결하기 위해 많은 개발자들이 Column
과 Spacer
, 혹은 Stack
과 Positioned
위젯을 사용하며 고군분투합니다. 하지만 이러한 접근 방식들은 한계가 명확하며, 결국에는 'RenderFlex overflowed' 에러를 마주하거나 스크롤 시 UI가 깨지는 현상을 경험하게 됩니다. 이 글에서는 Flutter의 강력한 스크롤 시스템인 Slivers를 활용하여 이 문제를 가장 우아하고 안정적으로 해결하는 방법을 심층적으로 다루겠습니다.
1. 왜 기존의 방식들은 실패하는가? (흔한 함정들)
완벽한 해결책을 알아보기 전에, 왜 일반적인 레이아웃 위젯들이 이 문제 앞에서 좌절하는지 이해하는 것이 중요합니다. 이는 Flutter의 레이아웃 메커니즘에 대한 이해를 돕고, Slivers의 필요성을 더욱 명확하게 만들어 줍니다.
가. 함정 1: Column
+ Expanded
/ Spacer
가장 먼저 떠올리는 방법은 Column
을 사용하는 것입니다. 콘텐츠 영역과 버튼 사이에 Spacer
나 Expanded
위젯을 넣어 남은 공간을 밀어내려는 시도입니다.
// 잘못된 예시: 스크롤이 필요할 때 문제가 발생합니다.
Scaffold(
body: Column(
children: [
// 상단 콘텐츠
Text('이용 약관'),
Text('... 매우 긴 내용 ...'),
// 버튼을 아래로 밀어내기 위한 시도
Spacer(), // 또는 Expanded(child: SizedBox()),
// 하단 버튼
ElevatedButton(
onPressed: () {},
child: Text('동의'),
),
],
),
);
이 코드는 시나리오 A (콘텐츠가 짧을 때)에서는 완벽하게 동작합니다. Spacer
가 남은 수직 공간을 모두 차지하여 버튼을 화면 맨 아래로 밀어내기 때문입니다. 하지만 시나리오 B (콘텐츠가 길 때)가 되면 재앙이 시작됩니다. Column
의 자식 위젯들의 전체 높이가 화면의 높이를 초과하게 되고, Flutter는 얼마나 큰 공간을 그려야 할지 계산할 수 없게 됩니다. 결국 악명 높은 'RenderFlex overflowed by ... pixels on the bottom' 에러를 뿜어내며 화면이 깨져버립니다. SingleChildScrollView
로 Column
을 감싸도 문제는 해결되지 않습니다. Expanded
나 Spacer
는 부모로부터 '한정된(finite)' 높이 제약을 받아야 동작하는데, SingleChildScrollView
는 자식에게 '무한한(infinite)' 높이를 허용하기 때문입니다.
나. 함정 2: Stack
+ Positioned
두 번째 시도는 Stack
을 이용하는 것입니다. 콘텐츠는 Stack
의 기본 자식으로 두고, 버튼은 Positioned
위젯으로 감싸 bottom: 0
속성을 주어 화면 하단에 고정하는 방법입니다.
// 잘못된 예시: 스크롤 시 콘텐츠를 가리는 문제가 발생합니다.
Scaffold(
body: Stack(
children: [
// 스크롤 가능한 콘텐츠
SingleChildScrollView(
child: Padding(
// 버튼이 가리는 영역만큼 하단에 공간 확보
padding: const EdgeInsets.only(bottom: 80.0),
child: Column(
children: [
Text('이용 약관'),
Text('... 매우 긴 내용 ...'),
],
),
),
),
// 화면 하단에 고정된 버튼
Positioned(
bottom: 16,
left: 16,
right: 16,
child: ElevatedButton(
onPressed: () {},
child: Text('동의'),
),
),
],
),
);
이 방법은 콘텐츠가 화면을 넘어가더라도 오버플로우 에러는 발생하지 않습니다. 하지만 근본적인 문제가 있습니다. 버튼은 항상 화면 하단에 '떠 있기' 때문에, 사용자가 콘텐츠를 스크롤하면 버튼이 마지막 내용을 가리게 됩니다. 이를 피하기 위해 SingleChildScrollView
의 자식에 padding
을 추가하는 꼼수를 사용하기도 하지만, 이는 버튼의 높이가 변경될 때마다 수동으로 값을 맞춰줘야 하는 매우 비효율적이고 불안정한 방법입니다. 또한, 우리가 원했던 시나리오 B (버튼이 콘텐츠의 맨 끝에 위치)를 전혀 만족시키지 못합니다.
이처럼 일반적인 'Box Protocol' 기반의 위젯들로는 두 가지 시나리오를 동시에, 그리고 우아하게 해결하기 어렵습니다. 이제 이 문제의 진정한 해결사인 Slivers의 세계로 들어가 보겠습니다.
2. 해법의 열쇠, Slivers와 CustomScrollView
Flutter에서 'Sliver'는 스크롤 가능한 영역의 일부를 의미하는 특별한 종류의 위젯입니다. 일반적인 위젯(Container
, Column
등)들이 사각형의 고정된 크기를 가지는 'Box' 렌더링 프로토콜을 따르는 반면, Sliver들은 스크롤 위치에 따라 자신의 형태와 크기를 동적으로 계산하는 'Sliver' 렌더링 프로토콜을 따릅니다. 이 덕분에 매우 유연하고 효율적인 스크롤 효과를 만들 수 있습니다.
이러한 Sliver들을 담는 그릇이 바로 CustomScrollView
입니다. ListView
나 GridView
가 단일 종류의 스크롤 규칙만 허용하는 것과 달리, CustomScrollView
는 다양한 종류의 Sliver들을 slivers
라는 리스트 안에 섞어서 배치할 수 있게 해주는 강력한 위젯입니다.
우리의 문제 해결 전략은 다음과 같습니다.
- 전체 화면을
CustomScrollView
로 감싼다. - 화면의 주요 콘텐츠(헤더, 본문 등)는
SliverToBoxAdapter
를 사용해 일반 위젯을 Sliver로 변환하여 배치한다. - 마지막으로, 하단 버튼 영역은
SliverFillRemaining
이라는 특별한 Sliver를 사용해 배치한다.
이 전략의 핵심은 바로 SliverFillRemaining
입니다. 이 위젯이 어떻게 마법을 부리는지 자세히 살펴보겠습니다.
SliverFillRemaining
: 남은 공간의 지배자
SliverFillRemaining
은 이름에서 알 수 있듯이, 뷰포트(viewport, 현재 화면에 보이는 영역)에서 다른 Sliver들이 차지하고 남은 '나머지 공간을 모두 채우는' 역할을 합니다. 이 위젯에는 우리의 운명을 결정할 매우 중요한 속성이 하나 있습니다: 바로 hasScrollBody
입니다.
hasScrollBody: true
(기본값): 이SliverFillRemaining
위젯이 주요 스크롤 대상임을 의미합니다. 이 경우, 위젯은 최소한 뷰포트 전체 높이만큼의 공간을 차지하려고 합니다. 만약 이전 Sliver들의 높이 합이 이미 뷰포트 높이를 초과했다면, 이 위젯은 그 뒤에 붙어서 또다시 뷰포트 높이만큼의 스크롤 영역을 만들어버립니다. 이는 우리가 원하는 동작이 아닙니다.hasScrollBody: false
: 이것이 바로 우리가 찾던 해답입니다. 이 설정을 하면,SliverFillRemaining
은 자신이 주요 스크롤 대상이 아니라고 선언합니다. 대신, 순수하게 남는 공간만 채우는 역할을 합니다.- 만약 이전 Sliver들을 배치하고도 화면에 공간이 남았다면(시나리오 A),
SliverFillRemaining
은 그 남은 공간을 모두 차지하여 자신의 자식(버튼)을 맨 아래에 배치합니다. - 만약 이전 Sliver들이 이미 화면을 가득 채우고 스크롤이 발생했다면(시나리오 B), '남은 공간'은 0이 됩니다. 따라서
SliverFillRemaining
은 어떠한 추가 공간도 차지하지 않고, 그냥 이전 Sliver 바로 다음에 자연스럽게 달라붙게 됩니다.
- 만약 이전 Sliver들을 배치하고도 화면에 공간이 남았다면(시나리오 A),
이 hasScrollBody: false
속성 하나로 우리는 두 가지 시나리오를 모두 처리할 수 있는 동적 레이아웃을 구현할 수 있게 됩니다. 아래 GIF는 이 원리가 실제로 어떻게 동작하는지 보여줍니다.

3. 전체 구현 코드 및 상세 분석
이제 이론을 바탕으로 실제 동작하는 전체 코드를 작성해 보겠습니다. 이 예제에서는 콘텐츠의 길이를 동적으로 조절할 수 있는 버튼을 추가하여 두 가지 시나리오를 직접 확인해 볼 수 있도록 구성했습니다.
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Dynamic Bottom Button Demo',
theme: ThemeData(
primarySwatch: Colors.indigo,
scaffoldBackgroundColor: Colors.grey[100],
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
home: const DynamicLayoutScreen(),
);
}
}
class DynamicLayoutScreen extends StatefulWidget {
const DynamicLayoutScreen({super.key});
@override
State createState() => _DynamicLayoutScreenState();
}
class _DynamicLayoutScreenState extends State {
// 콘텐츠의 길이를 조절하기 위한 리스트
final List<String> _contentItems = List.generate(5, (index) => 'Content Item ${index + 1}');
void _addContent() {
setState(() {
_contentItems.add('Content Item ${_contentItems.length + 1}');
});
}
void _removeContent() {
setState(() {
if (_contentItems.length > 1) {
_contentItems.removeLast();
}
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Sliver Magic'),
actions: [
IconButton(
icon: const Icon(Icons.remove),
onPressed: _removeContent,
tooltip: '콘텐츠 줄이기',
),
IconButton(
icon: const Icon(Icons.add),
onPressed: _addContent,
tooltip: '콘텐츠 늘리기',
),
],
),
// 핵심: CustomScrollView를 최상위 스크롤 위젯으로 사용
body: CustomScrollView(
// slivers 프로퍼티에 여러 종류의 Sliver들을 리스트로 전달
slivers: [
// 1. 상단 콘텐츠 영역
// 일반 위젯(Box protocol)을 Sliver로 변환해주는 어댑터
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'이용 약관',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
const Text(
'이 약관은... (중략) ... 여러분의 참여를 환영합니다. 화면 우측 상단의 +/- 버튼을 눌러 콘텐츠 길이를 조절해보세요.',
style: TextStyle(fontSize: 16, height: 1.5),
),
const SizedBox(height: 24),
// 동적으로 변하는 콘텐츠 리스트
..._contentItems.map((item) => Card(
margin: const EdgeInsets.symmetric(vertical: 8),
child: ListTile(
leading: const Icon(Icons.check_circle_outline, color: Colors.indigo),
title: Text(item),
subtitle: const Text('이것은 동적으로 추가된 콘텐츠입니다.'),
),
)).toList(),
],
),
),
),
// 2. 하단 버튼 영역 (마법이 일어나는 곳)
SliverFillRemaining(
// 이 옵션이 가장 중요합니다!
// true이면 이 sliver가 스크롤의 주 내용물(body)이 되어 항상 화면 전체를 채우려 하지만,
// false이면 단순히 '남은 공간'만 채우게 됩니다.
// 콘텐츠가 길어서 남은 공간이 없으면, 그냥 콘텐츠 바로 밑에 붙습니다.
hasScrollBody: false,
child: Container(
alignment: Alignment.bottomCenter,
padding: const EdgeInsets.all(16.0),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('버튼이 클릭되었습니다!')),
);
},
child: const Text('모두 확인하고 동의합니다'),
),
),
),
),
],
),
);
}
}
코드 분석
CustomScrollView
: 전체 바디를 이 위젯으로 감싸서, 우리가 Sliver들의 세상을 사용하겠다고 선언합니다. 일반적인SingleChildScrollView
나ListView
와는 달리, 다양한 종류의 스크롤 위젯을 조합할 수 있는 유연성을 제공합니다.slivers: [ ... ]
:CustomScrollView
의 핵심 프로퍼티입니다. 이 리스트 안에 우리가 화면에 표시할 Sliver 위젯들을 순서대로 넣어줍니다.SliverToBoxAdapter
: 우리의 첫 번째 자식입니다. 이름 그대로 일반적인 'Box' 위젯(Padding
,Column
,Text
,Card
등)을 Sliver 세상에서 사용할 수 있도록 변환해주는 '어댑터' 역할을 합니다. 우리는 스크롤되어야 할 모든 주요 콘텐츠를 이 위젯의 자식으로 배치했습니다.SliverFillRemaining
: 두 번째 자식이자, 이 글의 주인공입니다.hasScrollBody: false
: 다시 한번 강조하지만, 이 속성이 핵심입니다. 이로써SliverFillRemaining
은 공격적으로 공간을 차지하는 대신, 수동적으로 남은 공간만 채우는 역할을 수행하게 됩니다.child: Container(...)
:SliverFillRemaining
이 채운 공간 안에 실제 어떤 위젯을 표시할지 정의합니다. 우리는Container
를 사용해 버튼에 패딩을 주고,Alignment.bottomCenter
를 통해 버튼을 해당 영역의 맨 아래쪽에 배치했습니다.SizedBox
로 버튼의 너비를 화면 전체로 확장하여 일반적인 하단 버튼의 모습을 갖추었습니다.
이 코드를 실행하고 우측 상단의 '+' 버튼을 계속 눌러보세요. 처음에는 콘텐츠가 늘어나도 버튼이 항상 화면 맨 아래에 고정되어 있을 것입니다. 그러다 콘텐츠의 총 길이가 화면 높이를 초과하는 순간부터, 버튼은 더 이상 화면 하단에 고정되지 않고 스크롤을 따라 자연스럽게 위로 올라가며 콘텐츠의 맨 마지막에 위치하게 됩니다. 그 어떤 에러나 UI 깨짐 현상 없이, 완벽하게 두 가지 시나리오를 모두 만족시키는 것을 확인할 수 있습니다.
4. 심화 학습: 더 나아가기
SliverFillRemaining
을 이용한 기본 해법을 마스터했다면, 이제 실제 애플리케이션에서 마주할 수 있는 더 복잡한 시나리오에 대해 알아볼 시간입니다.
가. 스크롤에 따라 변하는 AppBar: SliverAppBar
CustomScrollView
의 진정한 힘은 SliverAppBar
와 함께 사용할 때 발휘됩니다. SliverAppBar
는 스크롤 시 사라지거나, 상단에 고정되거나, 혹은 작아지는 등 다채로운 효과를 줄 수 있는 AppBar입니다. 우리가 만든 하단 버튼 레이아웃과 완벽하게 호환됩니다.
Scaffold
의 `appBar` 프로퍼티를 사용하는 대신, CustomScrollView
의 slivers
리스트 가장 첫 번째에 SliverAppBar
를 추가하면 됩니다.
// ... Scaffold ...
body: CustomScrollView(
slivers: [
// AppBar를 Scaffold가 아닌 여기에 배치!
const SliverAppBar(
title: Text('Sliver Magic with AppBar'),
// 스크롤 시 AppBar가 상단에 고정됨
pinned: true,
// 스크롤을 위로 조금만 당겨도 AppBar가 나타남
floating: true,
// 확장되었을 때의 높이
expandedHeight: 200.0,
flexibleSpace: FlexibleSpaceBar(
title: Text('Flexible Space!'),
background: Image.network(
'https://images.unsplash.com/photo-1605204443024-05d4ba5b3e51?q=80&w=2070&auto=format&fit=crop',
fit: BoxFit.cover,
),
),
),
// 기존의 콘텐츠 Sliver
SliverToBoxAdapter(
// ...
),
// 기존의 버튼 Sliver
SliverFillRemaining(
hasScrollBody: false,
// ...
),
],
),
이렇게 하면 스크롤에 따라 멋지게 변하는 AppBar와 동적으로 위치가 변하는 하단 버튼이라는, 매우 세련되고 사용자 친화적인 UI를 동시에 구현할 수 있습니다.
나. 성능에 대한 고찰: 왜 Sliver가 효율적인가?
Sliver 기반의 스크롤 뷰(CustomScrollView
, ListView
, GridView
등)는 'lazy loading' 혹은 'lazy building' 방식을 사용합니다. 이는 화면에 실제로 보여지는 항목들만 렌더링하고 메모리에 올린다는 의미입니다. 예를 들어 1,000개의 아이템이 있는 리스트가 있다면, Column
안에 모든 아이템을 넣는 것은 1,000개의 위젯을 한 번에 생성하여 메모리 낭비와 성능 저하를 유발합니다. 하지만 SliverList
나 ListView
는 현재 화면에 보이는 10개 남짓의 아이템만 생성하고, 사용자가 스크롤하면 기존의 위젯을 재활용하거나 새로 생성하여 매우 효율적으로 동작합니다. 우리가 사용한 CustomScrollView
역시 이 원리를 따르므로, 아무리 긴 콘텐츠를 다루더라도 뛰어난 성능을 보장합니다.
결론: 이제 하단 버튼은 정복되었습니다
콘텐츠의 길이에 따라 화면 하단에 고정되거나, 혹은 콘텐츠의 끝에 위치해야 하는 동적 하단 버튼 UI는 Flutter 개발의 단골 과제입니다. Column
과 Spacer
, Stack
과 Positioned
를 이용한 접근은 특정 시나리오에서만 동작하거나 예기치 않은 오류를 발생시키는 함정이 많습니다.
하지만 CustomScrollView
를 기반으로, 주요 콘텐츠는 SliverToBoxAdapter
로 감싸고, 버튼 영역은 SliverFillRemaining(hasScrollBody: false)
를 사용하는 'Sliver' 기반의 접근법은 이 모든 문제를 한 번에 해결하는 가장 우아하고 강력하며 안정적인 솔루션입니다.
이 방법을 사용하면 다음과 같은 장점을 얻을 수 있습니다:
- 단일 코드로 두 가지 시나리오(짧은 콘텐츠/긴 콘텐츠)를 완벽하게 지원합니다.
- 'RenderFlex overflowed'와 같은 레이아웃 에러로부터 자유로워집니다.
SliverAppBar
등 다른 Sliver 위젯들과의 조합을 통해 확장성이 매우 뛰어납니다.- Sliver의 lazy-building 메커니즘 덕분에 성능적으로 매우 효율적입니다.
이제 복잡한 레이아웃 문제 앞에서 더 이상 좌절하지 마세요. Flutter의 Sliver 시스템을 적극적으로 활용하여 사용자에게는 완벽한 경험을, 개발자에게는 명쾌한 코드를 선사하시길 바랍니다. 이 글에서 다룬 원리와 코드가 여러분의 다음 Flutter 프로젝트에 훌륭한 무기가 되기를 기대합니다.
감사합니다 도움 받고 가요 :)
ReplyDelete