Flutter 튜토리얼 40편: 앱 아키텍처 기초
Flutter 앱 아키텍처 기초
Flutter 앱 아키텍처의 핵심 원칙을 알아봅니다. 잘 설계된 아키텍처는 앱의 유지보수성, 확장성, 테스트 가능성을 높입니다.
아키텍처가 필요한 이유
Why - 왜 아키텍처가 중요한가요?
작은 앱에서는 모든 코드를 한 파일에 넣어도 동작합니다. 하지만 앱이 커지고 팀원이 늘어나면 문제가 발생합니다. 코드가 얽히고설켜 수정 시 예상치 못한 버그가 생기며, 새로운 기능 추가가 점점 어려워집니다.
의도적인 아키텍처는 다음과 같은 이점을 제공합니다:
- 유지보수성: 코드 수정, 업데이트, 버그 수정이 쉬워집니다
- 확장성: 여러 개발자가 동시에 작업해도 충돌이 최소화됩니다
- 테스트 가능성: 명확한 입출력을 가진 간단한 클래스는 테스트하기 쉽습니다
- 인지 부하 감소: 새 팀원이 빠르게 적응하고, 코드 리뷰가 수월해집니다
- 더 나은 사용자 경험: 기능을 더 빠르게, 더 적은 버그로 출시할 수 있습니다
What - 아키텍처란 무엇인가요?
아키텍처(Architecture)1는 앱을 어떻게 구조화하고, 조직화하고, 설계할지에 관한 것입니다. 프로젝트 요구사항과 팀이 성장함에 따라 확장할 수 있는 구조를 만드는 것이 목표입니다.
Flutter 팀이 권장하는 아키텍처는 다음 핵심 원칙을 기반으로 합니다:
- 관심사의 분리(Separation of Concerns)
- 레이어드 아키텍처(Layered Architecture)
- 단일 진실 원천(Single Source of Truth)
- 단방향 데이터 흐름(Unidirectional Data Flow)
How - 이 가이드는 어떻게 활용하나요?
이 가이드는 성장하는 팀과 코드베이스를 가진 Flutter 앱을 위해 작성되었습니다. 여러 개발자가 같은 코드베이스에 기여하고, 기능이 풍부한 앱을 만들고 있다면 이 가이드가 도움이 될 것입니다.
Watch out - 주의사항
아키텍처에는 “정답”이 없습니다. 앱의 규모, 팀의 경험, 프로젝트 요구사항에 따라 적절한 수준을 선택하세요. 작은 앱에 복잡한 아키텍처를 적용하면 오히려 개발 속도가 느려질 수 있습니다.
관심사의 분리
Why - 왜 관심사를 분리해야 하나요?
한 클래스나 파일이 여러 가지 일을 담당하면 문제가 생깁니다. UI 코드와 비즈니스 로직이 섞여 있으면 UI를 바꿀 때 로직도 건드려야 하고, 로직을 바꿀 때 UI도 영향을 받습니다.
관심사를 분리하면:
- 각 부분을 독립적으로 수정할 수 있습니다
- 코드 재사용이 쉬워집니다
- 테스트가 간단해집니다
What - 관심사의 분리란 무엇인가요?
관심사의 분리(Separation of Concerns)2는 앱의 기능을 독립적이고 자체 완결적인 단위로 나누는 원칙입니다.
높은 수준의 분리: UI 로직과 비즈니스 로직을 분리합니다. 기능별 분리: 인증 로직과 검색 로직을 서로 다른 클래스에 둡니다.
How - Flutter에서 어떻게 적용하나요?
나쁜 예시 - 모든 것이 위젯에
class UserProfileScreen extends StatefulWidget { @override State<UserProfileScreen> createState() => _UserProfileScreenState();}
class _UserProfileScreenState extends State<UserProfileScreen> { User? user; bool isLoading = false;
@override void initState() { super.initState(); _loadUser(); }
Future<void> _loadUser() async { setState(() => isLoading = true);
// 직접 HTTP 요청 - UI와 데이터 로직이 혼재 final response = await http.get(Uri.parse('https://api.example.com/user')); final json = jsonDecode(response.body);
setState(() { user = User.fromJson(json); isLoading = false; }); }
@override Widget build(BuildContext context) { if (isLoading) return CircularProgressIndicator(); return Text(user?.name ?? ''); }}좋은 예시 - 관심사 분리
// 데이터 레이어: Repositoryclass UserRepository { final ApiClient _client;
UserRepository(this._client);
Future<User> getUser() async { final response = await _client.get('/user'); return User.fromJson(response); }}
// 로직 레이어: ViewModelclass UserProfileViewModel extends ChangeNotifier { final UserRepository _repository;
User? user; bool isLoading = false;
UserProfileViewModel(this._repository);
Future<void> loadUser() async { isLoading = true; notifyListeners();
user = await _repository.getUser();
isLoading = false; notifyListeners(); }}
// UI 레이어: Widget (UI만 담당)class UserProfileScreen extends StatelessWidget { final UserProfileViewModel viewModel;
const UserProfileScreen({required this.viewModel, super.key});
@override Widget build(BuildContext context) { return ListenableBuilder( listenable: viewModel, builder: (context, _) { if (viewModel.isLoading) { return const CircularProgressIndicator(); } return Text(viewModel.user?.name ?? ''); }, ); }}Watch out - 주의사항
위젯도 재사용 가능하고 간결하게 작성하세요. 한 위젯에 너무 많은 로직을 넣지 말고, 작은 단위로 분리하세요.
레이어드 아키텍처
Why - 왜 레이어로 나누나요?
레이어로 나누면 각 레이어는 인접한 레이어와만 통신합니다. UI 레이어는 데이터 레이어의 존재를 알 필요가 없고, 데이터 레이어도 UI를 알 필요가 없습니다. 이런 분리가 코드의 복잡성을 관리 가능한 수준으로 유지합니다.
What - 레이어드 아키텍처란 무엇인가요?
레이어드 아키텍처(Layered Architecture)는 앱을 역할과 책임에 따라 구분된 레이어로 조직하는 패턴입니다.
┌─────────────────────────────────────────┐│ UI Layer (표현 계층) ││ - 사용자에게 데이터 표시 ││ - 사용자 상호작용 처리 ││ - Widget, Screen │└─────────────────┬───────────────────────┘ │ ▼┌─────────────────────────────────────────┐│ Logic Layer (로직 계층) ││ - 비즈니스 로직 구현 ││ - UI와 Data 계층 중재 ││ - ViewModel, UseCase │└─────────────────┬───────────────────────┘ │ ▼┌─────────────────────────────────────────┐│ Data Layer (데이터 계층) ││ - 데이터 소스와 상호작용 ││ - 데이터 저장 및 제공 ││ - Repository, DataSource │└─────────────────────────────────────────┘How - 각 레이어의 역할은 무엇인가요?
UI Layer (표현 계층)
- 사용자에게 데이터를 표시합니다
- 사용자 상호작용(탭, 스와이프 등)을 처리합니다
- Widget과 Screen으로 구성됩니다
Logic Layer (로직 계층)
- 핵심 비즈니스 로직을 구현합니다
- UI 계층과 데이터 계층 사이의 상호작용을 중재합니다
- ViewModel, UseCase로 구성됩니다
- 선택적 계층: 단순한 CRUD 앱에서는 생략할 수 있습니다
Data Layer (데이터 계층)
- 데이터베이스, API, 플랫폼 플러그인과 상호작용합니다
- 로직 계층에 데이터와 메서드를 노출합니다
- Repository, DataSource로 구성됩니다
Watch out - 주의사항
Logic Layer는 복잡한 비즈니스 로직이 있을 때만 필요합니다. 사용자에게 데이터를 표시하고 변경하는 것이 주 기능인 앱(CRUD 앱)은 UI Layer와 Data Layer만으로 충분할 수 있습니다.
단일 진실 원천 (SSOT)
Why - 왜 단일 진실 원천이 필요한가요?
같은 데이터가 여러 곳에 저장되면 동기화 문제가 발생합니다. 한 곳에서 데이터를 수정했는데 다른 곳은 업데이트되지 않으면 불일치가 생깁니다. 이런 버그는 찾기도 어렵고 수정하기도 까다롭습니다.
What - 단일 진실 원천이란 무엇인가요?
단일 진실 원천(Single Source of Truth, SSOT)3은 앱의 각 데이터 타입이 하나의 정해진 위치에서만 관리되어야 한다는 원칙입니다. 데이터를 수정할 수 있는 것은 오직 SSOT 클래스뿐입니다.
How - 어떻게 구현하나요?
일반적으로 앱에서 각 데이터 타입의 SSOT는 Repository 클래스가 담당합니다. 데이터 타입별로 하나의 Repository가 있습니다.
// UserRepository가 User 데이터의 단일 진실 원천class UserRepository { final UserApi _api; final UserCache _cache;
UserRepository(this._api, this._cache);
// 캐시된 사용자 정보 (유일한 진실 원천) User? _currentUser;
User? get currentUser => _currentUser;
// 데이터 변경은 오직 이 메서드들을 통해서만 Future<User> fetchUser() async { _currentUser = await _api.getUser(); await _cache.save(_currentUser!); return _currentUser!; }
Future<void> updateProfile(String name) async { final updated = await _api.updateProfile(name); _currentUser = updated; await _cache.save(updated); }}클래스 내부에서도 SSOT 원칙을 적용할 수 있습니다:
class Order { final List<OrderItem> items; final double taxRate;
Order({required this.items, required this.taxRate});
// 파생 값은 getter로 계산 (별도 필드로 저장하지 않음) double get subtotal => items.fold(0, (sum, item) => sum + item.price); double get tax => subtotal * taxRate; double get total => subtotal + tax;}Watch out - 주의사항
SSOT는 같은 데이터를 여러 곳에서 관리하는 것을 방지합니다.
subtotal, tax, total을 별도 필드로 저장하면 업데이트 시 불일치가 발생할 수 있으므로, getter로 계산하는 것이 안전합니다.
단방향 데이터 흐름 (UDF)
Why - 왜 단방향 데이터 흐름을 사용하나요?
데이터가 양방향으로 자유롭게 흐르면 어디서 상태가 변경되었는지 추적하기 어렵습니다. 단방향으로 제한하면 데이터 흐름이 예측 가능해지고, 디버깅이 쉬워집니다.
What - 단방향 데이터 흐름이란 무엇인가요?
단방향 데이터 흐름(Unidirectional Data Flow, UDF)4은 상태(State)와 UI를 분리하는 설계 패턴입니다.
- 상태(State): 데이터 레이어 → 로직 레이어 → UI 레이어 (아래에서 위로)
- 이벤트(Event): UI 레이어 → 로직 레이어 → 데이터 레이어 (위에서 아래로)
┌────────────────────────────────────────────────────────┐│ State ↑ ││ ││ Data Layer ──────▶ Logic Layer ──────▶ UI Layer ││ ││ Event ↓ │└────────────────────────────────────────────────────────┘How - 사용자 상호작용의 흐름은 어떻게 되나요?
버튼 클릭부터 UI 업데이트까지의 흐름:
-
[UI Layer] 사용자가 버튼을 클릭합니다. 위젯의 이벤트 핸들러가 로직 계층의 메서드를 호출합니다.
-
[Logic Layer] 로직 클래스가 데이터를 변경할 수 있는 Repository 메서드를 호출합니다.
-
[Data Layer] Repository가 데이터를 업데이트하고 새 데이터를 로직 클래스에 제공합니다.
-
[Logic Layer] 로직 클래스가 새 상태를 저장하고 UI에 알립니다.
-
[UI Layer] UI가 새 상태를 화면에 표시합니다.
// 예시: 좋아요 버튼 클릭 흐름class PostDetailScreen extends StatelessWidget { final PostViewModel viewModel;
@override Widget build(BuildContext context) { return ListenableBuilder( listenable: viewModel, builder: (context, _) { return IconButton( // 1. UI Layer: 사용자 이벤트 발생 onPressed: () => viewModel.toggleLike(), icon: Icon( viewModel.post.isLiked ? Icons.favorite : Icons.favorite_border, ), ); }, ); }}
class PostViewModel extends ChangeNotifier { final PostRepository _repository; Post post;
PostViewModel(this._repository, this.post);
Future<void> toggleLike() async { // 2. Logic Layer: Repository 메서드 호출 final updatedPost = await _repository.toggleLike(post.id);
// 4. Logic Layer: 새 상태 저장 및 UI 알림 post = updatedPost; notifyListeners(); // 5. UI 업데이트 트리거 }}
class PostRepository { Future<Post> toggleLike(String postId) async { // 3. Data Layer: 데이터 업데이트 및 반환 final response = await _api.toggleLike(postId); return Post.fromJson(response); }}Watch out - 주의사항
데이터 변경은 항상 SSOT(데이터 레이어)에서 시작해야 합니다. UI에서 직접 데이터를 수정하지 마세요. 이 원칙을 지키면 코드가 이해하기 쉽고, 오류가 줄어들며, 예상치 못한 데이터 상태를 방지할 수 있습니다.
UI는 불변 상태의 함수
Why - 왜 UI를 상태의 함수로 봐야 하나요?
UI가 상태에 의존하지 않고 자체적으로 데이터를 관리하면 문제가 생깁니다. 앱이 닫혔다가 다시 열릴 때 데이터가 사라지고, 테스트하기 어렵고, 버그가 발생하기 쉽습니다.
What - “UI는 상태의 함수”란 무엇인가요?
Flutter는 선언적(Declarative) 프레임워크입니다. 앱의 현재 상태를 반영하도록 UI를 빌드합니다. 상태가 변경되면 해당 상태에 의존하는 UI의 리빌드를 트리거합니다.
UI = f(State)이것은 Flutter에서 “UI는 상태의 함수”라고 표현됩니다.
How - 어떻게 적용하나요?
// State (불변 데이터 클래스)class CounterState { final int count;
const CounterState({this.count = 0});
CounterState copyWith({int? count}) { return CounterState(count: count ?? this.count); }}
// ViewModel (상태 관리)class CounterViewModel extends ChangeNotifier { CounterState _state = const CounterState();
CounterState get state => _state;
void increment() { // 불변 상태: 기존 상태를 수정하지 않고 새 상태를 생성 _state = _state.copyWith(count: _state.count + 1); notifyListeners(); }}
// UI (상태를 화면에 표시)class CounterScreen extends StatelessWidget { final CounterViewModel viewModel;
const CounterScreen({required this.viewModel, super.key});
@override Widget build(BuildContext context) { return ListenableBuilder( listenable: viewModel, builder: (context, _) { // UI = f(State) return Text('Count: ${viewModel.state.count}'); }, ); }}Watch out - 주의사항
데이터가 UI를 주도해야 합니다. 그 반대가 아닙니다. 데이터는 불변이고 영속적이어야 하며, 뷰는 가능한 한 적은 로직을 포함해야 합니다.
확장성과 테스트 가능성
Why - 왜 확장성과 테스트 가능성이 중요한가요?
앱은 시간이 지나면서 변화합니다. 새로운 기능이 추가되고, 외부 서비스가 바뀌고, 팀원이 늘어납니다. 잘 설계된 아키텍처는 이런 변화에 유연하게 대응할 수 있습니다.
What - 확장 가능한 설계란 무엇인가요?
각 아키텍처 컴포넌트는 명확하게 정의된 입력과 출력을 가져야 합니다. 인터페이스(추상 클래스)를 사용하면 구체적인 구현을 쉽게 교체할 수 있습니다.
// 추상 인터페이스 정의abstract class AuthRepository { Future<User?> getCurrentUser(); Future<User> signIn(String email, String password); Future<void> signOut();}
// 실제 구현class FirebaseAuthRepository implements AuthRepository { final FirebaseAuth _auth;
FirebaseAuthRepository(this._auth);
@override Future<User?> getCurrentUser() async { final firebaseUser = _auth.currentUser; return firebaseUser != null ? User.fromFirebase(firebaseUser) : null; }
// ... 나머지 구현}
// 테스트용 Mock 구현class MockAuthRepository implements AuthRepository { User? _mockUser;
@override Future<User?> getCurrentUser() async => _mockUser;
void setMockUser(User user) => _mockUser = user;
// ... 나머지 구현}How - 테스트 가능한 코드를 어떻게 작성하나요?
ViewModel은 Repository를 모킹하여 독립적으로 테스트할 수 있습니다.
void main() { group('AuthViewModel', () { late MockAuthRepository mockRepository; late AuthViewModel viewModel;
setUp(() { mockRepository = MockAuthRepository(); viewModel = AuthViewModel(mockRepository); });
test('signIn updates user state', () async { final testUser = User(id: '1', name: 'Test'); mockRepository.setMockUser(testUser);
expect(viewModel.currentUser, equals(testUser)); }); });}Watch out - 주의사항
새 ViewModel을 추가해도 데이터 레이어나 비즈니스 로직 레이어의 기존 로직이 깨지지 않습니다. 이것이 레이어드 아키텍처의 장점입니다.
마무리
Flutter 앱 아키텍처의 핵심 원칙을 정리하면:
- 관심사의 분리: UI와 비즈니스 로직을 분리하고, 기능별로 코드를 조직
- 레이어드 아키텍처: UI, Logic, Data 계층으로 구분하여 복잡성 관리
- 단일 진실 원천: 각 데이터 타입은 하나의 위치에서만 관리
- 단방향 데이터 흐름: 상태는 아래에서 위로, 이벤트는 위에서 아래로
- UI = f(State): 불변 상태를 기반으로 UI 구성
다음 튜토리얼에서는 UI 레이어와 Data 레이어를 더 자세히 살펴보겠습니다.
Footnotes
-
아키텍처(Architecture): 소프트웨어의 구조, 구성 요소 간의 관계, 설계 원칙을 정의하는 청사진입니다. 좋은 아키텍처는 변화에 유연하게 대응할 수 있게 합니다. ↩
-
관심사의 분리(Separation of Concerns): 프로그램을 서로 다른 관심사(기능)를 처리하는 독립적인 부분으로 나누는 설계 원칙입니다. ↩
-
단일 진실 원천(SSOT): 모든 데이터 요소가 하나의 장소에서만 생성되거나 편집되도록 정보를 구조화하는 방식입니다. ↩
-
단방향 데이터 흐름(UDF): 데이터가 한 방향으로만 흐르도록 하여 상태 관리를 단순화하는 패턴입니다. React의 Flux 아키텍처에서 유래했습니다. ↩
공유
이 글이 도움이 되었다면 다른 사람과 공유해주세요!