Flutter(플러터) 개발의 정수는 '상태(State)'의 변화에 따라 UI가 살아 움직이게 만드는 것입니다. 로그인 여부에 따라 환영 메시지가 바뀌고, 사용자의 입력에 따라 새로운 옵션이 나타나는 등 앱의 모든 상호작용은 상태 변화와 그에 따른 UI의 재구성 과정입니다. 이 동적인 UI 구현의 중심에는 '조건부 렌더링', 즉 특정 조건에 따라 위젯을 보여주거나, 숨기거나, 혹은 다른 것으로 교체하는 기술이 있습니다.
이제 막 Flutter의 세계에 발을 들인 개발자부터 숙련된 개발자까지, 우리 모두는 비슷한 질문에 부딪힙니다. "이 버튼은 관리자에게만 보여야 하는데, 가장 효율적인 방법은 뭘까?", "체크박스를 선택하면 나타나는 이 입력 필드, 체크를 해제해도 사용자가 입력한 데이터가 사라지지 않게 하려면 어떻게 해야 하지?", "수십 개의 아이템을 가진 리스트의 일부를 잠시 숨길 때, 앱이 버벅이지 않게 하려면 어떤 방법을 써야 할까?"
이 글은 바로 그 질문들에 대한 종합적인 해답을 제시하기 위해 작성되었습니다. 단순히 코드를 나열하는 것을 넘어, 각 방법이 Flutter의 내부 렌더링 파이프라인에 어떤 영향을 미치는지, 성능과 메모리, 그리고 가장 중요한 '상태 보존'의 관점에서 어떤 차이를 만들어내는지 풀스택 개발자의 시각으로 깊이 있게 파헤쳐 봅니다. 가장 기본적이고 강력한 Collection if 부터, 상태 보존의 마법사 Visibility 와 Offstage 위젯까지, 여러분의 다음 프로젝트를 한 단계 더 높은 수준으로 이끌어 줄 모든 지식을 담았습니다.
- 상황에 따라 위젯을 보여주고 숨기는 5가지 핵심 방법 완벽 마스터
- 각 방법의 내부 동작 원리와 성능상 장단점 심층 비교 분석
- '상태 소실' 문제를 해결하고 사용자 경험을 극대화하는 실전 노하우
- 복잡한 동적 UI를 위한 최적의 아키텍처 선택 가이드
1. 가장 현대적이고 직관적인 해답: Collection `if`
Dart 2.3 버전에서 언어 자체에 추가된 Collection `if`는 Flutter 커뮤니티에 큰 환영을 받았습니다. 위젯 트리 구조 안에서 마치 일반 프로그래밍 언어의 `if`문처럼 자연스럽게 조건부 로직을 작성할 수 있게 되었기 때문입니다. 이 기능은 단순히 코드를 보기 좋게 만드는 것을 넘어, Flutter의 렌더링 방식에 직접적인 영향을 미치는 매우 효율적인 방법입니다.
핵심 원리: 위젯 트리에 존재조차 하지 않음
Collection `if`의 가장 중요한 특징은 "조건이 거짓(false)이면 해당 위젯은 위젯 트리에 아예 포함되지 않는다"는 것입니다. 이는 단순히 화면에 보이지 않는 것(invisibility)과는 근본적으로 다릅니다. 위젯이 '존재하지 않는(non-existent)' 상태가 되는 것입니다.
이것이 왜 중요할까요? Flutter 앱의 UI는 위젯 트리(Widget Tree), 엘리먼트 트리(Element Tree), 렌더 트리(Render Tree)라는 3개의 트리로 관리됩니다.
- 위젯 트리: 개발자가 작성한 코드의 구조적 표현입니다. 불변(immutable) 객체입니다.
- 엘리먼트 트리: 위젯 트리를 기반으로 생성되며, 위젯의 '상태'를 관리하고 실제 렌더링 객체와의 연결고리 역할을 합니다.
- 렌더 트리: 실제 화면에 어떻게 그릴지에 대한 정보(사이즈, 위치, 페인팅)를 담고 있습니다.
Collection `if`에서 조건이 `false`가 되면, 해당 위젯은 위젯 트리 구성 단계에서부터 제외됩니다. 따라서 그에 상응하는 엘리먼트 객체도, 렌더 객체도 생성되지 않습니다. 이는 곧 위젯의 생성(instantiation), 상태 초기화(state initialization), 레이아웃 계산(layout), 페인팅(painting)에 필요한 모든 비용이 '0'이 됨을 의미합니다. 특히 수백 개의 자식을 가질 수 있는 ListView나 복잡한 애니메이션을 포함한 위젯을 조건부로 처리할 때, 이 방식은 엄청난 성능상의 이점을 가져다줍니다.
다양한 활용 예제
Collection `if`는 children 프로퍼티를 가진 모든 위젯(Column, Row, ListView, Stack, Wrap 등)에서 강력한 힘을 발휘합니다.
// 유저의 상태를 나타내는 enum과 변수
enum UserRole { guest, freeUser, premiumUser }
final _userRole = UserRole.premiumUser;
final bool _isLoggedIn = true;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Dynamic UI Master'),
actions: [
// 로그인 상태일 때만 알림 아이콘 표시
if (_isLoggedIn)
IconButton(icon: Icon(Icons.notifications), onPressed: () {}),
// 로그인 상태가 아닐 때만 로그인 버튼 표시
if (!_isLoggedIn)
TextButton(
child: Text('LOGIN', style: TextStyle(color: Colors.white)),
onPressed: () { /* 로그인 로직 */ },
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Welcome!', style: Theme.of(context).textTheme.headlineMedium),
SizedBox(height: 20),
// 사용자의 역할(Role)에 따라 다른 위젯을 보여주는 if-else if-else 구조
if (_userRole == UserRole.premiumUser) ...[
Text('Premium-only content:'),
_buildPremiumFeatureCard(),
_buildPremiumFeatureCard(),
] else if (_userRole == UserRole.freeUser)
Text('Upgrade to Premium to unlock more features!')
else
Text('Please log in or sign up to see content.'),
SizedBox(height: 30),
// Spread Operator(...)와 함께 사용하여 조건부 리스트 추가
// 에러 메시지가 있을 경우에만 에러 메시지 위젯들을 추가
..._buildErrorMessages(),
],
),
),
);
}
// 에러 메시지 리스트를 조건부로 반환하는 헬퍼 함수
List<Widget> _buildErrorMessages() {
final errors = ['Invalid email', 'Password too short']; // 예시 에러
if (errors.isNotEmpty) {
// map을 사용하여 각 에러 문자열을 위젯으로 변환
return errors.map((error) =>
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text('• $error', style: TextStyle(color: Colors.red)),
)
).toList();
}
return []; // 에러가 없으면 빈 리스트 반환
}
Widget _buildPremiumFeatureCard() {
return Card(
margin: const EdgeInsets.symmetric(vertical: 8.0),
child: ListTile(
leading: Icon(Icons.star, color: Colors.amber),
title: Text('Exclusive Premium Feature'),
subtitle: Text('Enjoy unlimited access.'),
),
);
}
장점과 명확한 한계
- 최상의 가독성: 코드가 선언적(declarative)이고 직관적입니다. 위젯 트리의 구조를 그대로 유지하면서 조건부 로직을 명확하게 표현할 수 있어 동료 개발자가 코드를 이해하기 쉽습니다.
- 최고의 성능: 조건이 `false`일 때 아무런 비용이 발생하지 않습니다. 불필요한 위젯의 빌드, 레이아웃, 페인트 과정을 원천적으로 차단하므로 가장 효율적입니다.
- 코드의 간결함: 삼항 연산자나 별도의 함수 호출 없이도 깔끔하게 로직을 구현할 수 있습니다. 특히 여러 위젯을 한 번에 제어할 때(
if (...) ...[]) 그 진가가 드러납니다.
- 상태 소실 (State Loss): 이것이 Collection `if`를 사용하면 안 되는 결정적인 경우입니다. 위젯이 트리에서 완전히 제거되었다가 조건이 `true`가 될 때 다시 '새롭게' 추가됩니다. 만약 해당 위젯이
StatefulWidget이고 내부에 중요한 상태(예:TextFormField의 입력 값,ListView의 스크롤 위치, 진행 중인 애니메이션 상태)를 가지고 있었다면, 그 상태는 예외 없이 소실됩니다. 사용자가 열심히 입력한 폼 데이터가 사라지는 경험은 앱의 신뢰도를 떨어뜨리는 주범이 됩니다.
Collection `if`는 위젯의 상태 보존이 전혀 중요하지 않거나, 상태가 없는(stateless) 위젯을 조건부로 렌더링할 때 가장 먼저 고려해야 할, 강력하고 효율적인 최고의 선택지입니다.
2. 고전적이지만 여전히 유용한: 삼항 연산자
삼항 연산자(condition ? expr1 : expr2)는 Dart 언어의 기본 기능으로, Collection `if`가 등장하기 전까지 Flutter에서 조건부 UI를 구현하는 표준과도 같은 방법이었습니다. 단일 위젯의 자리를 놓고 두 개의 위젯 중 하나를 선택해야 하는 상황에서 매우 간결하고 효과적입니다.
다양한 위치에서의 활용
삼항 연산자는 children 리스트뿐만 아니라, 위젯의 단일 child 프로퍼티나 색상, 패딩 값 등 위젯을 반환하지 않는 곳에서도 유연하게 사용할 수 있다는 장점이 있습니다.
bool _isLoading = false;
bool _isFavorite = false;
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: _isLoading ? Colors.grey[200] : Colors.white, // 배경색 변경
body: Center(
// 로딩 상태에 따라 전혀 다른 두 위젯을 교체
child: _isLoading
? CircularProgressIndicator()
: Text('Content Loaded Successfully!'),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
setState(() {
_isFavorite = !_isFavorite;
});
},
// 아이콘과 툴팁 메시지를 동시에 변경
tooltip: _isFavorite ? 'Remove from favorites' : 'Add to favorites',
child: Icon(
_isFavorite ? Icons.favorite : Icons.favorite_border,
color: _isFavorite ? Colors.red : Colors.grey,
),
),
);
}
'아무것도 보여주지 않기'와 `SizedBox.shrink()`의 비밀
삼항 연산자의 구조상 expr1과 expr2 자리에는 반드시 위젯이 와야 합니다. `null`은 허용되지 않습니다. 그렇다면 "조건이 참이면 위젯을 보여주고, 거짓이면 아무것도 보여주지 마"는 어떻게 구현할까요? 이때 등장하는 것이 바로 '공간을 차지하지 않는 위젯' 패턴입니다.
SizedBox.shrink(): Flutter SDK가 제공하는, 가로와 세로 크기가 0인const위젯입니다.const이기 때문에 컴파일 시점에 상수로 처리되어 단 하나의 인스턴스만 생성되고 재사용됩니다. 따라서 빌드, 레이아웃, 페인트 비용이 거의 들지 않아 '빈 공간'을 표현하는 가장 효율적인 방법입니다.Container(): 비어있는Container역시 크기가 0인 위젯이지만,const생성자가 아니므로 필요할 때마다 새로운 인스턴스가 생성됩니다. 성능상 미세한 차이가 있으므로, 특별한 목적(나중에 데코레이션 등을 추가할 가능성)이 없다면 항상SizedBox.shrink()를 사용하는 것이 좋습니다.
bool _showError = true;
// ...
Column(
children: [
Text('Please enter your credentials'),
// 에러가 있을 때만 에러 메시지를 보여주고, 아닐 때는 공간을 차지하지 않음
_showError
? Text('Invalid credentials', style: TextStyle(color: Colors.red))
: SizedBox.shrink(),
ElevatedButton(child: Text('Submit'), onPressed: () {}),
],
)
이 패턴은 매우 유용하지만, 코드가 길어지고 로직이 복잡해지면 가독성을 해칠 수 있습니다. 위 예제는 `if (_showError) Text(...)` 와 같이 Collection `if`로 훨씬 깔끔하게 표현할 수 있습니다.
삼항 연산자를 중첩해서 사용하면 코드의 가독성이 급격히 떨어져 유지보수를 어렵게 만듭니다. 다음 코드는 피해야 할 대표적인 안티패턴입니다.
// 끔찍한 예시: 절대 이렇게 작성하지 마세요!
Widget buildStatusWidget(Status status) {
return status == Status.loading
? CircularProgressIndicator()
: status == Status.error
? Icon(Icons.error)
: status == Status.success
? Icon(Icons.check_circle)
: SizedBox.shrink();
}
이런 복잡한 분기는 `switch` 문을 사용하는 빌더 메서드나 Collection `if-else if-else` 구조로 리팩토링하는 것이 훨씬 바람직합니다.
장점과 단점 요약
- 장점:
- 범용성: 위젯뿐만 아니라 값(value)을 조건부로 할당하는 모든 곳에서 사용할 수 있습니다.
- 간결함: 두 개의 위젯이나 값을 간단히 교체하는 상황에서는 가장 짧고 명료한 코드를 제공합니다.
- 단점:
- 가독성 저하: 로직이 조금만 복잡해지거나 중첩되면 코드를 해석하기 매우 어려워집니다.
- 유연성 부족: '아무것도 보여주지 않는' 경우를 위해
SizedBox.shrink()같은 보일러플레이트 코드가 필요하며, 여러 위젯을 한 번에 제어하기 어렵습니다. - 상태 소실: Collection `if`와 마찬가지로, 조건에 따라 위젯이 교체되므로 이전 위젯의 상태는 당연히 소실됩니다.
3. 상태 보존의 해결사: `Visibility` 위젯
드디어 이 글의 핵심 주제인 '상태 유지' 문제를 해결할 수 있는 첫 번째 주자, Visibility 위젯입니다. 이 위젯은 자식 위젯을 위젯 트리에서 제거하지 않고, 단지 화면에 보여줄지 말지만을 제어합니다. 이 간단한 차이가 엄청난 가능성을 열어줍니다. Visibility는 위젯을 숨길 때 상태, 크기, 애니메이션 등을 어떻게 처리할지 매우 세밀하게 제어할 수 있는 강력한 옵션들을 제공합니다.
동작 방식과 핵심 프로퍼티 깊이 보기
Visibility의 기본은 visible 프로퍼티입니다. 이 값이 `true`이면 자식이 보이고, `false`이면 보이지 않습니다. 하지만 진짜 힘은 `visible`이 `false`일 때의 동작을 결정하는 'maintain' 계열 프로퍼티에서 나옵니다.
| 프로퍼티 | 기본값 | 설명 |
|---|---|---|
visible |
(필수) | true이면 자식 위젯을 보여주고, false이면 숨깁니다. |
maintainState |
false |
(가장 중요) true로 설정하면, 위젯이 보이지 않아도 State 객체를 포함한 하위 위젯 트리가 메모리에 그대로 유지됩니다. 모든 상태 정보가 보존됩니다. |
maintainSize |
false |
true로 설정하면, 위젯이 보이지 않아도 원래 차지하던 공간(크기)을 그대로 유지합니다. 레이아웃이 출렁이는 현상을 방지합니다. |
maintainAnimation |
false |
true로 설정하면, 보이지 않는 동안에도 자식의 애니메이션이 계속 실행됩니다. |
replacement |
SizedBox.shrink() |
visible이 false일 때 보여줄 대체 위젯을 지정합니다. 기본값은 공간을 차지하지 않는 위젯입니다. |
실전 시나리오: 사용자 입력을 잃지 않는 UI
사용자가 '배송지와 주문자 정보 동일' 체크박스를 선택하면 배송지 입력 폼이 사라지는 흔한 UI를 상상해 봅시다. 만약 사용자가 체크를 해제했을 때, 이전에 입력했던 배송지 정보가 그대로 복원되어야 사용자 경험이 좋을 것입니다. 바로 이런 상황이 Visibility와 maintainState: true가 빛을 발하는 순간입니다.
class ShippingForm extends StatefulWidget {
@override
_ShippingFormState createState() => _ShippingFormState();
}
class _ShippingFormState extends State<ShippingForm> {
bool _isSameAsBilling = false;
final _nameController = TextEditingController(text: '홍길동');
final _addressController = TextEditingController(text: '서울시 강남구');
@override
void dispose() {
_nameController.dispose();
_addressController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
// ... (주문자 정보 입력 폼)
CheckboxListTile(
title: Text('배송지와 주문자 정보 동일'),
value: _isSameAsBilling,
onChanged: (value) {
setState(() {
_isSameAsBilling = value!;
});
},
),
SizedBox(height: 16),
// 배송지 입력 폼을 Visibility로 감싼다
Visibility(
// 체크박스가 선택되면(true) 숨겨지도록(visible: false) 설정
visible: !_isSameAsBilling,
// 핵심: 이 옵션 덕분에 숨겨져도 컨트롤러와 입력값이 모두 유지됨
maintainState: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('배송지 정보', style: Theme.of(context).textTheme.titleLarge),
TextFormField(
controller: _nameController,
decoration: InputDecoration(labelText: '받는 사람'),
),
TextFormField(
controller: _addressController,
decoration: InputDecoration(labelText: '주소'),
),
],
),
),
],
);
}
}
위 예제에서 체크박스를 껐다 켰다 반복해도 '홍길동', '서울시 강남구'라는 입력값은 절대 사라지지 않습니다. maintainState: true가 TextFormField의 State 객체와 그 안에 연결된 TextEditingController를 메모리에서 날려버리지 않고 꽉 붙잡고 있기 때문입니다.
성능에 대한 고찰
Visibility는 강력하지만 공짜는 아닙니다. `visible: false` 상태에서도 위젯은 여전히 위젯 트리에 존재합니다.
maintainState: false(기본값): 자식 위젯의 상태는 파괴(dispose)되지만, 레이아웃 계산을 위한 최소한의 정보는 유지될 수 있습니다. Collection `if`보다는 비용이 더 들지만 상태를 유지하는 것보다는 저렴합니다.maintainState: true: 자식의 전체 하위 트리와 상태가 메모리에 상주합니다. 이는 상당한 메모리 사용으로 이어질 수 있으며, 보이지 않는 위젯이 여전히 백그라운드에서 리빌드될 수 있습니다. 따라서 꼭 필요한 경우에만 사용해야 합니다.
상태 보존이 요구사항 1순위일 때,
Visibility는 가장 명확하고 효과적인 해결책입니다. 하지만 그 편리함의 대가로 발생하는 메모리와 성능 비용을 항상 인지하고 트레이드오프를 고려해야 합니다.
4. 렌더링 최적화의 전문가: `Offstage` 위젯
Offstage 위젯은 Visibility와 매우 유사해 보이지만, 더 단순하고 명확한 한 가지 목적에 특화되어 있습니다. 바로 "위젯의 상태와 크기를 항상 유지하되, 화면에 그리는 비용(painting)과 터치를 감지하는 비용(hit-testing)만 제거하는 것"입니다.
Offstage(offstage: true)는 개념적으로 `Visibility(visible: false, maintainState: true, maintainSize: true, maintainAnimation: true)`와 거의 동일하게 동작합니다. 즉, Offstage는 `Visibility`의 '모든 것을 유지하는' 프리셋(preset) 버전이라고 이해할 수 있습니다.
`Offstage`는 언제, 왜 사용할까?
Offstage의 주된 무대는 여러 개의 복잡한 화면(페이지)을 전환하는 UI입니다. 대표적인 예가 바로 BottomNavigationBar와 함께 사용되는 화면 전환입니다. 사용자가 하단 탭을 빠르게 누르며 A, B, C 탭을 오갈 때, 각 탭의 화면이 매번 새로 그려진다면 버벅거릴 수 있습니다. 또한 A 탭에서 스크롤을 한참 내린 후 B 탭에 갔다가 다시 A로 돌아왔을 때 스크롤 위치가 맨 위로 초기화된다면 매우 불편할 것입니다.
이 문제를 해결하기 위해 Flutter는 IndexedStack이라는 위젯을 제공하는데, 그 내부 구현의 핵심이 바로 Offstage입니다.
// BottomNavigationBar와 함께 IndexedStack을 사용하는 전형적인 구조
class MainPage extends StatefulWidget {
@override
_MainPageState createState() => _MainPageState();
}
class _MainPageState extends State<MainPage> {
int _currentIndex = 0;
final List<Widget> _pages = [
HomePage(), // 매우 복잡하고 데이터가 많은 페이지
SearchPage(), // 스크롤 위치가 중요한 페이지
ProfilePage(),// 사용자 입력 폼이 있는 페이지
];
@override
Widget build(BuildContext context) {
return Scaffold(
body: IndexedStack(
index: _currentIndex,
children: _pages,
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: _currentIndex,
onTap: (index) {
setState(() {
_currentIndex = index;
});
},
items: [
BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Search'),
BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Profile'),
],
),
);
}
}
위 코드에서 IndexedStack은 _currentIndex에 해당하는 페이지만 화면에 보여주고, 나머지 페이지들은 Offstage(offstage: true)로 감싸서 숨겨둡니다. 숨겨진 페이지들은 화면에 그려지지는 않지만, 위젯 트리와 상태는 메모리에 그대로 살아있습니다. 덕분에 사용자가 탭을 전환할 때마다 페이지를 새로 빌드할 필요 없이 즉시 보여줄 수 있고, 각 페이지의 스크롤 위치나 입력 데이터 같은 상태가 완벽하게 보존됩니다.
`Offstage`의 비용: 페인팅 비용은 절약되지만, 숨겨진 모든 자식 위젯들은 여전히 레이아웃 계산 단계에 포함됩니다. 즉, 화면에 보이지 않아도 공간을 차지하고 빌드 메서드는 호출될 수 있습니다. 따라서 수백 개의 페이지를 Offstage로 관리하는 것은 메모리 낭비일 수 있으며, 이런 경우에는 AutomaticKeepAliveClientMixin과 같은 더 고급 상태 관리 기법을 고려해야 합니다.
5. 코드의 구조화를 위한 접근: 빌더 메서드/위젯
조건부 렌더링 로직이 복잡해지면 build 메서드가 수백 줄에 달하는 '괴물'이 되기 십상입니다. 이는 코드의 가독성을 해치고 유지보수를 극도로 어렵게 만듭니다. 이럴 때 복잡한 UI 구성 로직을 별도의 함수(메서드)나 독립된 위젯 클래스로 분리하는 것은 매우 현명한 전략입니다.
enum PageState { loading, error, success, empty }
class DataPage extends StatelessWidget {
final PageState _state;
final List<String> _data;
DataPage({required PageState state, List<String> data = const []})
: _state = state, _data = data;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Builder Pattern')),
// build 메서드는 전체적인 구조만 보여주고,
// 복잡한 내용은 _buildBody 메서드에 위임한다.
body: _buildBody(context),
);
}
// 페이지 상태에 따라 적절한 위젯을 반환하는 빌더 메서드
Widget _buildBody(BuildContext context) {
switch (_state) {
case PageState.loading:
return Center(child: CircularProgressIndicator());
case PageState.error:
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, color: Colors.red, size: 48),
SizedBox(height: 16),
Text('Failed to load data.'),
ElevatedButton(child: Text('Retry'), onPressed: () {}),
],
),
);
case PageState.empty:
return Center(child: Text('No data available.'));
case PageState.success:
return ListView.builder(
itemCount: _data.length,
itemBuilder: (context, index) => ListTile(title: Text(_data[index])),
);
}
}
}
메서드 분리 vs. 위젯 클래스 추출
빌더 패턴을 적용할 때 'private 메서드'로 만들지, '새로운 위젯 클래스'로 만들지 고민될 수 있습니다. 선택의 기준은 '리빌드(rebuild) 범위'와 '재사용성'입니다.
- 빌더 메서드: 구현이 간단하고 빠릅니다. 하지만 상위 위젯(
DataPage)이setState등으로 리빌드되면, 이 메서드도 항상 함께 호출됩니다. 메서드 내부의 위젯들이 실제로 변경되지 않았더라도 불필요한 재계산이 발생할 수 있습니다. - 위젯 클래스 추출: 조금 더 많은 코드가 필요하지만, 성능 최적화와 재사용성 측면에서 훨씬 유리합니다. 특히
StatelessWidget으로 추출하고const생성자를 사용하면, 전달되는 인자가 변경되지 않는 한 Flutter는 해당 위젯의 리빌드를 건너뛰는 최적화를 수행합니다.
로직이 복잡하고, 해당 UI 부분이 부모의 잦은 리빌드에 영향을 받지 않게 하고 싶다면, 주저하지 말고 별도의 위젯 클래스로 추출하세요. 이는 Flutter의 성능 최적화 원칙에 부합하는 가장 올바른 방법입니다.
Flutter 성능 최적화 공식 문서최종 비교 및 선택 가이드: 어떤 것을 언제 써야 할까?
지금까지 살펴본 모든 방법을 한눈에 비교하고, 여러분의 상황에 맞는 최적의 선택을 내릴 수 있도록 핵심 특징을 표로 정리했습니다. 이 표는 여러분이 동적 UI를 구현할 때마다 참고할 수 있는 강력한 의사결정 도구가 될 것입니다.
| 방법 | 핵심 원리 | 상태 보존 | 공간 차지 (숨겼을 때) | 성능 비용 (숨겼을 때) | 가장 적합한 사용 사례 |
|---|---|---|---|---|---|
| Collection `if` | 위젯 트리에서 완전히 제거. 존재하지 않음. | 아니오 (X) | 아니오 (X) | 없음 (최고) | - 상태 보존이 전혀 필요 없는 모든 경우 - 리스트에 조건부로 아이템 추가/제거 - 가장 먼저 고려해야 할 기본 옵션 |
| 삼항 연산자 | 두 위젯 중 하나로 교체. SizedBox.shrink()로 숨김 효과. |
아니오 (X) | 아니오 (X) | 매우 낮음 | - 아이콘, 색상, 텍스트 등 두 가지 상태를 간단히 전환 - 위젯이 아닌 값을 조건부로 할당할 때 |
| `Visibility` | 트리에 유지하되 렌더링만 제어. 옵션으로 상태/공간 유지. | 옵션 (O)maintainState: true |
옵션 (O)maintainSize: true |
중간 (상태 유지 시 메모리 비용 발생) |
- TextFormField, ListView 등 상태 유지가 필수적인 위젯을 숨길 때- UI 레이아웃이 변하지 않게 하면서 위젯을 숨길 때 |
| `Offstage` | 트리에 유지하고, 항상 상태와 공간을 보존. 페인팅만 생략. | 예 (O) | 예 (O) | 낮음-중간 (페인트 비용은 없지만 레이아웃 비용은 있음) |
- IndexedStack, TabBarView처럼 여러 페이지를 전환하며 상태를 완벽히 보존해야 할 때- 복잡한 위젯을 미리 빌드해두고 빠르게 보여줄 때 |
| 빌더 메서드/위젯 | 복잡한 조건부 로직을 함수나 클래스로 분리하여 코드 구조화. | (내부에서 사용하는 방법에 따라 다름) | - build 메서드가 너무 복잡하고 길어질 때- `switch-case`나 복잡한 `if-else`로 UI를 구성할 때 |
||
결론: 현명한 개발자의 선택
Flutter에서 조건부 UI 렌더링은 단순히 '보이게 할까, 말까'의 문제가 아닙니다. 그것은 앱의 성능, 메모리 효율성, 코드의 유지보수성, 그리고 최종적으로 사용자 경험의 품질을 결정하는 중요한 아키텍처 설계의 일부입니다.
우리는 이제 각 도구의 성격과 그에 따른 대가를 명확히 이해하게 되었습니다.
- 상태가 필요 없다면, 주저 없이 가장 가볍고 빠른 Collection `if`를 사용하세요.
- 사용자의 소중한 데이터를 지켜야 한다면, `Visibility`와
maintainState: true의 조합을 기억하세요. - 여러 페이지의 상태를 완벽하게 보존하며 부드러운 전환을 만들고 싶다면, `Offstage`(혹은
IndexedStack)가 정답입니다.
최고의 개발자는 가장 화려한 기술을 쓰는 사람이 아니라, 주어진 문제와 제약 조건 속에서 가장 적절하고 효율적인 도구를 선택할 줄 아는 사람입니다. 오늘 배운 지식을 바탕으로 여러분의 코드에서 마주치는 다양한 시나리오에 자신감을 가지고 최적의 해결책을 적용해 보시기 바랍니다. Flutter DevTools의 'Widget Inspector'를 열어 실제로 위젯 트리가 어떻게 변하는지 눈으로 확인해 보는 것도 여러분의 이해를 한층 더 깊게 만들어 줄 것입니다.
Post a Comment