오늘은 아키텍처에 대해서 적당히 알아보고 어떤 유용한 툴이 있고 많이 사용되는지 파악후 project에 적용해보고자 한다. 그냥 아키텍처를 어떻게 적용했는지만 보고싶다면 여기로
이전 Android Project를 하면서도 쓴 아키텍처인데 말 그대로 깔끔한 아키텍처로 개인 프로젝트에서는 굳이 안 써도 되겠지만 규모가 커질수록 많아지는 코드량과 복잡성을 해소하기 위한 기본적인 선택지 중 최선이 아닐까 생각한다.
이게 기본적인 CleanArchitecture에 관한 그림이다. 안으로 들어갈수록 고수준 정책과 추상화된 클래스가 배치된다. 화살표 방향으로 의존성이 흐르며, 이는 의존성 역전 원칙에 따라 구체적 구현보다 추상화된 구조에 의존하도록 한다.
👉 의존성 역전(DIP, Dependency Inversion Principle): 객체지향 설계 원칙 중 하나로, 고수준 모듈이 저수준 모듈에 의존하는 것을 피하고, 둘 다 추상화(인터페이스나 추상 클래스)에 의존하도록 하는 것이다. 쉽게 말해, 구체적인 구현(저수준 모듈)보다 추상적인 개념(인터페이스나 추상 클래스)에 의존하게 만들어서, 코드를 더 유연하고 변경에 강하게 만드는 방법이다.
예를 들어, 자동차가 엔진(구체적 구현)에 직접 의존하지 않고, 엔진 인터페이스(추상화)에 의존하게 만들면, 다른 엔진으로 쉽게 교체할 수 있는 것과 같다.
👉 고수준/저수준: 입/출력과의 거리를 의미하는 것. 추상화 될수록 고수준이다.
🍳 이전에 Android에서 작업했던 CleanArchitecture에서의 각 세부 영역들의 역할은 다음과 같다.
🍕 위의 규칙을 지켜 Flutter에서도 CleanArchitecture를 사용해서 진행해보고자 한다. Data는 매우 많은 정보를 내포하고 있고 Domain에서는 필요한 데이터를 필요한 곳에 뿌려줘야하는 비즈니스 로직을 포함하기 때문에 Data계층에서 필요한 데이터만 Domain이 사용해야 한다. 따라서 중요한점은 어떻게 Data 계층에서 받아온 데이터를 Domain이 가지고 있는 Entity로 변환해줄 수 있는지다.
가장 대표적인 방법으로는 위에서 설명한 의존성 역전 원칙을 사용하는 것. 인터페이스를 통해 필요한 데이터만 가져와서 적용할 수 있다면 데이터소스와 독립적으로 동작이 가능하기 때문에 유지보수 및 재사용성이 높아진다.
예시 코드를 통해 확인할 수 있다.
// 저수준 모듈: 구체적 구현
// repository가 변경되면 UserService에서 직접 RemoteUserRepository를 교체하고
// 해당 함수도 변경해야 함
class UserService {
RemoteUserRepository repository = RemoteUserRepository();
User fetchUser(int id) {
return repository.getUserById(id);
}
}
class RemoteUserRepository {
User getUserById(int id) {
// API 호출하여 사용자 데이터 가져오기
return User(id, 'John Doe');
}
}
class User {
final int id;
final String name;
User(this.id, this.name);
}
void main() {
UserService service = UserService();
User user = service.fetchUser(1);
print('User: ${user.name}');
}
아래는 interface를 통해 데이터를 넘기는 방식 즉, 의존성 역전 원칙을 따른방식이다. 인터페이스를 통해 domain에 전달해야 하는 로직을 관리하고 비즈니스 로직에서는 단순이 데이터를 받아서 가공후 viewd에 넘겨주는 역할을 한다. 따라서 datasource가 변경되더라도 핵심 비즈니스로직을 관리하는 UserService의 경우 변경되지 않기 때문에 유지보수성이 좋아진다.
// 추상화된 인터페이스 - 비즈니스 로직에 전달될 인터페이스
abstract class UserRepository {
User getUserById(int id);
}
// 저수준 모듈: 구체적 구현
// datasource 변경 시 변경될 코드 - 이부분만 변경하면 됨.
class RemoteUserRepository implements UserRepository {
@override
User getUserById(int id) {
// API 호출하여 사용자 데이터 가져오기
return User(id, 'John Doe');
}
}
// 고수준 모듈: 비즈니스 로직
// 추상화된 UserRepository가 변경되기 전까진 변경될 이유가 없음.
class UserService {
final UserRepository _repository;
UserService(this._repository);
User fetchUser(int id) {
return _repository.getUserById(id);
}
}
class User {
final int id;
final String name;
User(this.id, this.name);
}
void main() {
UserRepository repository = RemoteUserRepository();
UserService service = UserService(repository);
User user = service.fetchUser(1);
print('User: ${user.name}');
}
Atomic Design 패턴은 웹 디자인과 UI 개발에서 사용되는 설계 패턴으로, 화면을 이루는 컴포넌트들을 체계적으로 쪼개어 작은 단위로 구현한 후, 이들을 조합하여 복잡한 페이지를 만드는 방식이다. 이 패턴은 디자인 시스템을 보다 일관성 있고 확장 가능하게 만드는 데 중점을 둔다.
🍳 Atomic Design의 주요 구성 요소
Atoms (원자): 가장 기본적인 구성 요소들로, 더 이상 쪼갤 수 없는 UI의 최소 단위다. 예를 들면 버튼, 입력 필드, 레이블 같은 단순한 HTML 태그들이 이에 해당한다.
예시: <button>, <input>, <label>
등
Molecules (분자): 원자들이 결합하여 더 복잡한 기능을 수행하는 작은 컴포넌트다. 예를 들어, 입력 필드와 버튼이 결합되어 검색 폼을 구성하는 것과 같다.
예시: 검색 바(Search Bar), 카드 컴포넌트의 헤더.
Organisms (유기체): 분자들이 모여 더 큰 섹션을 이루는 구성 요소들이다. 이 단계에서는 여러 개의 분자나 원자들이 함께 결합되어 독립적인 UI 섹션을 형성한다.
예시: 내비게이션 바, 제품 목록, 카드 레이아웃.
Templates (템플릿): 유기체들이 모여서 페이지의 레이아웃을 구성한다. 템플릿은 반복적인 UI 구조를 정의하며, 실제 콘텐츠가 여기에 채워지기 전의 페이지 구조를 나타낸다.
예시: 블로그 포스트 레이아웃, 대시보드 레이아웃.
Pages (페이지): 템플릿에 실제 콘텐츠가 채워진 최종적인 페이지를 의미한다. 사용자가 상호작용하는 완성된 웹 페이지나 애플리케이션 화면을 구성한다.
예시: 실제 운영 중인 웹사이트의 블로그 페이지, 사용자 대시보드.
Atomic Design의 장점 -
재사용성: 작은 단위의 컴포넌트(원자, 분자)를 재사용하여 다양한 UI를 쉽게 구성할 수 있다.
일관성: 동일한 디자인 언어와 패턴을 사용하여 전체 시스템에서 일관성을 유지할 수 있다.
확장성: 작은 컴포넌트를 조합하여 쉽게 복잡한 UI를 확장할 수 있다.
유지보수성: 컴포넌트 단위로 개발하므로 변경 사항을 관리하고 유지보수하기 쉽다.
👉 요약
Atomic Design 패턴은 UI 컴포넌트를 가장 작은 단위인 원자(Atoms)부터 점진적으로 큰 단위인 페이지(Pages)로 구성해 나가는 설계 방식이다. 이 패턴을 통해 일관성 있고 재사용 가능한 UI를 설계하고 개발할 수 있다.
MVVM(Model-View-ViewModel) 패턴은 애플리케이션의 UI와 비즈니스 로직을 명확히 분리하여 개발의 효율성과 유지보수성을 높이는 아키텍처 패턴이다. 이 패턴은 세 가지 주요 구성 요소로 나뉜다:
Model: 애플리케이션의 데이터와 비즈니스 로직을 담당한다.
View: 사용자 인터페이스(UI)를 구성하며, 사용자와의 상호작용을 처리한다.
ViewModel: View와 Model 사이의 중재자 역할을 하며, 데이터 바인딩을 통해 View와 Model을 연결한다.
MVVM의 주요 특징 -
데이터 바인딩: View와 ViewModel 간의 양방향 데이터 바인딩을 통해 UI가 자동으로 업데이트된다.
테스트 용이성: 비즈니스 로직이 ViewModel에 캡슐화되기 때문에, UI와 독립적으로 테스트할 수 있다.
유지보수성: UI와 비즈니스 로직의 분리로 인해, 코드의 수정이 용이하고 유지보수가 간편하다.
MVVM의 장점 -
일관된 데이터 관리: 데이터 변경 사항이 자동으로 UI에 반영되므로, UI 업데이트를 신경 쓸 필요가 없다.
높은 재사용성: ViewModel을 여러 View에서 재사용할 수 있어, 코드 중복을 줄이고 효율성을 높인다.
독립적 개발: UI와 로직의 독립적인 개발이 가능해, 팀 작업 시 생산성을 높일 수 있다.
GetX, Bloc, Provider, reverpod 4가지를 비교하여 선택하기로 하였다.
특징: 경량화된 상태 관리, 의존성 주입, 라우팅 솔루션을 가지고 있고 BuildContext를 사용하지 않고도 상태를 관리 가능하다.
장점: 쉽고 간단한 API, 코드 작성량이 적다. 전역 상태 관리가 용이하며 강력한 의존성 주입 기능을 가지고 있다.
단점: BuildContext를 관리하지 않아서 문제가 발생할 수 있다. 큰 규모의 프로젝트에서 유지보수가 어렵다.
예시 코드:
코드 복사
class CounterController extends GetxController {
var count = 0.obs;
void increment() => count++;
}
class Home extends StatelessWidget {
Widget build(BuildContext context) {
final CounterController controller = Get.put(CounterController());
return Scaffold(
body: Center(
child: Obx(() => Text('Clicks: ${controller.count}')),
),
floatingActionButton: FloatingActionButton(
onPressed: controller.increment,
child: Icon(Icons.add),
),
);
}
}
특징: 이벤트 기반 상태 관리, Stream을 사용한 상태 관리, 상태의 흐름을 명확히 관리 가능하다.
장점: 엄격한 패턴 적용, 대규모 프로젝트에 적합하다. 상태 전이 과정이 명확하다.
단점: 상대적으로 복잡한 구조, 러닝 커브가 높다. 코드 작성량이 많다.
예시 코드:
enum CounterEvent { increment }
class CounterBloc extends Bloc<CounterEvent, int> {
CounterBloc() : super(0);
Stream<int> mapEventToState(CounterEvent event) async* {
if (event == CounterEvent.increment) {
yield state + 1;
}
}
}
class Home extends StatelessWidget {
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => CounterBloc(),
child: Scaffold(
body: Center(
child: BlocBuilder<CounterBloc, int>(
builder: (context, count) {
return Text('Clicks: $count');
},
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => context.read<CounterBloc>().add(CounterEvent.increment),
child: Icon(Icons.add),
),
),
);
}
}
특징: InheritedWidget을 기반으로 하는 상태 관리. Flutter 팀에서 권장하는 패턴.
장점: 간결하고 직관적, Flutter 기본 기능과 잘 통합됨. 확장성과 유지보수성이 좋음.
단점: 복잡한 상태 관리에는 적합하지 않음. BuildContext에 의존적이다.
예시 코드:
class Counter with ChangeNotifier {
int _count = 0;
int get count => _count;
void increment() {
_count++;
notifyListeners();
}
}
class Home extends StatelessWidget {
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) => Counter(),
child: Scaffold(
body: Center(
child: Consumer<Counter>(
builder: (context, counter, _) {
return Text('Clicks: ${counter.count}');
},
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => context.read<Counter>().increment(),
child: Icon(Icons.add),
),
),
);
}
}
특징: Provider의 개선된 버전, BuildContext 없이도 상태 관리 가능. 더욱 안전하고, 간결하며 테스트하기 쉬운 구조다.
장점: 글로벌 상태 관리, BuildContext에 비의존적. 고급 기능 지원, 간단한 API.
단점: 기존 Provider 사용 경험이 있는 경우에만 학습 곡선이 비교적 낮음. 초기 설정이 약간 복잡할 수 있음.
예시 코드:
final counterProvider = StateProvider<int>((ref) => 0);
class Home extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(counterProvider).state;
return Scaffold(
body: Center(child: Text('Clicks: $count')),
floatingActionButton: FloatingActionButton(
onPressed: () => ref.read(counterProvider).state++,
child: Icon(Icons.add),
),
);
}
}
결론:
GetX: 빠르고 간편한 상태 관리를 원하고, 작은 프로젝트에서 효율적으로 사용하기 좋다. 하지만 전역 상태 관리로 인한 복잡성에 유의해야 한다.
Bloc: 엄격한 패턴 적용과 상태 흐름의 명확성이 중요한 대규모 프로젝트에 적합하다. 러닝 커브가 다소 높다.
Provider: Flutter에서 권장하는 기본 상태 관리 패턴으로, 간결하고 직관적이다. 중소규모 프로젝트에 적합하다.
Riverpod: Provider의 단점을 보완한 개선된 툴로, BuildContext 없이도 상태 관리가 가능하다. 복잡한 프로젝트에서도 유연하다.
CleanArchitecture와 MVVM은 서로 양립하는 패턴이 아니므로 같이 사용해서 진행하고 MVVM을 선택한 이유는 이전에 ViewModel을 사용해서 프로젝트를 진행했었고 진행할 계획이 있기 때문이다.
CleanArchitecture와 MVVM 패턴을 같이 사용하고 거기에 상태관리툴 - Riverpod, 통신 - Dio, 권한 설정 - Permission Handler, 로컬 스토리지 - Hive, Url - Uri Launcher, Navigation - goRouter 을 사용해서 소개한 설명을 바탕으로 Flutter에서 CleanArchitecture를 통해 프로젝트를 구성해보고자 한다.
CleanArchitecture & MVVM 기본 구조 -
/lib
├── config
│ ├── env
│ ├── tool
├── core
│ ├── constants
│ ├── data
│ └── remote
│ └── local
│ ├── domain
│ └── entities
│ └── re
│
│ ├── domain
│
│
│
│
├── data
│ ├── models
│ ├── repositories
│ └── data_sources
├── domain
│ ├── entities
│ ├── repositories
│ └── usecases
├── presentation
│ ├── viewmodels (or blocs)
│ └── pages
└── providers.dart (or GetIt을 사용한다면 - injection_container)
core: 공통적으로 사용하는 유틸리티, 에러 처리, 및 기본적인 유즈케이스를 관리.
data: API 호출, 로컬 데이터베이스 등 실제 데이터 처리를 담당.
domain: 비즈니스 로직과 엔티티를 정의하고 관리.
presentation: UI와 상태 관리 로직을 포함.
필요한 패키지들은 Pub dev를 통해 installing 후 사용하였다. 다만 각 패키지 별로 OS에 따라서 추가적으로 해주어야 하는 설정이 있을 수 있으니 직접 pub dev에서 검색해보길 바란다.
flutter pub add riverpod
flutter pub add dio
flutter pub add permission_handler
flutter pub add hive
flutter pub add url_launcher
flutter pub add go_router
완성된 프로젝트 디렉토리 기본구조다. 이제부터 여기서 필요한 디렉토리가 있으면 추가할거고 파일들을 추가해서 관리하게 된다.
이렇게 생성한 기초 프로젝트는 Github에 올려두었다.
LineEngineering - 윤기영 (Flutter 인기 아키텍처 라이브러리 3종 비교 분석)
myming.log - 아토믹 패턴에 대해서
악어 velog - native앱 flutter로
메모하는 개발자 - Flutter CleanArchitecture
Arooong Bolg - Flutter CleanArchitecture적용해보기