Flutter 앱 아키텍처 가이드
작성일: 2026-04-06 Last Updated: 2026-04-06 대상: Flutter Mobile App (iOS / Android) 공통 참조:
../common/(인증, 보안, API 연동, 테스트 등은 공통 가이드 참조)
목차
- 아키텍처 개요 (Clean Architecture)
- 폴더 구조 (Feature-First)
- 상태 관리 (Riverpod)
- 네비게이션 (GoRouter)
- 의존성 주입 (DI)
- Repository 패턴
- 플랫폼 채널
- Flavor / Environment 분리
- 체크리스트
1. 아키텍처 개요 (Clean Architecture)
계층 구조
┌─────────────────────────────────────┐
│ Presentation │ ← UI, Widget, Controller/Notifier
├─────────────────────────────────────┤
│ Application │ ← Use Case, Service (비즈니스 흐름)
├─────────────────────────────────────┤
│ Domain │ ← Entity, Repository Interface, Value Object
├─────────────────────────────────────┤
│ Infrastructure │ ← Repository 구현체, Data Source, DTO
└─────────────────────────────────────┘
의존성 방향
| 규칙 | 설명 |
|---|---|
| 단방향 | Presentation → Application → Domain ← Infrastructure |
| Domain 독립 | Domain 계층은 어떤 외부 라이브러리에도 의존하지 않음 |
| 인터페이스 분리 | Domain에서 Repository 인터페이스 정의, Infrastructure에서 구현 |
계층별 책임
| 계층 | 포함 요소 | 의존 대상 | 금지 |
|---|---|---|---|
| Presentation | Widget, Page, Controller/Notifier | Application, Domain | 직접 DataSource 접근 |
| Application | UseCase, AppService | Domain | 직접 HTTP 호출, DB 접근 |
| Domain | Entity, ValueObject, RepoInterface | 없음 (순수 Dart) | Flutter 의존, 외부 패키지 |
| Infrastructure | RepoImpl, DataSource, DTO, Mapper | Domain, 외부 패키지 | UI 코드 |
2. 폴더 구조 (Feature-First)
전체 프로젝트 구조
lib/
├── main.dart # 앱 진입점
├── main_dev.dart # Dev flavor 진입점
├── main_staging.dart # Staging flavor 진입점
├── main_prod.dart # Prod flavor 진입점
│
├── app/ # 앱 설정
│ ├── app.dart # MaterialApp / ProviderScope
│ ├── router.dart # GoRouter 설정
│ ├── theme.dart # ThemeData 정의
│ └── env.dart # 환경 변수 관리
│
├── core/ # 공통 유틸 (feature와 무관)
│ ├── constants/ # 상수 (API URL, 키 등)
│ ├── errors/ # 커스텀 예외, Failure 클래스
│ ├── extensions/ # Dart/Flutter 확장 메서드
│ ├── network/ # Dio 클라이언트, 인터셉터
│ ├── storage/ # 로컬 저장소 (SharedPreferences, Hive)
│ ├── utils/ # 유틸 함수
│ └── widgets/ # 공통 위젯 (버튼, 로딩 등)
│
├── features/ # Feature 모듈 (핵심)
│ ├── auth/ # 인증 feature
│ │ ├── domain/
│ │ │ ├── entities/ # User, AuthToken
│ │ │ ├── repositories/ # AuthRepository (인터페이스)
│ │ │ └── usecases/ # LoginUseCase, LogoutUseCase
│ │ ├── data/
│ │ │ ├── datasources/ # AuthRemoteDataSource, AuthLocalDataSource
│ │ │ ├── models/ # UserModel (DTO, fromJson/toJson)
│ │ │ ├── mappers/ # UserMapper (DTO <-> Entity)
│ │ │ └── repositories/ # AuthRepositoryImpl
│ │ └── presentation/
│ │ ├── pages/ # LoginPage, RegisterPage
│ │ ├── widgets/ # LoginForm, SocialLoginButton
│ │ └── providers/ # authProvider, loginControllerProvider
│ │
│ ├── home/ # 홈 feature
│ ├── settings/ # 설정 feature
│ ├── subscription/ # 구독/결제 feature
│ └── notification/ # 알림 feature
│
└── l10n/ # 다국어 리소스
├── app_en.arb
└── app_ko.arb
Feature 모듈 내부 구조 (일관된 패턴)
features/{feature}/
├── domain/ # 비즈니스 규칙 (순수 Dart)
│ ├── entities/ # 비즈니스 엔티티
│ ├── repositories/ # Repository 인터페이스 (abstract class)
│ ├── usecases/ # 유스케이스 (하나의 비즈니스 동작)
│ └── value_objects/ # 값 객체 (Email, Password 등)
│
├── data/ # 데이터 접근 구현
│ ├── datasources/ # Remote/Local DataSource
│ ├── models/ # JSON 직렬화 가능 DTO
│ ├── mappers/ # DTO <-> Entity 변환
│ └── repositories/ # Repository 구현체
│
└── presentation/ # UI
├── pages/ # 전체 화면 위젯
├── widgets/ # feature 전용 위젯
└── providers/ # Riverpod Provider/Notifier
네이밍 컨벤션
| 유형 | 네이밍 | 예시 |
|---|---|---|
| 파일 | snake_case.dart | auth_repository.dart |
| 클래스 | PascalCase | AuthRepository |
| Provider | camelCaseProvider | authRepositoryProvider |
| UseCase | VerbNounUseCase | LoginUseCase |
| Page | NounPage | LoginPage |
| Widget | 기능 설명 | SocialLoginButton |
3. 상태 관리 (Riverpod)
권장 스택
| 라이브러리 | 버전 | 용도 |
|---|---|---|
flutter_riverpod | ^2.5+ | 상태 관리 |
riverpod_annotation | ^2.3+ | 코드 생성 기반 Provider |
riverpod_generator | ^2.4+ | 코드 생성기 |
Provider 유형 선택 가이드
| Provider 유형 | 용도 | 예시 |
|---|---|---|
@riverpod (함수) | 단순 값/비동기 데이터 | API 응답, 설정 값 |
@riverpod (클래스) | 상태 변경이 필요한 경우 | 폼 상태, 필터, 목록 관리 |
@Riverpod(keepAlive: true) | 앱 생명주기 동안 유지 | 인증 상태, 사용자 정보 |
코드 예시: Provider 정의
// features/auth/presentation/providers/auth_provider.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../domain/entities/user.dart';
import '../../domain/repositories/auth_repository.dart';
part 'auth_provider.g.dart';
/// 현재 인증 상태 (앱 전체 유지)
@Riverpod(keepAlive: true)
class AuthNotifier extends _$AuthNotifier {
@override
AsyncValue<User?> build() {
// 초기 상태: 저장된 토큰 확인
_checkAuthStatus();
return const AsyncValue.loading();
}
Future<void> _checkAuthStatus() async {
final repo = ref.read(authRepositoryProvider);
state = await AsyncValue.guard(() => repo.getCurrentUser());
}
Future<void> login({required String email, required String password}) async {
state = const AsyncValue.loading();
final repo = ref.read(authRepositoryProvider);
state = await AsyncValue.guard(
() => repo.login(email: email, password: password),
);
}
Future<void> logout() async {
final repo = ref.read(authRepositoryProvider);
await repo.logout();
state = const AsyncValue.data(null);
}
}
/// 로그인 여부 (파생 상태)
@riverpod
bool isAuthenticated(IsAuthenticatedRef ref) {
return ref.watch(authNotifierProvider).valueOrNull != null;
}
코드 예시: 비동기 데이터 로딩
// features/home/presentation/providers/dashboard_provider.dart
@riverpod
Future<DashboardData> dashboard(DashboardRef ref) async {
final repo = ref.watch(dashboardRepositoryProvider);
return repo.getDashboard();
}
// UI에서 사용
class DashboardPage extends ConsumerWidget {
const DashboardPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final dashboardAsync = ref.watch(dashboardProvider);
return dashboardAsync.when(
loading: () => const LoadingIndicator(),
error: (error, stack) => ErrorView(
message: error.toString(),
onRetry: () => ref.invalidate(dashboardProvider),
),
data: (data) => DashboardContent(data: data),
);
}
}
BLoC 대안 (팀 선호 시)
| 비교 항목 | Riverpod | BLoC |
|---|---|---|
| 학습 곡선 | 중간 | 높음 |
| 보일러플레이트 | 적음 (코드 생성) | 많음 (Event/State 클래스) |
| 테스트 용이성 | 높음 | 높음 |
| 대규모 팀 | 적합 | 매우 적합 (강제 패턴) |
| 코드 생성 | riverpod_generator | 선택적 (bloc 자 체 충분) |
BLoC 사용 시
flutter_bloc+bloc+equatable조합 권장.
4. 네비게이션 (GoRouter)
권장 패키지
| 패키지 | 용도 |
|---|---|
go_router | 선언적 라우팅 |
go_router_builder | 타입 안전 라우트 (코드 생성) |
라우터 설정
// app/router.dart
import 'package:go_router/go_router.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'router.g.dart';
@riverpod
GoRouter router(RouterRef ref) {
final isAuthenticated = ref.watch(isAuthenticatedProvider);
return GoRouter(
initialLocation: '/home',
debugLogDiagnostics: true,
// 인증 기반 리다이렉트
redirect: (context, state) {
final isLoginRoute = state.matchedLocation == '/login';
if (!isAuthenticated && !isLoginRoute) return '/login';
if (isAuthenticated && isLoginRoute) return '/home';
return null;
},
routes: [
// 로그인 (ShellRoute 밖)
GoRoute(
path: '/login',
builder: (context, state) => const LoginPage(),
),
// 메인 Shell (Bottom Navigation)
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) =>
MainScaffold(navigationShell: navigationShell),
branches: [
StatefulShellBranch(
routes: [
GoRoute(
path: '/home',
builder: (context, state) => const HomePage(),
routes: [
GoRoute(
path: 'detail/:id',
builder: (context, state) =>
DetailPage(id: state.pathParameters['id']!),
),
],
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: '/settings',
builder: (context, state) => const SettingsPage(),
),
],
),
],
),
],
);
}
딥 링크 설정
// app/router.dart (추가)
GoRouter(
// ...
// 딥 링크: myapp://host/path -> /path
// Universal Link: https://myapp.com/path -> /path
routes: [
GoRoute(
path: '/invite/:code',
builder: (context, state) =>
InvitePage(code: state.pathParameters['code']!),
),
// ...
],
);
iOS 설정 (ios/Runner/Info.plist):
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>myapp</string>
</array>
</dict>
</array>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:myapp.com</string>
</array>
Android 설정 (android/app/src/main/AndroidManifest.xml):
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="https" android:host="myapp.com"/>
</intent-filter>
<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="myapp"/>
</intent-filter>
5. 의존성 주입 (DI)
Riverpod 기반 DI (권장)
Riverpod 자체가 DI 컨테이너 역할을 수행하므로 별도 DI 패키지 불필요.
// core/network/dio_provider.dart
@Riverpod(keepAlive: true)
Dio dio(DioRef ref) {
final env = ref.watch(envProvider);
final dio = Dio(BaseOptions(
baseUrl: env.apiBaseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 30),
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'X-Client-Platform': 'flutter',
'X-Client-Version': env.appVersion,
},
));
// 인터셉터 등록
dio.interceptors.addAll([
AuthInterceptor(ref),
LogInterceptor(requestBody: env.isDev, responseBody: env.isDev),
RetryInterceptor(dio: dio, retries: 3),
]);
return dio;
}
// features/auth/data/datasources/auth_remote_data_source.dart
@riverpod
AuthRemoteDataSource authRemoteDataSource(AuthRemoteDataSourceRef ref) {
return AuthRemoteDataSource(dio: ref.watch(dioProvider));
}
// features/auth/data/repositories/auth_repository_impl.dart
@riverpod
AuthRepository authRepository(AuthRepositoryRef ref) {
return AuthRepositoryImpl(
remoteDataSource: ref.watch(authRemoteDataSourceProvider),
localDataSource: ref.watch(authLocalDataSourceProvider),
);
}
테스트에서 DI 오버라이드
// test/features/auth/auth_test.dart
void main() {
testWidgets('로그인 성공 시 홈 화면 이동', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
authRepositoryProvider.overrideWithValue(MockAuthRepository()),
dioProvider.overrideWithValue(mockDio),
],
child: const MyApp(),
),
);
// ...
});
}
6. Repository 패턴
Domain 계층 (인터페이스)
// features/auth/domain/repositories/auth_repository.dart
abstract class AuthRepository {
/// 이메일/비밀번호 로그인
Future<User> login({required String email, required String password});
/// OAuth PKCE 로그인 (공통 가이드 /platform/client/common/auth 참조)
Future<User> loginWithOAuth({required OAuthProvider provider});
/// 로그아웃
Future<void> logout();
/// 현재 인증 사용자 조회 (토큰 유효 시)
Future<User?> getCurrentUser();
/// 토큰 갱신
Future<AuthToken> refreshToken();
}
Infrastructure 계층 (구현체)
// features/auth/data/repositories/auth_repository_impl.dart
class AuthRepositoryImpl implements AuthRepository {
final AuthRemoteDataSource _remoteDataSource;
final AuthLocalDataSource _localDataSource;
AuthRepositoryImpl({
required AuthRemoteDataSource remoteDataSource,
required AuthLocalDataSource localDataSource,
}) : _remoteDataSource = remoteDataSource,
_localDataSource = localDataSource;
@override
Future<User> login({required String email, required String password}) async {
try {
final response = await _remoteDataSource.login(
email: email,
password: password,
);
// 토큰 로컬 저장
await _localDataSource.saveTokens(response.tokens);
// DTO -> Entity 변환
return response.user.toEntity();
} on DioException catch (e) {
throw _mapDioError(e);
}
}
@override
Future<User?> getCurrentUser() async {
final tokens = await _localDataSource.getTokens();
if (tokens == null) return null;
try {
final userModel = await _remoteDataSource.getMe();
return userModel.toEntity();
} on DioException catch (e) {
if (e.response?.statusCode == 401) {
await _localDataSource.clearTokens();
return null;
}
throw _mapDioError(e);
}
}
AppException _mapDioError(DioException e) {
return switch (e.type) {
DioExceptionType.connectionTimeout => const NetworkException('연결 시간 초과'),
DioExceptionType.receiveTimeout => const NetworkException('응답 시간 초과'),
_ when e.response?.statusCode == 401 => const AuthException('인증 실패'),
_ when e.response?.statusCode == 422 => ValidationException.fromResponse(e.response!),
_ => const ServerException('서버 오류가 발생했습니다'),
};
}
}
DTO (Data Transfer Object)
// features/auth/data/models/user_model.dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user_model.freezed.dart';
part 'user_model.g.dart';
@freezed
class UserModel with _$UserModel {
const factory UserModel({
required int id,
required String email,
required String name,
@JsonKey(name: 'avatar_url') String? avatarUrl,
@JsonKey(name: 'created_at') required DateTime createdAt,
}) = _UserModel;
factory UserModel.fromJson(Map<String, dynamic> json) =>
_$UserModelFromJson(json);
}
// Mapper 확장
extension UserModelMapper on UserModel {
User toEntity() => User(
id: id,
email: Email(email),
name: name,
avatarUrl: avatarUrl,
createdAt: createdAt,
);
}
7. 플랫폼 채널
Method Channel (1회성 호출)
// core/platform/native_bridge.dart
class NativeBridge {
static const _channel = MethodChannel('com.myapp/native');
/// 디바이스 고유 ID 조회
static Future<String> getDeviceId() async {
final id = await _channel.invokeMethod<String>('getDeviceId');
return id ?? 'unknown';
}
/// 네이티브 공유 다이얼로그
static Future<void> shareContent({
required String text,
String? imageUrl,
}) async {
await _channel.invokeMethod('share', {
'text': text,
'imageUrl': imageUrl,
});
}
}
iOS 구현 (ios/Runner/AppDelegate.swift):
let channel = FlutterMethodChannel(
name: "com.myapp/native",
binaryMessenger: controller.binaryMessenger
)
channel.setMethodCallHandler { (call, result) in
switch call.method {
case "getDeviceId":
result(UIDevice.current.identifierForVendor?.uuidString)
default:
result(FlutterMethodNotImplemented)
}
}
Android 구현 (android/app/src/.../MainActivity.kt):
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "com.myapp/native")
.setMethodCallHandler { call, result ->
when (call.method) {
"getDeviceId" -> result.success(
Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID)
)
else -> result.notImplemented()
}
}
Event Channel (스트림)
// core/platform/battery_monitor.dart
class BatteryMonitor {
static const _eventChannel = EventChannel('com.myapp/battery');
/// 배터리 레벨 스트림
static Stream<int> get batteryLevel {
return _eventChannel
.receiveBroadcastStream()
.map((event) => event as int);
}
}
Pigeon (타입 안전 채널) - 권장
수동 Method Channel 대신
pigeon패키지로 타입 안전 인터페이스 자동 생성 권장.
// pigeons/native_api.dart (Pigeon 정의 파일)
import 'package:pigeon/pigeon.dart';
@ConfigurePigeon(PigeonOptions(
dartOut: 'lib/core/platform/generated/native_api.g.dart',
kotlinOut: 'android/app/src/main/kotlin/com/myapp/NativeApi.g.kt',
swiftOut: 'ios/Runner/NativeApi.g.swift',
))
@HostApi()
abstract class NativeApi {
String getDeviceId();
@async
bool requestReview();
}
8. Flavor / Environment 분리
Flavor 구성
| Flavor | 용도 | API URL | 번들 ID 접미사 |
|---|---|---|---|
dev | 개발 | https://api-dev.myapp.com | .dev |
staging | QA/테스트 | https://api-staging.myapp.com | .staging |
prod | 프로덕션 | https://api.myapp.com | (없음) |
환경 설정 클래스
// app/env.dart
enum Flavor { dev, staging, prod }
class Env {
final Flavor flavor;
final String apiBaseUrl;
final String appVersion;
final bool enableLogging;
final String sentryDsn;
const Env._({
required this.flavor,
required this.apiBaseUrl,
required this.appVersion,
required this.enableLogging,
required this.sentryDsn,
});
bool get isDev => flavor == Flavor.dev;
bool get isProd => flavor == Flavor.prod;
static const dev = Env._(
flavor: Flavor.dev,
apiBaseUrl: 'https://api-dev.myapp.com',
appVersion: '1.0.0-dev',
enableLogging: true,
sentryDsn: '',
);
static const staging = Env._(
flavor: Flavor.staging,
apiBaseUrl: 'https://api-staging.myapp.com',
appVersion: '1.0.0-staging',
enableLogging: true,
sentryDsn: 'https://staging-dsn@sentry.io/xxx',
);
static const prod = Env._(
flavor: Flavor.prod,
apiBaseUrl: 'https://api.myapp.com',
appVersion: '1.0.0',
enableLogging: false,
sentryDsn: 'https://prod-dsn@sentry.io/xxx',
);
}
Flavor별 진입점
// main_dev.dart
import 'app/app.dart';
import 'app/env.dart';
void main() => runMyApp(Env.dev);
// main_staging.dart
void main() => runMyApp(Env.staging);
// main_prod.dart
void main() => runMyApp(Env.prod);
// app/app.dart
void runMyApp(Env env) {
WidgetsFlutterBinding.ensureInitialized();
runApp(
ProviderScope(
overrides: [
envProvider.overrideWithValue(env),
],
child: const MyApp(),
),
);
}
빌드 명령
# Dev
flutter run --flavor dev -t lib/main_dev.dart
# Staging
flutter run --flavor staging -t lib/main_staging.dart
# Prod (릴리즈)
flutter build appbundle --flavor prod -t lib/main_prod.dart --release
flutter build ipa --flavor prod -t lib/main_prod.dart --release
Android Flavor 설정 (android/app/build.gradle)
android {
flavorDimensions "environment"
productFlavors {
dev {
dimension "environment"
applicationIdSuffix ".dev"
resValue "string", "app_name", "MyApp Dev"
}
staging {
dimension "environment"
applicationIdSuffix ".staging"
resValue "string", "app_name", "MyApp Staging"
}
prod {
dimension "environment"
resValue "string", "app_name", "MyApp"
}
}
}
iOS Flavor 설정
Xcode에서 Dev, Staging, Prod Build Configuration + Scheme 생성.
ios/Flutter/ 에 flavor별 xcconfig 파일 배치:
ios/Flutter/
├── Dev.xcconfig
├── Staging.xcconfig
└── Prod.xcconfig
9. 체크리스트
아키텍처 설계 체크리스트
| # | 항목 | 필수 |
|---|---|---|
| 1 | Clean Architecture 4계층 분리 (Presentation/Application/Domain/Infrastructure) | O |
| 2 | Feature-First 폴더 구조 적용 | O |
| 3 | Domain 계층에 외부 패키지 의존 없음 (순수 Dart) | O |
| 4 | Repository 인터페이스는 Domain, 구현체는 Infrastructure | O |
| 5 | DTO와 Entity 분리 (freezed + json_serializable) | O |
| 6 | Riverpod Provider로 DI 관리 | O |
| 7 | GoRouter 기반 선언적 네비게이션 | O |
| 8 | Flavor 3종 (dev/staging/prod) 분리 | O |
| 9 | 딥 링크 / Universal Link 설정 | O |
| 10 | 플랫폼 채널 사용 시 Pigeon 코드 생성 | 권장 |
필수 패키지 목록
| 카테고리 | 패키지 | 용도 |
|---|---|---|
| 상태 관리 | flutter_riverpod, riverpod_annotation | Provider 기반 상태 관리 |
| 코드 생성 | riverpod_generator, build_runner | Provider 코드 생성 |
| 라우팅 | go_router | 선언적 라우팅 |
| HTTP | dio | API 클라이언트 |
| 직렬화 | freezed, json_serializable, freezed_annotation, json_annotation | 불변 DTO + JSON |
| 로컬 저장소 | shared_preferences, flutter_secure_storage | 설정/토큰 저장 |
| 에러 트래킹 | sentry_flutter | 크래시 리포트 |
| 린트 | flutter_lints 또는 very_good_analysis | 코드 품질 |
| 플랫폼 채널 | pigeon | 타입 안전 네이티브 통신 |