WidgetBook을 활용한 Flutter UI 문서화
Flutter 애플리케이션을 개발할 때 일관된 디자인 시스템을 유지하고 UI 컴포넌트를 문서화하는 것은 중요합니다. WidgetBook은 Flutter 위젯을 카탈로그화하고 대화형으로 테스트할 수 있는 도구로, Storybook이나 Styleguidist와 같은 웹 개발 도구에서 영감을 받았습니다. 이 문서에서는 WidgetBook을 설정하고 효과적으로 활용하는 방법에 대해 알아보겠습니다.
WidgetBook이란?
Section titled “WidgetBook이란?”WidgetBook은 다음과 같은 기능을 제공하는 Flutter 패키지입니다:
- UI 컴포넌트 문서화 및 카탈로그화
- 다양한 데이터 및 상태로 위젯 테스트
- 디자인 시스템 구축 및 유지보수
- 디자이너와 개발자 간 협업 촉진
- 위젯의 반응형 동작 시각화
패키지 설치
Section titled “패키지 설치”먼저 pubspec.yaml 파일에 필요한 패키지를 추가합니다:
dependencies: flutter: sdk: flutter # 기타 의존성...
dev_dependencies: flutter_test: sdk: flutter widgetbook: ^3.0.0 # 위젯북 코어 패키지 widgetbook_generator: ^3.0.0 # 코드 생성 도구 build_runner: ^2.1.10 # 코드 생성 실행기
기본 구조 설정
Section titled “기본 구조 설정”WidgetBook을 구성하기 위한 기본 파일을 생성합니다:
1. WidgetBook 실행 파일 생성
Section titled “1. WidgetBook 실행 파일 생성”독립적인 WidgetBook 앱을 위한 진입점을 생성합니다. 프로젝트 루트에 widgetbook.dart
파일을 생성합니다:
import 'package:flutter/material.dart';import 'package:widgetbook/widgetbook.dart';import 'package:your_app/theme.dart'; // 앱 테마 임포트
void main() { runApp(const WidgetbookApp());}
class WidgetbookApp extends StatelessWidget { const WidgetbookApp({super.key});
@override Widget build(BuildContext context) { return Widgetbook.material( // 앱 정보 설정 appInfo: AppInfo(name: 'MyApp 컴포넌트'),
// 테마 설정 themes: [ WidgetbookTheme( name: '라이트 테마', data: AppTheme.lightTheme, ), WidgetbookTheme( name: '다크 테마', data: AppTheme.darkTheme, ), ],
// 디바이스 프레임 설정 devices: [ Apple.iPhone13, Samsung.s21ultra, const DeviceInfo.custom( name: '태블릿', resolution: Resolution( nativeSize: DeviceSize(width: 1024, height: 768), scaleFactor: 1, ), ), ],
// 위젯 카테고리 설정 categories: [ WidgetbookCategory( name: '기본 컴포넌트', widgets: [ // 여기에 위젯 사용 사례 추가 ], categories: [ WidgetbookCategory( name: '버튼', widgets: [ // 버튼 위젯 사용 사례 ], ), WidgetbookCategory( name: '입력 필드', widgets: [ // 입력 필드 위젯 사용 사례 ], ), ], ), ], ); }}
2. 시작 스크립트 설정
Section titled “2. 시작 스크립트 설정”package.json 파일이나 Makefile에 WidgetBook 실행 명령어를 추가합니다:
{ "scripts": { "widgetbook": "flutter run -d chrome -t widgetbook.dart" }}
자동 코드 생성 설정
Section titled “자동 코드 생성 설정”WidgetBook은 코드 생성을 통해 설정의 복잡성을 줄일 수 있습니다. 다음과 같이 설정합니다:
1. widgetbook_component.dart 파일 생성
Section titled “1. widgetbook_component.dart 파일 생성”프로젝트에 lib/widgetbook/widgetbook_component.dart
파일을 생성합니다:
import 'package:flutter/material.dart';import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook;
// 코드 생성을 위한 애너테이션@widgetbook.App()class App extends StatelessWidget { const App({super.key});
@override Widget build(BuildContext context) { return Container(); }}
2. build.yaml 설정
Section titled “2. build.yaml 설정”프로젝트 루트에 build.yaml
파일을 생성하여 코드 생성 설정을 추가합니다:
targets: $default: builders: widgetbook_generator: options: output_directory: lib/widgetbook generator_type: widgetbook
3. 코드 생성 실행
Section titled “3. 코드 생성 실행”다음 명령어로 코드를 생성합니다:
flutter pub run build_runner build
위젯 사용 사례 문서화
Section titled “위젯 사용 사례 문서화”위젯을 문서화하기 위해 사용 사례(use case)를 정의합니다:
1. 기본 버튼 사용 사례 예제
Section titled “1. 기본 버튼 사용 사례 예제”import 'package:flutter/material.dart';import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook;
@widgetbook.UseCase( name: '기본 버튼', type: ElevatedButton, designLink: 'https://www.figma.com/file/...',)Widget elevatedButtonUseCase(BuildContext context) { return Center( child: ElevatedButton( onPressed: () {}, child: const Text('기본 버튼'), ), );}
@widgetbook.UseCase( name: '비활성화된 버튼', type: ElevatedButton,)Widget disabledElevatedButtonUseCase(BuildContext context) { return Center( child: ElevatedButton( onPressed: null, // null은 버튼을 비활성화함 child: const Text('비활성화된 버튼'), ), );}
@widgetbook.UseCase( name: '아이콘 버튼', type: ElevatedButton,)Widget iconElevatedButtonUseCase(BuildContext context) { return Center( child: ElevatedButton.icon( onPressed: () {}, icon: const Icon(Icons.favorite), label: const Text('좋아요'), ), );}
2. 텍스트 필드 사용 사례 예제
Section titled “2. 텍스트 필드 사용 사례 예제”import 'package:flutter/material.dart';import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook;
@widgetbook.UseCase( name: '기본 텍스트 필드', type: TextField,)Widget textFieldUseCase(BuildContext context) { return const Padding( padding: EdgeInsets.all(16.0), child: TextField( decoration: InputDecoration( labelText: '이름', hintText: '이름을 입력하세요', ), ), );}
@widgetbook.UseCase( name: '비밀번호 텍스트 필드', type: TextField,)Widget passwordTextFieldUseCase(BuildContext context) { return const Padding( padding: EdgeInsets.all(16.0), child: TextField( obscureText: true, decoration: InputDecoration( labelText: '비밀번호', hintText: '비밀번호를 입력하세요', suffixIcon: Icon(Icons.visibility), ), ), );}
@widgetbook.UseCase( name: '오류 상태 텍스트 필드', type: TextField,)Widget errorTextFieldUseCase(BuildContext context) { return const Padding( padding: EdgeInsets.all(16.0), child: TextField( decoration: InputDecoration( labelText: '이메일', hintText: '이메일을 입력하세요', errorText: '유효한 이메일 주소를 입력해주세요', ), ), );}
매개변수 제어를 위한 Knobs 사용
Section titled “매개변수 제어를 위한 Knobs 사용”Knobs를 사용하면 UI를 통해 위젯의 매개변수를 동적으로 변경할 수 있습니다:
import 'package:flutter/material.dart';import 'package:widgetbook/widgetbook.dart';import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook;
@widgetbook.UseCase( name: '커스터마이징 가능한 버튼', type: ElevatedButton,)Widget customizableButtonUseCase(BuildContext context) { // 텍스트 Knob final buttonText = context.knobs.text( label: '버튼 텍스트', initialValue: '버튼', );
// 색상 Knob final buttonColor = context.knobs.color( label: '버튼 색상', initialValue: Colors.blue, );
// 숫자 Knob (크기 조절) final fontSize = context.knobs.number( label: '글자 크기', initialValue: 16, min: 10, max: 30, );
// 불리언 Knob (활성화/비활성화) final isEnabled = context.knobs.boolean( label: '활성화', initialValue: true, );
// 옵션 Knob (버튼 형태) final buttonShape = context.knobs.options( label: '버튼 형태', options: [ Option(label: '기본', value: 0.0), Option(label: '둥근 모서리', value: 8.0), Option(label: '원형', value: 20.0), ], );
return Center( child: ElevatedButton( onPressed: isEnabled ? () {} : null, style: ElevatedButton.styleFrom( backgroundColor: buttonColor, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(buttonShape), ), ), child: Text( buttonText, style: TextStyle(fontSize: fontSize), ), ), );}
복잡한 컴포넌트 문서화
Section titled “복잡한 컴포넌트 문서화”더 복잡한 컴포넌트나 화면을 문서화하는 방법입니다:
1. 회원가입 폼 예제
Section titled “1. 회원가입 폼 예제”import 'package:flutter/material.dart';import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook;
@widgetbook.UseCase( name: '회원가입 폼', type: SignUpForm, designLink: 'https://www.figma.com/file/...',)Widget signUpFormUseCase(BuildContext context) { return const Padding( padding: EdgeInsets.all(16.0), child: SignUpForm(), );}
class SignUpForm extends StatefulWidget { const SignUpForm({super.key});
@override State<SignUpForm> createState() => _SignUpFormState();}
class _SignUpFormState extends State<SignUpForm> { final _formKey = GlobalKey<FormState>(); bool _obscurePassword = true;
@override Widget build(BuildContext context) { return Form( key: _formKey, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ const Text( '회원가입', style: TextStyle( fontSize: 24, fontWeight: FontWeight.bold, ), textAlign: TextAlign.center, ), const SizedBox(height: 24), TextFormField( decoration: const InputDecoration( labelText: '이름', prefixIcon: Icon(Icons.person), ), validator: (value) { if (value == null || value.isEmpty) { return '이름을 입력해주세요'; } return null; }, ), const SizedBox(height: 16), TextFormField( decoration: const InputDecoration( labelText: '이메일', prefixIcon: Icon(Icons.email), ), keyboardType: TextInputType.emailAddress, validator: (value) { if (value == null || value.isEmpty) { return '이메일을 입력해주세요'; } if (!value.contains('@')) { return '유효한 이메일 주소를 입력해주세요'; } return null; }, ), const SizedBox(height: 16), TextFormField( decoration: InputDecoration( labelText: '비밀번호', prefixIcon: const Icon(Icons.lock), suffixIcon: IconButton( icon: Icon( _obscurePassword ? Icons.visibility : Icons.visibility_off, ), onPressed: () { setState(() { _obscurePassword = !_obscurePassword; }); }, ), ), obscureText: _obscurePassword, validator: (value) { if (value == null || value.isEmpty) { return '비밀번호를 입력해주세요'; } if (value.length < 8) { return '비밀번호는 8자 이상이어야 합니다'; } return null; }, ), const SizedBox(height: 24), ElevatedButton( onPressed: () { if (_formKey.currentState!.validate()) { // 폼 처리 로직 } }, child: const Text('가입하기'), ), const SizedBox(height: 16), TextButton( onPressed: () {}, child: const Text('이미 계정이 있으신가요? 로그인하기'), ), ], ), ); }}
2. 카드 컴포넌트 예제
Section titled “2. 카드 컴포넌트 예제”import 'package:flutter/material.dart';import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook;
@widgetbook.UseCase( name: '제품 카드', type: ProductCard,)Widget productCardUseCase(BuildContext context) { final isDiscounted = context.knobs.boolean( label: '할인 적용', initialValue: false, );
final rating = context.knobs.slider( label: '평점', initialValue: 4.5, min: 1.0, max: 5.0, divisions: 8, );
return Center( child: ProductCard( title: '스마트폰', imageUrl: 'https://example.com/smartphone.jpg', price: 1000000, discountPrice: isDiscounted ? 850000 : null, rating: rating, onPressed: () {}, ), );}
class ProductCard extends StatelessWidget { final String title; final String imageUrl; final int price; final int? discountPrice; final double rating; final VoidCallback onPressed;
const ProductCard({ super.key, required this.title, required this.imageUrl, required this.price, this.discountPrice, required this.rating, required this.onPressed, });
@override Widget build(BuildContext context) { return Card( clipBehavior: Clip.antiAlias, elevation: 2, child: InkWell( onTap: onPressed, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 이미지 플레이스홀더 AspectRatio( aspectRatio: 16 / 9, child: Container( color: Colors.grey[300], child: const Center(child: Icon(Icons.image)), ), ), Padding( padding: const EdgeInsets.all(12.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 16, ), ), const SizedBox(height: 4), if (discountPrice != null) ...[ Text( '${price.toString().replaceAllMapped(RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]},')}원', style: TextStyle( decoration: TextDecoration.lineThrough, color: Colors.grey[600], ), ), Text( '${discountPrice.toString().replaceAllMapped(RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]},')}원', style: const TextStyle( fontWeight: FontWeight.bold, color: Colors.red, ), ), ] else Text( '${price.toString().replaceAllMapped(RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]},')}원', style: const TextStyle(fontWeight: FontWeight.bold), ), const SizedBox(height: 8), Row( children: [ Icon(Icons.star, color: Colors.amber, size: 18), const SizedBox(width: 4), Text(rating.toStringAsFixed(1)), ], ), ], ), ), ], ), ), ); }}
반응형 디자인 테스트
Section titled “반응형 디자인 테스트”WidgetBook을 사용하여 반응형 디자인을 테스트하는 방법:
import 'package:flutter/material.dart';import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook;
@widgetbook.UseCase( name: '반응형 레이아웃', type: ResponsiveLayout,)Widget responsiveLayoutUseCase(BuildContext context) { return const ResponsiveLayout( mobileChild: MobileView(), tabletChild: TabletView(), desktopChild: DesktopView(), );}
class ResponsiveLayout extends StatelessWidget { final Widget mobileChild; final Widget tabletChild; final Widget desktopChild;
const ResponsiveLayout({ super.key, required this.mobileChild, required this.tabletChild, required this.desktopChild, });
@override Widget build(BuildContext context) { final width = MediaQuery.of(context).size.width;
if (width < 600) { return mobileChild; } else if (width < 1200) { return tabletChild; } else { return desktopChild; } }}
class MobileView extends StatelessWidget { const MobileView({super.key});
@override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('모바일 뷰')), body: ListView( children: List.generate( 10, (index) => ListTile( leading: CircleAvatar(child: Text('${index + 1}')), title: Text('항목 ${index + 1}'), subtitle: const Text('모바일 레이아웃'), ), ), ), bottomNavigationBar: BottomNavigationBar( items: const [ BottomNavigationBarItem(icon: Icon(Icons.home), label: '홈'), BottomNavigationBarItem(icon: Icon(Icons.search), label: '검색'), BottomNavigationBarItem(icon: Icon(Icons.person), label: '프로필'), ], currentIndex: 0, onTap: (_) {}, ), ); }}
class TabletView extends StatelessWidget { const TabletView({super.key});
@override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('태블릿 뷰')), body: Row( children: [ NavigationRail( selectedIndex: 0, onDestinationSelected: (_) {}, destinations: const [ NavigationRailDestination( icon: Icon(Icons.home), label: Text('홈'), ), NavigationRailDestination( icon: Icon(Icons.search), label: Text('검색'), ), NavigationRailDestination( icon: Icon(Icons.person), label: Text('프로필'), ), ], ), Expanded( child: GridView.builder( padding: const EdgeInsets.all(16), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, crossAxisSpacing: 16, mainAxisSpacing: 16, ), itemCount: 10, itemBuilder: (context, index) { return Card( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ CircleAvatar( radius: 30, child: Text('${index + 1}'), ), const SizedBox(height: 8), Text('항목 ${index + 1}'), const Text('태블릿 레이아웃'), ], ), ); }, ), ), ], ), ); }}
class DesktopView extends StatelessWidget { const DesktopView({super.key});
@override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('데스크톱 뷰')), body: Row( children: [ Drawer( child: ListView( children: [ const DrawerHeader( child: Text( '메뉴', style: TextStyle( color: Colors.white, fontSize: 24, ), ), decoration: BoxDecoration( color: Colors.blue, ), ), ListTile( leading: const Icon(Icons.home), title: const Text('홈'), selected: true, onTap: () {}, ), ListTile( leading: const Icon(Icons.search), title: const Text('검색'), onTap: () {}, ), ListTile( leading: const Icon(Icons.person), title: const Text('프로필'), onTap: () {}, ), ], ), ), Expanded( child: GridView.builder( padding: const EdgeInsets.all(16), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 4, crossAxisSpacing: 16, mainAxisSpacing: 16, ), itemCount: 20, itemBuilder: (context, index) { return Card( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ CircleAvatar( radius: 30, child: Text('${index + 1}'), ), const SizedBox(height: 8), Text('항목 ${index + 1}'), const Text('데스크톱 레이아웃'), ], ), ); }, ), ), ], ), ); }}
위젯북에서 상태 관리
Section titled “위젯북에서 상태 관리”WidgetBook에서 Provider, Riverpod 등의 상태 관리 도구를 사용하는 방법:
Riverpod과 함께 사용하기
Section titled “Riverpod과 함께 사용하기”import 'package:flutter/material.dart';import 'package:flutter_riverpod/flutter_riverpod.dart';import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook;
// 카운터 상태 제공자final counterProvider = StateProvider<int>((ref) => 0);
@widgetbook.UseCase( name: 'Riverpod 카운터', type: CounterWidget,)Widget riverpodCounterUseCase(BuildContext context) { return ProviderScope( child: CounterWidget(), );}
class CounterWidget extends ConsumerWidget { const CounterWidget({super.key});
@override Widget build(BuildContext context, WidgetRef ref) { final count = ref.watch(counterProvider);
return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( '$count', style: Theme.of(context).textTheme.headline2, ), const SizedBox(height: 16), ElevatedButton( onPressed: () => ref.read(counterProvider.notifier).state++, child: const Text('증가'), ), const SizedBox(height: 8), ElevatedButton( onPressed: () => ref.read(counterProvider.notifier).state--, child: const Text('감소'), ), ], ), ); }}
동적 API 데이터 모의 처리
Section titled “동적 API 데이터 모의 처리”실제 API 데이터를 시뮬레이션하여 데이터 의존적인 위젯을 문서화하는 방법:
import 'package:flutter/material.dart';import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook;
// 모의 데이터 서비스class MockDataService { Future<List<User>> getUsers() async { // API 호출 시뮬레이션 await Future.delayed(const Duration(seconds: 1));
return [ User(id: 1, name: '홍길동', email: 'hong@example.com'), User(id: 2, name: '김철수', email: 'kim@example.com'), User(id: 3, name: '이영희', email: 'lee@example.com'), ]; }}
class User { final int id; final String name; final String email;
User({required this.id, required this.name, required this.email});}
@widgetbook.UseCase( name: '사용자 목록', type: UserListWidget,)Widget userListUseCase(BuildContext context) { final isLoading = context.knobs.boolean( label: '로딩 중', initialValue: false, );
final hasError = context.knobs.boolean( label: '오류 발생', initialValue: false, );
final isEmpty = context.knobs.boolean( label: '빈 목록', initialValue: false, );
return UserListWidget( mockService: MockDataService(), isLoading: isLoading, hasError: hasError, isEmpty: isEmpty, );}
class UserListWidget extends StatefulWidget { final MockDataService mockService; final bool isLoading; final bool hasError; final bool isEmpty;
const UserListWidget({ super.key, required this.mockService, this.isLoading = false, this.hasError = false, this.isEmpty = false, });
@override State<UserListWidget> createState() => _UserListWidgetState();}
class _UserListWidgetState extends State<UserListWidget> { late Future<List<User>> _usersFuture;
@override void initState() { super.initState(); _loadUsers(); }
@override void didUpdateWidget(UserListWidget oldWidget) { super.didUpdateWidget(oldWidget);
if (oldWidget.isLoading != widget.isLoading || oldWidget.hasError != widget.hasError || oldWidget.isEmpty != widget.isEmpty) { _loadUsers(); } }
void _loadUsers() { if (widget.isLoading) { _usersFuture = Future.delayed(const Duration(days: 1), () => <User>[]); } else if (widget.hasError) { _usersFuture = Future.error('데이터 로드 중 오류가 발생했습니다.'); } else if (widget.isEmpty) { _usersFuture = Future.value(<User>[]); } else { _usersFuture = widget.mockService.getUsers(); } }
@override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('사용자 목록')), body: FutureBuilder<List<User>>( future: _usersFuture, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator()); }
if (snapshot.hasError) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon(Icons.error, size: 48, color: Colors.red), const SizedBox(height: 16), Text('오류: ${snapshot.error}'), const SizedBox(height: 16), ElevatedButton( onPressed: () => setState(() => _loadUsers()), child: const Text('다시 시도'), ), ], ), ); }
final users = snapshot.data ?? [];
if (users.isEmpty) { return const Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.people, size: 48, color: Colors.grey), SizedBox(height: 16), Text('사용자 정보가 없습니다.'), ], ), ); }
return ListView.builder( itemCount: users.length, itemBuilder: (context, index) { final user = users[index];
return ListTile( leading: CircleAvatar( child: Text(user.name.substring(0, 1)), ), title: Text(user.name), subtitle: Text(user.email), trailing: const Icon(Icons.chevron_right), onTap: () {}, ); }, ); }, ), ); }}
효과적인 위젯북 구성 팁
Section titled “효과적인 위젯북 구성 팁”위젯북을 더 효과적으로 활용하기 위한 팁:
-
논리적으로 카테고리화:
- 위젯을 기능, 유형 또는 페이지별로 그룹화
- 데이터 입력, 탐색, 표시 등으로 분류
-
일관된 명명 규칙:
- 명확하고 일관된 이름으로 컴포넌트와 사용 사례 명명
- 패턴 사용(예: “기본”, “비활성화”, “오류 상태”)
-
연동 문서화:
- Figma나 Zeplin 링크 포함하여 디자인과 코드 매핑
- 필요한 경우 추가 설명이나 사용 지침 제공
-
자동화 및 CI 통합:
- 커밋 또는 병합 시 위젯북 자동 빌드
- 위젯북 웹 버전을 팀 내부 서버에 배포
WidgetBook은 Flutter 애플리케이션의 UI 컴포넌트를 문서화하고 테스트하기 위한 강력한 도구입니다. 이를 통해 디자이너와 개발자 간의 협업을 촉진하고, 일관된 디자인 시스템을 유지할 수 있습니다. 특히 팀 규모가 커지거나 프로젝트가 복잡해질수록 WidgetBook의 가치는 더욱 커집니다.
효과적인 UI 문서화는 코드의 재사용성을 높이고, 디자인 일관성을 유지하며, 신규 팀원의 온보딩을 용이하게 하는 등 다양한 이점을 제공합니다. WidgetBook을 프로젝트에 통합하여 효율적인 UI 개발 환경을 구축해 보세요.