[Flutter] 위젯에서 비즈니스 로직 분리하기
지금까지 플러터팀에서 공개한 앱 아키텍처 문서를 번역한 포스팅을 작성했습니다.
플러터팀에서 공개한 앱 아키텍쳐 가이드 문서의 핵심은 “위젯에서 비즈니스 로직을 분리하기”입니다. 위젯에서 분리된 로직은 역할에 따라 ViewModel, UseCase(Optional), Repository, Service로 나눈 뒤 의존성 주입을 통해 객체 간의 의존성을 약하게 만들어야 합니다. 이러한 아키텍처는 표준적인 코드 구조를 바탕으로 협업을 용이하게 만들고, 가독성, 유지보수성, 확장성, 재사용성, 테스트 가능성 등 다양한 이익을 가져다줍니다.
이번 포스팅에서는 플러터 프로젝트 생성 시 함께 생성된 카운터 코드가 어떤 문제가 있는지 살펴보고, ChangeNotifier를 이용해 비즈니스 로직을 위젯에서 분리해보며 아키텍쳐 도입을 위한 중요한 첫 발을 내딛어 봅시다.
위젯과 비즈니스로직이 분리가 안된 카운터 프로젝트
먼저 기본 카운터 프로젝트의 코드를 살펴봅시다.
class VanilaCounterView extends StatefulWidget {
const VanilaCounterView({super.key});
@override
State<VanilaCounterView> createState() => _VanilaCounterViewState();
}
class _VanilaCounterViewState extends State<VanilaCounterView> {
int _counter = 0;
void _incrementCounter() {
setState(() {
_counter++;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Vanila Counter App'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
Text(
'$_counter',
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}플러터 앱 개발의 기본이 되는 StatefulWidget, State, setState를 활용해 간단한 카운터가 구현됐습니다. 어떤 문제가 있을까요?
1️⃣ _VanilaCounterViewState 클래스에서 _counter 상태를 직접 관리하고 있어 UI와 상태가 강하게 결합되어 있습니다. 또한 _incrementCounter 메서드는 단순한 카운터 증가 기능이지만, 카운터 앱에서는 앱의 핵심 기능인 비즈니스 로직입니다. 핵심 기능이 UI 로직에 섞여 있어 비즈니스 로직이 복잡한 경우 가독성, 유지보수성과 확장성을 떨어트립니다.
2️⃣ 상태 관리를 StatefulWidget 에서 직접 하고 있기 때문에, 단위 테스트가 불가능해 위젯 테스트만 가능합니다. 만약 비즈니스 로직이 UI 로직에서 분리되어 있었다면 단위 테스트가 가능했을 것입니다.
위젯에서 비즈니스로직 분리하기
카운터 프로젝트가 비즈니스 로직이 복잡하진 않지만 위젯에서 비즈니스 로직을 분리하기 좋은 예제입니다. 상태 관리 패키지를 활용해도 좋지만 플러터의 바닐라 상태관리 도구인 ChangeNotifier와 ListenableBuilder를 활용해 봅시다.
먼저 핵심 비즈니스 로직을 ChangeNotifier 클래스를 확장한 CounterModel 클래스를 선언하고, 카운터를 증가시키는 비즈니스 로직을 구현해 봅시다.
class CounterModel extends ChangeNotifier {
int _count;
int get count => _count;
CounterModel({required int count}) : _count = count;
void increment() {
_count++;
notifyListeners();
}
}_count 상태의 초기값은 생성자를 통해 전달받아 초기화합니다. increment 메소드에서는 _count 값을 증가시키고, ChangeNotifier가 제공해주는 notifyListeners 메소드를 호출합니다.
CounterModel에 대한 간단한 단위 테스트를 만들며 위젯에서 비즈니스 로직을 분리하며 생긴 테스트 가능성을 확인해 봅시다. 프로젝트 root의 test 디렉토리 하위에 counter_model_test.dart 파일을 생성한 뒤 아래와 같이 테스트 코드를 작성해 봅시다.
import 'package:counter/data/change_notifier/counter_model.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('CounterModel', () {
test('생성자로 전달한 값이 count의 초기값으로 사용되야함', () {
final counterModel = CounterModel(count: 5);
expect(counterModel.count, 5);
});
test('increment 메서드를 호출하면 count 값은 1 증가 해야함', () {
final counterModel = CounterModel(count: 0);
counterModel.increment();
expect(counterModel.count, 1);
});
test('increment 메서드를 호출하면 listener들이 호출되야함', () {
final counterModel = CounterModel(count: 0);
var listenerCallCount = 0;
counterModel.addListener(() {
listenerCallCount++;
});
counterModel.increment();
expect(listenerCallCount, 1);
});
});
}CounterModel이라는 group을 생성한 뒤 3가지 테스트 케이스를 작성했습니다.
final counterModel = CounterModel(count: 5);
expect(counterModel.count, 5);count 값을 생성자로 받았기 때문에 생성자로 전달한 값으로 초기화가 잘 되는지 테스트할 수 있습니다.
// increment 메서드를 호출하면 count 값은 1 증가 해야함
final counterModel = CounterModel(count: 0);
counterModel.increment();
expect(counterModel.count, 1);
// increment 메서드를 호출하면 listener들이 호출되야함
final counterModel = CounterModel(count: 0);
var listenerCallCount = 0;
counterModel.addListener(() {
listenerCallCount++;
});
counterModel.increment();
expect(listenerCallCount, 1);increment 메소드 호출 시 값이 증가하는지에 대한 테스트는 물론 ChangeNotifier가 제공하는 notifyListeners를 잘 호출하는지를 addListener를 통해 테스트할 수 있습니다.
카운터와 달리 비즈니스 로직이 복잡한 케이스에서도 상태를 외부에서 주입받고, 위젯과 비즈니스 로직이 분리되어 있다면 테스트 가능성을 확보할 수 있습니다.
분리된 비즈니스 로직을 위젯에서 적용하기
테스트 코드까지 갖추어진 CounterModel을 위젯에 적용해 봅시다.
import 'package:counter/data/change_notifier/counter_model.dart';
import 'package:flutter/material.dart';
class VanilaCounterView extends StatelessWidget {
final CounterModel counterModel;
const VanilaCounterView({required this.counterModel, super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Vanila Counter App'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'You have pushed the button this many times:',
),
ListenableBuilder(
listenable: counterModel,
builder: (context, child) => Text(
'${counterModel.count}',
style: Theme.of(context).textTheme.headlineMedium,
),
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: counterModel.increment,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}CounterModel로 분리된 비즈니스 로직을 적용한 위젯 입니다. StatefulWidget 확장 대신 StatelessWidget을 확장하도록 변경했습니다.
class VanilaCounterView extends StatelessWidget {
// ... 생략FloatingActionButton에서는 State에 구현되어 있던 increment 메소드 대신 CounterModel의 increment 메소드를 호출하도록 변경했습니다.
// ... 생략
floatingActionButton: FloatingActionButton(
onPressed: counterModel.increment,
tooltip: 'Increment',
child: const Icon(Icons.add),
),카운트를 표시하던 Text는 ListenableBuilder로 감싸도록 변경했습니다.
// ...생략
ListenableBuilder(
listenable: counterModel,
builder: (context, child) => Text(
'${counterModel.count}',
style: Theme.of(context).textTheme.headlineMedium,
),
),변경 후 실행해 봅시다. 플로팅 액션 버튼을 터치하면 카운터가 상태가 변경되고, 변경된 상태가 화면에 잘 표시됩니다.
위젯에서 분리된 비즈니스 로직의 주는 또 다른 가치
가장 큰 차이는 더 이상 상태를 직접 저장하고, 변경된 상태를 반영하기 위해 setState를 호출하여 위젯을 리빌드할 필요가 없다는 점입니다. 즉, StatefulWidget을 확장할 필요 없이 StatelessWidget으로 구현할 수 있어 UI를 구성하는 관심사만 집중된 간결한 코드만 남게 됩니다.
이는 단순히 가독성, 확장성, 재사용성, 유지보수성, 테스트 가능성과 같은 개발 측면의 이점뿐만 아니라, 유저 경험에도 긍정적인 영향을 줍니다. 불필요한 rebuild를 줄여 성능을 최적화할 수 있기 때문입니다.
흔히 아키텍처를 적용하면 보일러플레이트 코드가 많아진다고 우려하지만, 이번 접근에서는 그러한 단점이 보이지 않습니다. 이런데도 위젯에 비즈니스 로직을 포함해서 개발하실 건가요?
이어보기
위젯에서 비즈니스 로직을 분리 했다면, 비즈니스로직이 다루는 상태와 위젯의 효율적인 동기화 방법에 대한 고민이 필요합니다. 다음 포스팅에서 위젯과 상태의 동기화에 대한 인사이트를 얻을 수 있습니다.
