팩토리 패턴을 응용한 플러터의 클린 아키텍처
클린 아키텍처
전 아직도 이 그림이 뭔지 하나도 모르겠습니다. 그럼에도 불구하고 여러 블로그를 탐방하며 클린 아키텍처를 최대한 이해하려고 했고, 저만의 클린 코드를 작성해 보았습니다.
클린 아키텍처를 공부하면서 맘에 안들었던 점
폴더 구조가 크게 두가지가 있습니다. Feature-first와 Layer-first 입니다. 최상위에 위치하는 폴더가 앱의 기능 단위인 것과 클린 아키텍처의 Data, Domain, Presentation 계층인 것입니다.
그런데 전... 둘 다 썩 맘에 들지 않았습니다. Feature-first는 기능 단위로 코드 구조가 눈에 확 들어와서 좋긴 한데, 둘 이상의 Feature가 공유하는 코드가 있다면 그게 전부 common 폴더로 모이게 됩니다. 그러면 common 폴더가 비대해지면서 폴더 구조의 체계가 모호해지는 부분이 있습니다. Layer-first는 기능을 수정해야 할 때 폴더를 이리 저리 돌아다녀야 하고... 좀 난잡한 느낌이 듭니다.
그리고 클린 아키텍처를 공부하면서 새롭게 배운 단어들이 생소했습니다. Entity, DTO, Domain, Presentation... 이 단어들을 쓰고 싶지 않았습니다. 뭐 얼마든지 바꾸면 되겠지만, 네이밍 관련해서도 깊게 고민했습니다. 실제로 저와 같이 사이드 프로젝트 진행하는 동료들이 제 프로젝트를 보고 원하는 코드를 찾는 데 좀 어려웠다고 전했습니다. 클린 아키텍처를 잘 모르는 개발자라면 entity가 뭐고, domain이 뭔지 잘 모릅니다.
또한 DTO 클래스의 필요성을 전혀 느끼지 못했습니다. 왜냐하면 Dart에는 factory 생성자가 있기 때문입니다. 굳이 DTO 클래스를 만들지 않더라도 충분히 외부 환경에 유연하게 대응할 수 있겠다고 생각했습니다.
그렇게 고민을 하면서 저만의 클린 코드를 만들게 되었습니다. 먼저, 코드의 세부 구성부터 보여드리겠습니다.
외부 모델 DTO와 내부 모델 Entity -> Model로 통일
데이터 모델의 경우, DTO와 Entity로 나눕니다. DTO는 서버나 로컬 DB와 같은 외부에서 받는 모델이며 data 계층에 구현합니다. 반면 Entity는 presentation 계층을 위한 내부 모델이며 domain 계층에 구현합니다.
클린 아키텍처는 이걸 따로 구현해서 서버 환경에 유연하게 대응하라는데... Flutter의 Dart는 굳이 그럴 필요가 있을까 싶습니다. 위에서 언급했듯이 factory 생성자가 있기 때문입니다.
factory 패턴이라고도 부릅니다. 클래스를 만들면 보통은 생성자라고 해서 인스턴스를 생성할 때의 규칙을 정의하는 부분이 있습니다. 그런데 이 생성자를 만드는 부분에 factory 키워드를 붙여주면 생성자를 함수처럼 사용할 수 있습니다. 파라미터 부분에 required this.id, required this.name... 이런식으로 쓰는게 아니라 Map<String, dynamic> json과 같이 함수의 파라미터처럼 정의해줄 수 있습니다. 플러터를 하면 무조건 보게 되는 fromJson 네임드 생성자 많이 보셨죠? 비슷한 방법입니다.
final class UserModel {
/// 유저 ID
final int id;
/// 유저 닉네임
final String nickname;
/// 유저 상태 메세지
final String? stateMsg;
/// 유저 프로필 URL
final String? profileUrl;
/// 매너온도
final double mannerTemperature;
UserModel({
required this.id,
required this.nickname,
required this.stateMsg,
required this.profileUrl,
required this.mannerTemperature,
});
factory UserModel.fromJson(Map<String, dynamic> json) {
return switch (ApiEnv.serverEnvironment) {
ServerEnvironment.dev => UserModel(
id: json['id'],
nickname: json['nickname'],
stateMsg: json['stateMsg'],
profileUrl: json['profileUrl'],
mannerTemperature: json['mannerTemperature'],
),
ServerEnvironment.production => UserModel(
id: json['id'],
nickname: json['userNickname'],
stateMsg: json['userStateMsg'],
profileUrl: json['userprofile'],
mannerTemperature: json['usermt'],
),
};
}
Map<String, dynamic> toJson() {
return switch (ApiEnv.serverEnvironment) {
ServerEnvironment.dev => {
'id': id,
'nickname': nickname,
'stateMsg': stateMsg,
'profileUrl': profileUrl,
'mannerTemperature': mannerTemperature,
},
ServerEnvironment.production => {
'id': id,
'userNickname': nickname,
'userStateMsg': stateMsg,
'userprofile': profileUrl,
'usermt': mannerTemperature,
},
};
}
}
factory UserModel.fromJson과 toJson에 주목해주세요. switch 문으로 분기 처리 후에 서버의 환경에 따라 객체를 적절히 반환해주고 있습니다. 서버 환경마다 데이터 구조가 달라도 문제 없습니다. fromJson과 toJson만 잘 수정해주면 됩니다.
이렇게 하면 DTO와 Entity를 굳이 나누지 않고 Model 클래스로 통일할 수 있습니다. 생소한 두 단어를 쓰지 않고 Model이란 직관적인 단어를 사용하여 가독성을 증가시킵니다. 코드의 양도 줄어듭니다.
Repository 인터페이스를 통해 구현체 반환
리포지토리 인터페이스는 다음과 같이 구성했습니다.
abstract interface class AuthRepository {
factory AuthRepository() {
return switch (ApiEnv.serverEnvironment) {
ServerEnvironment.dev => AuthRepoImplDev(),
ServerEnvironment.production => AuthRepoImplProduction(),
};
}
/// 이메일 로그인
Future<TokenModel> emailLogin({
required String email,
required String password,
});
/// 소셜 로그인
Future<TokenModel> socialLogin({
required String idToken,
required String accessToken,
});
/// 액세스 토큰 재발급
Future<String> refreshAccessToken({required String refreshToken});
}
인터페이스 코드입니다. abstract와 interface 키워드가 붙어있습니다. 그리고 factory AuthRepository() 생성자를 통해 서버 환경별로 구현체를 알맞게 반환합니다.
factory 키워드가 참 좋습니다. abstract 클래스는 인스턴스를 만들지 못하는 클래스인데, factory 생성자를 통해 인스턴스를 만들어낼 수 있습니다.
다음은 Use Case 코드의 일부입니다.
sealed class AuthUseCase {
static final _tokenStorage = TokenStorage();
static final _repo = AuthRepository();
static Future<bool> signIn({
required AuthType authType,
String? email,
String? password,
}) async {
...
...
3번째 라인에 static final _repo = AuthRepository(); 코드 보이시나요? 여기서 AuthRepository 클래스는 분명 추상화된 클래스이지만, factory 생성자를 통해 서버 환경에 맞는 구현체를 반환할 것입니다.
폴더구조
코드 역할을 기반으로 폴더 구조를 형성하면 어떨까 싶습니다. 데이터 구조를 담당하는 Model, 외부에서 데이터를 불러오는 Repository 처럼 말이죠.
따라서 저는 코드의 역할을 최상위 폴더로 설정합니다. Feature-first도 Layer-first도 아닌 Role-first(?) 입니다.
전체
전체적인 구조입니다. lib 아래에 core, model, provider, repository, ui, use_case가 있습니다.
core
core 폴더에는 main.dart 파일, 여러 상수(Color, asset path string, native_key, server IP string 등), 파이어베이스 환경 설정 파일, Go Router, local database(ObjectBox, flutter secure storage 등) 설정 등이 들어갑니다. 앱의 환경, 전체적으로 쓰이는 상수, 유틸리티 등을 넣습니다.
model
model 폴더에는 데이터 모델 코드가 들어갑니다. 데이터 모델을 담당하는 Model 클래스는 factory + fromJson, toJson과 함께 DTO와 Entity의 역할을 동시에 수행합니다.
provider
provider는 Flutter에서 뷰모델의 역할을 수행합니다. Flutter에서는 상태 관리를 다룰 때 provider라는 용어를 사용하기 때문에, view_model보다 provider라는 이름이 더 자연스럽고 Flutter스럽다고 생각하여 폴더 이름을 provider로 정했습니다.
일반적으로 클린 아키텍처에서는 뷰모델을 presentation 레이어에 포함시키지만, 저는 뷰모델을 최상위 폴더에 배치하는 것이 더 적절하다고 판단했습니다. 그 이유는 하나의 뷰모델이 여러 feature UI에서 공유될 수 있기 때문입니다.
이러한 경우, 해당 뷰모델을 common 디렉터리에 넣기보다는 독립적인 provider 폴더로 분리함으로써 폴더 구조의 명확성과 유지보수성을 높일 수 있다고 생각했습니다.
repository
repository에는 interface와 implements가 있습니다. factory 패턴을 통해 서버 환경에 따라 각 repository를 자연스럽게 반환합니다.
use_case
use_case에는 외부와 통신하는 비즈니스 로직이 들어갑니다. provider, ui 등에서 바로 repository에 접근하지 않고 use case를 거쳐 데이터를 불러옵니다.
ui
마지막으로, ui 입니다. screen, widget, controller로 나뉘며 controller는 내부 ui의 상태 관리를 위한 로직을 넣습니다. use_case는 외부 통신을 통한 비즈니스 로직에 중점을 둔다면 controller는 내부 ui 로직에 중점을 둡니다.
common 폴더에는 공통적으로 쓰이는 ui가 들어있습니다. (다이얼로그, 스낵바, 바텀 네비게이션 등)
나만의 플러터 클린 아키텍처 완성(?)
아키텍처를 따르는 이유 중에 하나가 협업을 위한 것도 있습니다. 유명한 아키텍처를 따른다면 협업할 때에도 문제 없이 코드를 작성하고 협업을 진행할 수 있습니다. 그런데 위와 같이 프로젝트를 구성하는 개발자가 많지는 않을 것 같습니다 ㅠ 하지만 저만의 클린 코드를 구성해봐서 나름 뿌듯합니다. 앞으로도 이런 방식으로 프로젝트를 진행해보려고 합니다.
댓글
댓글 쓰기