Flutter Atomic Design: 체계적인 위젯 구조화 완전 가이드

woogi·2025년 5월 22일
0

Flutter Tips

목록 보기
2/2
post-thumbnail

안녕하세요, woogi입니다! 🙋‍♂️

Flutter로 복잡한 앱을 개발하다 보면 위젯이 끝없이 중첩되고, 비슷한 UI를 반복해서 만들게 됩니다. "이 버튼 컴포넌트 어디 있었지?", "이 카드 레이아웃 다른 곳에서도 썼는데..." 같은 상황이 익숙하다면, 이 글이 정말 도움이 될 것입니다!

이 글에서 함께 배워볼 내용들:

  • Atomic Design 5단계 계층 구조와 Flutter 적용법
  • Feature-First 아키텍처와 조화로운 컴포넌트 배치 전략
  • 재사용 가능한 위젯 설계 원칙
  • 실전에서 바로 쓸 수 있는 구체적인 예제들

Atomic Design이란? 🧬

Atomic Design은 화학의 원자 개념을 UI 설계에 적용한 방법론입니다. 작은 컴포넌트부터 시작해서 점진적으로 조합하여 복잡한 화면을 만드는 체계적인 접근법이죠.

5단계 계층 구조

Atoms → Molecules → Organisms → Templates → Pages
 원자     분자        유기체      템플릿      페이지

각 단계는 명확한 역할과 책임을 가지고 있으며, 하위 단계의 컴포넌트들을 조합해서 상위 단계를 구성합니다.


1. Atoms (원자) - 기본 UI 요소

가장 작은 단위의 UI 컴포넌트로, 더 이상 나눌 수 없는 기본 요소들입니다.

특징

  • 단일 기능만 수행합니다
  • 상태를 가지지 않습니다 (Stateless)
  • props로만 데이터를 받아옵니다
  • 최대한 재사용 가능하게 설계합니다

실제 예제

기본 버튼 컴포넌트:

// 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. Molecules (분자) - 원자들의 조합

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();
  }
}

3. Organisms (유기체) - 복합 기능 블록

여러 분자와 원자가 결합해서 완전한 기능을 제공하는 독립적인 컴포넌트입니다.

특징

  • 완전한 기능을 수행하는 컴포넌트입니다
  • 복잡한 상태와 로직을 가질 수 있습니다
  • 비즈니스 로직과 연결될 수 있습니다

실제 예제

상품 카드:

// 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),
          ),
        ],
      ),
    );
  }
}

4. Templates (템플릿) - 페이지 레이아웃

유기체들을 배치한 페이지의 구조적 틀입니다.

특징

  • 레이아웃과 구조만 정의합니다
  • 실제 데이터는 포함하지 않습니다
  • 여러 페이지에서 재사용 가능한 골격입니다

실제 예제

// 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,
    );
  }
}

5. Pages (페이지) - 완성된 화면

실제 데이터가 채워진 완전한 사용자 인터페이스입니다.

특징

  • 실제 데이터와 상태 관리가 연결되어 있습니다
  • 사용자가 최종적으로 보는 화면입니다
  • 라우팅의 대상이 되는 컴포넌트입니다

실제 예제

// 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);
  }
}

Feature-First 환경에서의 컴포넌트 배치 전략 📁

폴더 구조

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/  # (필요시에만)

배치 원칙

🌍 Shared Components (공통 컴포넌트)

  • 3개 이상의 feature에서 사용될 가능성이 있는 컴포넌트
  • 비즈니스 로직과 무관한 순수 UI 컴포넌트
  • 디자인 시스템의 일부가 될 수 있는 컴포넌트

🎯 Feature-Specific Components (기능별 컴포넌트)

  • 특정 feature의 도메인 지식이 필요한 컴포넌트
  • 해당 feature에서만 사용될 것으로 예상되는 컴포넌트
  • feature별 비즈니스 로직과 밀접하게 연관된 컴포넌트

실전 적용 가이드 💡

1. 새 컴포넌트 생성 시 판단 플로우

새 UI 요소가 필요합니다
        ↓
기존 컴포넌트 재사용 가능할까요?
        ↓ No
어떤 레벨의 컴포넌트일까요?
        ↓
┌─────────────────────────────────────────┐
│ Atom: 기본 UI 요소                       │
│ - 버튼, 입력필드, 텍스트, 아이콘           │ 
│ → shared/widgets/atoms/                │
└─────────────────────────────────────────┘
        ↓
┌─────────────────────────────────────────┐
│ Molecule: 원자들의 조합                   │
│ - 범용적? → shared/widgets/molecules/    │
│ - 기능 특화? → features/.../molecules/   │
└─────────────────────────────────────────┘
        ↓
┌─────────────────────────────────────────┐
│ Organism: 완전한 기능 블록                │
│ → features/.../organisms/              │
│ (거의 항상 feature-specific)              │
└─────────────────────────────────────────┘

2. 컴포넌트 리팩토링 패턴

점진적 추상화 접근법:

// 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;
  
  // 범용적인 구현...
}

3. 모범 사례와 안티패턴

✅ 좋은 예: 단일 책임 원칙

// 가격 표시만 담당하는 컴포넌트
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으로 더 체계적이고 확장 가능한 구조를 가질 수 있을 것입니다! 🚀


이 글이 도움이 되셨다면 좋아요와 댓글로 피드백을 남겨주세요. 궁금한 점이나 추가로 다뤘으면 하는 주제가 있다면 언제든 말씀해 주세요!

profile
안녕하세요! 👋 저는 6년차 Flutter 개발자 우기입니다.

0개의 댓글