안녕하세요, woogi입니다! 🙋♂️
Flutter로 복잡한 앱을 개발하다 보면 위젯이 끝없이 중첩되고, 비슷한 UI를 반복해서 만들게 됩니다. "이 버튼 컴포넌트 어디 있었지?", "이 카드 레이아웃 다른 곳에서도 썼는데..." 같은 상황이 익숙하다면, 이 글이 정말 도움이 될 것입니다!
이 글에서 함께 배워볼 내용들:
Atomic Design은 화학의 원자 개념을 UI 설계에 적용한 방법론입니다. 작은 컴포넌트부터 시작해서 점진적으로 조합하여 복잡한 화면을 만드는 체계적인 접근법이죠.
Atoms → Molecules → Organisms → Templates → Pages
원자 분자 유기체 템플릿 페이지
각 단계는 명확한 역할과 책임을 가지고 있으며, 하위 단계의 컴포넌트들을 조합해서 상위 단계를 구성합니다.
가장 작은 단위의 UI 컴포넌트로, 더 이상 나눌 수 없는 기본 요소들입니다.
기본 버튼 컴포넌트:
// shared/widgets/atoms/buttons/primary_button.dart
class PrimaryButton extends StatelessWidget {
final String text;
final VoidCallback? onPressed;
final bool isLoading;
final ButtonSize size;
const PrimaryButton({
Key? key,
required this.text,
this.onPressed,
this.isLoading = false,
this.size = ButtonSize.medium,
}) : super(key: key);
Widget build(BuildContext context) {
return SizedBox(
height: _getButtonHeight(),
child: ElevatedButton(
onPressed: isLoading ? null : onPressed,
style: _getButtonStyle(context),
child: isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(text),
),
);
}
double _getButtonHeight() {
switch (size) {
case ButtonSize.small: return 32.0;
case ButtonSize.medium: return 44.0;
case ButtonSize.large: return 56.0;
}
}
ButtonStyle _getButtonStyle(BuildContext context) {
return ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.primary,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
);
}
}
enum ButtonSize { small, medium, large }
텍스트 컴포넌트:
// shared/widgets/atoms/text/heading_text.dart
class HeadingText extends StatelessWidget {
final String text;
final HeadingLevel level;
final Color? color;
final TextAlign? textAlign;
const HeadingText({
Key? key,
required this.text,
this.level = HeadingLevel.h2,
this.color,
this.textAlign,
}) : super(key: key);
Widget build(BuildContext context) {
return Text(
text,
style: _getTextStyle(context),
textAlign: textAlign,
);
}
TextStyle _getTextStyle(BuildContext context) {
final theme = Theme.of(context).textTheme;
final baseStyle = switch (level) {
HeadingLevel.h1 => theme.headlineLarge,
HeadingLevel.h2 => theme.headlineMedium,
HeadingLevel.h3 => theme.headlineSmall,
HeadingLevel.h4 => theme.titleLarge,
};
return baseStyle?.copyWith(color: color) ?? TextStyle(color: color);
}
}
enum HeadingLevel { h1, h2, h3, h4 }
2개 이상의 원자가 결합해서 특정 기능을 수행하는 컴포넌트입니다.
라벨이 있는 입력 필드:
// shared/widgets/molecules/form_fields/labeled_text_field.dart
class LabeledTextField extends StatelessWidget {
final String label;
final String? hint;
final String? errorText;
final TextEditingController? controller;
final bool isRequired;
final bool obscureText;
const LabeledTextField({
Key? key,
required this.label,
this.hint,
this.errorText,
this.controller,
this.isRequired = false,
this.obscureText = false,
}) : super(key: key);
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildLabel(context),
const SizedBox(height: 8),
TextField(
controller: controller,
obscureText: obscureText,
decoration: InputDecoration(
hintText: hint,
errorText: errorText,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
],
);
}
Widget _buildLabel(BuildContext context) {
return RichText(
text: TextSpan(
text: label,
style: Theme.of(context).textTheme.labelLarge,
children: [
if (isRequired)
TextSpan(
text: ' *',
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
],
),
);
}
}
검색 바:
// shared/widgets/molecules/search/search_bar.dart
class AppSearchBar extends StatefulWidget {
final String? hint;
final Function(String)? onChanged;
final VoidCallback? onClear;
const AppSearchBar({
Key? key,
this.hint,
this.onChanged,
this.onClear,
}) : super(key: key);
State<AppSearchBar> createState() => _AppSearchBarState();
}
class _AppSearchBarState extends State<AppSearchBar> {
final TextEditingController _controller = TextEditingController();
Widget build(BuildContext context) {
return TextField(
controller: _controller,
onChanged: widget.onChanged,
decoration: InputDecoration(
hintText: widget.hint ?? '검색어를 입력하세요',
prefixIcon: const Icon(Icons.search),
suffixIcon: _controller.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_controller.clear();
widget.onClear?.call();
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
),
),
);
}
void dispose() {
_controller.dispose();
super.dispose();
}
}
여러 분자와 원자가 결합해서 완전한 기능을 제공하는 독립적인 컴포넌트입니다.
상품 카드:
// features/products/presentation/widgets/organisms/product_card.dart
class ProductCard extends StatelessWidget {
final Product product;
final VoidCallback onTap;
final VoidCallback? onFavorite;
const ProductCard({
Key? key,
required this.product,
required this.onTap,
this.onFavorite,
}) : super(key: key);
Widget build(BuildContext context) {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildProductImage(),
Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
product.name,
style: Theme.of(context).textTheme.titleSmall,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
_buildPriceSection(context),
if (product.rating != null)
_buildRatingSection(),
],
),
),
],
),
),
);
}
Widget _buildProductImage() {
return Stack(
children: [
AspectRatio(
aspectRatio: 1,
child: ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
child: Image.network(
product.imageUrl,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
color: Colors.grey[200],
child: const Icon(Icons.image_not_supported),
);
},
),
),
),
if (product.discountPercentage > 0)
_buildDiscountBadge(),
if (onFavorite != null)
_buildFavoriteButton(),
],
);
}
Widget _buildDiscountBadge() {
return Positioned(
top: 8,
left: 8,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(4),
),
child: Text(
'${product.discountPercentage}% 할인',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
);
}
Widget _buildFavoriteButton() {
return Positioned(
top: 8,
right: 8,
child: GestureDetector(
onTap: onFavorite,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.8),
shape: BoxShape.circle,
),
child: Icon(
product.isFavorite ? Icons.favorite : Icons.favorite_border,
color: product.isFavorite ? Colors.red : Colors.grey,
size: 20,
),
),
),
);
}
Widget _buildPriceSection(BuildContext context) {
return Row(
children: [
if (product.originalPrice != null &&
product.originalPrice! > product.price) ...[
Text(
'₩${product.originalPrice!.toStringAsFixed(0)}',
style: TextStyle(
decoration: TextDecoration.lineThrough,
color: Colors.grey[600],
fontSize: 12,
),
),
const SizedBox(width: 4),
],
Text(
'₩${product.price.toStringAsFixed(0)}',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
fontSize: 16,
),
),
],
);
}
Widget _buildRatingSection() {
return Padding(
padding: const EdgeInsets.only(top: 4),
child: Row(
children: [
const Icon(Icons.star, color: Colors.amber, size: 16),
const SizedBox(width: 4),
Text(
product.rating!.toStringAsFixed(1),
style: const TextStyle(fontSize: 12),
),
],
),
);
}
}
유기체들을 배치한 페이지의 구조적 틀입니다.
// shared/widgets/templates/base_template.dart
class BaseTemplate extends StatelessWidget {
final String? title;
final Widget body;
final Widget? floatingActionButton;
final List<Widget>? actions;
final bool showBackButton;
final Widget? bottomNavigationBar;
const BaseTemplate({
Key? key,
this.title,
required this.body,
this.floatingActionButton,
this.actions,
this.showBackButton = false,
this.bottomNavigationBar,
}) : super(key: key);
Widget build(BuildContext context) {
return Scaffold(
appBar: _buildAppBar(context),
body: SafeArea(child: body),
floatingActionButton: floatingActionButton,
bottomNavigationBar: bottomNavigationBar,
);
}
PreferredSizeWidget? _buildAppBar(BuildContext context) {
if (title == null && !showBackButton && (actions?.isEmpty ?? true)) {
return null;
}
return AppBar(
title: title != null ? Text(title!) : null,
actions: actions,
leading: showBackButton
? IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.of(context).pop(),
)
: null,
);
}
}
실제 데이터가 채워진 완전한 사용자 인터페이스입니다.
// features/products/presentation/pages/product_list_page.dart
class ProductListPage extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final productsAsync = ref.watch(productsProvider);
return BaseTemplate(
title: '상품 목록',
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: AppSearchBar(
hint: '상품 검색',
onChanged: (query) {
ref.read(searchQueryProvider.notifier).state = query;
},
),
),
Expanded(
child: productsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => _buildErrorState(context, error),
data: (products) => _buildProductGrid(context, ref, products),
),
),
],
),
);
}
Widget _buildErrorState(BuildContext context, Object error) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 64, color: Colors.grey),
const SizedBox(height: 16),
Text('상품을 불러오지 못했습니다'),
const SizedBox(height: 16),
PrimaryButton(
text: '다시 시도',
onPressed: () {
// 재시도 로직
},
),
],
),
);
}
Widget _buildProductGrid(BuildContext context, WidgetRef ref, List<Product> products) {
if (products.isEmpty) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.inventory_2_outlined, size: 64, color: Colors.grey),
SizedBox(height: 16),
Text('상품이 없습니다'),
],
),
);
}
return GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.75,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: products.length,
itemBuilder: (context, index) {
final product = products[index];
return ProductCard(
product: product,
onTap: () => _navigateToProductDetail(context, product),
onFavorite: () => _toggleFavorite(ref, product),
);
},
);
}
void _navigateToProductDetail(BuildContext context, Product product) {
Navigator.pushNamed(context, '/product-detail', arguments: product);
}
void _toggleFavorite(WidgetRef ref, Product product) {
ref.read(favoritesProvider.notifier).toggle(product.id);
}
}
lib/
├── shared/
│ └── widgets/
│ ├── atoms/
│ │ ├── buttons/
│ │ ├── text/
│ │ ├── images/
│ │ └── indicators/
│ ├── molecules/
│ │ ├── form_fields/
│ │ ├── cards/
│ │ └── search/
│ ├── organisms/
│ │ └── navigation/
│ ├── templates/
│ └── index.dart
│
└── features/
└── {feature_name}/
└── presentation/
├── pages/
└── widgets/
├── atoms/ # (필요시에만)
├── molecules/
├── organisms/
└── templates/ # (필요시에만)
새 UI 요소가 필요합니다
↓
기존 컴포넌트 재사용 가능할까요?
↓ No
어떤 레벨의 컴포넌트일까요?
↓
┌─────────────────────────────────────────┐
│ Atom: 기본 UI 요소 │
│ - 버튼, 입력필드, 텍스트, 아이콘 │
│ → shared/widgets/atoms/ │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ Molecule: 원자들의 조합 │
│ - 범용적? → shared/widgets/molecules/ │
│ - 기능 특화? → features/.../molecules/ │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ Organism: 완전한 기능 블록 │
│ → features/.../organisms/ │
│ (거의 항상 feature-specific) │
└─────────────────────────────────────────┘
점진적 추상화 접근법:
// 1단계: Feature-specific으로 시작합니다
// features/auth/widgets/molecules/login_button.dart
class LoginButton extends StatelessWidget {
final VoidCallback onPressed;
final bool isLoading;
// 구현...
}
// 2단계: 다른 feature에서도 비슷한 요구사항이 생깁니다
// features/products/widgets/molecules/add_to_cart_button.dart
class AddToCartButton extends StatelessWidget {
// 유사한 구현...
}
// 3단계: 공통 패턴을 발견하면 shared로 추상화합니다
// shared/widgets/atoms/buttons/action_button.dart
class ActionButton extends StatelessWidget {
final String text;
final VoidCallback onPressed;
final bool isLoading;
final ButtonVariant variant;
// 범용적인 구현...
}
// 가격 표시만 담당하는 컴포넌트
class PriceDisplay extends StatelessWidget {
final double price;
final double? originalPrice;
final String currency;
const PriceDisplay({
Key? key,
required this.price,
this.originalPrice,
this.currency = '₩',
}) : super(key: key);
Widget build(BuildContext context) {
return Row(
children: [
if (originalPrice != null && originalPrice! > price) ...[
Text(
'$currency${originalPrice!.toStringAsFixed(0)}',
style: TextStyle(
decoration: TextDecoration.lineThrough,
color: Colors.grey[600],
fontSize: 12,
),
),
const SizedBox(width: 4),
],
Text(
'$currency${price.toStringAsFixed(0)}',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
],
);
}
}
// 가격 표시 + 장바구니 기능 + API 호출이 한 컴포넌트에 섞여 있습니다
class ProductActionWidget extends StatefulWidget {
final Product product;
_ProductActionWidgetState createState() => _ProductActionWidgetState();
}
class _ProductActionWidgetState extends State<ProductActionWidget> {
int cartQuantity = 0;
Widget build(BuildContext context) {
return Column(
children: [
// 가격 표시 (책임 1)
Text('₩${widget.product.price}'),
// 수량 선택 (책임 2)
Row(
children: [
IconButton(
icon: Icon(Icons.remove),
onPressed: () => setState(() => cartQuantity--),
),
Text('$cartQuantity'),
IconButton(
icon: Icon(Icons.add),
onPressed: () => setState(() => cartQuantity++),
),
],
),
// API 호출까지 (책임 3)
ElevatedButton(
onPressed: () async {
await CartService.addToCart(widget.product, cartQuantity);
// 성공 처리...
},
child: Text('장바구니 담기'),
),
],
);
}
}
Atomic Design을 Flutter에 적용하면 정말 많은 이점을 얻을 수 있습니다:
핵심은 작게 시작해서 점진적으로 확장하는 것입니다. 모든 컴포넌트를 처음부터 완벽하게 설계하려고 하지 말고, 필요에 따라 리팩토링하면서 구조를 발전시켜 나가시면 됩니다.
여러분의 Flutter 앱도 Atomic Design으로 더 체계적이고 확장 가능한 구조를 가질 수 있을 것입니다! 🚀
이 글이 도움이 되셨다면 좋아요와 댓글로 피드백을 남겨주세요. 궁금한 점이나 추가로 다뤘으면 하는 주제가 있다면 언제든 말씀해 주세요!