Flutter 튜토리얼 43편: 아키텍처 권장사항과 디자인 패턴

요약#

핵심 요지#

  • 관심사의 분리와 단방향 데이터 흐름은 강력히 권장되는 핵심 원칙입니다.
  • Repository 패턴으로 데이터 접근을 추상화하고, MVVM으로 UI 로직을 분리합니다.
  • 불변 데이터 모델과 Command 패턴으로 상태 관리의 복잡성을 줄입니다.
  • Optimistic State, Result 객체 등 실용적인 패턴으로 사용자 경험을 향상시킵니다.

문서가 설명하는 범위#

Flutter 공식 문서의 RecommendationsDesign patterns를 기반으로 Flutter 앱 개발에서 권장하는 아키텍처 원칙과 디자인 패턴을 설명합니다.

참고 자료#


핵심 아키텍처 원칙#

Why - 왜 아키텍처 원칙이 필요한가요?#

앱이 커지면 코드의 복잡성도 증가합니다. 명확한 원칙 없이 개발하면 코드가 뒤엉키고, 수정할 때마다 예상치 못한 버그가 발생합니다. 아키텍처 원칙은 이런 혼란을 방지하는 가이드라인입니다.

What - Flutter가 권장하는 원칙은 무엇인가요?#

Flutter 팀이 강력히 권장하는(Strongly Recommended) 원칙과 조건부로 권장하는(Conditional) 원칙이 있습니다.

권장 수준원칙설명
강력히 권장관심사의 분리UI와 비즈니스 로직을 분리
강력히 권장단방향 데이터 흐름상태는 아래→위, 이벤트는 위→아래
강력히 권장Repository 패턴데이터 접근을 추상화
강력히 권장MVVM 패턴ViewModel로 UI 로직 분리
강력히 권장의존성 주입전역 객체 대신 명시적 주입
강력히 권장테스트 작성Unit, Widget, Integration 테스트
조건부 권장불변 데이터 모델freezed, built_value 활용
조건부 권장Domain 레이어복잡한 비즈니스 로직이 있을 때만

How - 원칙을 적용하는 방법#

관심사의 분리 적용

// 나쁜 예: 위젯에 모든 로직이 혼재
class BadUserScreen extends StatefulWidget {
@override
State<BadUserScreen> createState() => _BadUserScreenState();
}
class _BadUserScreenState extends State<BadUserScreen> {
User? user;
@override
void initState() {
super.initState();
_loadUser();
}
Future<void> _loadUser() async {
// HTTP 호출이 위젯에 직접 있음
final response = await http.get(Uri.parse('https://api.example.com/user'));
setState(() {
user = User.fromJson(jsonDecode(response.body));
});
}
@override
Widget build(BuildContext context) {
return Text(user?.name ?? 'Loading...');
}
}
// 좋은 예: 각 계층이 명확히 분리됨
// Data Layer
class UserRepository {
final ApiClient _client;
UserRepository(this._client);
Future<User> getUser() async {
final response = await _client.get('/user');
return User.fromJson(response);
}
}
// Logic Layer
class UserViewModel extends ChangeNotifier {
final UserRepository _repository;
User? user;
UserViewModel(this._repository);
Future<void> loadUser() async {
user = await _repository.getUser();
notifyListeners();
}
}
// UI Layer
class GoodUserScreen extends StatelessWidget {
final UserViewModel viewModel;
const GoodUserScreen({required this.viewModel, super.key});
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: viewModel,
builder: (context, _) => Text(viewModel.user?.name ?? 'Loading...'),
);
}
}

Watch out - 주의사항#

  • Domain 레이어는 복잡한 비즈니스 로직이 있을 때만 추가하세요.
  • 단순한 CRUD 앱에서는 UI 레이어와 Data 레이어만으로 충분합니다.
  • 과도한 추상화는 오히려 복잡성을 증가시킵니다.

데이터 레이어 패턴#

Why - 왜 데이터 레이어가 중요한가요?#

데이터 레이어는 앱의 모든 데이터 흐름을 담당합니다. API, 데이터베이스, 파일 시스템 등 다양한 데이터 소스와 상호작용하며, 이를 앱의 나머지 부분에서 추상화합니다.

What - Repository 패턴이란 무엇인가요?#

Repository 패턴1은 데이터 접근 로직을 비즈니스 로직에서 분리하는 패턴입니다. Repository는 데이터의 단일 진실 원천(Single Source of Truth)으로서, 데이터의 일관성을 보장합니다.

graph TB subgraph "Data Layer" R[Repository] S1[API Service] S2[Database Service] S3[Cache Service] end subgraph "Logic Layer" VM[ViewModel] end VM --> R R --> S1 R --> S2 R --> S3

