Android 개발자가 Flutter 를 만났을 때
Clean Architecture 와 MVVM 으로 상태 관리 라이브러리 없이 풀어낸 Flutter 개발기
Android 개발자가 Flutter 를 만났을 때
Clean Architecture 와 MVVM 으로 상태 관리 라이브러리 없이 풀어낸 Flutter 개발기
Photo by AltumCode on Unsplash
몇 년 전만 해도, 크로스플랫폼 프레임워크는 대체로 ‘보조적인 개발 수단’ 정도로 여겨졌다고 생각했습니다. Flutter, React Native 등 다양한 선택지가 있었지만, 대부분은 다음과 같은 인식을 벗어나지 못했습니다.
- “퍼포먼스는 결국 Native가 최고야.”
- “Flutter를 사용하더라도 결국 상당 부분을 Native로 개발해야해”
- “한 번에 두 플랫폼을 개발할 수 있다지만, 결국 학습 비용도 크고 유지보수가 더 복잡하던데?”
Flutter도 그런 인식의 벽을 한동안 넘지 못했습니다. 특히 Android 개발자로서의 관점에서는 Jetpack 생태계와의 긴밀한 통합, Platform Channel의 복잡성, 생명주기와 상태관리의 이질감 등이 걸림돌로 보였습니다.
하지만 시간이 흐르면서 Flutter는 단순한 ‘대안’이 아닌, 충분히 실전에서도 주력 기술이 될 수 있는 수준까지 진화한 것 같습니다. 플랫폼 간 분리로 인한 개발 비효율과 인력 문제를 해결하기 위해, 성능 우려를 직접 검증한 후, Flutter를 도입하여 실제 서비스에 적용한 사례도 늘어나고 있는 추세니까요.
Android 개발을 3년 6개월동안 해오면서 항상 느꼈던 한계 중 하나는 개발 리소스의 확장성이었습니다. 플랫폼은 늘어가는데, 개발자는 한정적이기 때문입니다. 그런 와중에 Flutter는 단순히 크로스 플랫폼이라는 메리트를 넘어서, 개발자 친화적인 UI 선언형 방식과 높은 생산성을 보여주며 눈에 띄기 시작했고 1년정도 개발 중에 있습니다.
Flutter를 그냥 써보고 끝내고 싶지 않았고 Android 개발자로서의 설계 철학과 아키텍처 경험을 Flutter에서도 구현하고 싶었습니다.
또한, 이런 과정은 추구하는 설계 원칙과 개발 가치를 유지한 채, 멀티 플랫폼을 실현할 수 있는 도구로 단순한 앱 개발이 아니라 의미 있는 구조를 구현할 수 있는지 검증하는 도전이기도 했습니다.
- 프로젝트에 사용한 패키지
- 내가 생각한 Flutter 에서의 Clean Architecture
- 상태 관리 라이브러리를 사용하지 않은 이유
- Clean Architecture 와 상태 관리를 어떻게 적용했는지
해당 프로젝트에는
- Architecture: Clean Architecture, MVVM Pattern
- State management: ChangeNotifier, ListenableBuilder (No Library)
- Navigation: go_router
- DI: get_it, injectable
- REST API: dio
- Data class: freezed
- Error Handling: Result Object
- Shared Preferences: flutter_secure_storage
- CI/CD: Github Actions
와 같은 패키지가 사용되었으며 Architecture 와 Error Handling 은 공식 문서를 참고했습니다.
Clean Architecture in Flutter: 디렉터리가 아닌, 진짜 계층 분리를 위하여
Flutter 공식 문서나 튜토리얼을 보면 대부분의 프로젝트 구조는 이렇게 되어 있습니다.
lib/
├── data/
├── domain/
└── presentation/
겉보기에 깔끔해 보이지만, 제 입장에서는 이 구조가 상당히 찝찝했습니다. 왜냐하면, 이건 단순 디렉터리 분리일 뿐, 의존성 분리가 제대로 되지 않기 때문입니다.
Android에서는 각 레이어를 독립된 Module로 분리하고, 의존성 방향을 아예 구조적으로 통제할 수 있었는데 Flutter에서도 이런 구조적 분리를 가능하게 해주는 도구가 있을까 찾아보니 Melos 를 알게 되었습니다.
Melos는 Flutter에서 monorepo 스타일로 멀티 패키지를 관리할 수 있게 해주는 도구입니다. 이를 활용하여 다음과 같이 실제 의존성 방향까지 제어되는 구조를 만들 수 있었습니다.
packages/
├── domain/ # 순수한 비즈니스 로직, Entity, UseCase, Repository 인터페이스
├── data/ # Repository 구현체, Remote DataSource, LocalSource 등
├── app/ # presentation, main()
├── di/ # DI 초기화
이 구조의 장점은 명확합니다.
- domain은 data나 presentation을 전혀 모르기 때문에 SOLID 원칙을 지키기 유리합니다.
- Android에서 하던 것처럼 단방향 의존성을 유지할 수 있습니다.
- 테스트, 유지보수, 리팩토링 모두 구조적으로 안전합니다.
왜 상태관리 라이브러리를 사용하지 않았을까?
여러 Flutter 프로젝트나 회사들의 기술 스택을 보면 다양한 상태관리 라이브러리를 쓰고 있지만 저는 Flutter 자체의 프레임워크만으로 상태를 관리해보고 싶었으며 ChangeNotifier, ListenableBuilder, setState 등의 기본 제공 도구만으로도 충분히 가능하다고 생각했습니다.
제가 느낀 가장 큰 문제는 Riverpod 이나 BLoC 과 같은상태관리 라이브러리들은 Flutter 위에 또 다른 프레임워크를 얹는 느낌이었습니다. 또한, 초기 진입 장벽도 생기기 때문에 상태관리 라이브러리에 종속되지 않고 상태 변경만 책임지는 ViewModel을 만들고, 비즈니스 로직은 도메인 계층에 두려고 했습니다.
그래서 저는 MVVM 패턴을 기반으로, ViewModel에 ChangeNotifier를 상속하고 ListenableBuilder 를 활용해 View에서 상태를 구독하는 구조를 택했습니다.
Clean Architecture 와 MVVM, 그리고 상태 관리 및 Error Handling
클린 아키텍처는 3가지 계층으로 나눌 수 있습니다.
- Domain Layer: Entity 및 UseCase 를 포함한 핵심 비즈니스 로직입니다. 프레임워크 및 외부 시스템으로부터 독립적입니다.
- Data Layer: 네트워크 통신을 통한 API 호출이나 로컬 저장소의 호출을 처리하고 외부 데이터를 도메인 Entity에 매핑합니다.
- Presentation Layer: UI와 상태 관리 로직을 포함합니다. 해당 글에서는 MVVM-Pattern 을 적용하여 UseCase 를 통해 도메인 계층과 상호 작용합니다.
1. Domain Layer
도메인 계층은 Flutter나 외부 시스템과 무관하게 앱의 핵심 비즈니스 로직을 정의 해야됩니다. 이 계층은 아래 세 가지 요소로 구성됩니다.
Entity:
class CommonEntity {
final String code;
final Map<String, dynamic>? data;
CommonEntity({
required this.code,
this.data,
});
@override
String toString() {
return 'CommonEntity(code: $code, data: $data)';
}
}
Repository:
abstract class UserRepository {
Future<Result<CommonEntity>> createAccount({
required String email,
required String password,
});
...
}
UseCase:
class CreateAccountUseCase {
final UserRepository _userRepository;
CreateAccountUseCase(this._userRepository);
Future<Result<CommonEntity>> execute({
required String email,
required String password,
}) {
return _userRepository.createAccount(
email: email,
password: password,
);
}
}
도메인 계층에서의 지켜져야할 원칙은
- SRP (단일 책임 원칙): 각 Use Case는 하나의 책임만을 수행합니다.
- OCP (개방/폐쇄 원칙): Repository 구현을 바꾸더라도 Use Case는 변경되지 않습니다.
- ISP (인터페이스 분리 원칙): 외부 구현과 무관하게 동작해야 하므로, interface 를 정의해 의존성을 분리하며 필요한 기능만 Repository에 정의합니다.
와 같으며, 독립적이어야 하기에 순수 Dart 언어로만 구성하기 위해 Entity 를 만들기 위한 freezed 나 의존성 주입을 위한 패키지는 사용하지 않았습니다.
또한, Error Handling 을 위한 Sealed class 인 Result class 는
sealed class Result<T> {
const Result();
factory Result.success(T value) => Success(value);
factory Result.failure(String code, Map<String, dynamic>? data) => Failure(code, data);
factory Result.error(String error) => Error(error);
R when<R>({
required R Function(T value) success,
required R Function(String code, Map<String, dynamic>? data) failure,
required R Function(String error) error,
}) {
if (this is Success) {
return success((this as Success).value);
} else if (this is Failure) {
return failure((this as Failure).code, (this as Failure).data);
} else if (this is Error) {
return error((this as Error).error);
}
throw Exception('Unknown Result');
}
}
final class Success<T> extends Result<T> {
const Success(this.value);
final T value;
}
final class Failure<T> extends Result<T> {
const Failure(this.code, this.data);
final String code;
final Map<String, dynamic>? data;
}
final class Error<T> extends Result<T> {
const Error(this.error);
final String error;
}
공식 문서에서 제공하는 것에 더불어서 프레젠테이션 계층에서 상황에 따라 명확하게 분리하기 위한 when 함수까지 붙여서 위와 같이 사용하고 있습니다.
2. Data Layer
데이터 계층에서는 외부 데이터 소스(네트워크 통신을 통한 API 호출, 로컬 저장소 등)를 통해 데이터를 가져오고 도메인 모델로 매핑합니다.
DTO:
서버 응답을 직접 표현하는 모델로 직렬화/역직렬화와 보다 쉽게 DTO 를 생성하기 위해 freezed와 json_serializable을 사용해 boilerplate를 줄였습니다.
@freezed
class CommonDto with _$CommonDto {
const factory CommonDto({
required String code,
Map<String, dynamic>? data,
}) = _CommonDto;
factory CommonDto.fromJson(Map<String, dynamic> json) =>
_$CommonDtoFromJson(json);
}
DataSource/LocalSource:
DataSource 는 원격 저장소(Remote) 에서 실제 네트워크 통신을 통해 API 를 호출하는 로직만 담고 있고, LocalSource 는 ObjectBox 나 Shared Preference 와 같은 내부 DB 의 로직을 포함하고 있습니다. 마찬가지로, 추상화를 하고 구현체 클래스를 따로 두었습니다.
abstract class UserDataSource {
Future<Result<CommonDto>> createAccount({
required String email,
required String password,
});
... // 기타 여러 함수
}
class UserDataSourceImpl implements UserDataSource {
final NoneAuthApiService _noneAuthApiService;
@override
Future<Result<CommonDto>> createAccount({
required String email,
required String password,
}) {
return _noneAuthApiService.createAccount(
email: email,
password: password,
);
}
... // 기타 여러 함수
}
Service:
API 호출은 Service 레벨에서 실제 API별 엔드포인트와 파라미터만 명시합니다.
@lazySingleton
class NoneAuthApiService {
final NoneAuthApiClient _noneAuthApiClient;
NoneAuthApiService(
this._noneAuthApiClient,
);
/*
* ------------user---------------------
* */
Future<Result<CommonDto>> createAccount({
required String email,
required String password,
}) async {
return _noneAuthApiClient.request(
method: RestMethod.post,
path: '/api/v1/users/',
data: {
'email': email,
'password': password,
},
fromJson: CommonDto.fromJson,
);
}
... // 기타 여러 함수
}
ApiClient:
RestApiClient 는 공통적인 통신 로직을 캡슐화하고, AuthApiClient 와 NoneAuthApiClient 로 나누었습니다. 클래스명에서 알 수 있듯 두 클래스의 차이는 DioBuilder 를 통해 Client 를 생성할 때 interceptor 의 유뮤입니다.
enum RestMethod { get, post, put, patch, delete }
class RestApiClient {
RestApiClient({required this.dio});
final Dio dio;
Future<Result<T>> request<T>({
required RestMethod method,
required String path,
Map<String, dynamic>? queryParameters,
dynamic data,
required T Function(Map<String, dynamic>) fromJson,
}) async {
try {
final response = await _requestByMethod(
method: method,
path: path,
queryParameters: queryParameters,
data: data,
);
if (response.statusCode! >= 200 && response.statusCode! < 300) {
return Result.success(fromJson(response.data));
} else {
final data = CommonDto.fromJson(response.data);
return Result.failure(data.code, data.data);
}
} on DioException catch (e) {
if (e.response != null && e.response!.data != null) {
try {
final data = CommonDto.fromJson(e.response!.data);
return Result.failure(data.code, data.data);
} catch (_) {
return Result.error(e.message ?? 'Network error occurred');
}
}
return Result.error(e.message ?? 'Network error occurred');
} catch (e) {
return Result.error(e.toString());
}
}
Future<Response> _requestByMethod({
required RestMethod method,
required String path,
Map<String, dynamic>? queryParameters,
dynamic data,
}) {
switch (method) {
case RestMethod.get:
return dio.get(path, queryParameters: queryParameters);
case RestMethod.post:
return dio.post(path, data: data, queryParameters: queryParameters);
case RestMethod.put:
return dio.put(path, data: data, queryParameters: queryParameters);
case RestMethod.patch:
return dio.patch(path, data: data, queryParameters: queryParameters);
case RestMethod.delete:
return dio.delete(path, data: data, queryParameters: queryParameters);
}
}
}
@lazySingleton
class AuthApiClient extends RestApiClient {
AuthApiClient(AuthInterceptor authInterceptor)
: super(
dio: DioBuilder.createDio(
interceptors: [authInterceptor],
),
);
}
Repository Implementation:
Repository 구현체에서는 필요에 따라 DataSource 와 LocalSource 의 함수를 결합하여 사용하면 되며 Entity로 매핑한 후 Domain Layer로 전달합니다.
@LazySingleton(as: UserRepository)
class UserRepositoryImpl implements UserRepository {
final UserDataSource _userDataSource;
const UserRepositoryImpl(this._userDataSource);
@override
Future<Result<CommonEntity>> createAccount({
required String email,
required String password,
}) async {
final response = await _userDataSource.createAccount(
email: email,
password: password,
);
return CommonMapper.toEntityResult(response);
}
... // 기타 여러 함수
}
데이터 계층에서 지켜져야할 원칙은
- DIP (의존성 역전 원칙): 고수준 모듈은 저수준 모듈에 의존해서는 안됨으로 구현이 아닌 인터페이스에 의존합니다.
- LSP (리스코프 치환 원칙): Repository 에 영향을 주지 않고 DataSource 를 바꿀 수 있습니다.
3. Presentation Layer
프레젠테이션 계층에서는 MVVM-Pattern 을 적용하여 UI 및 상태 관리 로직을 포함합니다.
State:
@freezed
class SignUpUiState with _$SignUpUiState {
const factory SignUpUiState({
@Default(false) bool isLoading,
@Default(false) bool isSuccess,
String? code,
String? errorMessage,
}) = _SignUpUiState;
}
ViewModel:
특별한 상태관리 라이브러리 없이 ChangeNotifier 만으로 충분히 구조적이고 명확한 상태 흐름을 가질 수 있었습니다. 로딩, 성공, 에러 상태 등 ViewModel 이 직접 관리하고, View 에서는 이 ViewModel 만 구독하면 됩니다.
@injectable
class SignUpViewModel extends ChangeNotifier {
final CreateAccountUseCase _createAccountUseCase;
SignUpViewModel(this._createAccountUseCase);
SignUpUiState _state = const SignUpUiState();
SignUpUiState get state => _state;
Future<void> createAccount(String email, String password) async {
if (_state.value.isLoading) return; // 중복 호출 방지
_state.value = _state.value.copyWith(isLoading: true);
final result = await _createAccountUseCase.execute(
email: email,
password: password,
);
result.when(
success: (entity) {
... // 내부 로직
},
failure: (code, _) => _state = _state.copyWith(
isLoading: false,
code: code,
),
error: (message) => _state = _state.copyWith(
isLoading: false,
errorMessage: message,
),
);
notifyListeners();
}
void clearState() {
_state.value = const SignUpUiState();
}
}
Page:
class SignUpPage extends StatefulWidget {
const SignUpPage({super.key});
@override
State<SignUpPage> createState() => _SignUpPage();
}
class _SignUpPage extends State<SignUpPage> {
final _viewModel = getIt<SignUpPage>();
...
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
leading: IconButton(
padding: EdgeInsets.only(left: 16.w),
icon: Icon(Icons.arrow_back_ios, color: AppColors.white),
onPressed: () => context.pop(),
),
),
body: ListenableBuilder(
listenable: _viewModel,
builder: (context, _) {
final state = _viewModel.state;
if (state.isSuccess) {
// 내부 로직
}
if (state.code != null) {
// 내부 로직
}
return Stack(
children: [
// 내부 UI
if (state.isLoading) ...[
// 내부 로직
],
],
);
},
),
);
}
}
이로써 Clean Architecture 구조에 따라 분리하고, ChangeNotifier 와 ListenableBuilder 을 활용한 상태 관리로 유지보수성과 확장성을 확보했습니다. 각 계층이 담당하는 역할이 명확하며, SOLID 원칙을 통해 유연하고 테스트 가능한 구조를 만들어낼 수 있었습니다.
마지막으로 느낀 점은, 상태관리 라이브러리를 쓰지 않는다고 해서 아키텍처가 무너지진 않았던 것 같고 오히려 구조가 단순해지고 의존성이 줄어들며, 유지보수와 테스트가 훨씬 쉬워진 것 같습니다.
끝.
메타데이터
- post_id
- 2fc0fdaa4e3c
- slug
- android-개발자가-flutter-를-만났을-때-2fc0fdaa4e3c
- url
- https://medium.com/@DevYoon/android-%EA%B0%9C%EB%B0%9C%EC%9E%90%EA%B0%80-flutter-%EB%A5%BC-%EB%A7%8C%EB%82%AC%EC%9D%84-%EB%95%8C-2fc0fdaa4e3c
- canonical_url
- https://medium.com/@DevYoon/android-%EA%B0%9C%EB%B0%9C%EC%9E%90%EA%B0%80-flutter-%EB%A5%BC-%EB%A7%8C%EB%82%AC%EC%9D%84-%EB%95%8C-2fc0fdaa4e3c
- author_url
- https://medium.com/@DevYoon
- status
- ok
- fetched_at
- 2026-06-12 07:40:50