Flutter 튜토리얼 40편: 앱 아키텍처 기초

Flutter 앱 아키텍처 기초#

Flutter 앱 아키텍처의 핵심 원칙을 알아봅니다. 잘 설계된 아키텍처는 앱의 유지보수성, 확장성, 테스트 가능성을 높입니다.


아키텍처가 필요한 이유#

Why - 왜 아키텍처가 중요한가요?#

작은 앱에서는 모든 코드를 한 파일에 넣어도 동작합니다. 하지만 앱이 커지고 팀원이 늘어나면 문제가 발생합니다. 코드가 얽히고설켜 수정 시 예상치 못한 버그가 생기며, 새로운 기능 추가가 점점 어려워집니다.

의도적인 아키텍처는 다음과 같은 이점을 제공합니다:

  • 유지보수성: 코드 수정, 업데이트, 버그 수정이 쉬워집니다
  • 확장성: 여러 개발자가 동시에 작업해도 충돌이 최소화됩니다
  • 테스트 가능성: 명확한 입출력을 가진 간단한 클래스는 테스트하기 쉽습니다
  • 인지 부하 감소: 새 팀원이 빠르게 적응하고, 코드 리뷰가 수월해집니다
  • 더 나은 사용자 경험: 기능을 더 빠르게, 더 적은 버그로 출시할 수 있습니다

What - 아키텍처란 무엇인가요?#

아키텍처(Architecture)1는 앱을 어떻게 구조화하고, 조직화하고, 설계할지에 관한 것입니다. 프로젝트 요구사항과 팀이 성장함에 따라 확장할 수 있는 구조를 만드는 것이 목표입니다.

Flutter 팀이 권장하는 아키텍처는 다음 핵심 원칙을 기반으로 합니다:

  1. 관심사의 분리(Separation of Concerns)
  2. 레이어드 아키텍처(Layered Architecture)
  3. 단일 진실 원천(Single Source of Truth)
  4. 단방향 데이터 흐름(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 ?? '');
}
}

좋은 예시 - 관심사 분리

// 데이터 레이어: Repository
class UserRepository {
final ApiClient _client;
UserRepository(this._client);
Future<User> getUser() async {
final response = await _client.get('/user');
return User.fromJson(response);
}
}
// 로직 레이어: ViewModel
class 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 업데이트까지의 흐름:

  1. [UI Layer] 사용자가 버튼을 클릭합니다. 위젯의 이벤트 핸들러가 로직 계층의 메서드를 호출합니다.

  2. [Logic Layer] 로직 클래스가 데이터를 변경할 수 있는 Repository 메서드를 호출합니다.

  3. [Data Layer] Repository가 데이터를 업데이트하고 새 데이터를 로직 클래스에 제공합니다.

  4. [Logic Layer] 로직 클래스가 새 상태를 저장하고 UI에 알립니다.

  5. [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);
await viewModel.signIn('[email protected]', 'password');
expect(viewModel.currentUser, equals(testUser));
});
});
}

Watch out - 주의사항#

새 ViewModel을 추가해도 데이터 레이어나 비즈니스 로직 레이어의 기존 로직이 깨지지 않습니다. 이것이 레이어드 아키텍처의 장점입니다.


마무리#

Flutter 앱 아키텍처의 핵심 원칙을 정리하면:

  1. 관심사의 분리: UI와 비즈니스 로직을 분리하고, 기능별로 코드를 조직
  2. 레이어드 아키텍처: UI, Logic, Data 계층으로 구분하여 복잡성 관리
  3. 단일 진실 원천: 각 데이터 타입은 하나의 위치에서만 관리
  4. 단방향 데이터 흐름: 상태는 아래에서 위로, 이벤트는 위에서 아래로
  5. UI = f(State): 불변 상태를 기반으로 UI 구성

다음 튜토리얼에서는 UI 레이어와 Data 레이어를 더 자세히 살펴보겠습니다.


Footnotes#

  1. 아키텍처(Architecture): 소프트웨어의 구조, 구성 요소 간의 관계, 설계 원칙을 정의하는 청사진입니다. 좋은 아키텍처는 변화에 유연하게 대응할 수 있게 합니다.

  2. 관심사의 분리(Separation of Concerns): 프로그램을 서로 다른 관심사(기능)를 처리하는 독립적인 부분으로 나누는 설계 원칙입니다.

  3. 단일 진실 원천(SSOT): 모든 데이터 요소가 하나의 장소에서만 생성되거나 편집되도록 정보를 구조화하는 방식입니다.

  4. 단방향 데이터 흐름(UDF): 데이터가 한 방향으로만 흐르도록 하여 상태 관리를 단순화하는 패턴입니다. React의 Flux 아키텍처에서 유래했습니다.

공유

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

Flutter 튜토리얼 40편: 앱 아키텍처 기초
https://moodturnpost.net/posts/flutter/flutter-architecture-basics/
작성자
Moodturn
게시일
2026-01-08
Moodturn

목차