How - Repository 구현하기#

추상 Repository 정의

// 추상 인터페이스로 정의
abstract class BookingRepository {
Future<Result<List<Booking>>> getBookings();
Future<Result<Booking>> getBooking(String id);
Future<Result<void>> createBooking(Booking booking);
Future<Result<void>> deleteBooking(String id);
}

실제 구현체

// 원격 API를 사용하는 구현체
class BookingRepositoryRemote implements BookingRepository {
final ApiClient _apiClient;
final CacheService _cache;
BookingRepositoryRemote({
required ApiClient apiClient,
required CacheService cache,
}) : _apiClient = apiClient,
_cache = cache;
@override
Future<Result<List<Booking>>> getBookings() async {
try {
// 캐시 확인
final cached = await _cache.get<List<Booking>>('bookings');
if (cached != null) return Result.ok(cached);
// API 호출
final response = await _apiClient.get('/bookings');
final bookings = (response as List)
.map((e) => Booking.fromJson(e))
.toList();
// 캐시 저장
await _cache.set('bookings', bookings);
return Result.ok(bookings);
} catch (e) {
return Result.error(e as Exception);
}
}
// ... 나머지 메서드 구현
}

환경별 구현체 분리

// 개발용 Mock 구현체
class BookingRepositoryMock implements BookingRepository {
final List<Booking> _mockData = [
Booking(id: '1', destination: '서울', date: DateTime.now()),
Booking(id: '2', destination: '부산', date: DateTime.now()),
];
@override
Future<Result<List<Booking>>> getBookings() async {
await Future.delayed(const Duration(milliseconds: 500));
return Result.ok(_mockData);
}
// ... 나머지 메서드 구현
}
// main.dart에서 환경에 따라 다른 구현체 사용
void main() {
final repository = kDebugMode
? BookingRepositoryMock()
: BookingRepositoryRemote(apiClient: ApiClient(), cache: CacheService());
runApp(MyApp(bookingRepository: repository));
}

Watch out - 주의사항#

  • Repository는 데이터 타입별로 하나씩 만드세요 (UserRepository, BookingRepository 등).
  • Service는 단일 데이터 소스를 담당합니다 (ApiService, DatabaseService 등).
  • 추상 클래스를 사용하면 테스트와 환경 분리가 쉬워집니다.

UI 레이어 패턴#

Why - 왜 MVVM 패턴을 사용하나요?#

위젯에 비즈니스 로직이 섞이면 테스트가 어렵고 재사용성이 떨어집니다. MVVM(Model-View-ViewModel)2 패턴은 UI 로직을 ViewModel로 분리하여 이 문제를 해결합니다.

What - MVVM의 구성 요소#

graph LR V[View/Widget] <-->|데이터 바인딩| VM[ViewModel] VM <--> R[Repository] style V fill:#fce4ec style VM fill:#e8f5e9 style R fill:#fff3e0
구성 요소역할포함 내용
View화면 표시위젯, 레이아웃, 애니메이션
ViewModelUI 로직상태 관리, 데이터 변환, 이벤트 처리
Model데이터불변 데이터 클래스

How - ViewModel 구현하기#

기본 ViewModel 구조

class HomeViewModel extends ChangeNotifier {
final BookingRepository _bookingRepository;
final UserRepository _userRepository;
HomeViewModel({
required BookingRepository bookingRepository,
required UserRepository userRepository,
}) : _bookingRepository = bookingRepository,
_userRepository = userRepository;
// 상태
List<Booking> _bookings = [];
User? _user;
bool _isLoading = false;
String? _error;
// Getter
List<Booking> get bookings => _bookings;
User? get user => _user;
bool get isLoading => _isLoading;
String? get error => _error;
// 메서드
Future<void> load() async {
_isLoading = true;
_error = null;
notifyListeners();
try {
final results = await Future.wait([
_bookingRepository.getBookings(),
_userRepository.getCurrentUser(),
]);
_bookings = (results[0] as Result<List<Booking>>).value;
_user = (results[1] as Result<User?>).value;
} catch (e) {
_error = e.toString();
} finally {
_isLoading = false;
notifyListeners();
}
}
}

View에서 ViewModel 사용

