Skip to main content

Flutter 앱 아키텍처 가이드

작성일: 2026-04-06 Last Updated: 2026-04-06 대상: Flutter Mobile App (iOS / Android) 공통 참조: ../common/ (인증, 보안, API 연동, 테스트 등은 공통 가이드 참조)


목차

  1. 아키텍처 개요 (Clean Architecture)
  2. 폴더 구조 (Feature-First)
  3. 상태 관리 (Riverpod)
  4. 네비게이션 (GoRouter)
  5. 의존성 주입 (DI)
  6. Repository 패턴
  7. 플랫폼 채널
  8. Flavor / Environment 분리
  9. 체크리스트

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에서 구현

계층별 책임

계층포함 요소의존 대상금지
PresentationWidget, Page, Controller/NotifierApplication, Domain직접 DataSource 접근
ApplicationUseCase, AppServiceDomain직접 HTTP 호출, DB 접근
DomainEntity, ValueObject, RepoInterface없음 (순수 Dart)Flutter 의존, 외부 패키지
InfrastructureRepoImpl, DataSource, DTO, MapperDomain, 외부 패키지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.dartauth_repository.dart
클래스PascalCaseAuthRepository
ProvidercamelCaseProviderauthRepositoryProvider
UseCaseVerbNounUseCaseLoginUseCase
PageNounPageLoginPage
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 대안 (팀 선호 시)

비교 항목RiverpodBLoC
학습 곡선중간높음
보일러플레이트적음 (코드 생성)많음 (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
stagingQA/테스트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. 체크리스트

아키텍처 설계 체크리스트

#항목필수
1Clean Architecture 4계층 분리 (Presentation/Application/Domain/Infrastructure)O
2Feature-First 폴더 구조 적용O
3Domain 계층에 외부 패키지 의존 없음 (순수 Dart)O
4Repository 인터페이스는 Domain, 구현체는 InfrastructureO
5DTO와 Entity 분리 (freezed + json_serializable)O
6Riverpod Provider로 DI 관리O
7GoRouter 기반 선언적 네비게이션O
8Flavor 3종 (dev/staging/prod) 분리O
9딥 링크 / Universal Link 설정O
10플랫폼 채널 사용 시 Pigeon 코드 생성권장

필수 패키지 목록

카테고리패키지용도
상태 관리flutter_riverpod, riverpod_annotationProvider 기반 상태 관리
코드 생성riverpod_generator, build_runnerProvider 코드 생성
라우팅go_router선언적 라우팅
HTTPdioAPI 클라이언트
직렬화freezed, json_serializable, freezed_annotation, json_annotation불변 DTO + JSON
로컬 저장소shared_preferences, flutter_secure_storage설정/토큰 저장
에러 트래킹sentry_flutter크래시 리포트
린트flutter_lints 또는 very_good_analysis코드 품질
플랫폼 채널pigeon타입 안전 네이티브 통신

Related: 공통 API 연동 | 공통 인증 | 공통 테스트