튜토리얼을 넘어 확장 가능한 Flutter 앱 아키텍처
상태 관리부터 내비게이션, 의존성 주입, 기능 중심 프로젝트 구조에 이르는 프로덕션 Flutter 아키텍처 패턴.

튜토리얼 Flutter 앱은 약 20개 화면에 도달할 때까지는 잘 작동합니다. 하지만 그 이후에는 모든 라우트가 포함된 단일 main.dart 파일이 관리 불가능해지고, setState 호출은 경쟁 조건을 생성하며, "생성자를 통해 그냥 전달"하는 방식은 10개의 매개변수를 가진 위젯 트리를 만들고, 새로운 기능 하나를 추가할 때마다 기존 파일 5개를 건드리게 됩니다. 저를 포함한 여러 프로젝트에서 이런 현상을 목격했으며, 패턴은 항상 동일합니다. 팀은 약 3개월쯤에 한계에 부딪히고, 구조를 재조정하거나 개발 속도 저하를 감수해야 합니다.
제가 여기서 설명하는 아키텍처는 프로덕션 앱에 사용하는 방식입니다. 가장 간단한 접근 방식은 아니며, 바로 그 점이 중요합니다. 가장 간단한 접근 방식은 튜토리얼에서 가르치는 것이며, 앱에 실제 요구 사항이 생기는 순간 작동을 멈춥니다.
튜토리얼 아키텍처가 실패하는 이유
대부분의 Flutter 튜토리얼과 강좌는 평면적인 프로젝트 구조를 가르칩니다:
lib/
├── main.dart
├── home_screen.dart
├── detail_screen.dart
├── user_model.dart
├── api_service.dart
└── widgets/
├── custom_button.dart
└── loading_indicator.dart
이는 할 일 관리 앱에는 효과적입니다. 하지만 실제 앱에서는 예측 가능한 이유로 실패합니다:
파일이 자연스러운 경계 없이 커집니다. home_screen.dart 파일에 화면 레이아웃, 상태 관리, API 호출, 비즈니스 로직이 모두 포함되면 빠르게 500줄 이상으로 늘어납니다. 무엇을 추출해야 하고 어디로 가야 하는지에 대한 구조적 지침이 없습니다.
의존성이 암묵적이 됩니다. home_screen.dart가 ApiService()를 직접 인스턴스화하면, 테스트에서 모의 객체로 교체할 방법이 없고, 환경별로 다르게 구성할 방법이 없으며, 동일한 데이터가 필요한 화면 간에 단일 인스턴스를 공유할 방법도 없습니다.
내비게이션이 하드코딩된 라우트의 거미줄이 됩니다. 딥 링크, 인증 가드, 조건부 흐름이 모두 평면적인 라우트 목록에 추가됩니다. 가드와 리다이렉트가 있는 30개의 라우트가 생길 때쯤이면, 내비게이션 코드는 앱에서 가장 취약한 부분이 됩니다.
테스트가 비실용적이 됩니다. 의존성 주입 없이는 위젯 테스트가 서비스 수준이 아닌 HTTP 수준에서 모의 객체를 필요로 합니다. UI와 비즈니스 로직의 명확한 분리 없이는 동작보다는 구현 세부 사항을 테스트하게 됩니다.
기능 중심 프로젝트 구조
가장 영향력 있는 아키텍처 결정은 폴더 구조입니다. 저는 각 기능이 자체 화면, 위젯, 상태 및 모델을 소유하는 기능 중심 접근 방식을 사용합니다:
lib/
├── app/
│ ├── app.dart # MaterialApp configuration
│ ├── router.dart # GoRouter configuration
│ └── theme.dart # Theme data
├── core/
│ ├── constants/
│ │ ├── api_constants.dart
│ │ └── app_constants.dart
│ ├── errors/
│ │ ├── app_exception.dart
│ │ └── error_handler.dart
│ ├── network/
│ │ ├── api_client.dart
│ │ ├── interceptors/
│ │ └── network_info.dart
│ └── utils/
│ ├── date_utils.dart
│ └── string_utils.dart
├── features/
│ ├── auth/
│ │ ├── data/
│ │ │ ├── auth_repository.dart
│ │ │ └── auth_remote_source.dart
│ │ ├── domain/
│ │ │ ├── user_model.dart
│ │ │ └── auth_state.dart
│ │ ├── presentation/
│ │ │ ├── screens/
│ │ │ │ ├── login_screen.dart
│ │ │ │ └── register_screen.dart
│ │ │ ├── widgets/
│ │ │ │ ├── login_form.dart
│ │ │ │ └── social_login_buttons.dart
│ │ │ └── providers/
│ │ │ └── auth_provider.dart
│ │ └── auth.dart # Barrel file for public API
│ ├── home/
│ │ ├── data/
│ │ ├── domain/
│ │ ├── presentation/
│ │ └── home.dart
│ └── settings/
│ ├── data/
│ ├── domain/
│ ├── presentation/
│ └── settings.dart
└── shared/
├── widgets/
│ ├── app_button.dart
│ ├── app_text_field.dart
│ ├── loading_overlay.dart
│ └── error_view.dart
├── models/
│ └── paginated_response.dart
└── providers/
└── connectivity_provider.dart
규칙
기능은 다른 기능에서 임포트하지 않습니다. 기능 A가 기능 B의 데이터가 필요하다면, 그 데이터는 기능 B의 폴더가 아닌 공유 모델이나 공유 서비스에 존재해야 합니다. 이는 순환 의존성을 방지하고 기능을 독립적으로 배포할 수 있게 합니다(모듈형 모노레포로 전환할 경우 유용).
core/ 디렉터리에는 인프라가 포함됩니다. 네트워킹, 오류 처리, 상수, 유틸리티 등 모든 기능이 필요할 수 있지만, 어떤 단일 기능에도 속하지 않는 것들입니다.
shared/ 디렉터리에는 재사용 가능한 UI와 횡단 관심사가 포함됩니다. 여러 기능이 의존하는 공유 위젯, 공유 모델 및 프로바이더입니다.
배럴 파일은 각 기능의 공개 API를 노출합니다. 앱의 다른 부분은 내부 파일이 아닌 features/auth/auth.dart에서 임포트합니다. 이를 통해 외부 소비자를 손상시키지 않고 기능의 내부 구조를 리팩터링할 수 있습니다.
// features/auth/auth.dart
export 'domain/user_model.dart';
export 'domain/auth_state.dart';
export 'presentation/providers/auth_provider.dart';
// 내부 구현 세부 사항은 내보내지 않습니다
상태 관리: Riverpod
저는 프로덕션 프로젝트에서 Provider, Bloc, Riverpod을 사용해 보았습니다. 각각 장단점이 있으며, 선택은 기술적 우위보다는 팀의 익숙함에 더 많이 좌우됩니다. 그렇지만 저는 특정 이유로 새로운 프로젝트에서 Riverpod을 선택합니다.
Bloc 대신 Riverpod을 선택하는 이유
Bloc은 엔터프라이즈 배경을 가진 팀에게 탁월합니다. 이벤트/상태 패턴은 Redux 또는 MVI를 사용해 본 개발자에게 익숙합니다. 엄격한 단방향 데이터 흐름을 강제하고 예측 가능하며 테스트 가능한 상태 머신을 생성합니다. 단점은 장황함입니다. 간단한 기능에도 이벤트 클래스, 상태 클래스, 블록 클래스가 필요하며, 종종 세 개의 파일에 걸쳐 있습니다. 복잡한 비즈니스 로직과 여러 개발자가 있는 앱의 경우 이러한 절차가 효과를 발휘합니다. 빠르게 움직이는 소규모 팀에게는 오버헤드처럼 느껴질 수 있습니다.
Provider (원래 패키지)는 간단한 의존성 주입에는 잘 작동하지만 복잡한 상태 관리에는 어려움을 겪습니다. 스코핑을 위해 위젯 트리에 의존하므로, 상태의 생명주기가 UI 구조에 묶이게 됩니다. 위젯 트리를 리팩터링하면 상태 관리가 미묘하게 깨질 수 있습니다.
Riverpod은 Provider의 근본적인 한계를 해결합니다. 프로바이더는 전역적이며(위젯 트리에 의존하지 않음), 컴파일 타임에 안전하며, 자동 폐기, 패밀리 프로바이더, 비동기 상태와 같은 고급 패턴을 지원합니다. 또한 테스트 가능성을 유지하면서 Bloc보다 적은 상용구 코드를 생성합니다.
Riverpod 실전
일반적인 기능에 대한 상태 관리를 구성하는 방법은 다음과 같습니다:
// features/auth/domain/auth_state.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'user_model.dart';
part 'auth_state.freezed.dart';
@freezed
class AuthState with _$AuthState {
const factory AuthState.initial() = _Initial;
const factory AuthState.loading() = _Loading;
const factory AuthState.authenticated(User user) = _Authenticated;
const factory AuthState.unauthenticated() = _Unauthenticated;
const factory AuthState.error(String message) = _Error;
}
// features/auth/presentation/providers/auth_provider.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../data/auth_repository.dart';
import '../../domain/auth_state.dart';
part 'auth_provider.g.dart';
@riverpod
class AuthNotifier extends _$AuthNotifier {
@override
AuthState build() {
_checkAuthStatus();
return const AuthState.initial();
}
Future<void> _checkAuthStatus() async {
final repository = ref.read(authRepositoryProvider);
final user = await repository.getCurrentUser();
state = user != null
? AuthState.authenticated(user)
: const AuthState.unauthenticated();
}
Future<void> signIn(String email, String password) async {
state = const AuthState.loading();
final repository = ref.read(authRepositoryProvider);
try {
final user = await repository.signIn(email, password);
state = AuthState.authenticated(user);
} on AuthException catch (e) {
state = AuthState.error(e.message);
}
}
Future<void> signOut() async {
final repository = ref.read(authRepositoryProvider);
await repository.signOut();
state = const AuthState.unauthenticated();
}
}
코드 생성을 위한 @riverpod 어노테이션을 사용하면 수동 프로바이더 선언 상용구 코드를 제거할 수 있습니다. dart run build_runner build를 실행하여 프로바이더 코드를 생성하세요.
Bloc이 더 나은 경우
Riverpod을 선호함에도 불구하고, Bloc이 더 강력한 선택이 되는 시나리오가 있습니다:
- 강제된 패턴이 불일치를 방지하는 대규모 팀.
- 많은 전환을 가진 복잡한 상태 머신 — Bloc의 이벤트 시스템은 이를 명시적이고 테스트 가능하게 만듭니다.
- 재생/실행 취소 기능이 필요할 때 — Bloc의 이벤트 로그는 이를 간단하게 만듭니다.
- 팀이 이미 Bloc에 익숙할 때. 코드베이스 전반의 일관성은 둘 사이의 어떤 기술적 차이보다 중요합니다.
GoRouter를 사용한 내비게이션
GoRouter는 Flutter 내비게이션의 표준이 되었으며, 그럴 만한 이유가 있습니다. 선언적 라우팅, 딥 링크, 리다이렉트, 중첩 내비게이션을 기본적으로 지원합니다.
// 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 authState = ref.watch(authNotifierProvider);
return GoRouter(
initialLocation: '/',
redirect: (context, state) {
final isAuthenticated = authState is Authenticated;
final isAuthRoute = state.matchedLocation.startsWith('/auth');
if (!isAuthenticated && !isAuthRoute) return '/auth/login';
if (isAuthenticated && isAuthRoute) return '/';
return null;
},
routes: [
GoRoute(
path: '/auth/login',
builder: (context, state) => const LoginScreen(),
),
GoRoute(
path: '/auth/register',
builder: (context, state) => const RegisterScreen(),
),
ShellRoute(
builder: (context, state, child) => AppShell(child: child),
routes: [
GoRoute(
path: '/',
builder: (context, state) => const HomeScreen(),
),
GoRoute(
path: '/settings',
builder: (context, state) => const SettingsScreen(),
),
GoRoute(
path: '/item/:id',
builder: (context, state) {
final id = state.pathParameters['id']!;
return ItemDetailScreen(itemId: id);
},
),
],
),
],
);
}
내비게이션 가드
redirect 콜백은 인증 가드를 전역적으로 처리합니다. 하지만 역할 기반 접근, 온보딩 흐름, 기능 플래그와 같은 더 복잡한 로직이 필요할 수 있습니다. 저는 이러한 것들을 리다이렉트 체인으로 처리합니다:
redirect: (context, state) {
// 인증 확인
final isAuthenticated = authState is Authenticated;
if (!isAuthenticated && !state.matchedLocation.startsWith('/auth')) {
return '/auth/login';
}
// 온보딩 확인
if (isAuthenticated && !hasCompletedOnboarding && state.matchedLocation != '/onboarding') {
return '/onboarding';
}
// 이미 인증됨, 인증 화면에서 리다이렉트
if (isAuthenticated && state.matchedLocation.startsWith('/auth')) {
return '/';
}
return null; // 리다이렉트 없음
},
리포지토리 패턴
리포지토리는 비즈니스 로직과 데이터 소스 사이에 위치합니다. 데이터가 REST API, 로컬 데이터베이스, 캐시 또는 이 세 가지의 조합에서 오는지 여부를 추상화합니다.
// features/auth/data/auth_repository.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../domain/user_model.dart';
part 'auth_repository.g.dart';
@riverpod
AuthRepository authRepository(AuthRepositoryRef ref) {
return AuthRepository(
remoteSource: ref.read(authRemoteSourceProvider),
localSource: ref.read(authLocalSourceProvider),
);
}
class AuthRepository {
final AuthRemoteSource _remoteSource;
final AuthLocalSource _localSource;
AuthRepository({
required AuthRemoteSource remoteSource,
required AuthLocalSource localSource,
}) : _remoteSource = remoteSource,
_localSource = localSource;
Future<User?> getCurrentUser() async {
// 먼저 로컬 캐시 시도
final cachedUser = await _localSource.getCachedUser();
if (cachedUser != null) return cachedUser;
// 원격으로 대체
try {
final user = await _remoteSource.fetchCurrentUser();
if (user != null) await _localSource.cacheUser(user);
return user;
} catch (_) {
return null;
}
}
Future<User> signIn(String email, String password) async {
final user = await _remoteSource.signIn(email, password);
await _localSource.cacheUser(user);
await _localSource.saveToken(user.token);
return user;
}
Future<void> signOut() async {
await _remoteSource.signOut();
await _localSource.clearCache();
}
}
API를 직접 호출하지 않는 이유
상태 관리 코드에서 API를 직접 호출하면 몇 가지 문제가 발생합니다:
- 캐싱 로직이 비즈니스 로직으로 스며듭니다. API 호출 전에 캐시를 확인해야 할까요? 캐시는 얼마나 유효할까요? 이는 데이터 계층의 관심사이지 상태 관리의 관심사가 아닙니다.
- 오프라인 지원이 재작업이 됩니다. Bloc이
http.get을 직접 호출한다면, 오프라인 지원을 추가하는 것은 모든 Bloc을 수정하는 것을 의미합니다. 리포지토리를 사용하면 캐싱 및 대체 로직을 한 곳에 추가할 수 있습니다. - 테스트에 HTTP 모의 객체가 필요합니다. 리포지토리를 모의하는 것은 테스트당 하나의 모의 객체입니다. HTTP를 모의하는 것은 모든 테스트 케이스에 대해 응답 본문, 상태 코드 및 헤더를 설정해야 합니다.
서비스 계층
여러 리포지토리에 걸쳐 있거나 복잡한 비즈니스 로직을 포함하는 작업의 경우, 서비스 계층을 사용합니다:
class OrderService {
final OrderRepository _orderRepo;
final PaymentRepository _paymentRepo;
final NotificationRepository _notificationRepo;
OrderService({
required OrderRepository orderRepo,
required PaymentRepository paymentRepo,
required NotificationRepository notificationRepo,
}) : _orderRepo = orderRepo,
_paymentRepo = paymentRepo,
_notificationRepo = notificationRepo;
Future<Order> placeOrder(Cart cart, PaymentMethod method) async {
// 재고 확인
await _orderRepo.validateStock(cart.items);
// 결제 처리
final payment = await _paymentRepo.charge(method, cart.total);
// 주문 생성
final order = await _orderRepo.create(
items: cart.items,
paymentId: payment.id,
);
// 확인 메시지 전송 (비동기)
_notificationRepo.sendOrderConfirmation(order).ignore();
return order;
}
}
서비스는 UI나 단일 리포지토리에는 속하지 않는 비즈니스 로직을 포함합니다. 상태 관리 계층(Riverpod notifier, Bloc)이 서비스를 호출하고, 서비스는 리포지토리 간의 조정을 담당합니다.
오류 처리
일관된 오류 처리는 Flutter 아키텍처에서 가장 소홀히 다루어지는 측면 중 하나입니다. 저는 타입이 지정된 예외와 결과 타입을 조합하여 사용합니다.
// core/errors/app_exception.dart
sealed class AppException implements Exception {
final String message;
final String? code;
const AppException(this.message, {this.code});
}
class NetworkException extends AppException {
const NetworkException([String message = 'Network error']) : super(message);
}
class ServerException extends AppException {
final int statusCode;
const ServerException(this.statusCode, String message) : super(message);
}
class AuthException extends AppException {
const AuthException([String message = 'Authentication failed']) : super(message);
}
class ValidationException extends AppException {
final Map<String, String> fieldErrors;
const ValidationException(this.fieldErrors) : super('Validation failed');
}
API 클라이언트는 HTTP 오류를 포착하여 타입이 지정된 예외로 변환합니다:
// core/network/api_client.dart
class ApiClient {
final Dio _dio;
ApiClient(this._dio);
Future<T> get<T>(String path, {T Function(dynamic)? fromJson}) async {
try {
final response = await _dio.get(path);
return fromJson != null ? fromJson(response.data) : response.data as T;
} on DioException catch (e) {
throw _mapError(e);
}
}
AppException _mapError(DioException error) {
if (error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.receiveTimeout) {
return const NetworkException('Connection timed out');
}
final statusCode = error.response?.statusCode;
if (statusCode == null) return const NetworkException();
return switch (statusCode) {
401 => const AuthException('Session expired'),
403 => const AuthException('Access denied'),
422 => ValidationException(
_parseValidationErrors(error.response?.data),
),
_ => ServerException(statusCode, 'Server error'),
};
}
}
테스트 아키텍처
좋은 아키텍처는 테스트를 간단하게 만듭니다. 각 계층에는 자체 테스트 전략이 있습니다:
리포지토리 테스트
void main() {
late AuthRepository repository;
late MockAuthRemoteSource mockRemote;
late MockAuthLocalSource mockLocal;
setUp(() {
mockRemote = MockAuthRemoteSource();
mockLocal = MockAuthLocalSource();
repository = AuthRepository(
remoteSource: mockRemote,
localSource: mockLocal,
);
});
group('getCurrentUser', () {
test('returns cached user when available', () async {
final user = User(id: '1', name: 'Test');
when(() => mockLocal.getCachedUser()).thenAnswer((_) async => user);
final result = await repository.getCurrentUser();
expect(result, equals(user));
verifyNever(() => mockRemote.fetchCurrentUser());
});
test('fetches from remote when cache is empty', () async {
final user = User(id: '1', name: 'Test');
when(() => mockLocal.getCachedUser()).thenAnswer((_) async => null);
when(() => mockRemote.fetchCurrentUser()).thenAnswer((_) async => user);
when(() => mockLocal.cacheUser(any())).thenAnswer((_) async {});
final result = await repository.getCurrentUser();
expect(result, equals(user));
verify(() => mockLocal.cacheUser(user)).called(1);
});
});
}
Riverpod을 사용한 프로바이더 테스트
void main() {
test('signIn updates state to authenticated', () async {
final user = User(id: '1', name: 'Test');
final mockRepository = MockAuthRepository();
when(() => mockRepository.signIn(any(), any()))
.thenAnswer((_) async => user);
final container = ProviderContainer(
overrides: [
authRepositoryProvider.overrideWithValue(mockRepository),
],
);
final notifier = container.read(authNotifierProvider.notifier);
await notifier.signIn('test@test.com', 'password');
final state = container.read(authNotifierProvider);
expect(state, AuthState.authenticated(user));
});
}
Riverpod의 ProviderContainer와 오버라이드 메커니즘은 테스트를 깔끔하게 만듭니다. 위젯 트리 수준이 아닌 컨테이너 수준에서 의존성을 오버라이드합니다.
실용적인 절충점
이 아키텍처는 공짜가 아닙니다. 실제 비용이 따릅니다:
더 많은 파일. 튜토리얼 앱에서는 하나의 파일이었을 기능이 이 구조에서는 5~8개의 파일이 될 수 있습니다. 작은 앱(10개 미만의 화면)의 경우 과하다고 느껴질 수 있습니다.
학습 곡선. 프로젝트에 새로 참여하는 개발자는 계층 경계와 각 요소를 어디에 배치해야 하는지 이해해야 합니다. 문서화가 도움이 되지만, 여전히 적응 시간이 필요합니다.
코드 생성. Riverpod의 어노테이션 시스템, 불변 모델을 위한 Freezed, JSON 직렬화는 모두 build_runner를 필요로 합니다. 빌드 생성은 개발 단계에 추가되며 대규모 코드베이스에서는 느릴 수 있습니다.
과도한 추상화의 유혹. 깔끔한 아키텍처를 갖추면 "만약을 대비하여" 추상화를 추가하기 쉽습니다. 단 하나의 구현만 가질 데이터 소스에 대한 리포지토리 인터페이스는 가치 없이 복잡성만 더합니다. 저는 구체적인 두 번째 구현이 있을 때 추상화를 만들지, 그 전에는 만들지 않습니다.
이 아키텍처는 앱이 약 10개 이상의 화면을 가지고 있거나, 개발자가 한 명 이상이거나, 수개월 이상의 수명을 가질 때 가치가 있습니다. 해커톤 프로젝트나 개념 증명(PoC)의 경우, 가장 간단하게 작동하는 것을 사용하세요. 하지만 앱이 성장해야 한다는 것을 알 때는 초기에 구조에 투자하는 것이 나중에 여러 배의 비용을 절감할 수 있습니다.