class HomeScreen extends StatelessWidget {
final HomeViewModel viewModel;
const HomeScreen({required this.viewModel, super.key});
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: viewModel,
builder: (context, _) {
if (viewModel.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (viewModel.error != null) {
return Center(child: Text('오류: ${viewModel.error}'));
}
return Column(
children: [
// 사용자 정보
if (viewModel.user != null)
Text('안녕하세요, ${viewModel.user!.name}님'),
// 예약 목록
Expanded(
child: ListView.builder(
itemCount: viewModel.bookings.length,
itemBuilder: (context, index) {
final booking = viewModel.bookings[index];
return ListTile(
title: Text(booking.destination),
subtitle: Text(booking.formattedDate),
);
},
),
),
],
);
},
);
}
}

Watch out - 주의사항#

View에는 최소한의 로직만 포함해야 합니다. 다음 항목만 View에 두세요.

  • 조건부 위젯 표시를 위한 간단한 if문
  • 애니메이션 로직
  • 디바이스 정보 기반 레이아웃 결정
  • 기본적인 라우팅 로직

복잡한 로직은 모두 ViewModel로 이동하세요.


불변 데이터 모델#

Why - 왜 불변 모델을 사용하나요?#

가변 데이터 모델은 예상치 못한 곳에서 데이터가 변경될 수 있습니다. 불변 모델은 데이터 변경을 명시적으로 만들어 버그를 줄이고 단방향 데이터 흐름을 강화합니다.

What - 불변 데이터 모델이란?#

불변(Immutable) 객체는 생성 후 상태를 변경할 수 없습니다. 데이터를 수정하려면 새 객체를 생성해야 합니다.

// 불변 데이터 모델
class User {
final String id;
final String name;
final String email;
const User({
required this.id,
required this.name,
required this.email,
});
// 일부 필드만 변경한 새 객체 생성
User copyWith({
String? id,
String? name,
String? email,
}) {
return User(
id: id ?? this.id,
name: name ?? this.name,
email: email ?? this.email,
);
}
// JSON 직렬화
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'] as String,
name: json['name'] as String,
email: json['email'] as String,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'email': email,
};
// 동등성 비교
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is User &&
runtimeType == other.runtimeType &&
id == other.id &&
name == other.name &&
email == other.email;
@override
int get hashCode => id.hashCode ^ name.hashCode ^ email.hashCode;
}

How - freezed로 불변 모델 생성하기#

freezed3 패키지를 사용하면 보일러플레이트 코드를 자동 생성할 수 있습니다.

import 'package:freezed_annotation/freezed_annotation.dart';
part 'user.freezed.dart';
part 'user.g.dart';
@freezed
class User with _$User {
const factory User({
required String id,
required String name,
required String email,
@Default(false) bool isVerified,
}) = _User;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}
// 사용 예시
final user = User(id: '1', name: '홍길동', email: '[email protected]');
final updatedUser = user.copyWith(name: '김철수');

Watch out - 주의사항#

  • 불변 모델은 조건부 권장사항입니다. 작은 앱에서는 오버헤드일 수 있습니다.
  • freezed는 코드 생성 도구이므로 dart run build_runner build를 실행해야 합니다.
  • API 모델과 도메인 모델을 분리하면 복잡성이 줄어듭니다.

실용적인 디자인 패턴#

Command 패턴#

Why - 왜 Command 패턴을 사용하나요?

ViewModel의 메서드는 실행 중, 성공, 실패 등 다양한 상태를 가집니다. 각 메서드마다 이 상태를 관리하면 코드가 복잡해집니다.

What - Command 패턴이란?

Command4 패턴은 메서드 실행과 상태를 캡슐화합니다. 실행 중, 완료, 에러 상태를 자동으로 관리합니다.

class Command<T> extends ChangeNotifier {
Command(this._action);
final Future<T> Function() _action;
bool _running = false;
T? _result;
Exception? _error;
bool get running => _running;
T? get result => _result;
Exception? get error => _error;
bool get completed => _result != null;
Future<void> execute() async {
if (_running) return;
_running = true;
_error = null;
notifyListeners();
try {
_result = await _action();
} catch (e) {
_error = e as Exception;
} finally {
_running = false;
notifyListeners();
}
}
}

How - Command 사용하기

class BookingViewModel extends ChangeNotifier {
final BookingRepository _repository;
late final Command<List<Booking>> loadBookings;
late final Command<void> createBooking;
BookingViewModel(this._repository) {
loadBookings = Command(() => _repository.getBookings());
createBooking = Command(() => _repository.createBooking(_newBooking!));
}
Booking? _newBooking;
void setNewBooking(Booking booking) {
_newBooking = booking;
notifyListeners();
}
}
// View에서 사용
ListenableBuilder(
listenable: viewModel.loadBookings,
builder: (context, _) {
if (viewModel.loadBookings.running) {
return const CircularProgressIndicator();
}
if (viewModel.loadBookings.error != null) {
return Text('에러: ${viewModel.loadBookings.error}');
}
return BookingList(bookings: viewModel.loadBookings.result ?? []);
},
)

