feat: update docs

This commit is contained in:
ChangJoo Park(박창주) 2025-05-14 11:38:39 +09:00
parent 793a4dba07
commit cec27f7bc4
7 changed files with 11 additions and 1025 deletions

View file

@ -94,7 +94,6 @@ export const sidebars = [
{
label: "🧭 Part 9. 프로젝트 구조 & 아키텍처",
items: [
{ label: "클린 아키텍처", slug: "part9/clean-architecture" },
{ label: "기능별 vs 계층별 폴더 구조", slug: "part9/folder-structure" },
{ label: "멀티 모듈 아키텍처", slug: "part9/multi-module" },
],
@ -107,20 +106,20 @@ export const sidebars = [
{ label: "애니메이션", slug: "part10/animations" },
{ label: "접근성", slug: "part10/accessibility" },
{ label: "다국어 처리", slug: "part10/internationalization" },
{ label: "퍼포먼스 튜닝", slug: "part10/performance" },
{ label: "추천 패키지", slug: "part10/recommended-packages" },
{ label: "성능 최적화", slug: "part10/performance" },
// { label: "추천 패키지", slug: "part10/recommended-packages" },
],
},
{
label: "📚 부록",
items: [
{ label: "개발 도구와 링크", slug: "appendix/tools" },
// { label: "개발 도구와 링크", slug: "appendix/tools" },
{ label: "Flutter 오류 대응법", slug: "appendix/error-handling" },
{ label: "코드 템플릿", slug: "appendix/code-templates" },
{ label: "FAQ", slug: "appendix/faq" },
{ label: "소셜 로그인", slug: "appendix/social-login" },
{ label: "iOS 라이브 액티비티", slug: "appendix/live-activities" },
{ label: "WidgetBook", slug: "appendix/widgetbook" },
{ label: "FAQ", slug: "appendix/faq" },
],
},
];

View file

