본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.
본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2025. 10. 27. · 82 Views
Flutter 커스텀 위젯 만들기 완벽 가이드
Flutter에서 재사용 가능한 커스텀 위젯을 만드는 방법을 배웁니다. StatelessWidget과 StatefulWidget의 차이부터 실전 팁까지, 실무에서 바로 활용할 수 있는 내용을 담았습니다.
목차
1. StatelessWidget 기초
시작하며
여러분이 Flutter 앱을 만들 때 같은 디자인의 버튼이나 카드를 여러 곳에서 사용해야 하는 상황을 겪어본 적 있나요? 매번 똑같은 코드를 복사-붙여넣기 하다 보면 나중에 디자인을 수정할 때 모든 곳을 일일이 찾아다니며 고쳐야 하는 번거로움이 생깁니다.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 코드 중복은 유지보수를 어렵게 만들고, 버그가 발생할 확률도 높아집니다.
한 곳을 고쳤는데 다른 곳은 깜빡하는 실수도 흔하죠. 바로 이럴 때 필요한 것이 StatelessWidget입니다.
한 번만 정의해두면 어디서든 재사용할 수 있고, 수정도 한 곳만 하면 모든 곳에 자동으로 반영됩니다.
개요
간단히 말해서, StatelessWidget은 상태가 변하지 않는 위젯을 만들 때 사용하는 기본 클래스입니다. StatelessWidget은 한 번 생성되면 화면에 표시되는 내용이 변하지 않는 정적인 UI 컴포넌트를 만들 때 사용합니다.
예를 들어, 앱 로고, 고정된 텍스트 라벨, 아이콘 버튼 같은 경우에 매우 유용합니다. 기존에는 같은 UI를 매번 작성했다면, 이제는 한 번만 정의하고 필요한 곳에서 재사용할 수 있습니다.
StatelessWidget의 핵심 특징은 불변성(immutability), 재사용성, 그리고 성능 최적화입니다. 상태가 없기 때문에 Flutter가 렌더링을 최적화하기 쉽고, 코드도 간결해집니다.
이러한 특징들이 앱의 성능과 유지보수성을 크게 향상시킵니다.
코드 예제
import 'package:flutter/material.dart';
// 커스텀 로고 위젯 정의
class AppLogo extends StatelessWidget {
const AppLogo({super.key});
@override
Widget build(BuildContext context) {
// 로고 UI 구성
return Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(20),
),
child: const Icon(
Icons.flutter_dash,
size: 60,
color: Colors.white,
),
);
}
}
설명
이것이 하는 일: StatelessWidget을 상속받아 재사용 가능한 커스텀 위젯을 만듭니다. 이 예제에서는 앱 로고를 위젯으로 만들어서 여러 화면에서 일관되게 사용할 수 있도록 합니다.
첫 번째로, class AppLogo extends StatelessWidget으로 위젯 클래스를 선언합니다. StatelessWidget을 상속받음으로써 Flutter가 이 클래스를 위젯으로 인식하게 됩니다.
const 생성자를 사용하면 Flutter가 위젯을 재사용할 수 있어 성능이 향상됩니다. 그 다음으로, build 메서드가 실행되면서 실제 UI를 구성합니다.
이 메서드는 위젯이 화면에 표시될 때마다 호출되며, 내부에서 Container와 Icon을 조합하여 둥근 모서리를 가진 파란색 배경의 로고를 만듭니다. BuildContext는 위젯 트리에서 현재 위젯의 위치 정보를 제공합니다.
마지막으로, 완성된 Widget이 반환되어 화면에 표시됩니다. 이제 여러분은 AppLogo()를 원하는 곳 어디든 배치할 수 있습니다.
여러분이 이 코드를 사용하면 로고 디자인을 한 곳에서만 관리하면 되므로 유지보수가 쉬워집니다. 또한 코드 재사용으로 중복이 줄어들고, 앱 전체에서 일관된 디자인을 유지할 수 있습니다.
실전 팁
💡 생성자에 const를 붙이면 Flutter가 위젯을 캐싱하여 메모리와 성능을 최적화합니다. 가능한 한 항상 사용하세요.
💡 위젯 이름은 대문자로 시작하고 명확하게 지어야 합니다. MyWidget보다는 AppLogo처럼 역할을 알 수 있게 작성하세요.
💡 StatelessWidget은 상태가 없으므로 변수를 final로 선언해야 합니다. 변경 가능한 상태가 필요하면 StatefulWidget을 사용하세요.
💡 build 메서드 안에서 무거운 연산을 피하세요. 이 메서드는 자주 호출될 수 있으므로 성능에 영향을 줍니다.
2. StatefulWidget 이해하기
시작하며
여러분이 카운터 앱을 만들거나 사용자 입력에 반응하는 UI를 구현할 때 어떻게 해야 할지 고민해본 적 있나요? StatelessWidget만으로는 버튼을 눌렀을 때 숫자가 증가하는 것처럼 화면이 동적으로 변하는 기능을 만들 수 없습니다.
이런 문제는 실제 앱 개발에서 매우 자주 발생합니다. 대부분의 앱은 사용자 인터랙션에 반응해야 하고, 데이터가 변경되면 UI도 함께 업데이트되어야 합니다.
정적인 위젯만으로는 이런 동적인 기능을 구현할 수 없습니다. 바로 이럴 때 필요한 것이 StatefulWidget입니다.
내부 상태를 가지고 있어서 데이터가 변경되면 자동으로 화면을 다시 그려줍니다.
개요
간단히 말해서, StatefulWidget은 시간에 따라 변할 수 있는 상태를 가진 위젯을 만들 때 사용하는 클래스입니다. StatefulWidget은 사용자 입력, 네트워크 응답, 타이머 등에 반응하여 UI가 변경되어야 할 때 사용합니다.
예를 들어, 좋아요 버튼의 토글, 입력 폼의 유효성 검사, 로딩 스피너 표시 같은 경우에 매우 유용합니다. 기존의 StatelessWidget은 한 번 그려지면 변경할 수 없었다면, 이제는 setState()를 호출하여 원하는 시점에 화면을 다시 그릴 수 있습니다.
StatefulWidget의 핵심 특징은 State 객체를 통한 상태 관리, setState()를 통한 UI 업데이트, 그리고 생명주기 메서드입니다. 위젯과 State가 분리되어 있어 위젯이 재생성되어도 상태는 유지됩니다.
이러한 특징들이 복잡한 인터랙티브 UI를 구현할 수 있게 해줍니다.
코드 예제
import 'package:flutter/material.dart';
// StatefulWidget 선언
class CounterWidget extends StatefulWidget {
const CounterWidget({super.key});
@override
State<CounterWidget> createState() => _CounterWidgetState();
}
// State 클래스 - 실제 상태를 관리
class _CounterWidgetState extends State<CounterWidget> {
int _counter = 0; // 변경 가능한 상태 변수
void _incrementCounter() {
setState(() {
// setState가 UI 업데이트를 트리거
_counter++;
});
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Text('Count: $_counter', style: const TextStyle(fontSize: 24)),
ElevatedButton(
onPressed: _incrementCounter,
child: const Text('Increase'),
),
],
);
}
}
설명
이것이 하는 일: StatefulWidget과 State 클래스를 함께 사용하여 사용자 인터랙션에 반응하는 동적인 UI를 만듭니다. 버튼을 누를 때마다 카운터가 증가하고 화면에 즉시 반영됩니다.
첫 번째로, CounterWidget 클래스가 StatefulWidget을 상속받아 상태를 가질 수 있는 위젯으로 선언됩니다. createState() 메서드는 이 위젯과 연결될 State 객체를 생성합니다.
위젯 자체는 불변이지만, State 객체는 변경 가능한 상태를 관리합니다. 그 다음으로, _CounterWidgetState 클래스가 실제 상태와 로직을 관리합니다.
_counter 변수가 현재 카운트 값을 저장하고, _incrementCounter 메서드가 버튼 클릭을 처리합니다. 언더스코어(_)로 시작하는 이름은 Dart에서 private을 의미합니다.
핵심은 setState() 호출입니다. 이 함수 안에서 상태 변수를 변경하면 Flutter가 자동으로 build 메서드를 다시 실행하여 UI를 업데이트합니다.
setState() 없이 변수만 변경하면 화면에 반영되지 않으니 주의해야 합니다. 마지막으로, build 메서드가 현재 상태를 반영한 UI를 구성합니다.
버튼을 누를 때마다 _incrementCounter가 호출되고, setState()가 트리거되어, build가 다시 실행되면서 증가된 숫자가 화면에 표시됩니다. 여러분이 이 코드를 사용하면 사용자 인터랙션에 즉각 반응하는 앱을 만들 수 있습니다.
상태 관리가 명확하고, UI 업데이트 로직이 간단하며, Flutter가 최적화를 자동으로 처리해줍니다.
실전 팁
💡 setState() 안에서는 상태 변경만 하고 무거운 작업은 피하세요. 네트워크 호출이나 복잡한 계산은 setState() 밖에서 먼저 완료하세요.
💡 여러 상태를 한 번에 업데이트할 때는 하나의 setState()로 묶으세요. 여러 번 호출하면 불필요한 리빌드가 발생합니다.
💡 State 클래스 이름 앞에 언더스코어(_)를 붙여 private으로 만드세요. 외부에서 직접 접근할 필요가 없습니다.
💡 initState()와 dispose() 생명주기 메서드를 활용하여 리소스를 관리하세요. 타이머나 스트림 구독은 반드시 dispose에서 해제해야 합니다.
💡 상태가 복잡해지면 Provider나 Riverpod 같은 상태 관리 라이브러리 사용을 고려하세요. StatefulWidget은 간단한 로컬 상태에만 적합합니다.
3. 위젯 속성 전달하기
시작하며
여러분이 커스텀 버튼을 만들었는데 매번 같은 텍스트만 표시된다면 얼마나 불편할까요? 실무에서는 같은 디자인이지만 텍스트나 색상 등이 다른 여러 버튼이 필요한 경우가 대부분입니다.
이런 문제는 재사용 가능한 컴포넌트를 만들 때 항상 마주치는 과제입니다. 하드코딩된 값으로는 유연성이 없고, 매번 새로운 위젯을 만들어야 한다면 재사용의 의미가 없어집니다.
바로 이럴 때 필요한 것이 Constructor를 통한 속성 전달입니다. 부모 위젯에서 자식 위젯으로 데이터를 전달하여 같은 위젯을 다양하게 활용할 수 있습니다.
개요
간단히 말해서, Constructor 매개변수를 통해 부모 위젯에서 자식 위젯으로 데이터를 전달하는 방식입니다. 위젯 속성 전달은 컴포넌트를 재사용 가능하고 유연하게 만드는 핵심 기법입니다.
예를 들어, 같은 카드 위젯을 사용하되 제목과 내용만 다르게 표시하거나, 동일한 버튼 스타일에 다른 라벨과 액션을 적용할 때 매우 유용합니다. 기존에는 각각의 경우를 위해 별도의 위젯을 만들어야 했다면, 이제는 하나의 위젯에 다른 속성만 전달하면 됩니다.
핵심 특징은 final 키워드로 불변성 보장, required로 필수 매개변수 지정, 그리고 타입 안정성입니다. Dart의 named parameter를 사용하면 가독성도 높아집니다.
이러한 특징들이 버그를 줄이고 코드의 명확성을 높여줍니다.
코드 예제
import 'package:flutter/material.dart';
// 속성을 받는 커스텀 카드 위젯
class InfoCard extends StatelessWidget {
// final로 불변성 보장
final String title;
final String content;
final IconData icon;
final Color color;
// required로 필수 매개변수 지정
const InfoCard({
super.key,
required this.title,
required this.content,
required this.icon,
this.color = Colors.blue, // 기본값 제공
});
@override
Widget build(BuildContext context) {
return Card(
color: color.withOpacity(0.1),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Icon(icon, size: 40, color: color),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
Text(content, style: const TextStyle(fontSize: 14)),
],
),
),
],
),
),
);
}
}
// 사용 예시
// InfoCard(
// title: 'Welcome',
// content: 'Flutter is awesome!',
// icon: Icons.star,
// color: Colors.orange,
// )
설명
이것이 하는 일: 재사용 가능한 정보 카드 위젯을 만들되, 제목, 내용, 아이콘, 색상을 외부에서 전달받아 다양하게 사용할 수 있도록 합니다. 첫 번째로, 클래스 상단에 final 키워드로 인스턴스 변수를 선언합니다.
final은 한 번 할당되면 변경할 수 없음을 의미하며, StatelessWidget의 불변성 원칙을 지킵니다. title, content, icon, color 네 가지 속성을 정의했습니다.
그 다음으로, Constructor에서 이 속성들을 받습니다. required 키워드는 필수 매개변수를 의미하며, 호출할 때 반드시 값을 제공해야 합니다.
반면 color는 기본값이 Colors.blue로 설정되어 있어 생략 가능합니다. named parameter 방식({} 사용)으로 가독성을 높였습니다.
핵심은 이렇게 전달받은 속성을 build 메서드에서 활용하는 것입니다. $title, $content 같은 문자열 보간으로 텍스트에 삽입하고, icon과 color는 위젯의 속성으로 직접 전달합니다.
이로써 같은 InfoCard 위젯이 완전히 다른 내용과 스타일로 표시될 수 있습니다. 마지막으로, 실제 사용할 때는 주석에 표시된 것처럼 named argument로 값을 전달합니다.
IDE의 자동완성 기능도 잘 작동하고, 어떤 값을 전달하는지 명확하게 보입니다. 여러분이 이 패턴을 사용하면 하나의 위젯 정의로 무한히 다양한 인스턴스를 만들 수 있습니다.
코드 중복이 사라지고, 수정도 한 곳만 하면 되며, 타입 안정성 덕분에 런타임 에러도 줄어듭니다.
실전 팁
💡 required는 필수 매개변수에만 사용하고, 선택적 매개변수는 기본값을 제공하세요. 사용자 경험이 좋아집니다.
💡 속성이 4개 이상이면 named parameter를 사용하세요. positional parameter는 순서를 기억해야 해서 실수하기 쉽습니다.
💡 @immutable 어노테이션을 클래스에 추가하면 실수로 final을 빼먹었을 때 경고를 받을 수 있습니다.
💡 복잡한 객체를 전달할 때는 불변 클래스를 만들어 사용하세요. Map이나 List를 직접 전달하면 예상치 못한 변경이 발생할 수 있습니다.
💡 생성자가 너무 많은 매개변수를 받으면 Builder 패턴이나 Configuration 객체 사용을 고려하세요.
4. 커스텀 버튼 위젯
시작하며
여러분이 앱 전체에서 동일한 스타일의 버튼을 사용해야 하는데, ElevatedButton을 쓸 때마다 스타일 코드를 반복해서 작성하고 있나요? 디자인이 변경되면 모든 버튼을 찾아다니며 수정해야 하는 악몽 같은 상황이 벌어집니다.
이런 문제는 UI 일관성과 유지보수 측면에서 심각한 이슈를 만듭니다. 한 화면의 버튼은 업데이트했는데 다른 화면은 깜빡하면 앱 전체가 어색하게 보이고, 사용자 경험도 나빠집니다.
바로 이럴 때 필요한 것이 커스텀 버튼 위젯입니다. 앱의 브랜드 아이덴티티를 반영한 버튼을 한 번만 정의하고, 필요한 곳에서 일관되게 사용할 수 있습니다.
개요
간단히 말해서, 커스텀 버튼 위젯은 앱 전체에서 재사용할 수 있는 일관된 스타일의 버튼 컴포넌트입니다. 커스텀 버튼은 브랜드 색상, 그림자, 패딩, 텍스트 스타일 등을 미리 정의해두어 개발자가 매번 스타일을 지정할 필요가 없게 만듭니다.
예를 들어, 주요 액션용 Primary 버튼, 보조 액션용 Secondary 버튼, 위험한 작업용 Danger 버튼 등을 만들어두면 코드가 훨씬 명확해집니다. 기존에는 버튼마다 스타일 코드를 복사-붙여넣기 했다면, 이제는 PrimaryButton(onPressed: ..., text: ...)처럼 간단하게 사용할 수 있습니다.
핵심 특징은 캡슐화된 스타일 로직, 명확한 인터페이스(필수 매개변수와 선택적 매개변수), 그리고 일관성입니다. 버튼의 동작(onPressed)과 표시 내용(text)만 외부에서 제어하고, 나머지는 위젯 내부에서 처리합니다.
이러한 특징들이 코드베이스를 깔끔하고 관리하기 쉽게 만들어줍니다.
코드 예제
import 'package:flutter/material.dart';
class PrimaryButton extends StatelessWidget {
final VoidCallback? onPressed; // 버튼 클릭 시 실행될 함수
final String text;
final bool isLoading; // 로딩 상태 표시
const PrimaryButton({
super.key,
required this.onPressed,
required this.text,
this.isLoading = false,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity, // 전체 너비 사용
height: 50,
child: ElevatedButton(
onPressed: isLoading ? null : onPressed, // 로딩 중엔 비활성화
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
: Text(
text,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
);
}
}
// 사용 예시
// PrimaryButton(
// onPressed: () => print('Button clicked'),
// text: 'Submit',
// isLoading: false,
// )
설명
이것이 하는 일: 앱 전체에서 사용할 수 있는 Primary 버튼을 만들되, 스타일은 내부에 캡슐화하고 동작과 텍스트만 외부에서 제어할 수 있게 합니다. 로딩 상태도 자동으로 처리하여 사용자 경험을 향상시킵니다.
첫 번째로, VoidCallback? 타입으로 onPressed를 선언합니다. VoidCallback은 매개변수와 반환값이 없는 함수 타입이고, ?는 null이 가능함을 의미합니다.
null이면 버튼이 비활성화되는 Flutter의 기본 동작을 활용할 수 있습니다. isLoading 플래그로 로딩 상태를 관리합니다.
그 다음으로, SizedBox로 버튼의 크기를 제어합니다. width: double.infinity로 부모 위젯의 전체 너비를 사용하고, 높이는 50으로 고정하여 일관된 크기를 유지합니다.
이렇게 하면 모든 화면에서 버튼이 똑같은 크기로 표시됩니다. 핵심은 ElevatedButton.styleFrom을 사용한 스타일 정의입니다.
backgroundColor, foregroundColor로 색상을 지정하고, elevation으로 그림자를 추가하며, RoundedRectangleBorder로 둥근 모서리를 만듭니다. 이 모든 스타일이 위젯 내부에 캡슐화되어 있어 외부에서는 신경 쓸 필요가 없습니다.
마지막으로, child 부분에서 조건부 렌더링을 사용합니다. isLoading이 true면 작은 CircularProgressIndicator를 표시하고, false면 전달받은 텍스트를 표시합니다.
로딩 중일 때는 onPressed: isLoading ? null : onPressed로 버튼을 자동으로 비활성화합니다.
여러분이 이 코드를 사용하면 앱 전체에서 일관된 버튼 디자인을 유지할 수 있습니다. 디자인 변경도 이 위젯 하나만 수정하면 되고, 로딩 상태 처리도 자동화되어 개발 속도가 빨라집니다.
또한 코드가 훨씬 읽기 쉬워지고 의도가 명확해집니다.
실전 팁
💡 버튼 종류별로 PrimaryButton, SecondaryButton, DangerButton 등을 만들어두면 코드만 봐도 버튼의 역할을 즉시 알 수 있습니다.
💡 onPressed가 null이면 자동으로 비활성화되는 Flutter의 기본 동작을 활용하세요. 별도의 enabled 플래그가 필요 없습니다.
💡 로딩 상태의 CircularProgressIndicator 크기를 버튼 높이보다 작게 만들어야 레이아웃이 깨지지 않습니다.
💡 색상은 하드코딩하지 말고 Theme.of(context)나 별도의 AppColors 클래스를 사용하는 것이 좋습니다. 다크모드 대응도 쉬워집니다.
💡 접근성을 위해 버튼의 최소 크기를 44x44 이상으로 유지하세요. Material Design 가이드라인을 참고하세요.
5. 복합 위젯 구성하기
시작하며
여러분이 사용자 프로필 카드를 만들 때 아바타, 이름, 이메일, 팔로우 버튼 등 여러 요소를 매번 조합해야 한다면 얼마나 번거로울까요? 각 화면마다 이 요소들을 다시 배치하다 보면 레이아웃이 조금씩 달라지고 일관성이 깨집니다.
이런 문제는 복잡한 UI를 구성할 때 항상 발생합니다. 단순한 위젯은 재사용이 쉽지만, 여러 위젯이 조합된 복잡한 컴포넌트는 관리하기 어렵습니다.
특히 레이아웃 로직이 중복되면 유지보수가 악몽이 됩니다. 바로 이럴 때 필요한 것이 복합 위젯 구성입니다.
여러 개의 기본 위젯을 조합하여 하나의 재사용 가능한 복합 위젯을 만들 수 있습니다.
개요
간단히 말해서, 복합 위젯은 여러 개의 작은 위젯을 조합하여 만든 더 복잡한 UI 컴포넌트입니다. 복합 위젯 구성은 컴포지션(Composition) 패턴을 활용하는 Flutter의 핵심 개념입니다.
예를 들어, 프로필 카드, 제품 목록 아이템, 댓글 위젯처럼 여러 요소가 조합된 컴포넌트를 만들 때 사용합니다. 각 부분은 독립적으로 동작하지만 하나의 위젯으로 묶여 있습니다.
기존에는 복잡한 UI를 매번 처음부터 조립했다면, 이제는 한 번 잘 조합해둔 위젯을 여러 곳에서 재사용할 수 있습니다. 핵심 특징은 계층적 구조, 관심사의 분리(각 부분이 독립적), 그리고 조합 가능성입니다.
Column, Row, Padding, Card 같은 기본 위젯을 레고 블록처럼 쌓아올려 원하는 디자인을 만듭니다. 이러한 특징들이 복잡한 UI도 이해하기 쉽고 수정하기 쉽게 만들어줍니다.
코드 예제
import 'package:flutter/material.dart';
// 사용자 모델
class User {
final String name;
final String email;
final String avatarUrl;
User({required this.name, required this.email, required this.avatarUrl});
}
// 복합 위젯: 프로필 카드
class UserProfileCard extends StatelessWidget {
final User user;
final VoidCallback? onFollowPressed;
final bool isFollowing;
const UserProfileCard({
super.key,
required this.user,
this.onFollowPressed,
this.isFollowing = false,
});
@override
Widget build(BuildContext context) {
return Card(
elevation: 4,
margin: const EdgeInsets.all(8),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
// 아바타 섹션
CircleAvatar(
radius: 30,
backgroundImage: NetworkImage(user.avatarUrl),
),
const SizedBox(width: 16),
// 정보 섹션
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
user.name,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
user.email,
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
],
),
),
// 액션 섹션
ElevatedButton(
onPressed: onFollowPressed,
style: ElevatedButton.styleFrom(
backgroundColor: isFollowing ? Colors.grey : Colors.blue,
),
child: Text(isFollowing ? 'Following' : 'Follow'),
),
],
),
),
);
}
}
설명
이것이 하는 일: CircleAvatar, Text, Button 등 여러 기본 위젯을 Row와 Column으로 조합하여 완성된 사용자 프로필 카드를 만듭니다. 하나의 위젯이지만 여러 부분으로 구성되어 있습니다.
첫 번째로, User 데이터 모델을 정의합니다. 실무에서는 복잡한 데이터를 다루므로 별도의 클래스로 만들어 타입 안정성을 확보합니다.
이렇게 하면 IDE의 자동완성도 잘 작동하고 오타로 인한 버그도 줄어듭니다. 그 다음으로, 위젯의 구조를 계층적으로 설계합니다.
최상위는 Card로 감싸서 카드 형태를 만들고, 내부에 Padding으로 여백을 추가합니다. Row로 가로 배치를 하고, 그 안에 아바타-정보-버튼 세 섹션을 배치합니다.
각 섹션은 명확히 구분되어 이해하기 쉽습니다. 핵심은 Expanded 위젯의 사용입니다.
가운데 정보 섹션을 Expanded로 감싸면 남은 공간을 모두 차지하여 버튼이 항상 오른쪽 끝에 위치합니다. Column의 crossAxisAlignment: CrossAxisAlignment.start로 텍스트를 왼쪽 정렬합니다.
마지막으로, 각 부분은 독립적으로 동작하면서도 하나의 유기적인 컴포넌트를 이룹니다. 아바타는 NetworkImage로 이미지를 로드하고, 정보 섹션은 이름과 이메일을 표시하며, 버튼은 팔로우 상태에 따라 색상과 텍스트가 변합니다.
여러분이 이 패턴을 사용하면 복잡한 UI도 논리적으로 구성할 수 있습니다. 각 부분을 독립적으로 수정할 수 있고, 전체 구조를 한눈에 파악할 수 있으며, 다른 화면에서도 일관되게 재사용할 수 있습니다.
실전 팁
💡 복잡한 위젯은 주석으로 섹션을 구분하세요. // 아바타 섹션처럼 표시하면 코드 가독성이 크게 향상됩니다.
💡 각 섹션을 별도의 private 메서드로 분리하는 것도 좋은 방법입니다. _buildAvatar(), _buildInfo() 같은 메서드를 만들면 build가 간결해집니다.
💡 SizedBox로 위젯 간 간격을 조절하세요. Padding보다 더 간결하고 의도가 명확합니다.
💡 Expanded와 Flexible의 차이를 이해하세요. Expanded는 남은 공간을 모두 차지하고, Flexible은 필요한 만큼만 차지합니다.
💡 복합 위젯이 너무 복잡해지면 더 작은 위젯으로 분리하세요. 하나의 위젯은 하나의 책임만 가져야 합니다(단일 책임 원칙).
6. 콜백 함수 활용하기
시작하며
여러분이 커스텀 위젯에서 버튼을 눌렀을 때 부모 위젯의 상태를 변경해야 하는데 어떻게 해야 할지 막막했던 적 있나요? 자식 위젯은 부모의 상태에 직접 접근할 수 없기 때문에 데이터를 위로 전달하는 방법이 필요합니다.
이런 문제는 컴포넌트 간 통신에서 항상 발생합니다. 데이터는 위에서 아래로 전달하기 쉽지만, 이벤트나 액션을 아래에서 위로 전달하는 것은 초보자들이 자주 헤매는 부분입니다.
바로 이럴 때 필요한 것이 콜백 함수입니다. 부모가 함수를 자식에게 전달하고, 자식은 이벤트 발생 시 그 함수를 호출하여 부모와 통신합니다.
개요
간단히 말해서, 콜백 함수는 부모 위젯이 자식 위젯에게 함수를 전달하여 자식이 이벤트를 부모에게 알리는 패턴입니다. 콜백 함수는 Flutter에서 위젯 간 통신의 기본 메커니즘입니다.
예를 들어, 사용자가 커스텀 체크박스를 클릭했을 때, 드롭다운에서 항목을 선택했을 때, 또는 입력 필드의 값이 변경되었을 때 부모에게 알려야 하는 모든 상황에서 사용됩니다. 기존에는 전역 변수나 복잡한 상태 관리를 사용해야 했다면, 이제는 간단한 함수 전달만으로 깔끔하게 해결할 수 있습니다.
핵심 특징은 역방향 데이터 흐름(자식에서 부모로), 타입 안정성(VoidCallback, ValueChanged<T> 등), 그리고 느슨한 결합입니다. 자식 위젯은 부모가 누구인지 몰라도 되고, 그저 전달받은 함수를 호출하기만 하면 됩니다.
이러한 특징들이 재사용 가능하고 테스트 가능한 컴포넌트를 만들게 해줍니다.
코드 예제
import 'package:flutter/material.dart';
// 커스텀 카운터 위젯 (자식)
class CustomCounter extends StatelessWidget {
final int count;
final VoidCallback onIncrement; // 매개변수 없는 콜백
final VoidCallback onDecrement;
final ValueChanged<int> onCountChanged; // 값을 전달하는 콜백
const CustomCounter({
super.key,
required this.count,
required this.onIncrement,
required this.onDecrement,
required this.onCountChanged,
});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Count: $count', style: const TextStyle(fontSize: 24)),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
onPressed: onDecrement, // 콜백 호출
icon: const Icon(Icons.remove),
),
const SizedBox(width: 16),
IconButton(
onPressed: onIncrement, // 콜백 호출
icon: const Icon(Icons.add),
),
const SizedBox(width: 16),
IconButton(
onPressed: () => onCountChanged(count * 2), // 값 전달
icon: const Icon(Icons.close_fullscreen),
tooltip: 'Double',
),
],
),
],
),
),
);
}
}
// 부모 위젯 사용 예시
// CustomCounter(
// count: _count,
// onIncrement: () => setState(() => _count++),
// onDecrement: () => setState(() => _count--),
// onCountChanged: (newValue) => setState(() => _count = newValue),
// )
설명
이것이 하는 일: 자식 위젯(CustomCounter)이 버튼 클릭 이벤트를 감지하면 부모가 전달한 콜백 함수를 호출하여 부모의 상태를 변경하도록 요청합니다. 첫 번째로, 콜백 함수의 타입을 정의합니다.
VoidCallback은 void Function()의 별칭으로 매개변수와 반환값이 없는 함수입니다. ValueChanged<int>는 void Function(int)의 별칭으로 int 값을 받아 처리하는 함수입니다.
이런 타입 정의로 컴파일 타임에 오류를 잡을 수 있습니다. 그 다음으로, 위젯의 상태는 부모가 관리합니다.
CustomCounter는 StatelessWidget이며 count 값을 표시만 할 뿐 직접 변경하지 않습니다. 이것이 "단방향 데이터 흐름"의 핵심입니다.
데이터는 위에서 아래로(부모→자식), 이벤트는 아래에서 위로(자식→부모) 흐릅니다. 핵심은 이벤트 발생 시 콜백을 호출하는 부분입니다.
onPressed: onIncrement처럼 직접 전달하거나, onPressed: () => onCountChanged(count * 2)처럼 익명 함수로 감싸서 값을 계산한 후 전달할 수 있습니다. 화살표 함수 =>는 한 줄짜리 함수를 간결하게 작성하는 Dart 문법입니다.
마지막으로, 부모 위젯에서는 콜백 함수를 정의하여 전달합니다. 주석의 예시처럼 setState()를 호출하여 상태를 변경하면 Flutter가 자동으로 위젯 트리를 다시 빌드하고, CustomCounter는 업데이트된 count 값을 받아 화면에 표시합니다.
여러분이 이 패턴을 사용하면 컴포넌트 간 명확한 책임 분리가 이루어집니다. 자식은 UI와 이벤트 감지에만 집중하고, 부모는 상태 관리와 비즈니스 로직을 담당합니다.
코드가 훨씬 이해하기 쉽고 테스트하기도 쉬워집니다.
실전 팁
💡 콜백이 null일 수 있으면 VoidCallback?처럼 nullable로 만들고, 호출 전에 onPressed: callback != null ? callback : null 또는 callback?.call()로 체크하세요.
💡 복잡한 이벤트는 커스텀 클래스를 만들어 전달하세요. 예: ValueChanged<CounterEvent>처럼 이벤트 객체를 사용하면 확장성이 좋습니다.
💡 콜백 함수 이름은 on으로 시작하는 것이 Flutter의 관례입니다: onTap, onChanged, onSubmit 등.
💡 성능이 중요한 경우 콜백 함수를 StatefulWidget의 메서드로 만들어 재사용하세요. 익명 함수는 매번 새로 생성되어 불필요한 리빌드를 유발할 수 있습니다.
💡 너무 많은 콜백이 필요하면 Provider, Bloc 같은 상태 관리 라이브러리 사용을 고려하세요.
7. 테마 적용하기
시작하며
여러분이 앱의 모든 버튼, 텍스트, 카드에 일관된 색상과 스타일을 적용하려고 하는데 각 위젯마다 색상 코드를 하드코딩하고 있나요? 브랜드 색상이 바뀌거나 다크모드를 추가해야 할 때 수백 개의 위젯을 일일이 수정해야 하는 상황이 됩니다.
이런 문제는 디자인 시스템 없이 개발할 때 필연적으로 발생합니다. 색상, 폰트, 간격 등이 일관되지 않으면 앱이 어수선해 보이고, 디자인 변경 시 작업량이 기하급수적으로 늘어납니다.
바로 이럴 때 필요한 것이 Theme입니다. 앱 전체의 디자인을 한 곳에서 정의하고, 모든 위젯이 자동으로 그 스타일을 따르도록 만들 수 있습니다.
개요
간단히 말해서, Theme은 앱 전체의 시각적 스타일을 중앙에서 관리하는 Flutter의 디자인 시스템입니다. Theme을 사용하면 색상, 폰트, 버튼 스타일, 카드 스타일 등 모든 시각적 요소를 MaterialApp 레벨에서 정의할 수 있습니다.
예를 들어, 주요 브랜드 색상, 강조 색상, 배경색, 텍스트 스타일 등을 설정해두면 모든 하위 위젯이 자동으로 상속받습니다. 기존에는 각 위젯에서 color: Colors.blue처럼 직접 지정했다면, 이제는 color: Theme.of(context).primaryColor로 테마에서 가져와 사용할 수 있습니다.
핵심 특징은 중앙 집중식 관리, 자동 상속, 그리고 다크모드 지원입니다. ThemeData로 전체 테마를 정의하고, Theme.of(context)로 어디서든 접근할 수 있으며, 라이트/다크 테마를 쉽게 전환할 수 있습니다.
이러한 특징들이 일관된 디자인과 쉬운 유지보수를 가능하게 합니다.
코드 예제
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: 'Theme Demo',
// 라이트 테마 정의
theme: ThemeData(
primarySwatch: Colors.blue,
primaryColor: const Color(0xFF2196F3),
scaffoldBackgroundColor: Colors.white,
cardTheme: CardTheme(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
textTheme: const TextTheme(
headlineLarge: TextStyle(fontSize: 32, fontWeight: FontWeight.bold),
bodyLarge: TextStyle(fontSize: 16, color: Colors.black87),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
// 다크 테마 정의
darkTheme: ThemeData.dark().copyWith(
primaryColor: const Color(0xFF1976D2),
),
themeMode: ThemeMode.system, // 시스템 설정 따름
home: const HomePage(),
);
}
}
// 테마 사용 예시
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
appBar: AppBar(
title: const Text('Theme Example'),
backgroundColor: Theme.of(context).primaryColor,
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Hello Theme!',
style: Theme.of(context).textTheme.headlineLarge,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {},
child: const Text('Themed Button'),
),
],
),
),
);
}
}
설명
이것이 하는 일: MaterialApp에서 ThemeData를 정의하여 앱 전체의 디자인 시스템을 구축하고, 하위 위젯들이 Theme.of(context)로 스타일을 가져와 사용하도록 합니다. 첫 번째로, MaterialApp의 theme 속성에 ThemeData를 정의합니다.
primarySwatch는 Material Design의 색상 팔레트를 설정하고, primaryColor는 주요 브랜드 색상입니다. scaffoldBackgroundColor는 기본 배경색을 지정합니다.
이 값들이 앱 전체의 기본 색상 스킴을 결정합니다. 그 다음으로, 각 위젯 타입별 테마를 설정합니다.
cardTheme으로 모든 Card 위젯의 기본 스타일을 정의하고, textTheme으로 텍스트 스타일을, elevatedButtonTheme으로 버튼 스타일을 설정합니다. 이렇게 하면 개별 위젯에서 스타일을 지정하지 않아도 자동으로 테마가 적용됩니다.
핵심은 Theme.of(context) 메서드입니다. 이것은 위젯 트리를 따라 올라가며 가장 가까운 Theme을 찾아 반환합니다.
Theme.of(context).primaryColor처럼 사용하면 테마에 정의된 색상을 가져올 수 있고, Theme.of(context).textTheme.headlineLarge로 텍스트 스타일도 가져올 수 있습니다. 마지막으로, 다크모드 지원은 darkTheme 속성으로 간단히 구현됩니다.
ThemeData.dark()로 기본 다크 테마를 생성하고 copyWith로 원하는 부분만 수정합니다. themeMode: ThemeMode.system으로 설정하면 사용자의 시스템 설정을 자동으로 따릅니다.
여러분이 이 패턴을 사용하면 디자인 변경이 매우 쉬워집니다. 테마 정의 한 곳만 수정하면 앱 전체가 업데이트되고, 다크모드도 별도의 복잡한 로직 없이 지원할 수 있습니다.
일관된 디자인으로 전문적인 앱을 만들 수 있습니다.
실전 팁
💡 커스텀 색상은 extensions를 사용하세요: extension CustomColors on ThemeData { Color get success => Colors.green; } 이렇게 하면 Theme.of(context).success로 접근 가능합니다.
💡 복잡한 테마는 별도 파일로 분리하세요. app_theme.dart에 class AppTheme { static ThemeData lightTheme() {...} }처럼 만들면 관리가 쉽습니다.
💡 ThemeMode.system 대신 ThemeMode.light나 ThemeMode.dark로 고정할 수도 있고, 사용자가 앱 내에서 선택하게 만들 수도 있습니다.
💡 Material 3를 사용하려면 useMaterial3: true를 ThemeData에 추가하세요. 더 현대적인 디자인을 적용할 수 있습니다.
💡 폰트는 fontFamily 속성으로 커스텀 폰트를 지정할 수 있습니다. pubspec.yaml에 폰트를 등록하고 테마에서 참조하세요.
8. 조건부 렌더링
시작하며
여러분이 로딩 중일 때는 스피너를, 데이터가 있으면 리스트를, 에러가 발생하면 에러 메시지를 보여주고 싶은데 어떻게 구현해야 할지 고민해본 적 있나요? 앱의 상태에 따라 다른 UI를 표시하는 것은 매우 흔한 요구사항입니다.
이런 문제는 거의 모든 실무 앱에서 발생합니다. 네트워크 요청, 사용자 권한 확인, 빈 데이터 처리 등 상황에 따라 적절한 UI를 보여주는 것은 좋은 사용자 경험의 핵심입니다.
바로 이럴 때 필요한 것이 조건부 렌더링입니다. Dart의 조건문과 삼항 연산자를 활용하여 상태에 따라 다른 위젯을 반환할 수 있습니다.
개요
간단히 말해서, 조건부 렌더링은 상태나 조건에 따라 다른 위젯을 화면에 표시하는 기법입니다. 조건부 렌더링은 동적인 UI를 만드는 필수 기법입니다.
예를 들어, 로그인 여부에 따라 다른 화면 표시, 데이터 로딩 상태 표시, 에러 핸들링, 빈 리스트 처리 등 거의 모든 상황에서 사용됩니다. 기존에는 모든 위젯을 렌더링하고 CSS의 display 속성으로 숨기는 방식이었다면, Flutter에서는 필요한 위젯만 생성하여 성능도 최적화됩니다.
핵심 특징은 삼항 연산자(condition ? true : false), if 표현식, switch 표현식의 활용입니다.
build 메서드 내에서 조건에 따라 완전히 다른 위젯 트리를 반환할 수 있고, 렌더링되지 않는 위젯은 메모리도 사용하지 않습니다. 이러한 특징들이 효율적이고 반응적인 UI를 만들게 해줍니다.
코드 예제
import 'package:flutter/material.dart';
enum LoadingState { loading, success, error, empty }
class DataDisplay extends StatefulWidget {
const DataDisplay({super.key});
@override
State<DataDisplay> createState() => _DataDisplayState();
}
class _DataDisplayState extends State<DataDisplay> {
LoadingState _state = LoadingState.loading;
List<String> _data = [];
String _errorMessage = '';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Conditional Rendering')),
body: _buildBody(), // 상태에 따른 위젯 반환
);
}
// 조건부 렌더링 로직
Widget _buildBody() {
// switch 표현식 사용 (Dart 3.0+)
return switch (_state) {
LoadingState.loading => const Center(
child: CircularProgressIndicator(),
),
LoadingState.error => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error, size: 64, color: Colors.red),
const SizedBox(height: 16),
Text(_errorMessage),
ElevatedButton(
onPressed: _retry,
child: const Text('Retry'),
),
],
),
),
LoadingState.empty => const Center(
child: Text('No data available'),
),
LoadingState.success => ListView.builder(
itemCount: _data.length,
itemBuilder: (context, index) => ListTile(
title: Text(_data[index]),
),
),
};
}
// 삼항 연산자 예시
Widget _buildStatusIcon() {
return Icon(
_state == LoadingState.success ? Icons.check_circle : Icons.error,
color: _state == LoadingState.success ? Colors.green : Colors.red,
);
}
// if 표현식 예시 (컬렉션 내부)
Widget _buildFooter() {
return Column(
children: [
const Text('Footer'),
if (_data.length > 10) const Text('Showing top 10 items'),
if (_state == LoadingState.error)
TextButton(
onPressed: _retry,
child: const Text('Try again'),
),
],
);
}
void _retry() {
setState(() => _state = LoadingState.loading);
}
}
설명
이것이 하는 일: 앱의 현재 상태(로딩, 성공, 에러, 빈 데이터)를 판단하여 각 상황에 맞는 UI를 동적으로 렌더링합니다. 첫 번째로, enum LoadingState로 가능한 상태를 명확히 정의합니다.
문자열이나 숫자 대신 enum을 사용하면 오타를 방지하고 가능한 값들을 명확히 알 수 있습니다. IDE의 자동완성도 잘 작동합니다.
그 다음으로, _buildBody() 메서드에서 switch 표현식을 사용합니다. Dart 3.0 이상에서는 switch를 표현식으로 사용할 수 있어 각 경우에 대해 위젯을 직접 반환할 수 있습니다.
기존의 if-else 체인보다 훨씬 깔끔하고 가독성이 좋습니다. 핵심은 각 상태에 맞는 전혀 다른 위젯을 반환한다는 점입니다.
로딩 중에는 CircularProgressIndicator, 에러 발생 시에는 에러 아이콘과 재시도 버튼, 빈 데이터는 안내 메시지, 성공 시에는 ListView를 렌더링합니다. 조건에 맞지 않는 위젯은 아예 생성되지 않아 메모리 효율적입니다.
추가로, 삼항 연산자(condition ? true : false)는 간단한 조건에 적합합니다.
_buildStatusIcon()처럼 아이콘이나 색상만 바꿀 때 사용하면 코드가 간결해집니다. 컬렉션 내부에서는 if 표현식을 사용할 수 있어 _buildFooter()처럼 조건부로 자식 위젯을 추가할 수 있습니다.
마지막으로, setState()로 상태를 변경하면 build가 다시 실행되어 새로운 조건에 맞는 위젯이 렌더링됩니다. 이것이 Flutter의 선언적 UI 패러다임의 핵심입니다.
여러분이 이 패턴을 사용하면 복잡한 상태 전환도 명확하게 표현할 수 있습니다. 모든 가능한 상태를 다루므로 엣지 케이스도 놓치지 않고, 코드 리뷰 시에도 로직을 쉽게 이해할 수 있습니다.
실전 팁
💡 복잡한 조건문은 별도의 메서드로 분리하세요. build 메서드가 간결해지고 테스트도 쉬워집니다.
💡 null 처리는 ?? 연산자를 활용하세요: data ?? 'Default value'처럼 간결하게 작성할 수 있습니다.
💡 여러 조건을 체크할 때는 switch 표현식이 if-else보다 성능과 가독성 면에서 유리합니다.
💡 위젯 리스트에 조건부로 아이템을 추가할 때는 ...? spread operator를 사용하세요: [Text('Always'), ...?condition ? [Text('Conditional')] : null]
💡 Visibility 위젯으로 숨기는 것보다 조건부로 렌더링하지 않는 것이 성능에 좋습니다. 단, 애니메이션이 필요하면 Visibility나 AnimatedSwitcher를 사용하세요.
댓글 (0)
함께 보면 좋은 카드 뉴스
Riverpod 3.0 쇼핑 앱 종합 프로젝트 완벽 가이드
Flutter와 Riverpod 3.0을 활용한 실무 수준의 쇼핑 앱 개발 과정을 단계별로 학습합니다. 상품 목록, 장바구니, 주문, 인증, 검색 기능까지 모든 핵심 기능을 구현하며 상태 관리의 실전 노하우를 익힙니다.
Riverpod 3.0 Retry 자동 재시도 완벽 가이드
Riverpod 3.0에 새로 추가된 Retry 기능을 활용하여 네트워크 오류나 일시적인 실패 상황에서 자동으로 재시도하는 방법을 배웁니다. 초급 개발자도 쉽게 따라할 수 있도록 실무 예제와 함께 설명합니다.
Riverpod 3.0 requireValue로 Provider 결합하기
Riverpod 3.0에 새로 추가된 requireValue를 활용하여 여러 Provider의 데이터를 효율적으로 결합하는 방법을 배웁니다. 비동기 데이터를 마치 동기 데이터처럼 다루는 실전 패턴을 소개합니다.
Flutter 3.0 Offline 데이터 영속화 완벽 가이드
Flutter 3.0에서 새롭게 추가된 Offline 데이터 영속화 기능을 배웁니다. Storage 인터페이스부터 SharedPreferences 활용, 실전 예제까지 실무에서 바로 사용할 수 있는 패턴을 배워봅시다.
Riverpod 3.0 Mutation으로 폼 제출 완벽 가이드
Riverpod 3.0의 새로운 Mutation 기능으로 로그인과 회원가입 폼을 우아하게 처리하는 방법을 배웁니다. 로딩 상태, 에러 처리, 성공 처리까지 실무에서 바로 쓸 수 있는 패턴을 익혀보세요.