Result 패턴#

Why - 왜 Result 패턴을 사용하나요?

Dart의 예외는 선언이나 catch가 강제되지 않습니다. 에러 처리를 잊기 쉽고, 어떤 예외가 발생하는지 문서화되지 않습니다.

What - Result 패턴이란?

Result 패턴은 성공과 실패를 명시적인 타입으로 표현합니다.

sealed class Result<T> {
const Result();
factory Result.ok(T value) = Ok<T>;
factory Result.error(Exception error) = Error<T>;
bool get isOk => this is Ok<T>;
bool get isError => this is Error<T>;
T get value => (this as Ok<T>).value;
Exception get error => (this as Error<T>).error;
}
class Ok<T> extends Result<T> {
final T value;
const Ok(this.value);
}
class Error<T> extends Result<T> {
final Exception error;
const Error(this.error);
}

How - Result 사용하기

// Repository에서 Result 반환
class UserRepository {
Future<Result<User>> getUser(String id) async {
try {
final response = await _client.get('/users/$id');
return Result.ok(User.fromJson(response));
} on NotFoundException {
return Result.error(UserNotFoundException(id));
} on NetworkException catch (e) {
return Result.error(e);
}
}
}
// ViewModel에서 Result 처리
Future<void> loadUser(String id) async {
final result = await _repository.getUser(id);
switch (result) {
case Ok(value: final user):
_user = user;
case Error(error: final e):
_error = e.toString();
}
notifyListeners();
}

Optimistic State 패턴#

Why - 왜 Optimistic State를 사용하나요?

네트워크 요청은 시간이 걸립니다. 응답을 기다리는 동안 UI가 멈춰 있으면 사용자 경험이 나빠집니다.

What - Optimistic State란?

Optimistic State5는 서버 응답 전에 UI를 먼저 업데이트하는 패턴입니다. 대부분의 요청이 성공한다고 가정하고, 실패하면 롤백합니다.

// 좋아요 버튼 예시
class PostViewModel extends ChangeNotifier {
final PostRepository _repository;
Post _post;
PostViewModel(this._repository, this._post);
Post get post => _post;
Future<void> toggleLike() async {
// 1. 낙관적 업데이트: 즉시 UI 변경
final previousState = _post;
_post = _post.copyWith(
isLiked: !_post.isLiked,
likeCount: _post.isLiked ? _post.likeCount - 1 : _post.likeCount + 1,
);
notifyListeners();
// 2. 서버 요청
final result = await _repository.toggleLike(_post.id);
// 3. 실패 시 롤백
if (result.isError) {
_post = previousState;
notifyListeners();
// 에러 메시지 표시
}
}
}

Watch out - 주의사항#

  • Optimistic State는 되돌릴 수 있는 작업에만 사용하세요.
  • 결제, 삭제 등 중요한 작업은 서버 응답을 기다려야 합니다.
  • 실패 시 사용자에게 명확한 피드백을 제공하세요.

오프라인 우선 지원#

Why - 왜 오프라인 지원이 필요한가요?#

모바일 앱은 네트워크 연결이 불안정한 환경에서도 동작해야 합니다. 오프라인 지원이 있으면 사용자가 언제 어디서든 앱을 사용할 수 있습니다.

What - 오프라인 우선 아키텍처#

오프라인 우선(Offline-First)은 로컬 데이터를 기본으로 사용하고, 가능할 때 서버와 동기화하는 전략입니다.

graph TB subgraph "Repository" R[BookingRepository] end subgraph "Data Sources" L[Local Database] C[Cache] A[Remote API] end R --> L R --> C R --> A L -.->|동기화| A

How - 오프라인 우선 구현#