@ -75,7 +75,7 @@ abstract class SocialLoginProvider {
```yaml
dependencies:
kakao_flutter_sdk_user: ^1.6.0
kakao_flutter_sdk_user: ^1.9.7+3
```
### 2. 플랫폼별 설정
@ -222,149 +222,11 @@ void main() {
```yaml
dependencies:
flutter_naver_login: ^1.8.0
naver_login_sdk: ^3.0.0
```
### 2. 플랫폼별 설정
> 준비중입니다.
#### Android 설정
1. `android/app/src/main/res/values/strings.xml` 파일 생성 및 설정:
```xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="client_id">${YOUR_CLIENT_ID}</string>
<string name="client_secret">${YOUR_CLIENT_SECRET}</string>
<string name="client_name">${YOUR_APP_NAME}</string>
</resources>
```
2. `android/app/build.gradle` 파일의 `defaultConfig` 섹션에 매니페스트 플레이스홀더 추가:
```gradle
defaultConfig {
// ...
manifestPlaceholders += [
'naverClientId': '${YOUR_CLIENT_ID}',
'naverClientSecret': '${YOUR_CLIENT_SECRET}',
'naverClientName': '${YOUR_APP_NAME}'
]
}
```
3. `android/app/src/main/AndroidManifest.xml` 파일에 네이버 로그인 설정 추가:
```xml
<manifest ...>
<application ...>
<activity ...>
<!-- ... -->
<!-- 네이버 로그인 커스텀 URL 스킴 설정 -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="naver${YOUR_CLIENT_ID}" android:host="oauth"/>
</intent-filter>
</activity>
<!-- 네이버 로그인 추가 설정 -->
<meta-data
android:name="com.naver.sdk.clientId"
android:value="@string/client_id" />
<meta-data
android:name="com.naver.sdk.clientSecret"
android:value="@string/client_secret" />
<meta-data
android:name="com.naver.sdk.clientName"
android:value="@string/client_name" />
</application>
</manifest>
```
#### iOS 설정
1. `ios/Runner/Info.plist` 파일에 네이버 설정 추가:
```xml
<key>CFBundleURLTypes</key>
<array>
<!-- 기존 URL 스킴 설정 -->
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>naver${YOUR_CLIENT_ID}</string>
</array>
</dict>
</array>
<key>LSApplicationQueriesSchemes</key>
<array>
<!-- 기존 스킴 목록 -->
<string>naversearchapp</string>
<string>naversearchthirdlogin</string>
</array>
<key>naverClientId</key>
<string>${YOUR_CLIENT_ID}</string>
<key>naverClientSecret</key>
<string>${YOUR_CLIENT_SECRET}</string>
<key>naverServiceAppName</key>
<string>${YOUR_APP_NAME}</string>
```
### 3. 네이버 로그인 구현
```dart
import 'package:flutter_naver_login/flutter_naver_login.dart';
class NaverLoginProvider implements SocialLoginProvider {
@override
Future<SocialLoginResult> login() async {
try {
// 네이버 로그인 실행
NaverLoginResult result = await FlutterNaverLogin.logIn();
// 로그인 결과 확인
if (result.status == NaverLoginStatus.success) {
// 사용자 정보 가져오기
NaverAccessToken token = await FlutterNaverLogin.currentAccessToken;
NaverAccountResult account = await FlutterNaverLogin.currentAccount();
return SocialLoginResult.success(
accessToken: token.accessToken,
provider: 'naver',
email: account.email,
name: account.name,
profileImage: account.profileImage,
);
} else if (result.status == NaverLoginStatus.cancelledByUser) {
return const SocialLoginResult.cancelled(provider: 'naver');
} else {
return SocialLoginResult.error(
message: '네이버 로그인 실패: ${result.errorMessage}',
provider: 'naver',
);
}
} catch (error) {
return SocialLoginResult.error(
message: error.toString(),
provider: 'naver',
);
}
}
@override
Future<void> logout() async {
await FlutterNaverLogin.logOut();
}
}
```
## 애플 로그인 구현

View file

@ -22,7 +22,6 @@ Flutter 개발을 효과적으로 수행하기 위해서는 적절한 도구를
| **Awesome Flutter Snippets** | 자주 사용되는 Flutter 코드 조각 제공 |
| **Flutter Widget Snippets** | 위젯 코드 스니펫 제공 |
| **Pubspec Assist** | pubspec.yaml 파일 관리 도우미 |
| **bloc** | Bloc 패턴 개발 지원 |
| **Git History** | Git 이력 관리 시각화 |
| **Error Lens** | 인라인 오류 하이라이팅 |
@ -35,14 +34,6 @@ Flutter 개발을 효과적으로 수행하기 위해서는 적절한 도구를
| **Flipper** | Facebook의 모바일 앱 디버깅 플랫폼 | [웹사이트](https://fbflipper.com/) |
| **Sentry** | 실시간 에러 추적 | [웹사이트](https://sentry.io/) |
### UI 디자인 도구
| 도구 | 설명 | 링크 |
| ------------------------- | ------------------------ | ---------------------------------------------------------------- |
| **Figma** | 협업 기반 UI 디자인 도구 | [웹사이트](https://www.figma.com/) |
| **Adobe XD** | UI/UX 디자인 도구 | [웹사이트](https://www.adobe.com/products/xd.html) |
| **Flutter UI Challenges** | UI 구현 연습 프로젝트 | [GitHub](https://github.com/lohanidamodar/flutter_ui_challenges) |
### CI/CD 도구
| 도구 | 설명 | 링크 |

View file

@ -1,5 +1,5 @@
---
title: 퍼포먼스 튜닝
title: 성능 최적화
---
Flutter 앱의 성능은 사용자 경험에 직접적인 영향을 미치는 중요한 요소입니다. 앱이 부드럽게 작동하고, 반응이 빠르며, 자원을 효율적으로 사용할 때 사용자 만족도가 높아집니다. 이 장에서는 Flutter 앱의 성능을 최적화하기 위한 다양한 전략과 기법을 살펴보겠습니다.
@ -319,7 +319,7 @@ class ImageCache {
}
```
### 2. 디스포즈 패턴
### 2. 해제 패턴
`StatefulWidget`에서 리소스를 적절히 해제합니다:

View file

@ -775,11 +775,6 @@ go_router는 다른 Flutter 라우팅 라이브러리에 비해 몇 가지 장
- **go_router**: 공식 지원, 간단한 설정, 코드 생성 불필요
- **auto_route**: 코드 생성 기반, 타입 안전성, 더 많은 설정 필요
### 3. go_router vs get
- **go_router**: 공식 지원, Navigator 2.0 기반, URL 동기화 지원 강력
- **get**: 더 넓은 기능 세트 (상태 관리, 종속성 주입 등), 더 단순한 API
## 요약
- **go_router**는 Flutter 팀이 공식 지원하는 네비게이션 라이브러리로, Navigator 2.0의 기능을 더 쉽게 사용할 수 있게 해줍니다.

View file

@ -55,55 +55,7 @@ dependencies:
flutter pub get
```
#### 3. Android 설정
Android에서 Crashlytics를 설정하려면 `android/app/build.gradle` 파일에 다음 플러그인을 추가해야 합니다:
```gradle
// app/build.gradle 파일
dependencies {
// ... 기존 종속성
implementation 'com.google.firebase:firebase-crashlytics:18.4.0'
implementation 'com.google.firebase:firebase-analytics:21.3.0'
}
apply plugin: 'com.google.firebase.crashlytics'
```
그리고 프로젝트 수준의 `android/build.gradle` 파일에 다음 내용을 추가합니다:
```gradle
// project/build.gradle 파일
buildscript {
repositories {
// ... 기존 저장소
google()
}
dependencies {
// ... 기존 종속성
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.9.9'
}
}
```
#### 4. iOS 설정
iOS의 경우 `ios/Podfile`에 다음 내용을 추가합니다:
```ruby
target 'Runner' do
// ... 기존 내용
pod 'FirebaseCrashlytics'
end
```
그리고 다음 명령어를 실행합니다:
```bash
cd ios && pod install --repo-update
```
#### 5. Flutter 앱에서 Crashlytics 초기화
#### 3. Flutter 앱에서 Crashlytics 초기화
앱의 메인 파일에서 Crashlytics를 초기화합니다:
@ -259,7 +211,7 @@ pubspec.yaml에 다음 패키지를 추가합니다:
```yaml
dependencies:
sentry_flutter: ^7.9.0
sentry_flutter: ^8.14.2
```
패키지를 설치합니다:

View file

@ -1,813 +0,0 @@
---
title: 클린 아키텍처 도입하기
---
Flutter 앱의 규모가 커지고 복잡해질수록 코드의 유지보수성, 확장성, 테스트 용이성을 확보하는 것이 중요해집니다. 클린 아키텍처는 이러한 문제를 해결하기 위한 소프트웨어 설계 방법론으로, Flutter 앱에도 효과적으로 적용할 수 있습니다. 이 문서에서는 Flutter에 클린 아키텍처를 도입하는 방법을 실용적인 관점에서 살펴보겠습니다.
## 클린 아키텍처란?
클린 아키텍처는 로버트 C. 마틴(Robert C. Martin, 일명 Uncle Bob)이 제안한 소프트웨어 아키텍처 패턴으로, 다음과 같은 핵심 원칙을 따릅니다:
### 클린 아키텍처의 주요 원칙
1. **관심사 분리(Separation of Concerns)**: 서로 다른 책임을 가진 코드를 분리합니다.
2. **의존성 규칙(Dependency Rule)**: 모든 의존성은 외부 계층에서 내부 계층으로 향해야 합니다. 내부 계층은 외부 계층에 대해 알지 못합니다.
3. **비즈니스 규칙 독립성**: 비즈니스 규칙은 UI, 데이터베이스, 프레임워크, 외부 라이브러리와 독립적이어야 합니다.
### 클린 아키텍처의 계층
클린 아키텍처는 일반적으로 다음 세 가지 주요 계층으로 구성됩니다:
1. **도메인 계층(Domain Layer)**: 비즈니스 로직과 엔티티를 포함하는 핵심 계층입니다.
2. **데이터 계층(Data Layer)**: 데이터 소스와의 통신을 담당하는 계층입니다.
3. **프레젠테이션 계층(Presentation Layer)**: UI와 사용자 상호작용을 처리하는 계층입니다.
## Flutter에서의 클린 아키텍처 구현
Flutter에서 클린 아키텍처를 구현하기 위한 구체적인 접근 방식을 살펴보겠습니다.
### 1. 프로젝트 구조 설정
클린 아키텍처를 적용한 Flutter 프로젝트의 기본 구조는 다음과 같습니다:
```
lib/
├── core/ # 공통 기능 및 유틸리티
│ ├── error/ # 오류 처리
│ ├── network/ # 네트워크 관련
│ └── utils/ # 유틸리티 함수
├── data/ # 데이터 계층
│ ├── datasources/ # 데이터 소스 구현
│ │ ├── local/ # 로컬 데이터 소스
│ │ └── remote/ # 원격 데이터 소스
│ ├── models/ # 데이터 모델(DTO)
│ └── repositories/ # 리포지토리 구현
├── domain/ # 도메인 계층
│ ├── entities/ # 비즈니스 엔티티
│ ├── repositories/ # 리포지토리 인터페이스
│ └── usecases/ # 유스케이스(비즈니스 로직)
├── presentation/ # 프레젠테이션 계층
│ ├── pages/ # 화면 위젯
│ ├── providers/ # 상태 관리
│ └── widgets/ # 재사용 위젯
└── main.dart # 앱 진입점
```
이 구조는 기능별 구조와 결합하여 사용할 수도 있습니다:
```
lib/
├── core/ # 공통 기능 및 유틸리티
├── features/ # 기능별 구성
│ ├── auth/ # 인증 기능
│ │ ├── data/
│ │ ├── domain/
│ │ └── presentation/
│ │
│ ├── products/ # 상품 기능
│ │ ├── data/
│ │ ├── domain/
│ │ └── presentation/
│ │
│ └── cart/ # 장바구니 기능
│ ├── data/
│ ├── domain/
│ └── presentation/
└── main.dart # 앱 진입점
```
### 2. 계층별 구현 방법
#### 도메인 계층(Domain Layer)
도메인 계층은 비즈니스 로직을 담당하는 핵심 계층으로, 다음과 같은 요소로 구성됩니다:
1. **엔티티(Entities)**: 비즈니스 객체 모델
2. **리포지토리 인터페이스(Repository Interfaces)**: 데이터 액세스 추상화
3. **유스케이스(Use Cases)**: 비즈니스 로직을 캡슐화
```dart
// 1. 엔티티 정의
// domain/entities/product.dart
class Product {
final String id;
final String name;
final double price;
final String description;
const Product({
required this.id,
required this.name,
required this.price,
required this.description,
});
}
// 2. 리포지토리 인터페이스
// domain/repositories/product_repository.dart
abstract class ProductRepository {
Future<List<Product>> getProducts();
Future<Product> getProductById(String id);
Future<void> saveProduct(Product product);
Future<void> deleteProduct(String id);
}
// 3. 유스케이스
// domain/usecases/get_products.dart
class GetProducts {
final ProductRepository repository;
GetProducts(this.repository);
Future<List<Product>> execute() {
return repository.getProducts();
}
}
// domain/usecases/get_product_by_id.dart
class GetProductById {
final ProductRepository repository;
GetProductById(this.repository);
Future<Product> execute(String id) {
return repository.getProductById(id);
}
}
```
#### 데이터 계층(Data Layer)
데이터 계층은 데이터 저장소(API, 로컬 DB 등)와의 통신을 담당하고, 도메인 계층에 정의된 리포지토리 인터페이스를 구현합니다:
1. **데이터 모델(Data Models)**: API나 DB와 통신하기 위한 DTO(Data Transfer Object)
2. **데이터 소스(Data Sources)**: 실제 데이터 액세스 로직
3. **리포지토리 구현(Repository Implementations)**: 도메인 계층에 정의된 인터페이스 구현
```dart
// 1. 데이터 모델
// data/models/product_model.dart
class ProductModel {
final String id;
final String name;
final double price;
final String description;
const ProductModel({
required this.id,
required this.name,
required this.price,
required this.description,
});
// JSON 직렬화/역직렬화
factory ProductModel.fromJson(Map<String, dynamic> json) {
return ProductModel(
id: json['id'],
name: json['name'],
price: json['price'].toDouble(),
description: json['description'],
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'price': price,
'description': description,
};
}
// 도메인 엔티티로 변환
Product toEntity() {
return Product(
id: id,
name: name,
price: price,
description: description,
);
}
// 도메인 엔티티에서 변환
factory ProductModel.fromEntity(Product product) {
return ProductModel(
id: product.id,
name: product.name,
price: product.price,
description: product.description,
);
}
}
// 2. 데이터 소스
// data/datasources/remote/product_remote_data_source.dart
abstract class ProductRemoteDataSource {
Future<List<ProductModel>> getProducts();
Future<ProductModel> getProductById(String id);
Future<void> saveProduct(ProductModel product);
Future<void> deleteProduct(String id);
}
// data/datasources/remote/product_remote_data_source_impl.dart
class ProductRemoteDataSourceImpl implements ProductRemoteDataSource {
final http.Client client;
final String baseUrl;
ProductRemoteDataSourceImpl({
required this.client,
required this.baseUrl,
});
@override
Future<List<ProductModel>> getProducts() async {
try {
final response = await client.get(
Uri.parse('$baseUrl/products'),
headers: {'Content-Type': 'application/json'},
);
if (response.statusCode == 200) {
final List<dynamic> jsonList = json.decode(response.body);
return jsonList.map((json) => ProductModel.fromJson(json)).toList();
} else {
throw ServerException();
}
} catch (e) {
throw ServerException();
}
}
// 다른 메서드 구현...
}
// 3. 리포지토리 구현
// data/repositories/product_repository_impl.dart
class ProductRepositoryImpl implements ProductRepository {
final ProductRemoteDataSource remoteDataSource;
final NetworkInfo networkInfo;
ProductRepositoryImpl({
required this.remoteDataSource,
required this.networkInfo,
});
@override
Future<List<Product>> getProducts() async {
if (await networkInfo.isConnected) {
try {
final remoteProducts = await remoteDataSource.getProducts();
return remoteProducts.map((model) => model.toEntity()).toList();
} on ServerException {
throw ServerFailure();
}
} else {
throw NetworkFailure();
}
}
// 다른 메서드 구현...
}
```
#### 프레젠테이션 계층(Presentation Layer)
프레젠테이션 계층은 UI와 사용자 상호작용을 담당하며, Riverpod을 사용하여 상태를 관리합니다:
1. **상태 관리(State Management)**: Riverpod 프로바이더
2. **UI 위젯(UI Widgets)**: 화면 및 컴포넌트
```dart
// 1. 상태 관리 (Riverpod)
// presentation/providers/product_providers.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
// 의존성 주입을 위한 프로바이더
final productRepositoryProvider = Provider<ProductRepository>((ref) {
final remoteDataSource = ref.read(productRemoteDataSourceProvider);
final networkInfo = ref.read(networkInfoProvider);
return ProductRepositoryImpl(
remoteDataSource: remoteDataSource,
networkInfo: networkInfo,
);
});
final getProductsUseCaseProvider = Provider<GetProducts>((ref) {
final repository = ref.read(productRepositoryProvider);
return GetProducts(repository);
});
// 상태 프로바이더
final productsProvider = FutureProvider<List<Product>>((ref) async {
final getProductsUseCase = ref.read(getProductsUseCaseProvider);
return getProductsUseCase.execute();
});
final productDetailsProvider = FutureProvider.family<Product, String>((ref, id) async {
final getProductByIdUseCase = ref.read(getProductByIdUseCaseProvider);
return getProductByIdUseCase.execute(id);
});
// 2. UI 위젯
// presentation/pages/product_list_page.dart
class ProductListPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final productsAsync = ref.watch(productsProvider);
return Scaffold(
appBar: AppBar(title: Text('상품 목록')),
body: productsAsync.when(
data: (products) => ListView.builder(
itemCount: products.length,
itemBuilder: (context, index) {
final product = products[index];
return ListTile(
title: Text(product.name),
subtitle: Text('\$${product.price}'),
onTap: () => Navigator.of(context).pushNamed(
'/product/${product.id}',
),
);
},
),
loading: () => Center(child: CircularProgressIndicator()),
error: (error, stackTrace) => Center(
child: Text('오류가 발생했습니다: $error'),
),
),
);
}
}
```
### 3. 의존성 주입(Dependency Injection)
클린 아키텍처에서 의존성 주입은 매우 중요합니다. Riverpod을 사용하면 의존성 주입을 효과적으로 관리할 수 있습니다:
```dart
// core/di/injection_container.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
// 인프라 계층 프로바이더
final httpClientProvider = Provider<http.Client>((ref) {
return http.Client();
});
final networkInfoProvider = Provider<NetworkInfo>((ref) {
final connectivity = ref.read(connectivityProvider);
return NetworkInfoImpl(connectivity);
});
// 데이터 소스 프로바이더
final productRemoteDataSourceProvider = Provider<ProductRemoteDataSource>((ref) {
final client = ref.read(httpClientProvider);
return ProductRemoteDataSourceImpl(
client: client,
baseUrl: 'https://api.example.com',
);
});
// 리포지토리 프로바이더
final productRepositoryProvider = Provider<ProductRepository>((ref) {
final remoteDataSource = ref.read(productRemoteDataSourceProvider);
final networkInfo = ref.read(networkInfoProvider);
return ProductRepositoryImpl(
remoteDataSource: remoteDataSource,
networkInfo: networkInfo,
);
});
// 유스케이스 프로바이더
final getProductsUseCaseProvider = Provider<GetProducts>((ref) {
final repository = ref.read(productRepositoryProvider);
return GetProducts(repository);
});
final getProductByIdUseCaseProvider = Provider<GetProductById>((ref) {
final repository = ref.read(productRepositoryProvider);
return GetProductById(repository);
});
```
### 4. 에러 처리
클린 아키텍처에서 에러 처리는 일관되고 체계적으로 이루어져야 합니다:
```dart
// core/error/exceptions.dart
class ServerException implements Exception {}
class CacheException implements Exception {}
class NetworkException implements Exception {}
// core/error/failures.dart
abstract class Failure {}
class ServerFailure extends Failure {}
class CacheFailure extends Failure {}
class NetworkFailure extends Failure {}
// 에러 처리 예시
// data/repositories/product_repository_impl.dart
@override
Future<List<Product>> getProducts() async {
if (await networkInfo.isConnected) {
try {
final remoteProducts = await remoteDataSource.getProducts();
return remoteProducts.map((model) => model.toEntity()).toList();
} on ServerException {
throw ServerFailure();
}
} else {
throw NetworkFailure();
}
}
```
## 실제 Flutter 예제: Todo 앱
실제 Todo 앱 예제를 통해 클린 아키텍처를 적용하는 방법을 살펴보겠습니다.
### 도메인 계층 구현
```dart
// domain/entities/todo.dart
class Todo {
final String id;
final String title;
final String description;
final bool completed;
const Todo({
required this.id,
required this.title,
required this.description,
required this.completed,
});
}
// domain/repositories/todo_repository.dart
abstract class TodoRepository {
Future<List<Todo>> getTodos();
Future<Todo> getTodoById(String id);
Future<void> addTodo(Todo todo);
Future<void> updateTodo(Todo todo);
Future<void> deleteTodo(String id);
}
// domain/usecases/get_todos.dart
class GetTodos {
final TodoRepository repository;
GetTodos(this.repository);
Future<List<Todo>> execute() {
return repository.getTodos();
}
}
// domain/usecases/add_todo.dart
class AddTodo {
final TodoRepository repository;
AddTodo(this.repository);
Future<void> execute(Todo todo) {
return repository.addTodo(todo);
}
}
```
### 데이터 계층 구현
```dart
// data/models/todo_model.dart
import 'package:json_annotation/json_annotation.dart';
part 'todo_model.g.dart';
@JsonSerializable()
class TodoModel {
final String id;
final String title;
final String description;
final bool completed;
const TodoModel({
required this.id,
required this.title,
required this.description,
required this.completed,
});
factory TodoModel.fromJson(Map<String, dynamic> json) =>
_$TodoModelFromJson(json);
Map<String, dynamic> toJson() => _$TodoModelToJson(this);
// 변환 메서드
Todo toEntity() => Todo(
id: id,
title: title,
description: description,
completed: completed,
);
factory TodoModel.fromEntity(Todo todo) => TodoModel(
id: todo.id,
title: todo.title,
description: todo.description,
completed: todo.completed,
);
}
// data/datasources/todo_remote_data_source.dart
abstract class TodoRemoteDataSource {
Future<List<TodoModel>> getTodos();
Future<TodoModel> getTodoById(String id);
Future<void> addTodo(TodoModel todo);
Future<void> updateTodo(TodoModel todo);
Future<void> deleteTodo(String id);
}
// data/datasources/todo_remote_data_source_impl.dart
class TodoRemoteDataSourceImpl implements TodoRemoteDataSource {
final http.Client client;
final String baseUrl;
TodoRemoteDataSourceImpl({
required this.client,
required this.baseUrl,
});
@override
Future<List<TodoModel>> getTodos() async {
try {
final response = await client.get(
Uri.parse('$baseUrl/todos'),
headers: {'Content-Type': 'application/json'},
);
if (response.statusCode == 200) {
final List<dynamic> jsonList = json.decode(response.body);
return jsonList.map((json) => TodoModel.fromJson(json)).toList();
} else {
throw ServerException();
}
} catch (e) {
throw ServerException();
}
}
// 다른 메서드 구현...
}
// data/repositories/todo_repository_impl.dart
class TodoRepositoryImpl implements TodoRepository {
final TodoRemoteDataSource remoteDataSource;
final NetworkInfo networkInfo;
TodoRepositoryImpl({
required this.remoteDataSource,
required this.networkInfo,
});
@override
Future<List<Todo>> getTodos() async {
if (await networkInfo.isConnected) {
try {
final remoteTodos = await remoteDataSource.getTodos();
return remoteTodos.map((model) => model.toEntity()).toList();
} on ServerException {
throw ServerFailure();
}
} else {
throw NetworkFailure();
}
}
// 다른 메서드 구현...
}
```
### 프레젠테이션 계층 구현
```dart
// presentation/providers/todo_providers.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
// 리포지토리 프로바이더
final todoRepositoryProvider = Provider<TodoRepository>((ref) {
final remoteDataSource = ref.read(todoRemoteDataSourceProvider);
final networkInfo = ref.read(networkInfoProvider);
return TodoRepositoryImpl(
remoteDataSource: remoteDataSource,
networkInfo: networkInfo,
);
});
// 유스케이스 프로바이더
final getTodosUseCaseProvider = Provider<GetTodos>((ref) {
final repository = ref.read(todoRepositoryProvider);
return GetTodos(repository);
});
final addTodoUseCaseProvider = Provider<AddTodo>((ref) {
final repository = ref.read(todoRepositoryProvider);
return AddTodo(repository);
});
// 상태 프로바이더
final todosProvider = FutureProvider<List<Todo>>((ref) async {
final getTodosUseCase = ref.read(getTodosUseCaseProvider);
return getTodosUseCase.execute();
});
// 상태 관리 노티파이어
final todoListNotifierProvider = StateNotifierProvider<TodoListNotifier, AsyncValue<List<Todo>>>((ref) {
final getTodosUseCase = ref.read(getTodosUseCaseProvider);
final addTodoUseCase = ref.read(addTodoUseCaseProvider);
return TodoListNotifier(
getTodosUseCase: getTodosUseCase,
addTodoUseCase: addTodoUseCase,
);
});
class TodoListNotifier extends StateNotifier<AsyncValue<List<Todo>>> {
final GetTodos getTodosUseCase;
final AddTodo addTodoUseCase;
TodoListNotifier({
required this.getTodosUseCase,
required this.addTodoUseCase,
}) : super(const AsyncValue.loading()) {
_loadTodos();
}
Future<void> _loadTodos() async {
state = const AsyncValue.loading();
try {
final todos = await getTodosUseCase.execute();
state = AsyncValue.data(todos);
} catch (e, stackTrace) {
state = AsyncValue.error(e, stackTrace);
}
}
Future<void> addTodo(Todo todo) async {
try {
await addTodoUseCase.execute(todo);
_loadTodos(); // 목록 다시 로드
} catch (e) {
// 오류 처리
}
}
}
// presentation/pages/todo_list_page.dart
class TodoListPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final todosAsync = ref.watch(todoListNotifierProvider);
return Scaffold(
appBar: AppBar(title: Text('Todo 목록')),
body: todosAsync.when(
data: (todos) => ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
final todo = todos[index];
return ListTile(
title: Text(todo.title),
subtitle: Text(todo.description),
trailing: Checkbox(
value: todo.completed,
onChanged: (value) {
// 체크박스 상태 변경 처리
},
),
);
},
),
loading: () => Center(child: CircularProgressIndicator()),
error: (error, stackTrace) => Center(
child: Text('오류가 발생했습니다: $error'),
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
// 새 Todo 추가 화면으로 이동
Navigator.of(context).pushNamed('/add-todo');
},
child: Icon(Icons.add),
),
);
}
}
```
## 클린 아키텍처의 장점과 단점
### 장점
1. **관심사 분리**: 비즈니스 로직, 데이터 액세스, UI가 명확히 분리됩니다.
2. **테스트 용이성**: 각 계층이 독립적이므로 단위 테스트가 용이합니다.
3. **유지보수성**: 코드가 구조화되어 있어 변경이 필요할 때 영향 범위가 제한적입니다.
4. **확장성**: 새로운 기능을 추가하거나 기존 구현을 교체하기 쉽습니다.
5. **프레임워크 독립성**: 핵심 비즈니스 로직이 Flutter나 외부 라이브러리에 의존하지 않습니다.
### 단점
1. **초기 설정 복잡성**: 작은 프로젝트에서는 과도한 보일러플레이트 코드가 생길 수 있습니다.
2. **학습 곡선**: 팀원들이 아키텍처를 이해하고 적용하는 데 시간이 필요합니다.
3. **개발 속도**: 초기에는 개발 속도가 느려질 수 있습니다.
4. **코드량 증가**: 인터페이스, 구현체, 모델 변환 등으로 인해 코드량이 증가합니다.
## 실용적인 접근 방식
모든 프로젝트에 완전한 클린 아키텍처가 필요한 것은 아닙니다. 프로젝트 규모와 팀 특성에
따라 다음과 같이 접근할 수 있습니다:
### 소규모 프로젝트
소규모 프로젝트에서는 간소화된 아키텍처를 적용할 수 있습니다:
```
lib/
├── data/ # 데이터 액세스
│ ├── models/
│ └── repositories/
├── screens/ # UI 화면
│ ├── home/
│ └── details/
├── providers/ # 상태 관리
└── main.dart
```
### 중규모 프로젝트
중규모 프로젝트에서는 도메인 계층의 일부 개념을 도입할 수 있습니다:
```
lib/
├── data/ # 데이터 계층
│ ├── models/
│ └── repositories/
├── domain/ # 도메인 계층 (간소화)
│ └── usecases/
├── presentation/ # 프레젠테이션 계층
│ ├── pages/
│ └── providers/
├── core/ # 유틸리티
└── main.dart
```
### 대규모 프로젝트
대규모 프로젝트에서는 완전한 클린 아키텍처와 기능별 구조를 결합할 수 있습니다:
```
lib/
├── core/
├── features/
│ ├── auth/
│ │ ├── data/
│ │ ├── domain/
│ │ └── presentation/
│ ├── home/
│ │ ├── data/
│ │ ├── domain/
│ │ └── presentation/
│ └── settings/
│ ├── data/
│ ├── domain/
│ └── presentation/
└── main.dart
```
## 점진적 도입 전략
기존 프로젝트에 클린 아키텍처를 도입할 때는 점진적인 접근이 필요합니다:
1. **계층 분리부터 시작**: 먼저 UI, 비즈니스 로직, 데이터 액세스 코드를 분리합니다.
2. **한 기능씩 리팩토링**: 새로운 기능이나 중요한 기능부터 클린 아키텍처로 리팩토링합니다.
3. **테스트 작성**: 리팩토링과 함께 단위 테스트를 작성하여 안정성을 확보합니다.
4. **인터페이스 도입**: 점진적으로 인터페이스와 의존성 주입을 도입합니다.
## 결론
클린 아키텍처는 Flutter 앱의 확장성, 유지보수성, 테스트 용이성을 크게 향상시킬 수 있습니다. 하지만 모든 프로젝트에 동일한 수준으로 적용할 필요는 없으며, 프로젝트의 규모와 특성에 맞게 적절히 조정하는 것이 중요합니다.
처음에는 복잡해 보일 수 있지만, 핵심 원칙을 이해하고 점진적으로 도입한다면 장기적으로 더 안정적이고 유지보수하기 쉬운 코드베이스를 구축할 수 있습니다. 특히 팀 규모가 크거나 앱이 계속 성장할 것으로 예상되는 경우 클린 아키텍처의 도입을 고려해볼 가치가 있습니다.