class BookingRepository {
final LocalDatabase _localDb;
final ApiClient _apiClient;
final ConnectivityService _connectivity;
BookingRepository({
required LocalDatabase localDb,
required ApiClient apiClient,
required ConnectivityService connectivity,
}) : _localDb = localDb,
_apiClient = apiClient,
_connectivity = connectivity;
Future<Result<List<Booking>>> getBookings() async {
// 1. 로컬 데이터 먼저 반환
final localBookings = await _localDb.getBookings();
// 2. 온라인이면 서버와 동기화
if (await _connectivity.isOnline) {
try {
final remoteBookings = await _apiClient.get('/bookings');
await _localDb.saveBookings(remoteBookings);
return Result.ok(remoteBookings);
} catch (e) {
// 서버 실패해도 로컬 데이터 반환
}
}
return Result.ok(localBookings);
}
Future<Result<void>> createBooking(Booking booking) async {
// 1. 로컬에 먼저 저장 (pending 상태로)
await _localDb.saveBooking(booking.copyWith(syncStatus: SyncStatus.pending));
// 2. 온라인이면 즉시 동기화, 아니면 나중에
if (await _connectivity.isOnline) {
await _syncPendingBookings();
}
return Result.ok(null);
}
Future<void> _syncPendingBookings() async {
final pending = await _localDb.getPendingBookings();
for (final booking in pending) {
try {
await _apiClient.post('/bookings', booking.toJson());
await _localDb.updateSyncStatus(booking.id, SyncStatus.synced);
} catch (e) {
// 실패한 항목은 나중에 재시도
}
}
}
}

Watch out - 주의사항#

  • 오프라인 데이터임을 사용자에게 알려주세요.
  • 동기화 충돌 해결 전략을 미리 정의하세요.
  • 대용량 데이터는 선택적 동기화를 고려하세요.

앱 구조와 네이밍#

권장하는 폴더 구조#

lib/
├── main.dart
├── app.dart
├── config/
│ └── routes.dart
├── data/
│ ├── models/
│ │ └── booking.dart
│ ├── repositories/
│ │ └── booking_repository.dart
│ └── services/
│ └── api_client.dart
├── domain/
│ └── use_cases/
│ └── create_booking_use_case.dart
└── ui/
├── core/
│ ├── themes/
│ └── widgets/
└── booking/
├── booking_screen.dart
└── booking_view_model.dart

네이밍 컨벤션#

컴포넌트네이밍 예시설명
Screen/ViewHomeScreen, BookingDetailScreen전체 화면 위젯
ViewModelHomeViewModel, BookingDetailViewModelUI 로직 클래스
RepositoryUserRepository, BookingRepository데이터 접근 클래스
ServiceApiClient, AuthService외부 서비스 클래스
ModelUser, Booking데이터 클래스

Watch out - 주의사항#

  • Flutter SDK 클래스와 충돌하는 이름을 피하세요.
  • /widgets 폴더 대신 /ui/core/widgets를 사용하세요.
  • 기능별로 폴더를 구성하면 관련 코드를 쉽게 찾을 수 있습니다.

마무리#

아키텍처 권장사항과 디자인 패턴의 핵심을 정리하면 다음과 같습니다.

  1. 핵심 원칙: 관심사의 분리, 단방향 데이터 흐름, MVVM, Repository 패턴
  2. 불변 데이터: freezed로 불변 모델을 생성하여 예측 가능한 상태 관리
  3. Command 패턴: 메서드 실행 상태를 캡슐화하여 복잡성 감소
  4. Result 패턴: 에러 처리를 명시적으로 만들어 안정성 향상
  5. Optimistic State: 즉각적인 UI 피드백으로 사용자 경험 개선

다음 튜토리얼에서는 Flutter의 유닛 테스트를 자세히 살펴보겠습니다.


Footnotes#

  1. Repository 패턴: 데이터 접근 로직을 캡슐화하여 비즈니스 로직과 분리하는 디자인 패턴입니다. 데이터 소스(API, DB)의 변경이 비즈니스 로직에 영향을 주지 않게 합니다.

  2. MVVM(Model-View-ViewModel): UI 개발을 위한 아키텍처 패턴으로, View(UI), ViewModel(UI 로직), Model(데이터)을 분리합니다. Microsoft의 WPF에서 시작되어 다양한 플랫폼에서 사용됩니다.

  3. freezed: Dart에서 불변 클래스, 유니온 타입, 패턴 매칭을 지원하는 코드 생성 패키지입니다. copyWith, ==, hashCode, toJson 등의 보일러플레이트를 자동 생성합니다.

  4. Command 패턴: 요청을 객체로 캡슐화하는 행동 디자인 패턴입니다. Flutter에서는 비동기 메서드의 실행 상태(로딩, 성공, 에러)를 관리하는 데 활용합니다.

  5. Optimistic State(낙관적 상태): 서버 응답을 기다리지 않고 UI를 먼저 업데이트하는 패턴입니다. 대부분의 요청이 성공한다고 가정하여 응답성을 높이고, 실패 시 롤백합니다.

공유

이 글이 도움이 되었다면 다른 사람과 공유해주세요!

Flutter 튜토리얼 43편: 아키텍처 권장사항과 디자인 패턴
https://moodturnpost.net/posts/flutter/flutter-architecture-recommendations/
작성자
Moodturn
게시일
2026-01-08
Moodturn

목차